From b99b3f3451bcb2b4dfb353afa320d7340b84eae8 Mon Sep 17 00:00:00 2001
From: Flechazo <2558755403@qq.com>
Date: Sat, 3 Oct 2026 15:09:31 +0800
Subject: [PATCH] feat: add resilient server recovery and talent upgrades
---
README.md | 4 +-
RELEASE.md | 4 +-
build-release.ps1 | 10 +-
go/cmd/bd2client/main.go | 1 +
go/cmd/bd2server/main.go | 110 ++-
go/cmd/bd2server/preflight.go | 123 +++
go/internal/client/app/app.go | 6 +-
go/internal/client/app/app_test.go | 8 +-
go/internal/client/app/web/index.html | 6 +-
go/internal/client/config/release.go | 24 +-
go/internal/client/config/release_test.go | 9 +-
go/internal/client/setup/setup.go | 4 +-
go/internal/client/setup/setup_test.go | 2 +-
go/internal/server/accountstate/bootstrap.go | 101 +++
.../server/accountstate/bootstrap_test.go | 97 +++
go/internal/server/accountstate/repository.go | 89 ++-
.../server/accountstate/repository_test.go | 41 +-
go/internal/server/accountstate/schema.go | 9 +
.../server/accountstate/schema_test.go | 31 +-
.../server/accountstate/writer_lock_unix.go | 51 ++
.../accountstate/writer_lock_windows.go | 61 ++
go/internal/server/auth/service.go | 121 ++-
go/internal/server/auth/service_test.go | 88 ++-
go/internal/server/auth/store.go | 30 +-
go/internal/server/auth/store_test.go | 39 +
go/internal/server/battle/battle.go | 111 ++-
go/internal/server/battle/battle_test.go | 54 ++
.../server/bootstrap/bootstrap_test.go | 6 +-
go/internal/server/deck/preset.go | 3 +
go/internal/server/feature/standalone.go | 1 -
go/internal/server/gacha/gacha.go | 56 ++
go/internal/server/gacha/gacha_test.go | 75 ++
go/internal/server/gacha/schedule.go | 39 +
go/internal/server/gacha/schedule_test.go | 29 +
.../server/gamedata/equipment_gacha.go | 31 +-
.../server/gamedata/limited_costume.go | 98 +++
go/internal/server/lifecycle/gate.go | 85 +++
go/internal/server/lifecycle/gate_test.go | 31 +
go/internal/server/lifecycle/signals.go | 7 +
go/internal/server/lifecycle/signals_unix.go | 12 +
.../server/lifecycle/signals_windows.go | 7 +
go/internal/server/mail/grant_spool.go | 3 +-
go/internal/server/mail/mail.go | 322 +++++++-
go/internal/server/mail/mail_test.go | 264 +++++++
go/internal/server/player/collection.go | 50 ++
go/internal/server/player/equipment.go | 7 +-
go/internal/server/player/growth.go | 66 +-
go/internal/server/player/growth_entries.go | 7 +-
go/internal/server/player/inventory_slots.go | 3 +
go/internal/server/player/owned.go | 3 +
go/internal/server/player/starter.go | 1 +
go/internal/server/player/talent_upgrade.go | 59 +-
.../server/player/talent_upgrade_test.go | 252 +++++++
go/internal/server/session/server.go | 2 +-
go/internal/server/session/server_test.go | 26 +-
go/internal/server/stateio/stateio.go | 4 +
go/internal/server/transport/http.go | 97 ++-
go/internal/server/transport/http_test.go | 94 +++
go/internal/server/versionconfig/config.go | 28 +-
.../server/versionconfig/config_test.go | 17 +-
plugins/CaptureEnvironment/Plugin.cs | 4 +-
plugins/Generate-VersionSource.ps1 | 11 +-
plugins/LoginUI/CredentialStores.cs | 25 +-
plugins/LoginUI/LoginUI.csproj | 4 +
plugins/LoginUI/Plugin.cs | 712 +++++++++++++++---
tools/python/README.md | 13 +-
tools/python/deobfuscate_client_source.py | 146 +++-
tools/python/dev_mail_grant.py | 96 +--
tools/python/tests/test_offline_tools.py | 47 +-
versions.json | 6 +-
70 files changed, 3664 insertions(+), 419 deletions(-)
create mode 100644 go/cmd/bd2server/preflight.go
create mode 100644 go/internal/server/accountstate/bootstrap.go
create mode 100644 go/internal/server/accountstate/bootstrap_test.go
create mode 100644 go/internal/server/accountstate/writer_lock_unix.go
create mode 100644 go/internal/server/accountstate/writer_lock_windows.go
create mode 100644 go/internal/server/gamedata/limited_costume.go
create mode 100644 go/internal/server/lifecycle/gate.go
create mode 100644 go/internal/server/lifecycle/gate_test.go
create mode 100644 go/internal/server/lifecycle/signals.go
create mode 100644 go/internal/server/lifecycle/signals_unix.go
create mode 100644 go/internal/server/lifecycle/signals_windows.go
diff --git a/README.md b/README.md
index 1cc2c25..149269d 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ Copy-Item .\go\config.example.json .\go\config.json
.\build-release.ps1
```
-脚本在 Windows 本机构建,分别生成 `.build\bd2server-windows-x64.zip` 和 `.build\bd2client-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略和服务端数据目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件。
+脚本在 Windows 本机构建版本化归档,例如 `.build\bd2server-2.35.10+server.0.1.0-windows-x64.zip` 和 `.build\bd2client-2.35.10+client.0.1.0-windows-x64.zip`。纯服务端包只包含 `bd2server.exe`、seed、认证/资源策略和服务端数据目录;客户端包只包含 `bd2client.exe`、客户端插件和版本文件。
macOS 客户端使用系统 Cocoa / WKWebView,需要 macOS 原生构建环境。本地 Windows 发布脚本不会交叉编译或生成 macOS 发布包;找到 macOS 测试者或准备公开发布时,再通过 GitHub Actions 的 macOS runner 完成原生构建、测试及 `.app` 打包。签名和公证也应在该流程中配置。
@@ -88,4 +88,4 @@ BD2 Client Studio 是使用 Wails 的独立桌面客户端设置工具。Windows
本项目原创内容由 Flechazo 保留全部权利,具体中英双语条款见 [LICENSE](LICENSE)。通过项目权利人指定渠道合法取得副本的人,仅可用于个人学习、研究和本地非商业测试;未经项目权利人事先明确书面许可,禁止向第三方转发、镜像、上传、二次分发项目源码或构建产物,禁止售卖、收费提供、商业化运营或对公众开放基于本项目的服务。公开可见不代表允许转载或再分发。
-本项目是非官方开发与研究项目,与 Brown Dust II 及其权利人无隶属或授权关系。游戏名称、商标、客户端、游戏资源和数据归各自权利人所有;本项目的许可不覆盖它们,也不变更第三方依赖的许可证。项目按现状提供,无正确性、安全性、可用性或不侵权保证。使用者须自行确认适用法律、相关协议和第三方权利要求,并自行承担使用、客户端修改及本地服务运行风险。
\ No newline at end of file
+本项目是非官方开发与研究项目,与 Brown Dust II 及其权利人无隶属或授权关系。游戏名称、商标、客户端、游戏资源和数据归各自权利人所有;本项目的许可不覆盖它们,也不变更第三方依赖的许可证。项目按现状提供,无正确性、安全性、可用性或不侵权保证。使用者须自行确认适用法律、相关协议和第三方权利要求,并自行承担使用、客户端修改及本地服务运行风险。
diff --git a/RELEASE.md b/RELEASE.md
index 6c77b20..92df680 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -23,12 +23,12 @@
4. 确认健康检查成功:
```bash
- curl --fail --silent --show-error http://127.0.0.1:8080/healthz
+ curl --fail --silent --show-error http://127.0.0.1:8080/readyz
```
5. 让玩家使用独立的 `bd2client.exe` 选择游戏目录、填写服务器地址、选择 CDN,并安装 `BD2LocalIdentity.dll` 与 `BD2LoginUI.dll`。客户端工具说明见 `README.md`(客户端包)或仓库的 `docs/CLIENT.md`。
-`versions.json` 是服务端和客户端插件共用的版本选择,必须与各自可执行文件保持在同一目录。服务端从包内 `go/seed` 读取种子,不依赖当前工作目录。需要临时测试另一组版本时可传 `serve --version-config <文件>`。玩家存档位于 `data\state\state.db`;服务端以 Go 迁移器按相邻版本升级,并在同一个 SQLite 事务内完成迁移和最终校验,失败时不会留下部分升级。
+`versions.json` 是服务端和客户端插件共用的版本选择,必须与各自可执行文件保持在同一目录。`game_version` 锁定官方游戏兼容版本;`client_version` 与 `server_version` 分别标识客户端工具和纯服务端发布版本,并以 `游戏版本+组件.X.Y.Z` 组合。服务端从包内 `go/seed` 读取种子,不依赖当前工作目录。需要临时测试另一组版本时可传 `serve --version-config <文件>`。玩家存档位于 `data\state\state.db`;服务端以 Go 迁移器按相邻版本升级,并在同一个 SQLite 事务内完成迁移和最终校验,失败时不会留下部分升级。
`authentication.json` 和 `resources.json` 必须与 `bd2server.exe` 保持在同一目录。默认 `local` 认证模式继续自动本地登录;公网或联机服可以启用 OAuth,并把资源策略设为官方 CDN 或统一的服务器资源源。服主自建与反代使用相同的 `server` 模式;玩家本地资源目录仅由 `bd2client.exe` 配置。逐步配置方法见发布包内的 [服主第三方登录配置指南](AUTHENTICATION.md) 和 [服主资源与 CDN 配置指南](RESOURCES.md)。
diff --git a/build-release.ps1 b/build-release.ps1
index 11e5da9..5ddd5fc 100644
--- a/build-release.ps1
+++ b/build-release.ps1
@@ -14,12 +14,18 @@ $clientPackage = Join-Path $packageParent 'bd2client'
$serverGoDir = Join-Path $serverPackage 'go'
$serverStateDir = Join-Path $serverPackage 'data\state'
$clientPluginDir = Join-Path $clientPackage 'plugins'
-$serverArchive = Join-Path $buildRoot 'bd2server-windows-x64.zip'
-$clientArchive = Join-Path $buildRoot 'bd2client-windows-x64.zip'
$versionConfig = Join-Path $root 'versions.json'
$authenticationConfig = Join-Path $root 'authentication.json'
$resourceConfig = Join-Path $root 'resources.json'
+try {
+ $releaseVersions = Get-Content -LiteralPath $versionConfig -Raw | ConvertFrom-Json -ErrorAction Stop
+} catch {
+ throw "Could not read release versions at ${versionConfig}: $($_.Exception.Message)"
+}
+$serverArchive = Join-Path $buildRoot ("bd2server-{0}-windows-x64.zip" -f $releaseVersions.server_version)
+$clientArchive = Join-Path $buildRoot ("bd2client-{0}-windows-x64.zip" -f $releaseVersions.client_version)
+
if ([string]::IsNullOrWhiteSpace($GameDir)) {
$developmentConfigPath = Join-Path $goRoot 'config.json'
if (-not [IO.File]::Exists($developmentConfigPath)) {
diff --git a/go/cmd/bd2client/main.go b/go/cmd/bd2client/main.go
index 5c5d104..478107c 100644
--- a/go/cmd/bd2client/main.go
+++ b/go/cmd/bd2client/main.go
@@ -64,6 +64,7 @@ func runClient(args []string, options clientRunOptions) error {
logger.Error("client release version manifest is invalid", "error", err)
return err
}
+ logger.Info("client release loaded", "client_version", versions.ClientVersion, "game_version", versions.GameVersion)
if *gameDir == "" {
preferences, preferenceErr := clientconfig.LoadPreferences()
if preferenceErr != nil {
diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go
index 6c9493a..068a08c 100644
--- a/go/cmd/bd2server/main.go
+++ b/go/cmd/bd2server/main.go
@@ -2,12 +2,15 @@ package main
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
+ "os/signal"
"path/filepath"
"strings"
"time"
@@ -22,6 +25,7 @@ import (
"bd2server/internal/server/feature"
"bd2server/internal/server/gacha"
"bd2server/internal/server/gamedata"
+ "bd2server/internal/server/lifecycle"
"bd2server/internal/server/mail"
"bd2server/internal/server/missions"
"bd2server/internal/server/pictorial"
@@ -57,6 +61,8 @@ func main() {
err = stateCommand(os.Args[2:])
case "resources":
err = resourcesCommand(os.Args[2:])
+ case "preflight":
+ err = preflight(os.Args[2:])
case "help", "-h", "--help":
usage()
return
@@ -173,7 +179,7 @@ func serve(args []string) (serveErr error) {
cfg := bootstrap.Config{
BaseURL: base,
CDNURL: publicResources.ServerDataURL,
- Version: versions.ClientVersion,
+ Version: versions.GameVersion,
BundleVer: versions.BundleVersion,
GameDataURL: publicResources.GameDataURL,
GameDataVer: *gameDataVersion,
@@ -196,10 +202,10 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load starter player: %w", err)
}
- if login.Version != versions.ClientVersion || starter.Version != versions.ClientVersion {
- return fmt.Errorf("client version %s requires matching account and player seeds (got %s and %s)", versions.ClientVersion, login.Version, starter.Version)
+ if login.Version != versions.GameVersion || starter.Version != versions.GameVersion {
+ return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", versions.GameVersion, login.Version, starter.Version)
}
- gachaSchedule, err := gacha.LoadScheduleSeed(filepath.Clean(*gachaScheduleSeed), versions.ClientVersion)
+ gachaSchedule, err := gacha.LoadScheduleSeed(filepath.Clean(*gachaScheduleSeed), versions.GameVersion)
if err != nil {
return fmt.Errorf("load gacha schedule: %w", err)
}
@@ -214,6 +220,10 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("load active gacha GameData: %w", err)
}
+ limitedCostumes, err := gamedata.LoadLimitedCostumes(gameData, *gameDataVersion)
+ if err != nil {
+ return fmt.Errorf("load limited costume GameData: %w", err)
+ }
firstGacha, err := gamedata.LoadFirstGacha(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load first gacha GameData: %w", err)
@@ -236,11 +246,14 @@ func serve(args []string) (serveErr error) {
}
}
accountDomains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"}
- if !stateRepository.IsNew() {
- if err := stateRepository.RequireDomains(accountDomains...); err != nil {
- return fmt.Errorf("reject incomplete account database: %w", err)
- }
+ initializationState, err := stateRepository.InitializationState(accountDomains...)
+ if err != nil {
+ return fmt.Errorf("reject incomplete account database: %w", err)
}
+ if initializationState == accountstate.InitializationCorrupt {
+ return errors.New("reject incomplete account database: corrupt initialization state")
+ }
+ initializeAccount := initializationState == accountstate.InitializationPending
startupTransaction, err := stateRepository.BeginOperation()
if err != nil {
return fmt.Errorf("begin startup state transaction: %w", err)
@@ -455,6 +468,9 @@ func serve(args []string) (serveErr error) {
if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil {
return fmt.Errorf("bind base collection characters: %w", err)
}
+ if err := mailService.AttachCostumeRewards(collection, limitedCostumes); err != nil {
+ return fmt.Errorf("attach limited costume mail rewards: %w", err)
+ }
if reward, earned := worldService.EarnedQuestCostume(); earned {
if err := collection.AttachRewardCostume(reward); err != nil {
return fmt.Errorf("attach earned quest costume: %w", err)
@@ -539,13 +555,23 @@ func serve(args []string) (serveErr error) {
return err
}
}
- if stateRepository.IsNew() {
+ featured := gacha.ActivePickupCostumes(regularGacha, gachaSchedule, uint64(time.Now().UTC().UnixMilli()))
+ limitedIDs := limitedCostumes.Excluding(featured)
+ if len(limitedIDs) != 0 {
+ if err := mailService.EnsureStarterLimitedCostumes(limitedIDs, time.Now().UTC()); err != nil {
+ return fmt.Errorf("ensure account limited-costume entitlement: %w", err)
+ }
+ }
+ if initializeAccount {
if err := ensureAccountStateInitialized(
progressState, deckStateStore, ownedItems, ownedEquipment,
worldService.CharacterService(), collection, wallet, inventorySlots, mailService, missionService,
); err != nil {
return fmt.Errorf("initialize complete account state generation: %w", err)
}
+ if err := stateRepository.MarkInitializationComplete(); err != nil {
+ return fmt.Errorf("mark account initialization complete: %w", err)
+ }
}
problems, err := stateRepository.Validate()
if err != nil {
@@ -569,9 +595,15 @@ func serve(args []string) (serveErr error) {
if authService != nil {
authHandler = authService.Handler()
}
+ availability := lifecycle.NewGate()
+ instanceBytes := make([]byte, 16)
+ if _, err := rand.Read(instanceBytes); err != nil {
+ return fmt.Errorf("create server instance identity: %w", err)
+ }
+ instanceID := hex.EncodeToString(instanceBytes)
handler := transport.HTTP{
Dispatcher: dispatcher, Raw: game, Authentication: authentication,
- AuthenticationHandler: authHandler, ResourcePolicy: publicResources,
+ AuthenticationHandler: authHandler, ResourcePolicy: publicResources, Availability: availability, InstanceID: instanceID,
}.Handler()
server := &http.Server{
Addr: *listen,
@@ -581,8 +613,61 @@ func serve(args []string) (serveErr error) {
WriteTimeout: 20 * time.Second,
IdleTimeout: 60 * time.Second,
}
- slog.Info("BD2 server listening", "address", *listen, "client", cfg.Version, "bundle", cfg.BundleVer, "resourceMode", publicResources.Mode, "gameData", verifiedGameData.ArchivePath, "gameDataEntries", verifiedGameData.EntryCount, "accountSeed", *accountSeed)
- return server.ListenAndServe()
+ slog.Info("BD2 server listening", "address", *listen, "instance_id", instanceID, "server_version", versions.ServerVersion, "game_version", cfg.Version, "bundle", cfg.BundleVer, "resourceMode", publicResources.Mode, "gameData", verifiedGameData.ArchivePath, "gameDataEntries", verifiedGameData.EntryCount, "accountSeed", *accountSeed)
+ serveResult := make(chan error, 1)
+ go func() { serveResult <- server.ListenAndServe() }()
+ stateFailure := make(chan error, 1)
+ stopStateMonitor := make(chan struct{})
+ defer close(stopStateMonitor)
+ go func() {
+ ticker := time.NewTicker(250 * time.Millisecond)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if err := stateRepository.Check(); err != nil {
+ select {
+ case stateFailure <- err:
+ default:
+ }
+ return
+ }
+ case <-stopStateMonitor:
+ return
+ }
+ }
+ }()
+ signals := make(chan os.Signal, 1)
+ signal.Notify(signals, lifecycle.ShutdownSignals()...)
+ defer signal.Stop(signals)
+ var shutdownErr error
+ select {
+ case err := <-serveResult:
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+ case received := <-signals:
+ slog.Info("BD2 server draining", "signal", received.String())
+ case err := <-stateFailure:
+ shutdownErr = err
+ slog.Error("BD2 server state failed closed; draining for process recovery", "error", err)
+ }
+ availability.Drain()
+ drainContext, cancelDrain := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancelDrain()
+ if err := availability.Wait(drainContext); err != nil {
+ slog.Warn("BD2 request drain timed out", "error", err)
+ }
+ if err := server.Shutdown(drainContext); err != nil {
+ _ = server.Close()
+ return fmt.Errorf("shutdown drained server: %w", err)
+ }
+ if err := <-serveResult; err != nil && !errors.Is(err, http.ErrServerClosed) {
+ return err
+ }
+ slog.Info("BD2 server stopped after drain")
+ return shutdownErr
}
type accountStateInitializer interface {
@@ -641,6 +726,7 @@ func usage() {
fmt.Fprintln(os.Stderr, `bd2server - BrownDust II server
Usage:
+ bd2server preflight [--data-dir DIR] [--version-config FILE]
bd2server serve [--data-dir DIR] [--version-config FILE] [options]
bd2server resources fetch --output DIR [--version-config FILE]
bd2server state check [options]
diff --git a/go/cmd/bd2server/preflight.go b/go/cmd/bd2server/preflight.go
new file mode 100644
index 0000000..f2a161a
--- /dev/null
+++ b/go/cmd/bd2server/preflight.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "bd2server/internal/server/account"
+ "bd2server/internal/server/authconfig"
+ "bd2server/internal/server/deck"
+ "bd2server/internal/server/gacha"
+ "bd2server/internal/server/gamedata"
+ "bd2server/internal/server/mail"
+ "bd2server/internal/server/player"
+ "bd2server/internal/server/readonly"
+ "bd2server/internal/server/resourcepolicy"
+ "bd2server/internal/server/versionconfig"
+)
+
+// preflight validates the candidate binary's immutable configuration without
+// opening state.db or claiming writer_epoch. Deployment may run it while the
+// old instance is still active, then drain the old writer before activation.
+func preflight(args []string) error {
+ fs := flag.NewFlagSet("preflight", flag.ContinueOnError)
+ dataDir := fs.String("data-dir", "", "server data directory")
+ versionPath := fs.String("version-config", "", "repository versions.json override")
+ authPath := fs.String("authentication-config", "", "authentication.json override")
+ resourcePath := fs.String("resource-config", "", "resources.json override")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ versions, err := versionconfig.Find()
+ if *versionPath != "" {
+ versions, err = versionconfig.Load(*versionPath)
+ }
+ if err != nil {
+ return err
+ }
+ versionconfig.Use(versions)
+ if *authPath == "" {
+ *authPath, err = authconfig.BesideExecutable()
+ if err != nil {
+ return err
+ }
+ }
+ authentication, err := authconfig.Load(*authPath)
+ if err != nil {
+ return err
+ }
+ runtime, err := authentication.ResolveEnvironment()
+ if err != nil {
+ return err
+ }
+ clear(runtime.MasterKey)
+ if *resourcePath == "" {
+ *resourcePath, err = resourcepolicy.BesideExecutable()
+ if err != nil {
+ return err
+ }
+ }
+ if _, err := resourcepolicy.Load(*resourcePath); err != nil {
+ return err
+ }
+ if *dataDir == "" {
+ executable, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ *dataDir = filepath.Join(filepath.Dir(executable), "data")
+ }
+ gameData := filepath.Join(filepath.Clean(*dataDir), "resources", "GameData")
+ if _, _, err := gamedata.Ensure(context.Background(), nil, gameData, versions.GameDataVersion, resourcepolicy.OfficialGameDataURL); err != nil {
+ return fmt.Errorf("preflight GameData: %w", err)
+ }
+ seedRoot := versions.Resolve(versions.SeedDirectory)
+ login, err := account.Load(filepath.Join(seedRoot, "login_user.json"))
+ if err != nil {
+ return err
+ }
+ starter, err := player.Load(filepath.Join(seedRoot, "starter_player.json"))
+ if err != nil {
+ return err
+ }
+ if login.Version != versions.GameVersion || starter.Version != versions.GameVersion {
+ return errorsVersionMismatch(versions.GameVersion, login.Version, starter.Version)
+ }
+ if _, err := mail.Load(filepath.Join(seedRoot, "mail.json")); err != nil {
+ return err
+ }
+ if _, err := deck.LoadSeed(filepath.Join(seedRoot, "decks.json")); err != nil {
+ return err
+ }
+ if _, err := readonly.Load(filepath.Join(seedRoot, "readonly.json")); err != nil {
+ return err
+ }
+ schedule, err := gacha.LoadScheduleSeed(filepath.Join(seedRoot, "gacha_schedule.json"), versions.GameVersion)
+ if err != nil {
+ return err
+ }
+ var groups, steps []uint64
+ for _, window := range schedule.Schedules {
+ groups = append(groups, window.GroupID)
+ }
+ for _, window := range schedule.StepUps {
+ steps = append(steps, window.GroupID)
+ }
+ if _, _, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps); err != nil {
+ return err
+ }
+ if _, err := gamedata.LoadFirstGacha(gameData, versions.GameDataVersion); err != nil {
+ return err
+ }
+ if _, err := gamedata.LoadLimitedCostumes(gameData, versions.GameDataVersion); err != nil {
+ return err
+ }
+ return nil
+}
+
+func errorsVersionMismatch(want, login, player string) error {
+ return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", want, login, player)
+}
diff --git a/go/internal/client/app/app.go b/go/internal/client/app/app.go
index b4c5617..e54bc15 100644
--- a/go/internal/client/app/app.go
+++ b/go/internal/client/app/app.go
@@ -52,6 +52,8 @@ type Response struct {
type InitialState struct {
Platform string `json:"platform"`
+ ClientVersion string `json:"client_version"`
+ GameVersion string `json:"game_version"`
LogPath string `json:"log_path"`
GameDirectory string `json:"game_directory"`
ServerOrigin string `json:"server_origin"`
@@ -118,7 +120,7 @@ func (s *Studio) log() *slog.Logger {
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}
+ state := InitialState{Platform: runtime.GOOS, ClientVersion: s.options.Versions.ClientVersion, GameVersion: s.options.Versions.GameVersion, LogPath: s.options.LogPath, GameDirectory: s.options.InitialGameDir, ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}
if state.GameDirectory == "" {
return state
}
@@ -318,7 +320,7 @@ func (s *Studio) Launch(input Request) (Response, error) {
}
return Response{}, fmt.Errorf("launch Brown Dust II: %w", err)
}
- s.log().Info("game launch requested", "platform", installation.Kind, "client_version", status.ClientVersion)
+ s.log().Info("game launch requested", "platform", installation.Kind, "client_version", s.options.Versions.ClientVersion, "game_version", status.ClientVersion)
return success("Brown Dust II started", nil), nil
})
}
diff --git a/go/internal/client/app/app_test.go b/go/internal/client/app/app_test.go
index 2448df5..fd82e92 100644
--- a/go/internal/client/app/app_test.go
+++ b/go/internal/client/app/app_test.go
@@ -42,7 +42,7 @@ func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
dir := makeTestClient(t)
- studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
+ studio := NewStudio(Options{InitialGameDir: dir, Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0"}}, NativeHost{})
if state := studio.Initialize(); state.AutoOpen {
t.Fatal("missing settings enabled automatic workspace")
}
@@ -50,14 +50,14 @@ func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
t.Fatal(err)
}
state := studio.Initialize()
- if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" {
+ if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" || state.ClientVersion != "2.35.10+client.0.1.0" || state.GameVersion != "2.35.10" {
t.Fatalf("state=%+v", state)
}
}
func TestStudioSaveRemembersOnlyValidatedSettings(t *testing.T) {
dir := makeTestClient(t)
- studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{})
+ studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "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}
@@ -116,7 +116,7 @@ func TestStudioInitializeDefaultsWithoutGameDirectory(t *testing.T) {
func TestStudioBrowseUsesNativeDirectoryPicker(t *testing.T) {
dir := makeTestClient(t)
var title string
- studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{ClientVersion: "2.35.10"}}, NativeHost{
+ studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{
BrowseDirectory: func(_ context.Context, requested string) (string, error) {
title = requested
return dir, nil
diff --git a/go/internal/client/app/web/index.html b/go/internal/client/app/web/index.html
index 3bf9926..c9297be 100644
--- a/go/internal/client/app/web/index.html
+++ b/go/internal/client/app/web/index.html
@@ -46,7 +46,7 @@ main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro
官方模式由客户端直连版本锁定的官方 CDN。
当前设置尚未保存。
03
补丁与插件
操作前请先关闭游戏。补丁只修改内置 Intro 地址,并保留 resources.assets.bak 备份。安装插件前,请先手动安装 BepInEx。
-
+
CLIENT STUDIO
工具已退出
现在可以关闭这个页面。
@@ -69,7 +69,7 @@ if(!zhCN){
document.documentElement.lang='en';
document.title='BD2 Client Studio';
const translations=new Map(Object.entries({
- '客户端设置工具 · 配置保存在本机':'Client setup utility · Settings stay on this computer','退出工具 ↗':'Exit tool ↗','先从游戏目录':'Start with the game directory','开始。':'first.','选择已安装的游戏文件夹。':'Select the installed game folder.','我们会检查游戏程序和资源文件,再继续设置连接。':'We will inspect the game executable and assets before configuring the connection.','客户端配置':'Client configuration','选择游戏目录':'Select game directory','请选择包含':'Select the folder containing','的文件夹。':'.','游戏安装目录':'Game installation directory','浏览':'Browse','可直接填写路径,也可以浏览目录。':'Enter a path or browse for a directory.','检查目录':'Inspect directory','继续设置服务器':'Continue to server setup','此步骤只检查文件,不会修改游戏资源。':'This step only inspects files and does not modify game assets.','接下来,':'Next,','设置服务器。':'configure the server.','填写要连接的服务端地址。':'Enter the server you want to connect to.','进入工作台后,你可以选择资源来源、应用补丁和安装插件。':'In the workspace you can select a resource source, apply the patch, and install plugins.','目录已选择 · 之后仍可修改':'Directory selected · You can change it later','服务器地址':'Server address','填写完整地址,可以包含端口。':'Enter the complete origin, including a port when needed.','地址与端口':'Address and port','例如':'Example','不要添加':'Do not add','或其他路径。真实连接地址由 LI 插件读取。':'or another path. The LI plugin reads the actual connection address.','进入工作台':'Open workspace','返回目录设置':'Back to directory setup','客户端工作台':'Client workspace','管理连接设置,完成游戏启动前的准备。':'Manage connection settings and prepare the game client.','启动游戏':'Launch game','等待检查':'Waiting','连接设置':'Connection settings','随时可以修改':'Editable at any time','游戏目录':'Game directory','尚未选择':'Not selected','修改目录 ↗':'Change directory ↗','尚未填写':'Not entered','修改地址 ↗':'Change address ↗','资源来源':'Resource source','官方 CDN':'Official CDN','从官方节点下载资源,不占用服主的大文件流量。':'Download from official nodes without using the server owner’s bandwidth.','本机已下载资源':'Downloaded local resources','直接读取电脑上的 ServerData 和 GameData,不再下载相同文件。':'Read ServerData and GameData already stored on this computer.','服主资源':'Server-provided resources','由服务器提供资源地址;服主可自建 CDN,也可以反代官方 CDN。':'The server supplies resource URLs backed by either a private CDN or an official-CDN proxy.','本机 CDN 根目录':'Local CDN root directory','所选目录内必须同时包含':'The selected directory must contain both',' 和 ':' and ','。':'.','官方模式由客户端直连版本锁定的官方 CDN。':'Official mode connects directly to the release-locked official CDN.','验证资源来源 ↗':'Verify resource source ↗','保存连接设置':'Save connection settings','当前设置尚未保存。':'Current settings have not been saved.','补丁与插件':'Patch and plugins','操作前请先关闭游戏。补丁只修改内置 Intro 地址,并保留':'Close the game first. The patch only changes the built-in Intro address and retains a','备份。安装插件前,请先手动安装 BepInEx。':'backup. Install BepInEx manually before installing the plugins.','应用客户端补丁':'Apply client patch','安装 / 更新插件':'Install / update plugins','当前状态':'Current status','游戏程序':'Game executable','资源文件':'Asset file','需手动安装':'Manual install required','连接配置':'Connection configuration','尚未写入':'Not written','状态来自本机文件检查,不代表服务器在线。':'Status is based on local files and does not indicate server availability.','诊断日志:':'Diagnostic log:','重新检查 ↻':'Inspect again ↻','最近操作':'Recent operation','等待操作':'Waiting for an operation','选择游戏目录后即可开始检查。':'Select the game directory to begin.','查看操作记录':'View operation history','配置仅保存在你的电脑上':'Settings are stored only on your computer','不会保存账号凭据或服主密钥':'Account credentials and server-owner secrets are never stored','工具已退出':'Tool exited','现在可以关闭这个页面。':'You can close this page now.','选择目录':'Select directory','设置服务器':'Configure server','修改设置':'Edit settings','修改后请在工作台保存连接设置。':'Save changes from the workspace when finished.','完成修改':'Finish editing','请启用 JavaScript 以使用客户端设置工具。':'Enable JavaScript to use the client setup tool.','CDN 类型':'CDN source','客户端状态':'Client status','设置进度':'Setup progress','关闭设置':'Close settings'
+ '客户端设置工具 · 配置保存在本机':'Client setup utility · Settings stay on this computer','退出工具 ↗':'Exit tool ↗','先从游戏目录':'Start with the game directory','开始。':'first.','选择已安装的游戏文件夹。':'Select the installed game folder.','我们会检查游戏程序和资源文件,再继续设置连接。':'We will inspect the game executable and assets before configuring the connection.','客户端配置':'Client configuration','选择游戏目录':'Select game directory','请选择包含':'Select the folder containing','的文件夹。':'.','游戏安装目录':'Game installation directory','浏览':'Browse','可直接填写路径,也可以浏览目录。':'Enter a path or browse for a directory.','检查目录':'Inspect directory','继续设置服务器':'Continue to server setup','此步骤只检查文件,不会修改游戏资源。':'This step only inspects files and does not modify game assets.','接下来,':'Next,','设置服务器。':'configure the server.','填写要连接的服务端地址。':'Enter the server you want to connect to.','进入工作台后,你可以选择资源来源、应用补丁和安装插件。':'In the workspace you can select a resource source, apply the patch, and install plugins.','目录已选择 · 之后仍可修改':'Directory selected · You can change it later','服务器地址':'Server address','填写完整地址,可以包含端口。':'Enter the complete origin, including a port when needed.','地址与端口':'Address and port','例如':'Example','不要添加':'Do not add','或其他路径。真实连接地址由 LI 插件读取。':'or another path. The LI plugin reads the actual connection address.','进入工作台':'Open workspace','返回目录设置':'Back to directory setup','客户端工作台':'Client workspace','管理连接设置,完成游戏启动前的准备。':'Manage connection settings and prepare the game client.','启动游戏':'Launch game','等待检查':'Waiting','连接设置':'Connection settings','随时可以修改':'Editable at any time','游戏目录':'Game directory','尚未选择':'Not selected','修改目录 ↗':'Change directory ↗','尚未填写':'Not entered','修改地址 ↗':'Change address ↗','资源来源':'Resource source','官方 CDN':'Official CDN','从官方节点下载资源,不占用服主的大文件流量。':'Download from official nodes without using the server owner’s bandwidth.','本机已下载资源':'Downloaded local resources','直接读取电脑上的 ServerData 和 GameData,不再下载相同文件。':'Read ServerData and GameData already stored on this computer.','服主资源':'Server-provided resources','由服务器提供资源地址;服主可自建 CDN,也可以反代官方 CDN。':'The server supplies resource URLs backed by either a private CDN or an official-CDN proxy.','本机 CDN 根目录':'Local CDN root directory','所选目录内必须同时包含':'The selected directory must contain both',' 和 ':' and ','。':'.','官方模式由客户端直连版本锁定的官方 CDN。':'Official mode connects directly to the release-locked official CDN.','验证资源来源 ↗':'Verify resource source ↗','保存连接设置':'Save connection settings','当前设置尚未保存。':'Current settings have not been saved.','补丁与插件':'Patch and plugins','操作前请先关闭游戏。补丁只修改内置 Intro 地址,并保留':'Close the game first. The patch only changes the built-in Intro address and retains a','备份。安装插件前,请先手动安装 BepInEx。':'backup. Install BepInEx manually before installing the plugins.','应用客户端补丁':'Apply client patch','安装 / 更新插件':'Install / update plugins','当前状态':'Current status','游戏程序':'Game executable','资源文件':'Asset file','需手动安装':'Manual install required','连接配置':'Connection configuration','尚未写入':'Not written','状态来自本机文件检查,不代表服务器在线。':'Status is based on local files and does not indicate server availability.','客户端版本:':'Client version:','游戏版本:':'Game version:','诊断日志:':'Diagnostic log:','重新检查 ↻':'Inspect again ↻','最近操作':'Recent operation','等待操作':'Waiting for an operation','选择游戏目录后即可开始检查。':'Select the game directory to begin.','查看操作记录':'View operation history','配置仅保存在你的电脑上':'Settings are stored only on your computer','不会保存账号凭据或服主密钥':'Account credentials and server-owner secrets are never stored','工具已退出':'Tool exited','现在可以关闭这个页面。':'You can close this page now.','选择目录':'Select directory','设置服务器':'Configure server','修改设置':'Edit settings','修改后请在工作台保存连接设置。':'Save changes from the workspace when finished.','完成修改':'Finish editing','请启用 JavaScript 以使用客户端设置工具。':'Enable JavaScript to use the client setup tool.','CDN 类型':'CDN source','客户端状态':'Client status','设置进度':'Setup progress','关闭设置':'Close settings'
}));
const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let node;
while(node=walker.nextNode()){if(node.parentElement&&['SCRIPT','STYLE'].includes(node.parentElement.tagName))continue;let value=node.nodeValue;for(const [source,target] of translations)value=value.replaceAll(source,target);node.nodeValue=value}
@@ -121,7 +121,7 @@ $('install').onclick=()=>action('install',async()=>{requireDirectory();requireOr
$('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()}});
-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 navigateScene('desk')}}}catch(error){show(error.message||String(error),true)}finally{setBusy(false)}}
+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||'';$('clientReleaseVersion').textContent=initial.client_version||'';$('gameReleaseVersion').textContent=initial.game_version||'';$('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 navigateScene('desk')}}}catch(error){show(error.message||String(error),true)}finally{setBusy(false)}}
initialize();
})();
diff --git a/go/internal/client/config/release.go b/go/internal/client/config/release.go
index 6bd5ea9..2d54a47 100644
--- a/go/internal/client/config/release.go
+++ b/go/internal/client/config/release.go
@@ -14,15 +14,18 @@ import (
const ReleaseFileName = "versions.json"
var (
- clientVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
+ gameVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
+ releaseVersionPattern = regexp.MustCompile(`^([0-9]+\.[0-9]+\.[0-9]+)\+(client|server)\.([0-9]+\.[0-9]+\.[0-9]+)$`)
resourceVersionPattern = regexp.MustCompile(`^[0-9]{14}$`)
)
-// ReleaseVersions is the exact client/resource tuple supported by one
-// bd2client distribution. The release package carries the authoritative
-// versions.json next to bd2client.exe.
+// ReleaseVersions separates the official game compatibility version from the
+// independently evolving client and server software release versions. The
+// release package carries the authoritative versions.json beside bd2client.
type ReleaseVersions struct {
+ GameVersion string `json:"game_version"`
ClientVersion string `json:"client_version"`
+ ServerVersion string `json:"server_version"`
GameDataVersion string `json:"game_data_version"`
BundleVersion string `json:"bundle_version"`
SeedDirectory string `json:"seed_directory"`
@@ -48,9 +51,15 @@ func LoadReleaseVersions(path string) (ReleaseVersions, error) {
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return ReleaseVersions{}, errors.New("client release versions must contain exactly one JSON object")
}
- if !clientVersionPattern.MatchString(versions.ClientVersion) {
+ if !gameVersionPattern.MatchString(versions.GameVersion) {
+ return ReleaseVersions{}, fmt.Errorf("invalid game_version %q", versions.GameVersion)
+ }
+ if !validReleaseVersion(versions.ClientVersion, versions.GameVersion, "client") {
return ReleaseVersions{}, fmt.Errorf("invalid client_version %q", versions.ClientVersion)
}
+ if !validReleaseVersion(versions.ServerVersion, versions.GameVersion, "server") {
+ return ReleaseVersions{}, fmt.Errorf("invalid server_version %q", versions.ServerVersion)
+ }
if !resourceVersionPattern.MatchString(versions.BundleVersion) || !resourceVersionPattern.MatchString(versions.GameDataVersion) {
return ReleaseVersions{}, errors.New("bundle_version and game_data_version must be 14-digit timestamps")
}
@@ -61,6 +70,11 @@ func LoadReleaseVersions(path string) (ReleaseVersions, error) {
return versions, nil
}
+func validReleaseVersion(value, game, component string) bool {
+ match := releaseVersionPattern.FindStringSubmatch(value)
+ return len(match) == 4 && match[1] == game && match[2] == component
+}
+
func ReleaseVersionsBesideExecutable() (ReleaseVersions, error) {
executable, err := os.Executable()
if err != nil {
diff --git a/go/internal/client/config/release_test.go b/go/internal/client/config/release_test.go
index 5f8a186..adb4112 100644
--- a/go/internal/client/config/release_test.go
+++ b/go/internal/client/config/release_test.go
@@ -8,7 +8,7 @@ import (
func TestLoadReleaseVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), ReleaseFileName)
- data := `{"client_version":"2.35.10","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
+ data := `{"game_version":"2.35.10","client_version":"2.35.10+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
@@ -16,15 +16,16 @@ func TestLoadReleaseVersions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if got.ClientVersion != "2.35.10" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
+ if got.GameVersion != "2.35.10" || got.ClientVersion != "2.35.10+client.0.1.0" || got.ServerVersion != "2.35.10+server.0.1.0" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
t.Fatalf("versions=%+v", got)
}
}
func TestLoadReleaseVersionsRejectsUnknownAndTrailingData(t *testing.T) {
for name, data := range map[string]string{
- "unknown": `{"client_version":"2.35.10","unknown":true}`,
- "trailing": `{}` + `{}`,
+ "unknown": `{"game_version":"2.35.10","unknown":true}`,
+ "wrong-component": `{"game_version":"2.35.10","client_version":"2.35.10+server.0.1.0","server_version":"2.35.10+server.0.1.0"}`,
+ "trailing": `{}` + `{}`,
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), ReleaseFileName)
diff --git a/go/internal/client/setup/setup.go b/go/internal/client/setup/setup.go
index 7d213a8..20ba186 100644
--- a/go/internal/client/setup/setup.go
+++ b/go/internal/client/setup/setup.go
@@ -69,8 +69,8 @@ func Inspect(gameDir string, versions clientconfig.ReleaseVersions) (GameStatus,
if err != nil {
return status, err
}
- if status.ClientVersion != versions.ClientVersion {
- return status, fmt.Errorf("unsupported Brown Dust II client version %s; this bd2client release requires %s", status.ClientVersion, versions.ClientVersion)
+ if status.ClientVersion != versions.GameVersion {
+ return status, fmt.Errorf("unsupported Brown Dust II client version %s; this bd2client release requires %s", status.ClientVersion, versions.GameVersion)
}
if verified, verifyErr := introdb.VerifyClient(installation.Root); verifyErr == nil {
status.PatchedURL = verified.URL
diff --git a/go/internal/client/setup/setup_test.go b/go/internal/client/setup/setup_test.go
index a0f86b8..a0f748d 100644
--- a/go/internal/client/setup/setup_test.go
+++ b/go/internal/client/setup/setup_test.go
@@ -14,7 +14,7 @@ import (
func testVersions() clientconfig.ReleaseVersions {
return clientconfig.ReleaseVersions{
- ClientVersion: "2.35.10", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
+ GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0", ServerVersion: "2.35.10+server.0.1.0", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
}
}
diff --git a/go/internal/server/accountstate/bootstrap.go b/go/internal/server/accountstate/bootstrap.go
new file mode 100644
index 0000000..15d5b5b
--- /dev/null
+++ b/go/internal/server/accountstate/bootstrap.go
@@ -0,0 +1,101 @@
+package accountstate
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "sort"
+
+ "bd2server/internal/server/stateio"
+)
+
+const bootstrapDomain = "bootstrap"
+
+type InitializationStatus uint8
+
+const (
+ InitializationCorrupt InitializationStatus = iota
+ InitializationPending
+ InitializationComplete
+)
+
+type bootstrapSnapshot struct {
+ Version uint64 `json:"version"`
+}
+
+// InitializationState distinguishes a schema-only database left by an
+// interrupted first start from a complete account. Partial domain state is
+// rejected instead of being guessed or repaired at runtime.
+func (r *Repository) InitializationState(accountDomains ...string) (InitializationStatus, error) {
+ if r == nil {
+ return InitializationCorrupt, errors.New("accountstate: nil repository")
+ }
+ rows, err := r.db.Query(`SELECT name FROM domain_state ORDER BY name`)
+ if err != nil {
+ return InitializationCorrupt, fmt.Errorf("accountstate: list initialization domains: %w", err)
+ }
+ var found []string
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ rows.Close()
+ return InitializationCorrupt, err
+ }
+ found = append(found, name)
+ }
+ if err := rows.Close(); err != nil {
+ return InitializationCorrupt, err
+ }
+ var entryCount uint64
+ if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry`).Scan(&entryCount); err != nil {
+ return InitializationCorrupt, fmt.Errorf("accountstate: count initialization entries: %w", err)
+ }
+ if len(found) == 0 && entryCount == 0 {
+ return InitializationPending, nil
+ }
+ want := append([]string(nil), accountDomains...)
+ want = append(want, bootstrapDomain)
+ sort.Strings(want)
+ if !slices.Equal(found, want) {
+ return InitializationCorrupt, fmt.Errorf("accountstate: domains %v, want %v", found, want)
+ }
+ payload, err := r.Load(bootstrapDomain)
+ if err != nil {
+ return InitializationCorrupt, err
+ }
+ if err := validateBootstrap(payload); err != nil {
+ return InitializationCorrupt, err
+ }
+ return InitializationComplete, nil
+}
+
+// MarkInitializationComplete joins the active startup transaction. It must be
+// written only after every account domain and first-login entitlement exists;
+// a rollback therefore leaves a schema-only database that can be retried.
+func (r *Repository) MarkInitializationComplete() error {
+ payload, err := json.Marshal(bootstrapSnapshot{Version: 1})
+ if err != nil {
+ return err
+ }
+ return r.Save(bootstrapDomain, payload)
+}
+
+func validateBootstrap(payload []byte) error {
+ if len(payload) == 0 {
+ return errors.New("accountstate: missing bootstrap completion marker")
+ }
+ if err := stateio.RequireExactJSONObject(payload, "version"); err != nil {
+ return fmt.Errorf("accountstate: invalid bootstrap marker: %w", err)
+ }
+ var snapshot bootstrapSnapshot
+ if err := json.Unmarshal(payload, &snapshot); err != nil || snapshot.Version != 1 {
+ return errors.New("accountstate: malformed bootstrap completion marker")
+ }
+ canonical, _ := json.Marshal(snapshot)
+ if !bytes.Equal(payload, canonical) {
+ return errors.New("accountstate: non-canonical bootstrap completion marker")
+ }
+ return nil
+}
diff --git a/go/internal/server/accountstate/bootstrap_test.go b/go/internal/server/accountstate/bootstrap_test.go
new file mode 100644
index 0000000..309670c
--- /dev/null
+++ b/go/internal/server/accountstate/bootstrap_test.go
@@ -0,0 +1,97 @@
+package accountstate
+
+import (
+ "path/filepath"
+ "testing"
+)
+
+func TestInitializationMarkerMakesSchemaOnlyRollbackRetryable(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.db")
+ repository, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
+ t.Fatalf("initial state=%d err=%v", state, err)
+ }
+ operation, err := repository.BeginOperation()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.Save("wallet", []byte(`{"version":1}`)); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.MarkInitializationComplete(); err != nil {
+ t.Fatal(err)
+ }
+ if err := operation.Rollback(); err == nil {
+ t.Fatal("dirty rollback did not require repository reopen")
+ }
+ if err := repository.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ repository, err = Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer repository.Close()
+ if repository.IsNew() {
+ t.Fatal("schema-only retry incorrectly depends on file creation")
+ }
+ if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
+ t.Fatalf("post-rollback state=%d err=%v", state, err)
+ }
+}
+
+func TestInitializationMarkerCommitsCompleteAccountExactlyOnce(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.db")
+ repository, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ operation, err := repository.BeginOperation()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, domain := range []string{"mail", "wallet"} {
+ if err := repository.Save(domain, []byte(`{"version":1}`)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := repository.MarkInitializationComplete(); err != nil {
+ t.Fatal(err)
+ }
+ if err := operation.Commit(); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ repository, err = Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer repository.Close()
+ if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationComplete {
+ t.Fatalf("committed state=%d err=%v", state, err)
+ }
+}
+
+func TestInitializationStateRejectsPartialAccount(t *testing.T) {
+ repository, err := Open(filepath.Join(t.TempDir(), "state.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer repository.Close()
+ if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
+ t.Fatal(err)
+ }
+ if state, err := repository.InitializationState("mail", "wallet"); err == nil || state != InitializationCorrupt {
+ t.Fatalf("partial state=%d err=%v", state, err)
+ }
+}
diff --git a/go/internal/server/accountstate/repository.go b/go/internal/server/accountstate/repository.go
index 259cc2b..a4af9a9 100644
--- a/go/internal/server/accountstate/repository.go
+++ b/go/internal/server/accountstate/repository.go
@@ -18,21 +18,27 @@ import (
_ "modernc.org/sqlite"
)
-const schemaVersion = 2
+const schemaVersion = 3
var ErrClosed = errors.New("accountstate: transaction already finished")
+var ErrFenced = stateio.ErrWriterFenced
+var ErrWriterLocked = errors.New("accountstate: state database is already owned by another writer")
// Repository owns one SQLite connection. A request or a complete batch holds
// that connection from Begin until Commit or Rollback, serializing writers.
type Repository struct {
- db *sql.DB
- new bool
+ db *sql.DB
+ new bool
+ writerEpoch int64
+ writerLock *writerLock
- mu sync.Mutex
- failed error
- opMu sync.Mutex
- activeMu sync.RWMutex
- active *Tx
+ mu sync.Mutex
+ failed error
+ opMu sync.Mutex
+ activeMu sync.RWMutex
+ active *Tx
+ closeOnce sync.Once
+ closeErr error
}
var _ stateio.TransactionalStore = (*Repository)(nil)
@@ -44,14 +50,23 @@ func Open(path string) (_ *Repository, err error) {
return nil, errors.New("accountstate: path must end in state.db")
}
path = filepath.Clean(path)
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return nil, fmt.Errorf("accountstate: create state directory: %w", err)
+ }
+ writerLock, err := acquireWriterLock(path + ".lock")
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ err = errors.Join(err, writerLock.release())
+ }
+ }()
_, statErr := os.Stat(path)
fresh := errors.Is(statErr, os.ErrNotExist)
if statErr != nil && !fresh {
return nil, fmt.Errorf("accountstate: inspect database: %w", statErr)
}
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return nil, fmt.Errorf("accountstate: create state directory: %w", err)
- }
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("accountstate: open database: %w", err)
@@ -80,7 +95,35 @@ func Open(path string) (_ *Repository, err error) {
if _, err = db.ExecContext(ctx, "PRAGMA busy_timeout=5000"); err != nil {
return nil, fmt.Errorf("accountstate: set busy timeout: %w", err)
}
- return &Repository{db: db, new: fresh}, nil
+ epoch, err := claimWriterEpoch(ctx, db)
+ if err != nil {
+ return nil, err
+ }
+ return &Repository{db: db, new: fresh, writerEpoch: epoch, writerLock: writerLock}, nil
+}
+
+func claimWriterEpoch(ctx context.Context, db *sql.DB) (int64, error) {
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, fmt.Errorf("accountstate: begin writer claim: %w", err)
+ }
+ defer tx.Rollback()
+ var raw string
+ if err := tx.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&raw); err != nil {
+ return 0, fmt.Errorf("accountstate: read writer epoch: %w", err)
+ }
+ current, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || current < 0 || current == int64(^uint64(0)>>1) {
+ return 0, fmt.Errorf("accountstate: invalid writer epoch %q", raw)
+ }
+ next := current + 1
+ if _, err := tx.ExecContext(ctx, `UPDATE metadata SET value=? WHERE key='writer_epoch'`, strconv.FormatInt(next, 10)); err != nil {
+ return 0, fmt.Errorf("accountstate: advance writer epoch: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, fmt.Errorf("accountstate: commit writer claim: %w", err)
+ }
+ return next, nil
}
// SchemaVersion returns the on-disk version after all startup migrations.
@@ -134,6 +177,16 @@ func (r *Repository) Begin(ctx context.Context) (*Tx, error) {
if err != nil {
return nil, fmt.Errorf("accountstate: begin transaction: %w", err)
}
+ var rawEpoch string
+ if err := tx.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&rawEpoch); err != nil {
+ _ = tx.Rollback()
+ return nil, fmt.Errorf("accountstate: verify writer epoch: %w", err)
+ }
+ epoch, parseErr := strconv.ParseInt(rawEpoch, 10, 64)
+ if parseErr != nil || epoch != r.writerEpoch {
+ _ = tx.Rollback()
+ return nil, fmt.Errorf("%w: process=%d database=%q", ErrFenced, r.writerEpoch, rawEpoch)
+ }
if err := r.Check(); err != nil {
_ = tx.Rollback()
return nil, err
@@ -153,12 +206,18 @@ func (r *Repository) fail(err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.failed == nil {
- r.failed = fmt.Errorf("accountstate: transaction outcome uncertain; reopen database: %w", err)
+ r.failed = fmt.Errorf("%w: accountstate transaction outcome uncertain; reopen database: %v", stateio.ErrStateRecoveryRequired, err)
}
}
-// Close releases the SQLite connection. All transactions must be finished first.
-func (r *Repository) Close() error { return r.db.Close() }
+// Close releases the SQLite connection and then the cross-process writer lock.
+// All transactions must be finished first.
+func (r *Repository) Close() error {
+ r.closeOnce.Do(func() {
+ r.closeErr = errors.Join(r.db.Close(), r.writerLock.release())
+ })
+ return r.closeErr
+}
// LoadContext reads a domain outside a request transaction.
func (r *Repository) LoadContext(ctx context.Context, name string) ([]byte, int64, bool, error) {
diff --git a/go/internal/server/accountstate/repository_test.go b/go/internal/server/accountstate/repository_test.go
index a0865f1..c43bbf4 100644
--- a/go/internal/server/accountstate/repository_test.go
+++ b/go/internal/server/accountstate/repository_test.go
@@ -215,9 +215,48 @@ func TestOperationCommitAndCleanRollback(t *testing.T) {
requireState(t, r, "deck", []byte("committed"), 1)
}
+func TestSecondRepositoryIsRejectedUntilWriterCloses(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.db")
+ first, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second, err := Open(path); !errors.Is(err, ErrWriterLocked) {
+ if second != nil {
+ _ = second.Close()
+ }
+ t.Fatalf("second Open error=%v, want ErrWriterLocked", err)
+ }
+ var epoch int64
+ if err := first.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key = 'writer_epoch'`).Scan(&epoch); err != nil {
+ t.Fatal(err)
+ }
+ if epoch != first.writerEpoch {
+ t.Fatalf("rejected Open advanced writer epoch to %d, want %d", epoch, first.writerEpoch)
+ }
+ if err := first.Close(); err != nil {
+ t.Fatal(err)
+ }
+ second, err := Open(path)
+ if err != nil {
+ t.Fatalf("Open after writer Close: %v", err)
+ }
+ defer second.Close()
+}
+
+func TestWriterEpochStillFencesStaleRepositoryBeforeMutation(t *testing.T) {
+ r, _ := openTestRepository(t)
+ if _, err := r.db.Exec(`UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'writer_epoch'`); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := r.BeginOperation(); !errors.Is(err, ErrFenced) {
+ t.Fatalf("stale writer error=%v, want ErrFenced", err)
+ }
+}
+
func TestSchemaVersionRejected(t *testing.T) {
r, path := openTestRepository(t)
- if _, err := r.db.Exec(`UPDATE metadata SET value = '3' WHERE key = 'schema_version'`); err != nil {
+ if _, err := r.db.Exec(`UPDATE metadata SET value = '4' WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
diff --git a/go/internal/server/accountstate/schema.go b/go/internal/server/accountstate/schema.go
index 2df229b..6986121 100644
--- a/go/internal/server/accountstate/schema.go
+++ b/go/internal/server/accountstate/schema.go
@@ -17,6 +17,15 @@ type migration struct {
var schemaMigrations = []migration{
{from: 1, to: 2, up: migrateV1ToV2},
+ {from: 2, to: 3, up: migrateV2ToV3},
+}
+
+func migrateV2ToV3(ctx context.Context, tx *sql.Tx) error {
+ _, err := tx.ExecContext(ctx, `INSERT INTO metadata(key,value) VALUES('writer_epoch','0') ON CONFLICT(key) DO NOTHING`)
+ if err != nil {
+ return fmt.Errorf("add writer epoch: %w", err)
+ }
+ return nil
}
// initialize creates schema v1 for a new database, applies every adjacent Go
diff --git a/go/internal/server/accountstate/schema_test.go b/go/internal/server/accountstate/schema_test.go
index 29ac503..8316c96 100644
--- a/go/internal/server/accountstate/schema_test.go
+++ b/go/internal/server/accountstate/schema_test.go
@@ -68,7 +68,34 @@ func TestMigrationV1ToV2IsRepeatSafe(t *testing.T) {
}
}
-func TestOpenMigratesV1ToV2(t *testing.T) {
+func TestMigrationV2ToV3IsRepeatSafe(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.db")
+ createV1Database(t, path, "", "")
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+ tx, err := db.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer tx.Rollback()
+ if err := migrateV1ToV2(context.Background(), tx); err != nil {
+ t.Fatal(err)
+ }
+ for range 2 {
+ if err := migrateV2ToV3(context.Background(), tx); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var epoch string
+ if err := tx.QueryRow(`SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&epoch); err != nil || epoch != "0" {
+ t.Fatalf("writer epoch=%q err=%v", epoch, err)
+ }
+}
+
+func TestOpenMigratesV1ToCurrent(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "", "")
r, err := Open(path)
@@ -77,7 +104,7 @@ func TestOpenMigratesV1ToV2(t *testing.T) {
}
defer r.Close()
version, err := r.SchemaVersion()
- if err != nil || version != 2 {
+ if err != nil || version != schemaVersion {
t.Fatalf("schema version %d, error %v", version, err)
}
}
diff --git a/go/internal/server/accountstate/writer_lock_unix.go b/go/internal/server/accountstate/writer_lock_unix.go
new file mode 100644
index 0000000..4a2c87f
--- /dev/null
+++ b/go/internal/server/accountstate/writer_lock_unix.go
@@ -0,0 +1,51 @@
+//go:build linux || darwin
+
+package accountstate
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "sync"
+
+ "golang.org/x/sys/unix"
+)
+
+type writerLock struct {
+ file *os.File
+ once sync.Once
+ err error
+}
+
+func acquireWriterLock(path string) (*writerLock, error) {
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("accountstate: open writer lock: %w", err)
+ }
+ if err := unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
+ _ = file.Close()
+ if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) {
+ return nil, fmt.Errorf("%w: %s", ErrWriterLocked, path)
+ }
+ return nil, fmt.Errorf("accountstate: acquire writer lock: %w", err)
+ }
+ return &writerLock{file: file}, nil
+}
+
+func (l *writerLock) release() error {
+ if l == nil {
+ return nil
+ }
+ l.once.Do(func() {
+ unlockErr := unix.Flock(int(l.file.Fd()), unix.LOCK_UN)
+ closeErr := l.file.Close()
+ if unlockErr != nil {
+ unlockErr = fmt.Errorf("accountstate: release writer lock: %w", unlockErr)
+ }
+ if closeErr != nil {
+ closeErr = fmt.Errorf("accountstate: close writer lock: %w", closeErr)
+ }
+ l.err = errors.Join(unlockErr, closeErr)
+ })
+ return l.err
+}
diff --git a/go/internal/server/accountstate/writer_lock_windows.go b/go/internal/server/accountstate/writer_lock_windows.go
new file mode 100644
index 0000000..6ce3384
--- /dev/null
+++ b/go/internal/server/accountstate/writer_lock_windows.go
@@ -0,0 +1,61 @@
+//go:build windows
+
+package accountstate
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "sync"
+
+ "golang.org/x/sys/windows"
+)
+
+type writerLock struct {
+ file *os.File
+ overlapped windows.Overlapped
+ once sync.Once
+ err error
+}
+
+func acquireWriterLock(path string) (*writerLock, error) {
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("accountstate: open writer lock: %w", err)
+ }
+ lock := &writerLock{file: file}
+ err = windows.LockFileEx(
+ windows.Handle(file.Fd()),
+ windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
+ 0,
+ 1,
+ 0,
+ &lock.overlapped,
+ )
+ if err != nil {
+ _ = file.Close()
+ if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
+ return nil, fmt.Errorf("%w: %s", ErrWriterLocked, path)
+ }
+ return nil, fmt.Errorf("accountstate: acquire writer lock: %w", err)
+ }
+ return lock, nil
+}
+
+func (l *writerLock) release() error {
+ if l == nil {
+ return nil
+ }
+ l.once.Do(func() {
+ unlockErr := windows.UnlockFileEx(windows.Handle(l.file.Fd()), 0, 1, 0, &l.overlapped)
+ closeErr := l.file.Close()
+ if unlockErr != nil {
+ unlockErr = fmt.Errorf("accountstate: release writer lock: %w", unlockErr)
+ }
+ if closeErr != nil {
+ closeErr = fmt.Errorf("accountstate: close writer lock: %w", closeErr)
+ }
+ l.err = errors.Join(unlockErr, closeErr)
+ })
+ return l.err
+}
diff --git a/go/internal/server/auth/service.go b/go/internal/server/auth/service.go
index baeac66..06eb4b7 100644
--- a/go/internal/server/auth/service.go
+++ b/go/internal/server/auth/service.go
@@ -50,6 +50,14 @@ type deviceResult struct {
RefreshExpiresIn int64 `json:"refresh_expires_in"`
}
+type refreshAttemptResult struct {
+ Provider string `json:"provider"`
+ AccessToken string `json:"access_token"`
+ AccessExpiresAt int64 `json:"access_expires_at"`
+ RefreshToken string `json:"refresh_token"`
+ RefreshExpiresAt int64 `json:"refresh_expires_at"`
+}
+
func New(config authconfig.Runtime, store *Store) (*Service, error) {
if config.Mode != "oauth" || store == nil {
return nil, errors.New("auth: OAuth service requires oauth configuration and store")
@@ -731,6 +739,7 @@ func cleanupExpired(tx *sql.Tx, now int64) error {
}{
{`DELETE FROM devices WHERE expires_at<=?`, []any{now}},
{`DELETE FROM access_tokens WHERE expires_at<=? OR family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now, now}},
+ {`DELETE FROM refresh_attempts WHERE expires_at<=? OR family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now, now}},
// Used refresh rows remain until their family expires so their reuse can
// still revoke every credential in that family.
{`DELETE FROM refresh_tokens WHERE family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now}},
@@ -747,25 +756,69 @@ func cleanupExpired(tx *sql.Tx, now int64) error {
func (s *Service) refresh(w http.ResponseWriter, r *http.Request) {
var request struct {
RefreshToken string `json:"refresh_token"`
+ AttemptID string `json:"attempt_id"`
}
- if !decodeJSON(w, r, &request) || request.RefreshToken == "" {
+ if !decodeJSON(w, r, &request) {
+ return
+ }
+ if request.RefreshToken == "" || !validRefreshAttemptID(request.AttemptID) {
+ http.Error(w, "refresh_token and valid attempt_id required", http.StatusBadRequest)
return
}
now := s.store.now()
+ requestTokenHash := s.store.digest("refresh-token", request.RefreshToken)
+ attemptHash := s.store.digest("refresh-attempt", request.AttemptID)
tx, err := s.store.db.Begin()
if err != nil {
http.Error(w, "refresh unavailable", http.StatusInternalServerError)
return
}
defer tx.Rollback()
+ if err := cleanupExpired(tx, now.Unix()); err != nil {
+ http.Error(w, "refresh unavailable", http.StatusInternalServerError)
+ return
+ }
var familyID, accountID, provider, accountStatus string
var tokenExpires, familyExpires int64
var usedAt, revokedAt sql.NullInt64
- err = tx.QueryRow(`SELECT r.family_id,f.account_id,f.provider,a.status,r.expires_at,f.expires_at,r.used_at,COALESCE(r.revoked_at,f.revoked_at) FROM refresh_tokens r JOIN families f ON f.id=r.family_id JOIN accounts a ON a.id=f.account_id WHERE r.token_hash=?`, s.store.digest("refresh-token", request.RefreshToken)).Scan(&familyID, &accountID, &provider, &accountStatus, &tokenExpires, &familyExpires, &usedAt, &revokedAt)
- if err != nil || revokedAt.Valid || accountStatus != "active" || now.Unix() >= tokenExpires || now.Unix() >= familyExpires {
+ err = tx.QueryRow(`SELECT r.family_id,f.account_id,f.provider,a.status,r.expires_at,f.expires_at,r.used_at,COALESCE(r.revoked_at,f.revoked_at) FROM refresh_tokens r JOIN families f ON f.id=r.family_id JOIN accounts a ON a.id=f.account_id WHERE r.token_hash=?`, requestTokenHash).Scan(&familyID, &accountID, &provider, &accountStatus, &tokenExpires, &familyExpires, &usedAt, &revokedAt)
+ if err != nil || revokedAt.Valid || accountStatus != "active" || now.Unix() >= familyExpires {
+ w.Header().Set("X-BD2-Refresh-Invalid", "1")
http.Error(w, "refresh token invalid", http.StatusUnauthorized)
return
}
+ var savedRequestHash, resultCipher []byte
+ err = tx.QueryRow(`SELECT request_token_hash,result_cipher FROM refresh_attempts WHERE family_id=? AND attempt_hash=?`, familyID, attemptHash).Scan(&savedRequestHash, &resultCipher)
+ if err == nil {
+ if subtle.ConstantTimeCompare(savedRequestHash, requestTokenHash) != 1 {
+ w.Header().Set("X-BD2-Refresh-Invalid", "1")
+ http.Error(w, "refresh attempt_id already belongs to another request", http.StatusConflict)
+ return
+ }
+ plain, openErr := s.store.open(refreshAttemptSealID(familyID, attemptHash), "result", resultCipher)
+ if openErr != nil {
+ http.Error(w, "refresh unavailable", http.StatusInternalServerError)
+ return
+ }
+ var saved refreshAttemptResult
+ decodeErr := json.Unmarshal(plain, &saved)
+ clear(plain)
+ if decodeErr != nil || saved.Provider == "" || saved.AccessToken == "" || saved.RefreshToken == "" {
+ http.Error(w, "refresh unavailable", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, saved.deviceResult(now.Unix()))
+ return
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ http.Error(w, "refresh unavailable", http.StatusInternalServerError)
+ return
+ }
+ if now.Unix() >= tokenExpires {
+ w.Header().Set("X-BD2-Refresh-Invalid", "1")
+ http.Error(w, "refresh token expired", http.StatusUnauthorized)
+ return
+ }
if usedAt.Valid {
if _, err := tx.Exec(`UPDATE families SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, now.Unix(), familyID); err != nil {
http.Error(w, "refresh unavailable", http.StatusInternalServerError)
@@ -775,6 +828,7 @@ func (s *Service) refresh(w http.ResponseWriter, r *http.Request) {
http.Error(w, "refresh unavailable", http.StatusInternalServerError)
return
}
+ w.Header().Set("X-BD2-Refresh-Invalid", "1")
http.Error(w, "refresh token replayed", http.StatusUnauthorized)
return
}
@@ -788,12 +842,13 @@ func (s *Service) refresh(w http.ResponseWriter, r *http.Request) {
http.Error(w, "refresh unavailable", http.StatusInternalServerError)
return
}
- updated, err := tx.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL`, now.Unix(), s.store.digest("refresh-token", request.RefreshToken))
+ updated, err := tx.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL`, now.Unix(), requestTokenHash)
if err != nil {
http.Error(w, "refresh unavailable", http.StatusInternalServerError)
return
}
if count, err := rowsAffected(updated); err != nil || count != 1 {
+ w.Header().Set("X-BD2-Refresh-Invalid", "1")
http.Error(w, "refresh token invalid", http.StatusUnauthorized)
return
}
@@ -810,11 +865,67 @@ func (s *Service) refresh(w http.ResponseWriter, r *http.Request) {
http.Error(w, "refresh unavailable", http.StatusInternalServerError)
return
}
+ result := refreshAttemptResult{
+ Provider: provider, AccessToken: newAccess, AccessExpiresAt: now.Add(s.config.AccessTTL).Unix(),
+ RefreshToken: newRefresh, RefreshExpiresAt: refreshExpiry,
+ }
+ plain, err := json.Marshal(result)
+ if err != nil {
+ http.Error(w, "refresh unavailable", http.StatusInternalServerError)
+ return
+ }
+ resultCipher, err = s.store.seal(refreshAttemptSealID(familyID, attemptHash), "result", plain)
+ clear(plain)
+ if err != nil {
+ http.Error(w, "refresh unavailable", http.StatusInternalServerError)
+ return
+ }
+ if _, err = tx.Exec(`INSERT INTO refresh_attempts(family_id,attempt_hash,request_token_hash,result_cipher,created_at,expires_at) VALUES(?,?,?,?,?,?)`, familyID, attemptHash, requestTokenHash, resultCipher, now.Unix(), refreshExpiry); err != nil {
+ http.Error(w, "refresh unavailable", http.StatusInternalServerError)
+ return
+ }
if err = tx.Commit(); err != nil {
http.Error(w, "refresh unavailable", http.StatusInternalServerError)
return
}
- writeJSON(w, http.StatusOK, deviceResult{Provider: provider, AccessToken: newAccess, AccessExpiresIn: int64(s.config.AccessTTL.Seconds()), RefreshToken: newRefresh, RefreshExpiresIn: refreshExpiry - now.Unix()})
+ writeJSON(w, http.StatusOK, result.deviceResult(now.Unix()))
+}
+
+func validRefreshAttemptID(value string) bool {
+ if len(value) < 16 || len(value) > 128 {
+ return false
+ }
+ for _, item := range value {
+ if item < 'a' || item > 'z' {
+ if item < 'A' || item > 'Z' {
+ if item < '0' || item > '9' {
+ if item != '-' && item != '_' {
+ return false
+ }
+ }
+ }
+ }
+ }
+ return true
+}
+
+func refreshAttemptSealID(familyID string, attemptHash []byte) string {
+ return familyID + ":" + base64.RawURLEncoding.EncodeToString(attemptHash)
+}
+
+func (r refreshAttemptResult) deviceResult(now int64) deviceResult {
+ accessTTL := r.AccessExpiresAt - now
+ if accessTTL < 0 {
+ accessTTL = 0
+ }
+ refreshTTL := r.RefreshExpiresAt - now
+ if refreshTTL < 0 {
+ refreshTTL = 0
+ }
+ return deviceResult{
+ Provider: r.Provider, AccessToken: r.AccessToken, AccessExpiresIn: accessTTL,
+ RefreshToken: r.RefreshToken, RefreshExpiresIn: refreshTTL,
+ }
}
func (s *Service) revoke(w http.ResponseWriter, r *http.Request) {
diff --git a/go/internal/server/auth/service_test.go b/go/internal/server/auth/service_test.go
index f5515a0..e2a2455 100644
--- a/go/internal/server/auth/service_test.go
+++ b/go/internal/server/auth/service_test.go
@@ -147,7 +147,7 @@ func TestRefreshRotationReplayRevokesFamily(t *testing.T) {
service, store := testService(t)
first := completeAndPoll(t, service, store, "refresh-device", "discord", "https://discord.com", "123456789")
handler := service.Handler()
- response := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken})
+ response := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "rotation-attempt-0001"})
if response.Code != http.StatusOK {
t.Fatalf("refresh status=%d body=%q", response.Code, response.Body.String())
}
@@ -165,14 +165,14 @@ func TestRefreshRotationReplayRevokesFamily(t *testing.T) {
t.Fatalf("new access token rejected: %v", err)
}
- replay := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken})
+ replay := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "replay-attempt-000002"})
if replay.Code != http.StatusUnauthorized {
t.Fatalf("replay status=%d body=%q", replay.Code, replay.Body.String())
}
if _, err := service.ValidateAccess(rotated.AccessToken); err == nil {
t.Fatal("refresh replay did not revoke the token family")
}
- next := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": rotated.RefreshToken})
+ next := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": rotated.RefreshToken, "attempt_id": "after-replay-attempt-3"})
if next.Code != http.StatusUnauthorized {
t.Fatalf("family refresh after replay status=%d", next.Code)
}
@@ -191,12 +191,92 @@ func TestRevokeInvalidatesAccessAndRefreshFamily(t *testing.T) {
if _, err := service.ValidateAccess(tokens.AccessToken); err == nil {
t.Fatal("revoked access token remained valid")
}
- refresh := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken})
+ refresh := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken, "attempt_id": "revoked-attempt-0001"})
if refresh.Code != http.StatusUnauthorized {
t.Fatalf("revoked refresh status=%d", refresh.Code)
}
}
+func TestRefreshRetryWithSameAttemptReturnsCommittedRotation(t *testing.T) {
+ service, store := testService(t)
+ first := completeAndPoll(t, service, store, "refresh-retry-device", "discord", "https://discord.com", "123456789")
+ handler := service.Handler()
+ request := map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "stable-attempt-000001"}
+ response := postJSON(handler, "/auth/session/refresh", request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("first refresh status=%d body=%q", response.Code, response.Body.String())
+ }
+ var rotated deviceResult
+ if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
+ t.Fatal(err)
+ }
+ retry := postJSON(handler, "/auth/session/refresh", request)
+ if retry.Code != http.StatusOK {
+ t.Fatalf("retry status=%d body=%q", retry.Code, retry.Body.String())
+ }
+ var replayed deviceResult
+ if err := json.Unmarshal(retry.Body.Bytes(), &replayed); err != nil {
+ t.Fatal(err)
+ }
+ if replayed.Provider != rotated.Provider || replayed.AccessToken != rotated.AccessToken || replayed.RefreshToken != rotated.RefreshToken {
+ t.Fatalf("retry returned a different rotation: first=%+v retry=%+v", rotated, replayed)
+ }
+ if _, err := service.ValidateAccess(rotated.AccessToken); err != nil {
+ t.Fatalf("idempotent retry revoked the family: %v", err)
+ }
+ var sealed []byte
+ if err := store.db.QueryRow(`SELECT result_cipher FROM refresh_attempts`).Scan(&sealed); err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(sealed, []byte(rotated.AccessToken)) || bytes.Contains(sealed, []byte(rotated.RefreshToken)) {
+ t.Fatal("refresh attempt result was stored outside AES-GCM ciphertext")
+ }
+}
+
+func TestRefreshRequiresStableAttemptID(t *testing.T) {
+ service, store := testService(t)
+ tokens := completeAndPoll(t, service, store, "refresh-attempt-required", "discord", "https://discord.com", "123456789")
+ response := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken})
+ if response.Code != http.StatusBadRequest {
+ t.Fatalf("missing attempt status=%d body=%q", response.Code, response.Body.String())
+ }
+ if _, err := service.ValidateAccess(tokens.AccessToken); err != nil {
+ t.Fatalf("malformed refresh request changed credential family: %v", err)
+ }
+}
+
+func TestRefreshCommittedAttemptReplaysAfterRequestTokenExpiry(t *testing.T) {
+ service, store := testService(t)
+ first := completeAndPoll(t, service, store, "refresh-expiry-device", "discord", "https://discord.com", "123456789")
+ request := map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "expiry-replay-attempt-01"}
+ response := postJSON(service.Handler(), "/auth/session/refresh", request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("first refresh status=%d body=%q", response.Code, response.Body.String())
+ }
+ var rotated deviceResult
+ if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.db.Exec(`UPDATE refresh_tokens SET expires_at=? WHERE token_hash=?`, testNowUnix-1, store.digest("refresh-token", first.RefreshToken)); err != nil {
+ t.Fatal(err)
+ }
+ store.now = func() time.Time { return time.Unix(testNowUnix+int64((16*time.Minute).Seconds()), 0) }
+ replay := postJSON(service.Handler(), "/auth/session/refresh", request)
+ if replay.Code != http.StatusOK {
+ t.Fatalf("expired request token replay status=%d body=%q", replay.Code, replay.Body.String())
+ }
+ var replayed deviceResult
+ if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil {
+ t.Fatal(err)
+ }
+ if replayed.AccessToken != rotated.AccessToken || replayed.RefreshToken != rotated.RefreshToken {
+ t.Fatalf("replay changed committed rotation: first=%+v replay=%+v", rotated, replayed)
+ }
+ if replayed.AccessExpiresIn != 0 {
+ t.Fatalf("expired replay access TTL=%d, want 0", replayed.AccessExpiresIn)
+ }
+}
+
func TestSensitiveAuthenticationMaterialIsNotStoredInPlaintext(t *testing.T) {
service, store := testService(t)
handler := service.Handler()
diff --git a/go/internal/server/auth/store.go b/go/internal/server/auth/store.go
index 262704f..62a9c1d 100644
--- a/go/internal/server/auth/store.go
+++ b/go/internal/server/auth/store.go
@@ -18,7 +18,7 @@ import (
_ "modernc.org/sqlite"
)
-const schemaVersion = 1
+const schemaVersion = 2
var (
ErrUnauthorized = errors.New("auth: unauthorized")
@@ -84,16 +84,38 @@ func Open(path string, masterKey []byte) (*Store, error) {
var version int
err = tx.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
- if _, err = tx.Exec(`INSERT INTO metadata(key,value) VALUES('schema_version',?)`, schemaVersion); err != nil {
+ if _, err = tx.Exec(`INSERT INTO metadata(key,value) VALUES('schema_version',1)`); err != nil {
return nil, err
}
- version = schemaVersion
+ version = 1
} else if err != nil {
return nil, err
}
- if version != schemaVersion {
+ if version < 1 || version > schemaVersion {
return nil, fmt.Errorf("auth: schema version %d, want %d", version, schemaVersion)
}
+ for version < schemaVersion {
+ switch version {
+ case 1:
+ if _, err = tx.Exec(`CREATE TABLE refresh_attempts (
+ family_id TEXT NOT NULL REFERENCES families(id),
+ attempt_hash BLOB NOT NULL,
+ request_token_hash BLOB NOT NULL,
+ result_cipher BLOB NOT NULL,
+ created_at INTEGER NOT NULL,
+ expires_at INTEGER NOT NULL,
+ PRIMARY KEY(family_id,attempt_hash)
+ ) WITHOUT ROWID`); err != nil {
+ return nil, fmt.Errorf("auth: migrate schema 1->2: %w", err)
+ }
+ version = 2
+ default:
+ return nil, fmt.Errorf("auth: missing adjacent migration %d->%d", version, version+1)
+ }
+ if _, err = tx.Exec(`UPDATE metadata SET value=? WHERE key='schema_version'`, version); err != nil {
+ return nil, fmt.Errorf("auth: record schema version %d: %w", version, err)
+ }
+ }
if err = tx.Commit(); err != nil {
return nil, err
}
diff --git a/go/internal/server/auth/store_test.go b/go/internal/server/auth/store_test.go
index 073b446..9667181 100644
--- a/go/internal/server/auth/store_test.go
+++ b/go/internal/server/auth/store_test.go
@@ -2,6 +2,7 @@ package auth
import (
"bytes"
+ "database/sql"
"path/filepath"
"testing"
)
@@ -23,6 +24,44 @@ func TestStoreRequiresAndClearsExactMasterKey(t *testing.T) {
}
}
+func TestStoreMigratesSchemaV1ToV2(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "auth.db")
+ store, err := Open(path, bytes.Repeat([]byte{0x61}, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Close(); err != nil {
+ t.Fatal(err)
+ }
+ db, err := sql.Open("sqlite", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`DROP TABLE refresh_attempts; UPDATE metadata SET value='1' WHERE key='schema_version'`); err != nil {
+ db.Close()
+ t.Fatal(err)
+ }
+ if err := db.Close(); err != nil {
+ t.Fatal(err)
+ }
+ reopened, err := Open(path, bytes.Repeat([]byte{0x61}, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reopened.Close()
+ var version int
+ if err := reopened.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil {
+ t.Fatal(err)
+ }
+ if version != 2 {
+ t.Fatalf("schema_version=%d, want 2", version)
+ }
+ var table string
+ if err := reopened.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_attempts'`).Scan(&table); err != nil {
+ t.Fatal(err)
+ }
+}
+
func TestStorePersistsExplicitSchemaVersionAndRejectsUnknownVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.db")
store, err := Open(path, bytes.Repeat([]byte{0x35}, 32))
diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go
index 78c5d5c..4238edb 100644
--- a/go/internal/server/battle/battle.go
+++ b/go/internal/server/battle/battle.go
@@ -16,13 +16,8 @@ import (
type Service struct {
mu sync.Mutex
- entered bool
- index uint64
- round uint64
- monster uint64
- deck uint64
- pack int
- initialBlue [][]byte
+ states map[string]*battleState
+ activeSession string
gameDataRoot string
gameDataVersion string
inventory *player.Inventory
@@ -32,12 +27,49 @@ type Service struct {
onTutorialWin func() error
}
+type battleState struct {
+ entered bool
+ index uint64
+ round uint64
+ monster uint64
+ deck uint64
+ pack int
+ initialBlue [][]byte
+}
+
+// BeginSession discards an unfinished battle when LoginUser creates a new
+// game session. Persistent rewards are written only by a successful BattleEnd
+// request transaction, so reconnect returns to the last pre-battle commit.
+func (s *Service) BeginSession(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if id == "" {
+ return
+ }
+ if s.states == nil {
+ s.states = make(map[string]*battleState)
+ }
+ if s.states[id] == nil {
+ if len(s.states) >= 1024 {
+ for key := range s.states {
+ if key != id {
+ delete(s.states, key)
+ break
+ }
+ }
+ }
+ s.states[id] = &battleState{}
+ }
+ s.activeSession = id
+}
+
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory, currentPack func() (int, error)) *Service {
return &Service{
gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion,
inventory: inventory, currentPack: currentPack, loadRewards: gamedata.BattleDeckRewards,
+ states: make(map[string]*battleState),
}
}
@@ -62,9 +94,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
s.mu.Lock()
defer s.mu.Unlock()
+ state := s.stateLocked()
switch path {
case "/BattleVerifyState":
- if !s.entered {
+ if !state.entered {
return 0, nil, true, errors.New("battle: verify before enter")
}
// Packet code 142 follows BattleVerify(141). State 3 is the protocol's
@@ -109,42 +142,42 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
// The local engine is the normal deterministic engine.
response = wire.AppendVarint(response, 6, 1)
- s.entered, s.index, s.round, s.initialBlue = true, 0, 0, nil
- s.monster, s.deck, s.pack = monster, deck, packID
+ state.entered, state.index, state.round, state.initialBlue = true, 0, 0, nil
+ state.monster, state.deck, state.pack = monster, deck, packID
slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode)
return 52, response, true, nil
case "/BattleRetry":
- if !s.entered {
+ if !state.entered {
return 0, nil, true, errors.New("battle: retry before enter")
}
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("battle: retry missing battle index")
}
- if len(s.initialBlue) == 0 {
+ if len(state.initialBlue) == 0 {
return 0, nil, true, errors.New("battle: retry before initial battle state")
}
var response []byte
- for _, character := range s.initialBlue {
+ for _, character := range state.initialBlue {
response = wire.AppendBytes(response, 2, character)
}
response = wire.AppendVarint(response, 3, index)
- s.index, s.round = index, 0
+ state.index, state.round = index, 0
return 58, response, true, nil
case "/BattleStart":
- if !s.entered {
+ if !state.entered {
return 0, nil, true, errors.New("battle: start before enter")
}
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("battle: invalid battle index")
}
- if s.index != 0 && index != s.index {
- return 0, nil, true, fmt.Errorf("battle: index changed from %d to %d", s.index, index)
+ if state.index != 0 && index != state.index {
+ return 0, nil, true, fmt.Errorf("battle: index changed from %d to %d", state.index, index)
}
- s.index, s.round = index, s.round+1
- if s.round == 1 {
- s.initialBlue = nil
+ state.index, state.round = index, state.round+1
+ if state.round == 1 {
+ state.initialBlue = nil
}
var response []byte
err = wire.Walk(request, func(field wire.Field) error {
@@ -155,8 +188,8 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
response = wire.AppendBytes(response, 1, field.Value)
} else if field.Number == 5 {
response = wire.AppendBytes(response, 2, field.Value)
- if s.round == 1 {
- s.initialBlue = append(s.initialBlue, append([]byte(nil), field.Value...))
+ if state.round == 1 {
+ state.initialBlue = append(state.initialBlue, append([]byte(nil), field.Value...))
}
}
return nil
@@ -165,11 +198,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return 0, nil, true, err
}
// Stable per-battle/round seed; reproducible across retries.
- seed := index*7919 + s.round*104729
+ seed := index*7919 + state.round*104729
response = wire.AppendVarint(response, 3, seed)
return 14, response, true, nil
case "/BattleEnd":
- if !s.entered {
+ if !state.entered {
return 0, nil, true, errors.New("battle: end before enter")
}
result, found, err := wire.Varint(request, 2)
@@ -187,19 +220,19 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return 0, nil, true, err
}
rewardBundle := false
- if result == 1 && s.inventory != nil && s.monster != 0 && s.gameDataRoot != "" {
- if s.pack <= 0 {
+ if result == 1 && s.inventory != nil && state.monster != 0 && s.gameDataRoot != "" {
+ if state.pack <= 0 {
return 0, nil, true, errors.New("battle: victory has no locked pack")
}
loader := s.loadRewards
if loader == nil {
loader = gamedata.BattleDeckRewards
}
- rewards, rewardErr := loader(s.gameDataRoot, s.gameDataVersion, s.pack, s.deck)
+ rewards, rewardErr := loader(s.gameDataRoot, s.gameDataVersion, state.pack, state.deck)
if rewardErr != nil {
- return 0, nil, true, fmt.Errorf("battle: pack %d monster %d deck %d rewards: %w", s.pack, s.monster, s.deck, rewardErr)
+ return 0, nil, true, fmt.Errorf("battle: pack %d monster %d deck %d rewards: %w", state.pack, state.monster, state.deck, rewardErr)
}
- items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack%d:monster%d:deck%d", s.pack, s.monster, s.deck), rewards)
+ items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack%d:monster%d:deck%d", state.pack, state.monster, state.deck), rewards)
if grantErr != nil {
return 0, nil, true, grantErr
}
@@ -223,11 +256,27 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
response = wire.AppendBytes(response, field, nil)
}
- s.entered, s.deck, s.pack, s.initialBlue = false, 0, 0, nil
+ state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil
return 15, response, true, nil
case "/BattleExit":
- s.entered, s.index, s.round, s.deck, s.pack, s.initialBlue = false, 0, 0, 0, 0, nil
+ state.entered, state.index, state.round, state.deck, state.pack, state.initialBlue = false, 0, 0, 0, 0, nil
return 388, nil, true, nil
}
panic("unreachable")
}
+
+func (s *Service) stateLocked() *battleState {
+ if s.states == nil {
+ s.states = make(map[string]*battleState)
+ }
+ key := s.activeSession
+ if key == "" {
+ key = "__direct_test__"
+ }
+ state := s.states[key]
+ if state == nil {
+ state = &battleState{}
+ s.states[key] = state
+ }
+ return state
+}
diff --git a/go/internal/server/battle/battle_test.go b/go/internal/server/battle/battle_test.go
index 121d524..348cf17 100644
--- a/go/internal/server/battle/battle_test.go
+++ b/go/internal/server/battle/battle_test.go
@@ -1,6 +1,7 @@
package battle
import (
+ "fmt"
"testing"
"bd2server/internal/server/gamedata"
@@ -163,3 +164,56 @@ func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) {
t.Fatalf("reward leaked into pack21 identity: %+v", got)
}
}
+
+func TestNewGameSessionDiscardsUnfinishedBattleWithoutRewards(t *testing.T) {
+ s := NewService("", "", nil, nil)
+ s.BeginSession("session-a")
+ enter := wire.AppendVarint(request(1), 4, 10)
+ enter = wire.AppendVarint(enter, 5, 1)
+ if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
+ t.Fatal(err)
+ }
+ start := wire.AppendVarint(request(2), 2, 99)
+ start = wire.AppendBytes(start, 5, wire.AppendVarint(nil, 1, 123))
+ if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
+ t.Fatal(err)
+ }
+ s.BeginSession("session-b")
+ end := wire.AppendVarint(request(3), 2, 1)
+ if _, _, _, err := s.Handle("/BattleEnd", end); err == nil {
+ t.Fatal("new session resumed unfinished battle")
+ }
+}
+
+func TestBattleStateIsIsolatedPerGameSession(t *testing.T) {
+ s := NewService("", "", nil, nil)
+ enter := wire.AppendVarint(request(1), 4, 10)
+ enter = wire.AppendVarint(enter, 5, 1)
+ s.BeginSession("session-a")
+ if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
+ t.Fatal(err)
+ }
+ s.BeginSession("session-b")
+ end := wire.AppendVarint(request(2), 2, 1)
+ if _, _, _, err := s.Handle("/BattleEnd", end); err == nil {
+ t.Fatal("session-b observed session-a battle")
+ }
+ s.BeginSession("session-a")
+ if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
+ t.Fatalf("session-a lost its own battle: %v", err)
+ }
+}
+
+func TestRepeatedCurrentSessionSelectionDoesNotEvictOtherBattles(t *testing.T) {
+ service := NewService("", "", nil, nil)
+ for index := 0; index < 1024; index++ {
+ service.BeginSession(fmt.Sprintf("session-%d", index))
+ }
+ service.BeginSession("session-1023")
+ if len(service.states) != 1024 {
+ t.Fatalf("reselecting current session changed cache size: %d", len(service.states))
+ }
+ if service.states["session-1023"] == nil {
+ t.Fatal("reselecting current session evicted its battle state")
+ }
+}
diff --git a/go/internal/server/bootstrap/bootstrap_test.go b/go/internal/server/bootstrap/bootstrap_test.go
index 0d5d29f..3e07e1a 100644
--- a/go/internal/server/bootstrap/bootstrap_test.go
+++ b/go/internal/server/bootstrap/bootstrap_test.go
@@ -11,7 +11,7 @@ import (
func TestMaintenance(t *testing.T) {
req := wire.AppendVarint(nil, 1, 2)
req = wire.AppendVarint(req, 2, 8)
- response, err := Maintenance(versionconfig.Client(), versionconfig.Bundle(), req)
+ response, err := Maintenance(versionconfig.Game(), versionconfig.Bundle(), req)
if err != nil {
t.Fatal(err)
}
@@ -31,7 +31,7 @@ func TestMaintenance(t *testing.T) {
}
func TestServerInfoNoOfficialEndpoints(t *testing.T) {
- c := Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: versionconfig.Client(), BundleVer: versionconfig.Bundle()}
+ c := Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: versionconfig.Game(), BundleVer: versionconfig.Bundle()}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
@@ -63,7 +63,7 @@ func TestServerInfoIncludesLocalGameData(t *testing.T) {
c := Config{
BaseURL: "http://127.0.0.1:8080/game/",
CDNURL: "http://127.0.0.1:8080/assets/ServerData",
- Version: versionconfig.Client(), BundleVer: versionconfig.Bundle(),
+ Version: versionconfig.Game(), BundleVer: versionconfig.Bundle(),
GameDataURL: "http://127.0.0.1:8080/assets/GameData",
GameDataVer: "20260921140855",
}
diff --git a/go/internal/server/deck/preset.go b/go/internal/server/deck/preset.go
index 7e68325..a87da8f 100644
--- a/go/internal/server/deck/preset.go
+++ b/go/internal/server/deck/preset.go
@@ -73,6 +73,9 @@ func (s *Store) AttachPresetRuntime(wallet *player.Wallet, characters *player.Ch
func (s *Store) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
+ if id == "" || id == s.sessionID {
+ return
+ }
s.sessionID = id
s.replies = map[string]deckReply{}
}
diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/feature/standalone.go
index 523e297..96d25b1 100644
--- a/go/internal/server/feature/standalone.go
+++ b/go/internal/server/feature/standalone.go
@@ -27,7 +27,6 @@ var standaloneDefaults = map[string]int{
"/HuntingGroundInfoList": 387,
"/FriendRecommend": 211,
"/SupporterStatus": 438,
- "/MailHistoryInfo": 138,
"/SupporterBattleInfo": 439,
"/InnOpen": 109,
}
diff --git a/go/internal/server/gacha/gacha.go b/go/internal/server/gacha/gacha.go
index 61f5301..7e5e27b 100644
--- a/go/internal/server/gacha/gacha.go
+++ b/go/internal/server/gacha/gacha.go
@@ -942,6 +942,11 @@ func (s *Service) pointExchange(request []byte, seq uint64) (int, []byte, bool,
if err != nil {
return 147, nil, true, errors.New("gacha: invalid point exchange selection")
}
+ if s.equipmentCatalog != nil {
+ if group, ok := s.equipmentCatalog.Group(groupID); ok {
+ return s.pointExchangeEquipment(group, selectedItemID, seq)
+ }
+ }
group, ok := s.regular.Group(groupID)
if !ok || group.GachaType != 1 || group.PickUpExchangeCost == 0 {
return 147, nil, true, fmt.Errorf("gacha: group %d does not support costume point exchange", groupID)
@@ -971,6 +976,57 @@ func (s *Service) pointExchange(request []byte, seq uint64) (int, []byte, bool,
return 147, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil
}
+func (s *Service) pointExchangeEquipment(group gamedata.EquipmentGachaGroup, selectedItemID, seq uint64) (int, []byte, bool, error) {
+ if s.equipmentInventory == nil {
+ return 147, nil, true, errors.New("gacha: equipment inventory not attached")
+ }
+ if group.ID == 0 || group.PickUpExchangeCost == 0 || group.PickUpItemID == 0 {
+ return 147, nil, true, fmt.Errorf("gacha: group %d does not support equipment point exchange", group.ID)
+ }
+ // Non-selection equipment banners send the proto3 default zero. Refuse a
+ // client-supplied item ID so the request cannot exchange points for an
+ // arbitrary equipment design.
+ if selectedItemID != 0 {
+ return 147, nil, true, fmt.Errorf("gacha: equipment group %d does not support pickup selection", group.ID)
+ }
+ identity := fmt.Sprintf("gacha-point-equipment:%s:%d:%d:seq:%d", s.loginIdentity(), group.ID, group.PickUpItemID, seq)
+ equipmentIdentity := identity + ":equip"
+ if _, already := s.collection.Grant(identity); already {
+ entry, found := s.equipmentInventory.Granted(equipmentIdentity)
+ if !found {
+ return 147, nil, true, fmt.Errorf("gacha: equipment exchange retry %s is missing its instance", identity)
+ }
+ bundle := wire.AppendBytes(nil, 4, player.EquipmentWire(entry))
+ return 147, wire.AppendBytes(nil, 1, bundle), true, nil
+ }
+ if s.collection.GachaUser(group.ID).Point < group.PickUpExchangeCost {
+ return 147, nil, true, errors.New("gacha: insufficient equipment pickup exchange point")
+ }
+ main, sub, private, err := s.equipmentCatalog.RollOptions(group.PickUpItemID)
+ if err != nil {
+ return 147, nil, true, err
+ }
+ entry := player.Equipment{ID: group.PickUpItemID, 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}
+ }
+ saved, err := s.equipmentInventory.GrantGeneratedOnce(equipmentIdentity, entry)
+ if err != nil {
+ return 147, nil, true, err
+ }
+ if _, err := s.collection.GrantGachaPointEquipment(identity, group.ID, group.PickUpExchangeCost); err != nil {
+ return 147, nil, true, err
+ }
+ bundle := wire.AppendBytes(nil, 4, player.EquipmentWire(saved))
+ return 147, wire.AppendBytes(nil, 1, bundle), true, nil
+}
+
func (s *Service) loginIdentity() string {
s.sessionMu.RLock()
defer s.sessionMu.RUnlock()
diff --git a/go/internal/server/gacha/gacha_test.go b/go/internal/server/gacha/gacha_test.go
index 01c3228..c7cbd00 100644
--- a/go/internal/server/gacha/gacha_test.go
+++ b/go/internal/server/gacha/gacha_test.go
@@ -895,6 +895,81 @@ func TestGachaMultiBuyCurrent23510CostumeAndEquipmentPools(t *testing.T) {
}
}
+func TestEquipmentPointExchangeUsesGameDataAndIsIdempotent(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")
+ }
+ infinite, err := gamedata.LoadInfiniteGacha(root, version)
+ if err != nil {
+ t.Fatal(err)
+ }
+ regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{71}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{208})
+ if err != nil {
+ t.Fatal(err)
+ }
+ group, found := equipmentCatalog.Group(208)
+ if !found || group.PickUpExchangeCost != 200 || group.PickUpItemID != 943213 {
+ t.Fatalf("equipment group 208=%+v found=%v", group, found)
+ }
+ storage := stateio.NewMemory()
+ collection, err := player.OpenCollectionStore(storage, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 1234})
+ if err != nil {
+ t.Fatal(err)
+ }
+ equipment, err := player.OpenEquipmentInventory(storage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := collection.GrantEquipmentPurchase("seed-equipment-points", 200, player.GachaPurchase{
+ Group: gamedata.GachaGroupDesign{ID: 208, PointCount: 1},
+ }); err != nil {
+ t.Fatal(err)
+ }
+ service, err := NewService(infinite, regular, collection, wallet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service.AttachEquipmentGacha(equipmentCatalog, equipment)
+ service.BeginSession("equipment-exchange-login")
+ request := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, 208)
+ code, response, handled, err := service.Handle("/GachaPointExchange", request)
+ if err != nil || !handled || code != 147 {
+ t.Fatalf("equipment exchange code=%d handled=%v err=%v", code, handled, err)
+ }
+ bundle, found, err := wire.Bytes(response, 1)
+ if err != nil || !found || countFields(bundle, 4) != 1 {
+ t.Fatalf("equipment exchange bundle=%x found=%v err=%v", bundle, found, err)
+ }
+ entries := equipment.All()
+ if len(entries) != 1 || entries[0].ID != 943213 || len(entries[0].Rank) != 3 || entries[0].InvenIndex == 0 {
+ t.Fatalf("equipment exchange entry=%+v", entries)
+ }
+ if user := collection.GachaUser(208); user.Point != 0 || user.ExchangeItemCount != 1 || user.TotalBuyCount != 0 {
+ t.Fatalf("equipment exchange user=%+v", user)
+ }
+ if got := wallet.Snapshot().FreeJewelry; got != 1234 {
+ t.Fatalf("equipment exchange changed wallet=%d", got)
+ }
+ _, replay, _, err := service.Handle("/GachaPointExchange", request)
+ if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 1 {
+ t.Fatalf("equipment exchange replay changed result: equipment=%d err=%v", len(equipment.All()), err)
+ }
+ invalid := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 78), 2, 208), 3, 943213)
+ if _, _, _, err := service.Handle("/GachaPointExchange", invalid); err == nil || len(equipment.All()) != 1 {
+ t.Fatalf("equipment exchange accepted invalid selection: equipment=%d err=%v", len(equipment.All()), err)
+ }
+}
+
func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
const stepUpGroupID = 29
stepUpGachaIDs := []uint64{8100118, 8100119, 8100120, 8100121}
diff --git a/go/internal/server/gacha/schedule.go b/go/internal/server/gacha/schedule.go
index c5b04cc..6744d1e 100644
--- a/go/internal/server/gacha/schedule.go
+++ b/go/internal/server/gacha/schedule.go
@@ -7,6 +7,8 @@ import (
"io"
"os"
"strings"
+
+ "bd2server/internal/server/gamedata"
)
// ScheduleSeed contains server-owned, versioned dynamic gacha facts. GameData
@@ -18,6 +20,43 @@ type ScheduleSeed struct {
StepUps []ScheduleWindow `json:"step_ups"`
}
+// ActivePickupCostumes returns only explicitly featured costume IDs from
+// schedule windows active at the supplied Unix millisecond. The client uses
+// the half-open interval [start,end); unopened and expired GameData groups are
+// never treated as current UP banners.
+func ActivePickupCostumes(catalog *gamedata.RegularGachaCatalog, seed *ScheduleSeed, now uint64) map[uint64]bool {
+ result := map[uint64]bool{}
+ if catalog == nil || seed == nil {
+ return result
+ }
+ active := func(window ScheduleWindow) bool {
+ return window.StartTime <= now && now < window.EndTime
+ }
+ for _, window := range seed.Schedules {
+ if !active(window) {
+ continue
+ }
+ if group, ok := catalog.Group(window.GroupID); ok && group.GachaType == 1 && group.PickUpCostumeID != 0 {
+ result[group.PickUpCostumeID] = true
+ }
+ }
+ for _, window := range seed.StepUps {
+ if !active(window) {
+ continue
+ }
+ stepUp, ok := catalog.StepUp(window.GroupID)
+ if !ok {
+ continue
+ }
+ for _, step := range stepUp.Steps {
+ if group, ok := catalog.Group(step.GroupID); ok && group.GachaType == 1 && group.PickUpCostumeID != 0 {
+ result[group.PickUpCostumeID] = true
+ }
+ }
+ }
+ return result
+}
+
type ScheduleWindow struct {
GroupID uint64 `json:"group_id"`
StartTime uint64 `json:"start_time"`
diff --git a/go/internal/server/gacha/schedule_test.go b/go/internal/server/gacha/schedule_test.go
index c9f8dcf..5f55111 100644
--- a/go/internal/server/gacha/schedule_test.go
+++ b/go/internal/server/gacha/schedule_test.go
@@ -58,6 +58,35 @@ func TestScheduleSeedStrictValidation(t *testing.T) {
}
}
+func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) {
+ character := gamedata.CharacterDesign{ID: 1, HP: 1, CostumeMaxLevel: 5}
+ catalog, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
+ 11: {ID: 11, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 101, Weight: 1}}},
+ 12: {ID: 12, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 102, Weight: 1}}},
+ }, map[uint64]gamedata.CharacterDesign{101: character, 102: character})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, group := range []gamedata.GachaGroupDesign{
+ {ID: 1, GachaType: 1, PointCount: 1, PickUpCostumeID: 101, OneTimeGachaID: 11},
+ {ID: 2, GachaType: 1, PointCount: 1, PickUpCostumeID: 102, OneTimeGachaID: 12},
+ } {
+ if err := catalog.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ seed := &ScheduleSeed{Schedules: []ScheduleWindow{
+ {GroupID: 1, StartTime: 100, EndTime: 200},
+ {GroupID: 2, StartTime: 200, EndTime: 300},
+ }}
+ if got := ActivePickupCostumes(catalog, seed, 100); !got[101] || got[102] || len(got) != 1 {
+ t.Fatalf("at start active=%v", got)
+ }
+ if got := ActivePickupCostumes(catalog, seed, 200); got[101] || !got[102] || len(got) != 1 {
+ t.Fatalf("at end active=%v", got)
+ }
+}
+
func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T) {
seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10")
if err != nil {
diff --git a/go/internal/server/gamedata/equipment_gacha.go b/go/internal/server/gamedata/equipment_gacha.go
index e2c8ece..722b2d3 100644
--- a/go/internal/server/gamedata/equipment_gacha.go
+++ b/go/internal/server/gamedata/equipment_gacha.go
@@ -31,7 +31,10 @@ type EquipmentGacha struct {
Pool []WeightedEquipment
TicketOnly bool
}
-type EquipmentGachaGroup struct{ ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64 }
+type EquipmentGachaGroup struct {
+ ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64
+ PickUpExchangeCost, PickUpItemID uint64
+}
type EquipmentFixedDesign struct {
ID, SRCount, URCount uint64
Reset bool
@@ -89,9 +92,23 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
ten, _ := packedInts(raw, 33)
fixed, _ := packedInts(raw, 10)
points, _ := packedInts(raw, 27)
- if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] != 1 || len(points) != 1 || points[0] != 1 {
+ gachaTypes, _ := packedInts(raw, 17)
+ pickupEnabled, _ := packedInts(raw, 20)
+ pickupCosts, _ := packedInts(raw, 25)
+ pickupItems, _ := packedInts(raw, 26)
+ if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] != 1 || len(points) != 1 || points[0] != 1 ||
+ len(gachaTypes) != 1 || gachaTypes[0] != 2 || len(pickupEnabled) > 1 || len(pickupCosts) > 1 || len(pickupItems) > 1 {
return nil, fmt.Errorf("gamedata: equipment group %d malformed", groupID)
}
+ pickupCost, pickupItem := uint64(0), uint64(0)
+ if len(pickupEnabled) == 1 && pickupEnabled[0] == 1 {
+ if len(pickupCosts) != 1 || pickupCosts[0] == 0 || len(pickupItems) != 1 || pickupItems[0] == 0 {
+ return nil, fmt.Errorf("gamedata: equipment group %d has malformed pickup exchange", groupID)
+ }
+ pickupCost, pickupItem = pickupCosts[0], pickupItems[0]
+ } else if len(pickupEnabled) != 0 || len(pickupCosts) != 0 || len(pickupItems) != 0 {
+ return nil, fmt.Errorf("gamedata: equipment group %d has inconsistent pickup exchange", groupID)
+ }
// The absence of SelectCount/GachaSubType is deliberate evidence that
// this live group is not an equipment 12PICK configuration.
for _, field := range []int{16, 29, 31, 36} {
@@ -99,7 +116,10 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
return nil, fmt.Errorf("gamedata: equipment group %d unexpectedly has selection field %d=%v", groupID, field, v)
}
}
- group := EquipmentGachaGroup{ID: groupID, FixedID: fixed[0], PointCount: points[0], OneTimeGachaID: one[0], TenTimeGachaID: ten[0]}
+ group := EquipmentGachaGroup{
+ ID: groupID, FixedID: fixed[0], PointCount: points[0], OneTimeGachaID: one[0], TenTimeGachaID: ten[0],
+ PickUpExchangeCost: pickupCost, PickUpItemID: pickupItem,
+ }
c.groups[groupID] = group
for _, id := range []uint64{one[0], ten[0]} {
g, err := loadEquipmentGacha(db, id)
@@ -114,6 +134,11 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
}
}
}
+ if group.PickUpItemID != 0 {
+ if err := c.loadEquipmentTree(db, WeightedEquipment{ID: group.PickUpItemID}); err != nil {
+ return nil, fmt.Errorf("gamedata: equipment group %d pickup: %w", groupID, err)
+ }
+ }
}
// Guaranteed equipment tickets are standalone GachaTable rows: they have
// no diamond price, at least one resource-ticket id, and an equipment-only
diff --git a/go/internal/server/gamedata/limited_costume.go b/go/internal/server/gamedata/limited_costume.go
new file mode 100644
index 0000000..16aca00
--- /dev/null
+++ b/go/internal/server/gamedata/limited_costume.go
@@ -0,0 +1,98 @@
+package gamedata
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ _ "modernc.org/sqlite"
+)
+
+// LimitedCostumeCatalog is the authoritative LimitedCostumeTable membership
+// plus the character/growth design required to grant those costumes. It is a
+// reward lookup only and never changes any gacha pool.
+type LimitedCostumeCatalog struct {
+ ids []uint64
+ characters map[uint64]CharacterDesign
+}
+
+func LoadLimitedCostumes(root, version string) (*LimitedCostumeCatalog, error) {
+ plain, err := ReadQuestDatabase(root, version)
+ if err != nil {
+ return nil, err
+ }
+ dir, err := os.MkdirTemp("", "bd2-limited-costumes-")
+ if err != nil {
+ return nil, err
+ }
+ defer os.RemoveAll(dir)
+ path := filepath.Join(dir, "common.db")
+ if err := os.WriteFile(path, plain, 0o600); err != nil {
+ return nil, err
+ }
+ db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
+ if err != nil {
+ return nil, err
+ }
+ defer db.Close()
+ rows, err := db.Query("SELECT id FROM LimitedCostumeTable ORDER BY id")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ catalog := &LimitedCostumeCatalog{characters: map[uint64]CharacterDesign{}}
+ for rows.Next() {
+ var costumeID uint64
+ if err := rows.Scan(&costumeID); err != nil {
+ return nil, err
+ }
+ if costumeID == 0 {
+ return nil, errors.New("gamedata: limited costume has zero id")
+ }
+ design, err := loadGachaCharacterDesign(db, costumeID)
+ if err != nil {
+ return nil, fmt.Errorf("gamedata: limited costume %d: %w", costumeID, err)
+ }
+ catalog.ids = append(catalog.ids, costumeID)
+ catalog.characters[costumeID] = design
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ if len(catalog.ids) == 0 {
+ return nil, errors.New("gamedata: limited costume catalog is empty")
+ }
+ return catalog, nil
+}
+
+func (c *LimitedCostumeCatalog) IDs() []uint64 {
+ if c == nil {
+ return nil
+ }
+ return append([]uint64(nil), c.ids...)
+}
+
+func (c *LimitedCostumeCatalog) Character(costumeID uint64) (CharacterDesign, bool) {
+ if c == nil {
+ return CharacterDesign{}, false
+ }
+ design, ok := c.characters[costumeID]
+ return design, ok
+}
+
+// Excluding returns stable LimitedCostumeTable order without the supplied
+// currently featured pickup costume IDs.
+func (c *LimitedCostumeCatalog) Excluding(excluded map[uint64]bool) []uint64 {
+ if c == nil {
+ return nil
+ }
+ ids := make([]uint64, 0, len(c.ids))
+ for _, id := range c.ids {
+ if !excluded[id] {
+ ids = append(ids, id)
+ }
+ }
+ return ids
+}
diff --git a/go/internal/server/lifecycle/gate.go b/go/internal/server/lifecycle/gate.go
new file mode 100644
index 0000000..cfd739b
--- /dev/null
+++ b/go/internal/server/lifecycle/gate.go
@@ -0,0 +1,85 @@
+// Package lifecycle coordinates graceful server replacement. It never moves
+// live request or battle memory between processes: draining stops admission,
+// lets committed requests finish, and requires clients to create a new session.
+package lifecycle
+
+import (
+ "context"
+ "sync"
+)
+
+type Gate struct {
+ mu sync.Mutex
+ ready bool
+ inFlight int
+ drained chan struct{}
+}
+
+func NewGate() *Gate {
+ return &Gate{ready: true, drained: make(chan struct{})}
+}
+
+func (g *Gate) Ready() bool {
+ if g == nil {
+ return true
+ }
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return g.ready
+}
+
+// BeginRequest admits one request while ready. The returned completion
+// callback must be invoked exactly once.
+func (g *Gate) BeginRequest() (func(), bool) {
+ if g == nil {
+ return func() {}, true
+ }
+ g.mu.Lock()
+ if !g.ready {
+ g.mu.Unlock()
+ return nil, false
+ }
+ g.inFlight++
+ g.mu.Unlock()
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ g.mu.Lock()
+ g.inFlight--
+ if !g.ready && g.inFlight == 0 {
+ select {
+ case <-g.drained:
+ default:
+ close(g.drained)
+ }
+ }
+ g.mu.Unlock()
+ })
+ }, true
+}
+
+func (g *Gate) Drain() {
+ if g == nil {
+ return
+ }
+ g.mu.Lock()
+ if g.ready {
+ g.ready = false
+ if g.inFlight == 0 {
+ close(g.drained)
+ }
+ }
+ g.mu.Unlock()
+}
+
+func (g *Gate) Wait(ctx context.Context) error {
+ if g == nil {
+ return nil
+ }
+ select {
+ case <-g.drained:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
diff --git a/go/internal/server/lifecycle/gate_test.go b/go/internal/server/lifecycle/gate_test.go
new file mode 100644
index 0000000..1d40823
--- /dev/null
+++ b/go/internal/server/lifecycle/gate_test.go
@@ -0,0 +1,31 @@
+package lifecycle
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestDrainRejectsNewRequestsAndWaitsForAdmittedRequest(t *testing.T) {
+ gate := NewGate()
+ done, ok := gate.BeginRequest()
+ if !ok || !gate.Ready() {
+ t.Fatal("ready gate rejected request")
+ }
+ gate.Drain()
+ if gate.Ready() {
+ t.Fatal("draining gate remained ready")
+ }
+ if _, ok := gate.BeginRequest(); ok {
+ t.Fatal("draining gate admitted new request")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
+ defer cancel()
+ if err := gate.Wait(ctx); err == nil {
+ t.Fatal("drain completed before admitted request")
+ }
+ done()
+ if err := gate.Wait(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/go/internal/server/lifecycle/signals.go b/go/internal/server/lifecycle/signals.go
new file mode 100644
index 0000000..7e5993e
--- /dev/null
+++ b/go/internal/server/lifecycle/signals.go
@@ -0,0 +1,7 @@
+package lifecycle
+
+import "os"
+
+// ShutdownSignals is kept platform-specific because Windows does not define
+// the Unix rolling-restart signal.
+func ShutdownSignals() []os.Signal { return shutdownSignals() }
diff --git a/go/internal/server/lifecycle/signals_unix.go b/go/internal/server/lifecycle/signals_unix.go
new file mode 100644
index 0000000..d6c1f2a
--- /dev/null
+++ b/go/internal/server/lifecycle/signals_unix.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package lifecycle
+
+import (
+ "os"
+ "syscall"
+)
+
+func shutdownSignals() []os.Signal {
+ return []os.Signal{os.Interrupt, syscall.SIGTERM, syscall.SIGUSR2}
+}
diff --git a/go/internal/server/lifecycle/signals_windows.go b/go/internal/server/lifecycle/signals_windows.go
new file mode 100644
index 0000000..314f1f4
--- /dev/null
+++ b/go/internal/server/lifecycle/signals_windows.go
@@ -0,0 +1,7 @@
+//go:build windows
+
+package lifecycle
+
+import "os"
+
+func shutdownSignals() []os.Signal { return []os.Signal{os.Interrupt} }
diff --git a/go/internal/server/mail/grant_spool.go b/go/internal/server/mail/grant_spool.go
index 6e1fbdd..eea3822 100644
--- a/go/internal/server/mail/grant_spool.go
+++ b/go/internal/server/mail/grant_spool.go
@@ -53,7 +53,8 @@ func (g compensation) validate() error {
return errors.New("mail: invalid compensation identity, content, time or rewards")
}
for _, reward := range g.rewards {
- if reward.Count == 0 || reward.Count > math.MaxInt32 || reward.ID > math.MaxInt32 || (!currencyRewardTypes[reward.Type] && !supportedItemDBInfoReward(reward)) ||
+ costumeReward := reward.Type == 11 && reward.ID != 0 && reward.Count <= 6
+ if reward.Count == 0 || reward.Count > math.MaxInt32 || reward.ID > math.MaxInt32 || (!currencyRewardTypes[reward.Type] && !supportedItemDBInfoReward(reward) && !costumeReward) ||
(currencyRewardTypes[reward.Type] && reward.ID != 0) {
return fmt.Errorf("mail: compensation %q has unsupported reward type=%d id=%d count=%d", g.identity, reward.Type, reward.ID, reward.Count)
}
diff --git a/go/internal/server/mail/mail.go b/go/internal/server/mail/mail.go
index 6bd7e46..fcc0d82 100644
--- a/go/internal/server/mail/mail.go
+++ b/go/internal/server/mail/mail.go
@@ -23,6 +23,13 @@ import (
const packetCode = 131
const openPacketCode = 132
+const historyPacketCode = 138
+
+const (
+ mailHistoryPeriod = 30 * 24 * time.Hour
+ mailHistoryPageMax = 100
+ starterLimitedCostumeIdentity = "starter:limited-costumes:not-current-pickup:v1"
+)
// itemDBInfoTypes are ElementType values whose successful mail claim is
// represented by ItemDBInfo in RewardDBInfoBundle. They are deliberately
@@ -64,17 +71,21 @@ var currencyRewardTypes = map[uint64]bool{
// localized/template-driven mail form). Reward fields are parallel arrays:
// type, table ID, and amount respectively.
type MailDBInfo struct {
- MailID uint64 `json:"mail_id"`
- MailType uint64 `json:"mail_type,omitempty"`
- TemplateID uint64 `json:"template_id,omitempty"`
- Sender string `json:"sender,omitempty"`
- Title string `json:"title,omitempty"`
- Body string `json:"body,omitempty"`
- ExpiresAt uint64 `json:"expires_at"`
- RewardTypes []uint64 `json:"reward_types,omitempty"`
- RewardIDs []uint64 `json:"reward_ids,omitempty"`
- RewardCounts []uint64 `json:"reward_counts,omitempty"`
- SentAt uint64 `json:"sent_at"`
+ MailID uint64 `json:"mail_id"`
+ MailType uint64 `json:"mail_type,omitempty"`
+ TemplateID uint64 `json:"template_id,omitempty"`
+ Sender string `json:"sender,omitempty"`
+ Title string `json:"title,omitempty"`
+ Body string `json:"body,omitempty"`
+ ExpiresAt uint64 `json:"expires_at"`
+ RewardTypes []uint64 `json:"reward_types,omitempty"`
+ RewardIDs []uint64 `json:"reward_ids,omitempty"`
+ RewardCounts []uint64 `json:"reward_counts,omitempty"`
+ IsOpen bool `json:"is_open,omitempty"`
+ OpenTime uint64 `json:"open_time,omitempty"`
+ SentAt uint64 `json:"sent_at"`
+ HistoryDeleteTime uint64 `json:"history_delete_time,omitempty"`
+ IsCash bool `json:"is_cash,omitempty"`
}
type Starter struct {
@@ -160,7 +171,20 @@ func (m MailDBInfo) encode() []byte {
if len(m.RewardCounts) != 0 {
result = wire.AppendBytes(result, 10, packed(m.RewardCounts))
}
- return wire.AppendVarint(result, 13, m.SentAt)
+ if m.IsOpen {
+ result = wire.AppendVarint(result, 11, 1)
+ }
+ if m.OpenTime != 0 {
+ result = wire.AppendVarint(result, 12, m.OpenTime)
+ }
+ result = wire.AppendVarint(result, 13, m.SentAt)
+ if m.HistoryDeleteTime != 0 {
+ result = wire.AppendVarint(result, 14, m.HistoryDeleteTime)
+ }
+ if m.IsCash {
+ result = wire.AppendVarint(result, 15, 1)
+ }
+ return result
}
func (s *Starter) Handle(path string, request []byte) (int, []byte, bool, error) {
@@ -209,8 +233,9 @@ type stateSnapshot struct {
NextDynamicMailID uint64 `json:"next_dynamic_mail_id"`
}
-// Service owns mailbox visibility and idempotent reward delivery. Starter is
-// immutable source data; only opened IDs are persisted in the player state.
+// Service owns mailbox visibility, claim history, and idempotent reward
+// delivery. Starter is immutable source data; mutable rows live in the player
+// state alongside the bounded core snapshot.
type Service struct {
mu sync.Mutex
Starter *Starter
@@ -220,9 +245,25 @@ type Service struct {
storage stateio.AtomicEntryStore
inventory *player.Inventory
wallet *player.Wallet
+ collection *player.CollectionStore
+ costumeDesign player.CostumeDesignSource
state stateSnapshot
dynamic map[uint64]MailDBInfo
issued map[string]uint64
+ history map[uint64]MailDBInfo
+ now func() time.Time
+}
+
+// AttachCostumeRewards enables ElementType 11 mail attachments. The catalog is
+// GameData-backed and the collection ledger makes repeated MailOpen calls safe.
+func (s *Service) AttachCostumeRewards(collection *player.CollectionStore, design player.CostumeDesignSource) error {
+ if s == nil || collection == nil || design == nil {
+ return errors.New("mail: invalid costume reward configuration")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.collection, s.costumeDesign = collection, design
+ return nil
}
type fileStamp struct {
@@ -243,13 +284,13 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve
}
s := &Service{Starter: starter, storage: entries, inventory: inventory, wallet: wallet,
state: stateSnapshot{Version: versionconfig.State(), NextDynamicMailID: starter.MaxMailID + 1},
- dynamic: map[uint64]MailDBInfo{}, issued: map[string]uint64{}}
+ dynamic: map[uint64]MailDBInfo{}, issued: map[string]uint64{}, history: map[uint64]MailDBInfo{}, now: time.Now}
b, err := storage.Load("mail")
if err != nil {
return nil, fmt.Errorf("mail: load state: %w", err)
}
if b == nil {
- if err := stateio.RequireNoEntries(entries, "mail", "dynamic", "issued"); err != nil {
+ if err := stateio.RequireNoEntries(entries, "mail", "dynamic", "issued", "history"); err != nil {
return nil, fmt.Errorf("mail: invalid storage: %w", err)
}
return s, nil
@@ -286,6 +327,25 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve
}
s.issued[identity] = id
}
+ if id, exists := s.issued[starterLimitedCostumeIdentity]; exists {
+ if err := validateStarterLimitedCostumeMail(s.dynamic[id], nil); err != nil {
+ return nil, fmt.Errorf("mail: invalid persisted starter limited-costume gift: %w", err)
+ }
+ }
+ rawHistory, err := entries.ListEntries("mail", "history")
+ if err != nil {
+ return nil, err
+ }
+ for key, payload := range rawHistory {
+ id, parseErr := strconv.ParseUint(key, 10, 64)
+ var entry MailDBInfo
+ if parseErr != nil || id == 0 || json.Unmarshal(payload, &entry) != nil || entry.MailID != id ||
+ !entry.IsOpen || entry.OpenTime == 0 || entry.HistoryDeleteTime <= entry.OpenTime ||
+ len(entry.RewardTypes) != len(entry.RewardIDs) || len(entry.RewardTypes) != len(entry.RewardCounts) {
+ return nil, fmt.Errorf("mail: invalid history entry %q", key)
+ }
+ s.history[id] = entry
+ }
// A watched development seed is allowed to append immutable static mails
// between runs. Move the dynamic allocator above that range as long as none
// of the already-persisted dynamic IDs collide with the expanded starter.
@@ -342,7 +402,7 @@ func (s *Service) AttachSeedPath(path string) error {
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
- if path != "/MailInfo" && path != "/MailOpen" {
+ if path != "/MailInfo" && path != "/MailOpen" && path != "/MailHistoryInfo" {
return 0, nil, false, nil
}
if s == nil || s.Starter == nil || s.inventory == nil || s.wallet == nil {
@@ -367,6 +427,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
return packetCode, s.info(), true, nil
}
+ if path == "/MailHistoryInfo" {
+ response, err := s.historyInfo(request)
+ return historyPacketCode, response, true, err
+ }
response, err := s.open(request)
return openPacketCode, response, true, err
}
@@ -483,10 +547,14 @@ func (s *Service) open(request []byte) ([]byte, error) {
var bundle []byte
next := stateSnapshot{Version: s.state.Version, Opened: append([]uint64(nil), s.state.Opened...), NextDynamicMailID: s.state.NextDynamicMailID}
+ openedAt := s.now().UTC()
+ changes := make([]stateio.EntryMutation, 0, len(selected))
+ newHistory := make([]MailDBInfo, 0, len(selected))
for _, entry := range selected {
identity := fmt.Sprintf("mail:%d", entry.MailID)
rewards := make([]gamedata.Reward, len(entry.RewardTypes))
var items []gamedata.BattleReward
+ var costumeIDs []uint64
for i := range entry.RewardTypes {
reward := gamedata.Reward{Type: entry.RewardTypes[i], ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]}
rewards[i] = reward
@@ -495,6 +563,13 @@ func (s *Service) open(request []byte) ([]byte, error) {
currency := wire.AppendVarint(nil, 3, reward.Type)
currency = wire.AppendVarint(currency, 4, reward.Count)
bundle = wire.AppendBytes(bundle, 1, currency)
+ case reward.Type == 11:
+ if s.collection == nil || s.costumeDesign == nil || reward.ID == 0 || reward.Count == 0 || reward.Count > 6 {
+ return nil, errors.New("mail: costume reward service unavailable or reward invalid")
+ }
+ for copy := uint64(0); copy < reward.Count; copy++ {
+ costumeIDs = append(costumeIDs, reward.ID)
+ }
case reward.Type == 28:
// DataManager recognizes this type, but RewardDBInfoBundle
// carries MyRoomTrophyDBInfo in a separate field. Encoding it as
@@ -527,17 +602,152 @@ func (s *Service) open(request []byte) ([]byte, error) {
view = wire.AppendVarint(view, 4, item.Count)
bundle = wire.AppendBytes(bundle, 6, view)
}
+ if len(costumeIDs) != 0 {
+ grant, err := s.collection.GrantCostumes(identity+":costumes", costumeIDs, s.costumeDesign)
+ if err != nil {
+ return nil, err
+ }
+ var mileage uint64
+ for _, exchange := range grant.Exchanges {
+ if exchange.ExchangeItemType != 20 || ^uint64(0)-mileage < exchange.ExchangeCount {
+ return nil, errors.New("mail: unsupported costume overflow exchange")
+ }
+ mileage += exchange.ExchangeCount
+ }
+ if mileage != 0 {
+ if _, err := s.wallet.GrantMileageOnce(identity+":costume-overflow", mileage); err != nil {
+ return nil, err
+ }
+ }
+ bundle = appendCollectionRewardBundle(bundle, s.collection, grant)
+ }
if !containsID(next.Opened, entry.MailID) {
next.Opened = append(next.Opened, entry.MailID)
}
+ if _, exists := s.history[entry.MailID]; !exists {
+ history := entry
+ history.IsOpen = true
+ history.OpenTime = uint64(openedAt.UnixMilli())
+ history.HistoryDeleteTime = uint64(openedAt.Add(mailHistoryPeriod).UnixMilli())
+ payload, err := json.Marshal(history)
+ if err != nil {
+ return nil, err
+ }
+ changes = append(changes, stateio.EntryMutation{Bucket: "history", Key: strconv.FormatUint(entry.MailID, 10), Payload: payload})
+ newHistory = append(newHistory, history)
+ }
}
sort.Slice(next.Opened, func(i, j int) bool { return next.Opened[i] < next.Opened[j] })
- if err := s.commit(next); err != nil {
+ if err := s.commitWithEntries(next, changes); err != nil {
return nil, err
}
+ for _, entry := range newHistory {
+ s.history[entry.MailID] = entry
+ }
return wire.AppendBytes(nil, 1, bundle), nil
}
+func appendCollectionRewardBundle(bundle []byte, collection *player.CollectionStore, grant player.CollectionGrant) []byte {
+ newCharacters := make(map[uint64]player.Character, len(grant.CharacterIndices))
+ for _, index := range grant.CharacterIndices {
+ if character, found := collection.CharacterByIndex(index); found {
+ newCharacters[character.ConnectPotentialCostume] = character
+ bundle = wire.AppendBytes(bundle, 2, player.CharacterWire(character))
+ }
+ }
+ for _, index := range grant.CostumeIndices {
+ if costume, found := collection.CostumeByIndex(index); found {
+ bundle = wire.AppendBytes(bundle, 3, player.CostumeWire(costume))
+ }
+ }
+ for position, costumeID := range grant.ViewCostumeIDs {
+ view := wire.AppendVarint(nil, 2, costumeID)
+ view = wire.AppendVarint(view, 3, 11)
+ view = wire.AppendVarint(view, 4, 1)
+ bundle = wire.AppendBytes(bundle, 6, view)
+ if character, found := newCharacters[costumeID]; found {
+ charView := wire.AppendVarint(nil, 2, character.ID)
+ charView = wire.AppendVarint(charView, 3, 6)
+ charView = wire.AppendVarint(charView, 4, 1)
+ if position != 0 {
+ charView = wire.AppendVarint(charView, 6, uint64(position))
+ }
+ bundle = wire.AppendBytes(bundle, 6, charView)
+ }
+ }
+ for _, upgrade := range grant.Upgrades {
+ info := wire.AppendVarint(nil, 1, upgrade.InvenIndex)
+ info = wire.AppendVarint(info, 2, 11)
+ info = wire.AppendVarint(info, 3, upgrade.CostumeID)
+ if upgrade.Before != 0 {
+ info = wire.AppendVarint(info, 4, upgrade.Before)
+ }
+ info = wire.AppendVarint(info, 5, upgrade.After)
+ if upgrade.SortID != 0 {
+ info = wire.AppendVarint(info, 6, upgrade.SortID)
+ }
+ bundle = wire.AppendBytes(bundle, 9, info)
+ }
+ var mileage uint64
+ for _, exchange := range grant.Exchanges {
+ info := wire.AppendVarint(nil, 1, exchange.OriginalItemType)
+ info = wire.AppendVarint(info, 2, exchange.OriginalItemID)
+ info = wire.AppendVarint(info, 3, exchange.OriginalCount)
+ info = wire.AppendVarint(info, 4, exchange.ExchangeItemType)
+ if exchange.ExchangeItemID != 0 {
+ info = wire.AppendVarint(info, 5, exchange.ExchangeItemID)
+ }
+ info = wire.AppendVarint(info, 6, exchange.ExchangeCount)
+ if exchange.SortID != 0 {
+ info = wire.AppendVarint(info, 7, exchange.SortID)
+ }
+ bundle = wire.AppendBytes(bundle, 8, info)
+ mileage += exchange.ExchangeCount
+ }
+ if mileage != 0 {
+ repaid := wire.AppendVarint(nil, 2, 20)
+ repaid = wire.AppendVarint(repaid, 3, mileage)
+ bundle = wire.AppendBytes(bundle, 10, repaid)
+ }
+ return bundle
+}
+
+func (s *Service) historyInfo(request []byte) ([]byte, error) {
+ start, _, err := wire.Varint(request, 2)
+ if err != nil {
+ return nil, errors.New("mail: invalid history start index")
+ }
+ count, present, err := wire.Varint(request, 3)
+ if err != nil || !present || count == 0 {
+ return nil, errors.New("mail: invalid history select count")
+ }
+ if count > mailHistoryPageMax {
+ count = mailHistoryPageMax
+ }
+ now := uint64(s.now().UTC().UnixMilli())
+ ids := make([]uint64, 0, len(s.history))
+ for id, entry := range s.history {
+ if entry.HistoryDeleteTime > now {
+ ids = append(ids, id)
+ }
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] })
+ total := len(ids)
+ var result []byte
+ selected := uint64(0)
+ for _, id := range ids {
+ if start != 0 && id >= start {
+ continue
+ }
+ if selected >= count {
+ break
+ }
+ result = wire.AppendBytes(result, 1, s.history[id].encode())
+ selected++
+ }
+ return wire.AppendVarint(result, 2, uint64(total)), nil
+}
+
func requestMailIDs(request []byte) ([]uint64, error) {
var result []uint64
err := wire.Walk(request, func(field wire.Field) error {
@@ -578,6 +788,21 @@ func (s *Service) commit(next stateSnapshot) error {
return s.persist(next)
}
+func (s *Service) commitWithEntries(next stateSnapshot, changes []stateio.EntryMutation) error {
+ if len(changes) == 0 {
+ return s.commit(next)
+ }
+ b, err := json.Marshal(next)
+ if err != nil {
+ return err
+ }
+ if err := s.storage.SaveWithEntries("mail", b, changes); err != nil {
+ return fmt.Errorf("mail: persist state and history: %w", err)
+ }
+ s.state = next
+ return nil
+}
+
func (s *Service) persist(next stateSnapshot) error {
b, err := json.Marshal(next)
if err != nil {
@@ -603,6 +828,67 @@ func (s *Service) EnqueueCompensation(identity, title, body string, rewards []ga
return s.enqueueCompensations([]compensation{grant})
}
+// EnsureStarterLimitedCostumes issues the new-player limited-costume gift at
+// most once for this account. The durable issued-identity row is written in the
+// same transaction as the mail, so retries and restarts return without
+// allocating another mail ID. Six copies mean one acquisition plus five
+// duplicate upgrades, producing enhancement +5 when claimed.
+func (s *Service) EnsureStarterLimitedCostumes(costumeIDs []uint64, sentAt time.Time) error {
+ if s == nil {
+ return errors.New("mail: unavailable starter limited-costume service")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if id, issued := s.issued[starterLimitedCostumeIdentity]; issued {
+ return validateStarterLimitedCostumeMail(s.dynamic[id], s.costumeDesign)
+ }
+ if len(costumeIDs) == 0 || s.costumeDesign == nil {
+ return errors.New("mail: starter limited-costume gift is empty or unavailable")
+ }
+ seen := make(map[uint64]bool, len(costumeIDs))
+ rewards := make([]gamedata.Reward, 0, len(costumeIDs))
+ for _, costumeID := range costumeIDs {
+ design, ok := s.costumeDesign.Character(costumeID)
+ if costumeID == 0 || seen[costumeID] || !ok || design.CostumeMaxLevel != 5 {
+ return fmt.Errorf("mail: invalid starter limited costume %d", costumeID)
+ }
+ seen[costumeID] = true
+ rewards = append(rewards, gamedata.Reward{Type: 11, ID: costumeID, Count: 6})
+ }
+ grant := compensation{
+ identity: starterLimitedCostumeIdentity,
+ title: "New Player Limited Costumes",
+ body: "Limited costumes not featured in the current pickup banners. Each costume is enhanced to +5 when claimed.",
+ rewards: rewards,
+ sentAt: sentAt,
+ }
+ if err := grant.validate(); err != nil {
+ return err
+ }
+ return s.enqueueCompensations([]compensation{grant})
+}
+
+func validateStarterLimitedCostumeMail(entry MailDBInfo, design player.CostumeDesignSource) error {
+ if entry.MailID == 0 || len(entry.RewardTypes) == 0 || len(entry.RewardTypes) != len(entry.RewardIDs) || len(entry.RewardTypes) != len(entry.RewardCounts) {
+ return errors.New("invalid starter gift reward arrays")
+ }
+ seen := make(map[uint64]bool, len(entry.RewardTypes))
+ for i, typ := range entry.RewardTypes {
+ id := entry.RewardIDs[i]
+ if typ != 11 || id == 0 || entry.RewardCounts[i] != 6 || seen[id] {
+ return errors.New("starter gift must contain unique six-copy costumes")
+ }
+ seen[id] = true
+ if design != nil {
+ costume, ok := design.Character(id)
+ if !ok || costume.CostumeMaxLevel != 5 {
+ return fmt.Errorf("starter gift costume %d is unavailable or not enhancement +5", id)
+ }
+ }
+ }
+ return nil
+}
+
// enqueueCompensations shares the EnqueueCompensation allocator and durable
// identity ledger. The entire batch is prepared before a single atomic write;
// neither storage nor memory can retain a partially imported grant spool.
diff --git a/go/internal/server/mail/mail_test.go b/go/internal/server/mail/mail_test.go
index b9a8144..f030a68 100644
--- a/go/internal/server/mail/mail_test.go
+++ b/go/internal/server/mail/mail_test.go
@@ -130,6 +130,270 @@ func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) {
}
}
+func TestMailHistoryPersistsPagesAndKeepsFirstOpenTime(t *testing.T) {
+ seed := &Starter{Version: "2.35.10", MailCount: 4, MaxMailID: 13, Mails: []MailDBInfo{
+ {MailID: 11, MailType: 2, Title: "first", ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{4}, RewardIDs: []uint64{0}, RewardCounts: []uint64{1}},
+ {MailID: 12, MailType: 2, Title: "second", ExpiresAt: 200, SentAt: 20, RewardTypes: []uint64{4}, RewardIDs: []uint64{0}, RewardCounts: []uint64{2}},
+ {MailID: 13, MailType: 2, Title: "third", ExpiresAt: 300, SentAt: 30, RewardTypes: []uint64{4}, RewardIDs: []uint64{0}, RewardCounts: []uint64{3}},
+ }}
+ storage := stateio.NewMemory()
+ inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := player.OpenWallet(storage, player.Currency{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ service, err := OpenService(storage, seed, inv, wallet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ openedAt := time.Date(2026, 10, 3, 5, 6, 7, 8_000_000, time.UTC)
+ service.now = func() time.Time { return openedAt }
+ open := wire.AppendVarint(nil, 1, 1)
+ open = wire.AppendBytes(open, 2, packed([]uint64{11, 12, 13}))
+ if _, _, _, err := service.Handle("/MailOpen", open); err != nil {
+ t.Fatal(err)
+ }
+
+ request := wire.AppendVarint(nil, 1, 2)
+ request = wire.AppendVarint(request, 2, 0)
+ request = wire.AppendVarint(request, 3, 2)
+ code, response, handled, err := service.Handle("/MailHistoryInfo", request)
+ if err != nil || !handled || code != historyPacketCode {
+ t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
+ }
+ entries := historyEntries(t, response)
+ if len(entries) != 2 || historyID(t, entries[0]) != 13 || historyID(t, entries[1]) != 12 {
+ t.Fatalf("first page IDs=%v", historyIDs(t, entries))
+ }
+ total, found, err := wire.Varint(response, 2)
+ if err != nil || !found || total != 3 {
+ t.Fatalf("total=%d found=%v err=%v", total, found, err)
+ }
+ if isOpen, found, _ := wire.Varint(entries[0], 11); !found || isOpen != 1 {
+ t.Fatalf("history is_open=%d found=%v", isOpen, found)
+ }
+ if got, found, _ := wire.Varint(entries[0], 12); !found || got != uint64(openedAt.UnixMilli()) {
+ t.Fatalf("open_time=%d found=%v", got, found)
+ }
+ if got, found, _ := wire.Varint(entries[0], 14); !found || got != uint64(openedAt.Add(mailHistoryPeriod).UnixMilli()) {
+ t.Fatalf("history_delete_time=%d found=%v", got, found)
+ }
+
+ next := wire.AppendVarint(nil, 1, 3)
+ next = wire.AppendVarint(next, 2, 12)
+ next = wire.AppendVarint(next, 3, 2)
+ _, response, _, err = service.Handle("/MailHistoryInfo", next)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries = historyEntries(t, response)
+ if len(entries) != 1 || historyID(t, entries[0]) != 11 {
+ t.Fatalf("second page IDs=%v", historyIDs(t, entries))
+ }
+
+ service.now = func() time.Time { return openedAt.Add(24 * time.Hour) }
+ if _, _, _, err := service.Handle("/MailOpen", wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, 13)); err != nil {
+ t.Fatal(err)
+ }
+ reopened, err := OpenService(storage, seed, inv, wallet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ reopened.now = service.now
+ _, response, _, err = reopened.Handle("/MailHistoryInfo", request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ entries = historyEntries(t, response)
+ if got, _, _ := wire.Varint(entries[0], 12); got != uint64(openedAt.UnixMilli()) {
+ t.Fatalf("retry changed first open time to %d", got)
+ }
+
+ reopened.now = func() time.Time { return openedAt.Add(mailHistoryPeriod) }
+ _, response, _, err = reopened.Handle("/MailHistoryInfo", request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entries := historyEntries(t, response); len(entries) != 0 {
+ t.Fatalf("expired history remains: %v", historyIDs(t, entries))
+ }
+ if total, _, _ := wire.Varint(response, 2); total != 0 {
+ t.Fatalf("expired total=%d", total)
+ }
+}
+
+func TestMailHistoryRejectsInvalidPagination(t *testing.T) {
+ storage := stateio.NewMemory()
+ inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := player.OpenWallet(storage, player.Currency{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ service, err := OpenService(storage, &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 10}, inv, wallet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := wire.AppendVarint(nil, 1, 1)
+ if _, _, handled, err := service.Handle("/MailHistoryInfo", request); !handled || err == nil {
+ t.Fatalf("handled=%v err=%v", handled, err)
+ }
+}
+
+type mailCostumeDesign map[uint64]gamedata.CharacterDesign
+
+func (d mailCostumeDesign) Character(costumeID uint64) (gamedata.CharacterDesign, bool) {
+ value, ok := d[costumeID]
+ return value, ok
+}
+
+func TestMailCostumeRewardCreatesEnhancementFiveIdempotently(t *testing.T) {
+ seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{
+ MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10,
+ RewardTypes: []uint64{11}, RewardIDs: []uint64{206}, RewardCounts: []uint64{6},
+ }}}
+ storage := stateio.NewMemory()
+ inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := player.OpenWallet(storage, player.Currency{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ collection, err := player.OpenCollectionStore(storage, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := collection.BindBaseCharacters(nil); err != nil {
+ t.Fatal(err)
+ }
+ service, err := OpenService(storage, seed, inv, wallet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := service.AttachCostumeRewards(collection, mailCostumeDesign{206: {
+ ID: 20, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 10,
+ }}); err != nil {
+ t.Fatal(err)
+ }
+ request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 11)
+ _, response, _, err := service.Handle("/MailOpen", request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := collection.Costumes(); len(got) != 1 || got[0].ID != 206 || got[0].Level != 5 {
+ t.Fatalf("costumes=%+v", got)
+ }
+ bundle, found, err := wire.Bytes(response, 1)
+ if err != nil || !found {
+ t.Fatalf("bundle: found=%v err=%v", found, err)
+ }
+ var characters, costumes, upgrades int
+ if err := wire.Walk(bundle, func(field wire.Field) error {
+ switch field.Number {
+ case 2:
+ characters++
+ case 3:
+ costumes++
+ case 9:
+ upgrades++
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if characters != 1 || costumes != 1 || upgrades != 5 {
+ t.Fatalf("characters=%d costumes=%d upgrades=%d", characters, costumes, upgrades)
+ }
+ if _, _, _, err := service.Handle("/MailOpen", request); err != nil {
+ t.Fatal(err)
+ }
+ if got := collection.Costumes(); len(got) != 1 || got[0].Level != 5 {
+ t.Fatalf("retry costumes=%+v", got)
+ }
+}
+
+func TestEnsureStarterLimitedCostumesIsDurablyIdempotent(t *testing.T) {
+ storage := stateio.NewMemory()
+ service, _, _ := spoolTestService(t, storage)
+ design := mailCostumeDesign{
+ 206: {ID: 20, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 10},
+ 306: {ID: 30, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 10},
+ }
+ collection, err := player.OpenCollectionStore(storage, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := service.AttachCostumeRewards(collection, design); err != nil {
+ t.Fatal(err)
+ }
+ firstTime := time.Date(2026, 10, 3, 8, 0, 0, 0, time.UTC)
+ if err := service.EnsureStarterLimitedCostumes([]uint64{206}, firstTime); err != nil {
+ t.Fatal(err)
+ }
+ firstID := service.issued[starterLimitedCostumeIdentity]
+ if firstID == 0 || len(service.dynamic) != 1 {
+ t.Fatalf("first issue: id=%d dynamic=%v", firstID, service.dynamic)
+ }
+ // A retry after the schedule changed must preserve the originally frozen
+ // mail rather than allocating a second ID or replacing its attachments.
+ if err := service.EnsureStarterLimitedCostumes([]uint64{306}, firstTime.Add(24*time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if service.issued[starterLimitedCostumeIdentity] != firstID || len(service.dynamic) != 1 || service.dynamic[firstID].RewardIDs[0] != 206 {
+ t.Fatalf("in-process retry changed gift: issued=%v dynamic=%v", service.issued, service.dynamic)
+ }
+
+ reopened, _, _ := spoolTestService(t, storage)
+ if err := reopened.AttachCostumeRewards(collection, design); err != nil {
+ t.Fatal(err)
+ }
+ if err := reopened.EnsureStarterLimitedCostumes([]uint64{306}, firstTime.Add(48*time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if reopened.issued[starterLimitedCostumeIdentity] != firstID || len(reopened.dynamic) != 1 || reopened.dynamic[firstID].RewardIDs[0] != 206 {
+ t.Fatalf("restart retry changed gift: issued=%v dynamic=%v", reopened.issued, reopened.dynamic)
+ }
+}
+
+func historyEntries(t *testing.T, response []byte) [][]byte {
+ t.Helper()
+ var entries [][]byte
+ if err := wire.Walk(response, func(field wire.Field) error {
+ if field.Number == 1 {
+ entries = append(entries, append([]byte(nil), field.Value...))
+ }
+ return nil
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return entries
+}
+
+func historyID(t *testing.T, entry []byte) uint64 {
+ t.Helper()
+ id, found, err := wire.Varint(entry, 1)
+ if err != nil || !found {
+ t.Fatalf("history ID: found=%v err=%v", found, err)
+ }
+ return id
+}
+
+func historyIDs(t *testing.T, entries [][]byte) []uint64 {
+ t.Helper()
+ ids := make([]uint64, 0, len(entries))
+ for _, entry := range entries {
+ ids = append(ids, historyID(t, entry))
+ }
+ return ids
+}
+
func TestStarterContentTicketOnlyAllowsAuditedSingleUseReward(t *testing.T) {
for _, reward := range []struct {
id, count uint64
diff --git a/go/internal/server/player/collection.go b/go/internal/server/player/collection.go
index 9a904b7..b56e727 100644
--- a/go/internal/server/player/collection.go
+++ b/go/internal/server/player/collection.go
@@ -469,6 +469,21 @@ func (s *CollectionStore) GrantRegular(identity string, costumeIDs []uint64, des
if identity == "" || len(costumeIDs) == 0 || design == nil {
return CollectionGrant{}, errors.New("player: invalid regular gacha grant")
}
+ return s.GrantCostumes(identity, costumeIDs, design)
+}
+
+type CostumeDesignSource interface {
+ Character(costumeID uint64) (gamedata.CharacterDesign, bool)
+}
+
+// GrantCostumes grants a deterministic costume-copy sequence from any
+// GameData-backed reward catalog. It is shared by gacha and new-player mail;
+// the identity makes retry return the original characters, costumes and
+// upgrade records without applying the copies twice.
+func (s *CollectionStore) GrantCostumes(identity string, costumeIDs []uint64, design CostumeDesignSource) (CollectionGrant, error) {
+ if identity == "" || len(costumeIDs) == 0 || design == nil {
+ return CollectionGrant{}, errors.New("player: invalid costume reward grant")
+ }
return s.grantCostumes(identity, costumeIDs, design.Character, nil)
}
@@ -516,6 +531,41 @@ func (s *CollectionStore) GrantGachaPointCostume(identity string, group gamedata
})
}
+// GrantGachaPointEquipment records an equipment pickup exchange in the same
+// collection ledger used by costume pickup exchanges. The equipment instance
+// itself belongs to EquipmentInventory; the request transaction makes both
+// domain writes atomic.
+func (s *CollectionStore) GrantGachaPointEquipment(identity string, groupID, exchangeCost uint64) (CollectionGrant, error) {
+ if identity == "" || groupID == 0 || exchangeCost == 0 {
+ return CollectionGrant{}, errors.New("player: invalid gacha point equipment exchange")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if grant, ok := s.data.Grants[identity]; ok {
+ return cloneGrant(grant), nil
+ }
+ next := cloneCollection(s.data)
+ key := strconv.FormatUint(groupID, 10)
+ user, ok := next.GachaUsers[key]
+ if !ok || user.Point < exchangeCost {
+ return CollectionGrant{}, errors.New("player: insufficient gacha point")
+ }
+ if user.ExchangeItemCount == ^uint64(0) {
+ return CollectionGrant{}, errors.New("player: gacha item exchange count overflow")
+ }
+ user.GroupID = groupID
+ user.Point -= exchangeCost
+ user.ExchangeItemCount++
+ next.GachaUsers[key] = user
+ next.GachaPointExchange[identity] = GachaPointExchange{GroupID: groupID, Count: exchangeCost}
+ grant := CollectionGrant{}
+ next.Grants[identity] = grant
+ if err := s.commit(next); err != nil {
+ return CollectionGrant{}, err
+ }
+ return grant, nil
+}
+
func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog, purchase GachaPurchase) (CollectionGrant, error) {
if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 {
return CollectionGrant{}, errors.New("player: invalid regular gacha purchase")
diff --git a/go/internal/server/player/equipment.go b/go/internal/server/player/equipment.go
index e01a6ab..4d4e1e1 100644
--- a/go/internal/server/player/equipment.go
+++ b/go/internal/server/player/equipment.go
@@ -133,6 +133,9 @@ func (s *EquipmentInventory) AttachOptionReroll(design *gamedata.EquipmentOption
func (s *EquipmentInventory) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
+ if id == "" || id == s.sessionID {
+ return
+ }
s.sessionID = id
s.smeltCache = make(map[string]smeltingReply)
}
@@ -789,7 +792,7 @@ func (s *EquipmentInventory) upgradeOnce(request []byte) (int, []byte, bool, err
return errors.New("player: EquipUpgrade invalid material")
}
var item Item
- if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
+ if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil {
return err
}
if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) {
@@ -1199,7 +1202,7 @@ func equipmentRequestItems(request []byte, number int, operation string) ([]Item
return fmt.Errorf("player: %s invalid material", operation)
}
var item Item
- if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
+ if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil {
return err
}
if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) {
diff --git a/go/internal/server/player/growth.go b/go/internal/server/player/growth.go
index 5e3002f..b9f2844 100644
--- a/go/internal/server/player/growth.go
+++ b/go/internal/server/player/growth.go
@@ -2,9 +2,12 @@ package player
import (
"encoding/binary"
+ "encoding/hex"
+ "encoding/json"
"errors"
"fmt"
"strconv"
+ "strings"
"sync"
"bd2server/internal/server/gamedata"
@@ -36,12 +39,14 @@ type CharacterStore struct {
promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error)
talentGrowth *gamedata.TalentGrowthDesign
sessionID string
- talentReplies map[string]talentUpgradeReply
+ talentReplies map[string]map[string]talentUpgradeReply
+ talentApplied map[string]talentUpgradeReply
}
type talentUpgradeReply struct {
- code int
- body []byte
+ Digest string `json:"digest"`
+ Code int `json:"code"`
+ Body []byte `json:"body,omitempty"`
}
func (s *CharacterStore) AttachWallet(wallet *Wallet) error {
@@ -70,8 +75,24 @@ func (s *CharacterStore) AttachTalentGrowth(design *gamedata.TalentGrowthDesign)
func (s *CharacterStore) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
+ if id == "" || id == s.sessionID {
+ return
+ }
s.sessionID = id
- s.talentReplies = make(map[string]talentUpgradeReply)
+ if s.talentReplies == nil {
+ s.talentReplies = make(map[string]map[string]talentUpgradeReply)
+ }
+ if s.talentReplies[id] == nil {
+ if len(s.talentReplies) >= 1024 {
+ for oldID := range s.talentReplies {
+ if oldID != id {
+ delete(s.talentReplies, oldID)
+ break
+ }
+ }
+ }
+ s.talentReplies[id] = make(map[string]talentUpgradeReply)
+ }
}
// AttachMaxHealth makes growth and post-battle revival consume the same
@@ -104,7 +125,7 @@ func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Invent
if !ok {
return nil, errors.New("player: character store requires atomic entries")
}
- s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, talentReplies: make(map[string]talentUpgradeReply)}
+ s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, talentReplies: make(map[string]map[string]talentUpgradeReply), talentApplied: make(map[string]talentUpgradeReply)}
s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials)
}
@@ -123,6 +144,13 @@ func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Invent
if len(orphaned) != 0 {
return nil, errors.New("player: character entries exist without core")
}
+ applied, err := entries.ListEntries("characters", "talent_upgrades")
+ if err != nil {
+ return nil, err
+ }
+ if len(applied) != 0 {
+ return nil, errors.New("player: talent upgrade entries exist without character core")
+ }
return s, validateCharacters(s.characters)
}
saved, loaded, err := loadCharacterEntries(entries, data)
@@ -142,12 +170,40 @@ func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Invent
return nil, fmt.Errorf("player: current character state omits seeded inventory index %d", character.InvenIndex)
}
}
+ if err := s.loadTalentUpgradeLedger(entries); err != nil {
+ return nil, err
+ }
if err := validateCharacters(s.characters); err != nil {
return nil, err
}
return s, nil
}
+func (s *CharacterStore) loadTalentUpgradeLedger(entries stateio.AtomicEntryStore) error {
+ rows, err := entries.ListEntries("characters", "talent_upgrades")
+ if err != nil {
+ return err
+ }
+ for key, payload := range rows {
+ parts := strings.Split(key, ":")
+ if len(parts) != 2 {
+ return fmt.Errorf("player: invalid talent upgrade ledger key %q", key)
+ }
+ index, indexErr := strconv.ParseUint(parts[0], 10, 64)
+ level, levelErr := strconv.ParseUint(parts[1], 10, 64)
+ var reply talentUpgradeReply
+ if indexErr != nil || levelErr != nil || index == 0 || level < 2 || json.Unmarshal(payload, &reply) != nil ||
+ reply.Code != talentSkillUpgradePacketCode || len(reply.Digest) != 64 {
+ return fmt.Errorf("player: invalid talent upgrade ledger entry %q", key)
+ }
+ if _, err := hex.DecodeString(reply.Digest); err != nil {
+ return fmt.Errorf("player: invalid talent upgrade digest %q", key)
+ }
+ s.talentApplied[key] = reply
+ }
+ return nil
+}
+
func (s *CharacterStore) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
diff --git a/go/internal/server/player/growth_entries.go b/go/internal/server/player/growth_entries.go
index 5ff34d9..53a76e2 100644
--- a/go/internal/server/player/growth_entries.go
+++ b/go/internal/server/player/growth_entries.go
@@ -65,6 +65,10 @@ func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characte
}
func (s *CharacterStore) persist(next []Character) error {
+ return s.persistWithChanges(next, nil)
+}
+
+func (s *CharacterStore) persistWithChanges(next []Character, additional []stateio.EntryMutation) error {
if err := validateCharacters(next); err != nil {
return err
}
@@ -74,7 +78,8 @@ func (s *CharacterStore) persist(next []Character) error {
}
current := make(map[uint64]bool, len(next))
order := make([]uint64, 0, len(next))
- changes := make([]stateio.EntryMutation, 0)
+ changes := make([]stateio.EntryMutation, 0, len(additional)+len(next))
+ changes = append(changes, additional...)
for _, character := range next {
index := character.InvenIndex
current[index] = true
diff --git a/go/internal/server/player/inventory_slots.go b/go/internal/server/player/inventory_slots.go
index 9ab41d6..79ed56e 100644
--- a/go/internal/server/player/inventory_slots.go
+++ b/go/internal/server/player/inventory_slots.go
@@ -103,6 +103,9 @@ func (s *InventorySlots) AttachDevelopmentSettings(path string) {
func (s *InventorySlots) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
+ if id == "" || id == s.sessionID {
+ return
+ }
s.sessionID = id
s.replies = map[string]inventorySlotReply{}
}
diff --git a/go/internal/server/player/owned.go b/go/internal/server/player/owned.go
index bf97ee7..08a9da3 100644
--- a/go/internal/server/player/owned.go
+++ b/go/internal/server/player/owned.go
@@ -475,6 +475,9 @@ func ItemWire(item Item) []byte {
b = wire.AppendVarint(b, f.n, f.v)
}
}
+ if item.ExpiryTime != 0 {
+ b = wire.AppendVarint(b, 8, item.ExpiryTime)
+ }
if item.SortID != 0 {
b = wire.AppendVarint(b, 9, item.SortID)
}
diff --git a/go/internal/server/player/starter.go b/go/internal/server/player/starter.go
index 1e66c14..25784b9 100644
--- a/go/internal/server/player/starter.go
+++ b/go/internal/server/player/starter.go
@@ -21,6 +21,7 @@ type Item struct {
Count uint64 `json:"count"`
KeepFlag uint64 `json:"keep_flag,omitempty"`
TimeValue uint64 `json:"time_value,omitempty"`
+ ExpiryTime uint64 `json:"expiry_time,omitempty"`
Pictorialbook *Pictorial `json:"pictorialbook,omitempty"`
SortID uint64 `json:"sort_id,omitempty"`
UseCount uint64 `json:"use_count,omitempty"`
diff --git a/go/internal/server/player/talent_upgrade.go b/go/internal/server/player/talent_upgrade.go
index 5663a53..eb28723 100644
--- a/go/internal/server/player/talent_upgrade.go
+++ b/go/internal/server/player/talent_upgrade.go
@@ -1,12 +1,16 @@
package player
import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
"errors"
"fmt"
"math"
"strconv"
"bd2server/internal/server/gamedata"
+ "bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
@@ -14,23 +18,43 @@ const talentSkillUpgradePacketCode = 44
func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool, error) {
seq, found, err := wire.Varint(request, 1)
- if err != nil || !found || seq == 0 {
+ if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, true, errors.New("player: TalentSkillUpgrade missing sequence")
}
index, found, err := wire.Varint(request, 2)
- if err != nil || !found || index == 0 {
+ if err != nil || !found || index == 0 || index > math.MaxInt64 {
return 0, nil, true, errors.New("player: TalentSkillUpgrade missing character")
}
materials, err := equipmentRequestItems(request, 3, "TalentSkillUpgrade")
if err != nil {
return 0, nil, true, err
}
+ for _, material := range materials {
+ if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 ||
+ material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 ||
+ material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 {
+ return 0, nil, true, errors.New("player: TalentSkillUpgrade material exceeds protocol range")
+ }
+ }
s.mu.Lock()
defer s.mu.Unlock()
- cacheKey := "talent-upgrade:" + s.sessionID + ":seq:" + strconv.FormatUint(seq, 10)
- if reply, ok := s.talentReplies[cacheKey]; ok {
- return reply.code, append([]byte(nil), reply.body...), true, nil
+ digestBytes := sha256.Sum256(request)
+ digest := hex.EncodeToString(digestBytes[:])
+ sessionID := s.sessionID
+ if sessionID == "" {
+ sessionID = "__direct__"
+ }
+ if s.talentReplies[sessionID] == nil {
+ s.talentReplies[sessionID] = make(map[string]talentUpgradeReply)
+ }
+ replies := s.talentReplies[sessionID]
+ cacheKey := "seq:" + strconv.FormatUint(seq, 10)
+ if reply, ok := replies[cacheKey]; ok {
+ if reply.Digest != digest {
+ return 0, nil, true, errors.New("player: TalentSkillUpgrade sequence reused with different request")
+ }
+ return reply.Code, append([]byte(nil), reply.Body...), true, nil
}
if s.talentGrowth == nil || s.wallet == nil || s.inventory == nil {
return 0, nil, true, errors.New("player: talent upgrade unavailable")
@@ -51,6 +75,11 @@ func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool,
if position < 0 && !fromCollection {
return 0, nil, true, fmt.Errorf("player: unknown talent character inventory index %d", index)
}
+ previousLedgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10)
+ if reply, ok := s.talentApplied[previousLedgerKey]; ok && reply.Digest == digest {
+ replies[cacheKey] = reply
+ return reply.Code, append([]byte(nil), reply.Body...), true, nil
+ }
rule, err := s.talentGrowth.UpgradeRule(current.ID, current.TalentLevel)
if err != nil {
return 0, nil, true, fmt.Errorf("player: resolve talent upgrade: %w", err)
@@ -74,12 +103,16 @@ func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool,
previousID := current.ID
current.TalentLevel++
+ ledgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10)
+ if _, exists := s.talentApplied[ledgerKey]; exists {
+ return 0, nil, true, fmt.Errorf("player: talent upgrade ledger already contains target %s", ledgerKey)
+ }
if fromCollection {
if err := s.collection.CanUpdateCharacter(previousID, current); err != nil {
return 0, nil, true, fmt.Errorf("player: validate collection talent upgrade: %w", err)
}
}
- identity := cacheKey + ":character:" + strconv.FormatUint(index, 10) + ":level:" + strconv.FormatUint(rule.CurrentLevel, 10)
+ identity := "talent-upgrade:" + sessionID + ":" + cacheKey + ":character:" + strconv.FormatUint(index, 10) + ":level:" + strconv.FormatUint(rule.CurrentLevel, 10)
if gold != 0 {
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
return 0, nil, true, fmt.Errorf("player: consume talent upgrade gold: %w", err)
@@ -90,21 +123,31 @@ func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool,
return 0, nil, true, fmt.Errorf("player: consume talent upgrade items: %w", err)
}
}
+ reply := talentUpgradeReply{Digest: digest, Code: talentSkillUpgradePacketCode}
+ ledgerPayload, err := json.Marshal(reply)
+ if err != nil {
+ return 0, nil, true, err
+ }
+ ledgerChange := stateio.EntryMutation{Bucket: "talent_upgrades", Key: ledgerKey, Payload: ledgerPayload}
if fromCollection {
if err := s.collection.UpdateCharacter(previousID, current); err != nil {
return 0, nil, true, fmt.Errorf("player: persist collection talent upgrade: %w", err)
}
+ if err := s.store.SaveWithEntries("characters", nil, []stateio.EntryMutation{ledgerChange}); err != nil {
+ return 0, nil, true, fmt.Errorf("player: persist talent upgrade replay ledger: %w", err)
+ }
} else {
next := append([]Character(nil), s.characters...)
next[position] = current
- if err := s.persist(next); err != nil {
+ if err := s.persistWithChanges(next, []stateio.EntryMutation{ledgerChange}); err != nil {
return 0, nil, true, fmt.Errorf("player: persist talent upgrade: %w", err)
}
s.characters = next
}
// TalentSkillUpgradeResponse.item_info is an optional grant list. Current
// GameData defines no refund, so the correct protobuf response is empty.
- s.talentReplies[cacheKey] = talentUpgradeReply{code: talentSkillUpgradePacketCode}
+ s.talentApplied[ledgerKey] = reply
+ replies[cacheKey] = reply
return talentSkillUpgradePacketCode, nil, true, nil
}
diff --git a/go/internal/server/player/talent_upgrade_test.go b/go/internal/server/player/talent_upgrade_test.go
index c690277..60ef76c 100644
--- a/go/internal/server/player/talent_upgrade_test.go
+++ b/go/internal/server/player/talent_upgrade_test.go
@@ -1,9 +1,11 @@
package player
import (
+ "math"
"path/filepath"
"testing"
+ "bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
@@ -73,6 +75,9 @@ func TestTalentSkillUpgradeConsumesExactCostsPersistsAndReplays(t *testing.T) {
if err := inventory.CanConsume([]Item{book}); err != nil {
t.Fatalf("one talent book should remain: %v", err)
}
+ // Session dispatch activates SessionAware handlers before every request.
+ // Re-activating the same session must retain the sequence replay result.
+ characters.BeginSession("login-a")
code, response, handled, err = characters.Handle("/TalentSkillUpgrade", request)
if err != nil || !handled || code != talentSkillUpgradePacketCode || len(response) != 0 {
t.Fatalf("replay code=%d handled=%v response=%x err=%v", code, handled, response, err)
@@ -90,6 +95,253 @@ func TestTalentSkillUpgradeConsumesExactCostsPersistsAndReplays(t *testing.T) {
if got, found := reloaded.Find(77); !found || got.TalentLevel != 2 || got.TalentExp != 14 {
t.Fatalf("reloaded character=%+v found=%v", got, found)
}
+ if err := reloaded.AttachWallet(wallet); err != nil {
+ t.Fatal(err)
+ }
+ if err := reloaded.AttachTalentGrowth(testTalentGrowthDesign()); err != nil {
+ t.Fatal(err)
+ }
+ reloaded.BeginSession("login-after-restart")
+ if code, body, handled, err := reloaded.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
+ t.Fatalf("restart replay code=%d handled=%v body=%x err=%v", code, handled, body, err)
+ }
+ if got, _ := reloaded.Find(77); got.TalentLevel != 2 || wallet.Snapshot().Gold != 4000 {
+ t.Fatalf("restart replay duplicated upgrade: character=%+v gold=%d", got, wallet.Snapshot().Gold)
+ }
+}
+
+func TestTalentSkillUpgradeRejectsSameSessionSequenceWithDifferentRequest(t *testing.T) {
+ store := testStore(filepath.Join(t.TempDir(), "state.json"))
+ inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ books, err := inventory.GrantOnce("books", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 2}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := OpenWallet(store, Currency{Gold: 5000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = characters.AttachWallet(wallet)
+ _ = characters.AttachTalentGrowth(testTalentGrowthDesign())
+ characters.BeginSession("login-a")
+ book := books[0]
+ book.Count = 1
+ request := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 1000})
+ if _, _, _, err := characters.Handle("/TalentSkillUpgrade", request); err != nil {
+ t.Fatal(err)
+ }
+ different := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 999})
+ if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", different); err == nil || !handled {
+ t.Fatalf("different request replay handled=%v err=%v", handled, err)
+ }
+ if got, _ := characters.Find(77); got.TalentLevel != 2 || wallet.Snapshot().Gold != 4000 {
+ t.Fatalf("different replay mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold)
+ }
+}
+
+func TestTalentSkillUpgradeRetainsSequenceDigestAcrossInterleavedSessions(t *testing.T) {
+ store := testStore(filepath.Join(t.TempDir(), "state.json"))
+ inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ books, err := inventory.GrantOnce("interleaved-books", []gamedata.BattleReward{
+ {Type: 8, ID: 3, Count: 1},
+ {Type: 8, ID: 4, Count: 1},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := OpenWallet(store, Currency{Gold: 5000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 42}}, inventory, "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = characters.AttachWallet(wallet)
+ _ = characters.AttachTalentGrowth(testTalentGrowthDesign())
+ characters.BeginSession("login-a")
+ first := talentUpgradeRequest(9, 77, books[0], Item{Type: 4, Count: 1000})
+ if _, _, _, err := characters.Handle("/TalentSkillUpgrade", first); err != nil {
+ t.Fatal(err)
+ }
+ characters.BeginSession("login-b")
+ characters.BeginSession("login-a")
+ second := talentUpgradeRequest(9, 77, books[1], Item{Type: 4, Count: 2000})
+ if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", second); err == nil || !handled {
+ t.Fatalf("interleaved session forgot sequence digest: handled=%v err=%v", handled, err)
+ }
+ if got, _ := characters.Find(77); got.TalentLevel != 2 || got.TalentExp != 42 || wallet.Snapshot().Gold != 4000 {
+ t.Fatalf("reused sequence mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold)
+ }
+ if err := inventory.CanConsume([]Item{books[1]}); err != nil {
+ t.Fatalf("reused sequence consumed second-rank material: %v", err)
+ }
+}
+
+func TestTalentSkillUpgradeRejectsOutOfRangeItemMetadata(t *testing.T) {
+ store := testStore(filepath.Join(t.TempDir(), "state.json"))
+ inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ books, err := inventory.GrantOnce("range-book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := OpenWallet(store, Currency{Gold: 1000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = characters.AttachWallet(wallet)
+ _ = characters.AttachTalentGrowth(testTalentGrowthDesign())
+ book := books[0]
+ book.Count = 1
+ book.ExpiryTime = math.MaxInt64 + 1
+ request := talentUpgradeRequest(1, 77, book, Item{Type: 4, Count: 1000})
+ if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", request); err == nil || !handled {
+ t.Fatalf("out-of-range metadata accepted: handled=%v err=%v", handled, err)
+ }
+ if got, _ := characters.Find(77); got.TalentLevel != 1 || wallet.Snapshot().Gold != 1000 {
+ t.Fatalf("out-of-range request mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold)
+ }
+}
+
+func TestTalentSkillUpgradeConsumesOneMaterialAcrossMultipleStacks(t *testing.T) {
+ store := testStore(filepath.Join(t.TempDir(), "state.json"))
+ inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ books, err := inventory.GrantOnce("split-books", []gamedata.BattleReward{
+ {Type: 8, ID: 3, Count: 1},
+ {Type: 8, ID: 3, Count: 1},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := OpenWallet(store, Currency{Gold: 1000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ design := testTalentGrowthDesign()
+ level := design.Levels[[2]uint64{904, 1}]
+ level.Costs = []gamedata.PromotionCost{{Type: 8, ID: 3, Count: 2}, {Type: 4, Count: 1000}}
+ design.Levels[[2]uint64{904, 1}] = level
+ _ = characters.AttachWallet(wallet)
+ _ = characters.AttachTalentGrowth(design)
+ request := talentUpgradeRequest(1, 77, books[0], books[1], Item{Type: 4, Count: 1000})
+ if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
+ t.Fatalf("split-stack upgrade code=%d handled=%v body=%x err=%v", code, handled, body, err)
+ }
+ if got, _ := characters.Find(77); got.TalentLevel != 2 || got.TalentExp != 14 || wallet.Snapshot().Gold != 0 {
+ t.Fatalf("split-stack result character=%+v gold=%d", got, wallet.Snapshot().Gold)
+ }
+ if err := inventory.CanConsume([]Item{books[0]}); err == nil {
+ t.Fatal("first material stack survived consumption")
+ }
+ if err := inventory.CanConsume([]Item{books[1]}); err == nil {
+ t.Fatal("second material stack survived consumption")
+ }
+}
+
+func TestTalentSkillUpgradeLedgerSurvivesSQLiteCloseAndReopen(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "state.db")
+ repository, err := accountstate.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ inventory, err := OpenInventory(repository, &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ books, err := inventory.GrantOnce("sqlite-talent-book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err := OpenWallet(repository, Currency{Gold: 5000})
+ if err != nil {
+ t.Fatal(err)
+ }
+ characters, err := OpenCharacterStore(repository, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, characters.EnsurePersisted} {
+ if err := ensure(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _ = characters.AttachWallet(wallet)
+ _ = characters.AttachTalentGrowth(testTalentGrowthDesign())
+ characters.BeginSession("before-restart")
+ request := talentUpgradeRequest(9, 77, books[0], Item{Type: 4, Count: 1000})
+ operation, err := repository.BeginOperation()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
+ _ = operation.Rollback()
+ t.Fatalf("upgrade code=%d handled=%v body=%x err=%v", code, handled, body, err)
+ }
+ if err := operation.Commit(); err != nil {
+ t.Fatal(err)
+ }
+ if err := repository.Close(); err != nil {
+ t.Fatal(err)
+ }
+
+ repository, err = accountstate.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer repository.Close()
+ inventory, err = OpenInventory(repository, &Starter{Version: "2.35.10"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ wallet, err = OpenWallet(repository, Currency{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ characters, err = OpenCharacterStore(repository, nil, inventory, "", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = characters.AttachWallet(wallet)
+ _ = characters.AttachTalentGrowth(testTalentGrowthDesign())
+ characters.BeginSession("after-restart")
+ operation, err = repository.BeginOperation()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
+ _ = operation.Rollback()
+ t.Fatalf("replay code=%d handled=%v body=%x err=%v", code, handled, body, err)
+ }
+ if err := operation.Commit(); err != nil {
+ t.Fatal(err)
+ }
+ if current, found := characters.Find(77); !found || current.TalentLevel != 2 || current.TalentExp != 14 || wallet.Snapshot().Gold != 4000 {
+ t.Fatalf("restarted state character=%+v found=%v gold=%d", current, found, wallet.Snapshot().Gold)
+ }
}
func TestTalentSkillUpgradeRejectsInvalidStateWithoutCharging(t *testing.T) {
diff --git a/go/internal/server/session/server.go b/go/internal/server/session/server.go
index bed1119..ad82b6a 100644
--- a/go/internal/server/session/server.go
+++ b/go/internal/server/session/server.go
@@ -134,7 +134,7 @@ func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport
if s.auth != nil {
accountID, err = s.auth.AuthenticateLogin(request)
if err != nil {
- return transport.RawReply{}, fmt.Errorf("LoginUser authentication: %w", err)
+ return transport.RawReply{}, fmt.Errorf("%w: %v", transport.ErrAccessCredentialInvalid, err)
}
if accountID == "" {
return transport.RawReply{}, errors.New("LoginUser authentication returned an empty account ID")
diff --git a/go/internal/server/session/server_test.go b/go/internal/server/session/server_test.go
index 0ade2cc..005988c 100644
--- a/go/internal/server/session/server_test.go
+++ b/go/internal/server/session/server_test.go
@@ -1,6 +1,7 @@
package session
import (
+ "database/sql"
"encoding/base64"
"encoding/json"
"errors"
@@ -124,13 +125,14 @@ func TestBatchUsesOneAccountTransaction(t *testing.T) {
if _, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie); err == nil {
t.Fatal("partially failing batch was accepted")
}
- verified, err := accountstate.Open(statePath)
+ verified, err := sql.Open("sqlite", statePath)
if err != nil {
t.Fatal(err)
}
defer verified.Close()
for name, want := range map[string]string{"wallet": "old-wallet", "items": "old-items"} {
- got, err := verified.Load(name)
+ var got []byte
+ err := verified.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got)
if err != nil || string(got) != want {
t.Fatalf("batch rollback %s=%q err=%v", name, got, err)
}
@@ -467,16 +469,18 @@ func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T)
if test.fail && requestErr == nil || !test.fail && requestErr != nil {
t.Fatalf("request err=%v", requestErr)
}
- reader := repository
- if test.fail {
- reader, err = accountstate.Open(statePath)
- if err != nil {
- t.Fatal(err)
- }
- defer reader.Close()
- }
for _, name := range []string{"wallet", "items"} {
- got, err := reader.Load(name)
+ var got []byte
+ if test.fail {
+ reader, openErr := sql.Open("sqlite", statePath)
+ if openErr != nil {
+ t.Fatal(openErr)
+ }
+ err = reader.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got)
+ reader.Close()
+ } else {
+ got, err = repository.Load(name)
+ }
if err != nil || string(got) != test.want+name {
t.Fatalf("%s=%q err=%v", name, got, err)
}
diff --git a/go/internal/server/stateio/stateio.go b/go/internal/server/stateio/stateio.go
index acf089a..376a702 100644
--- a/go/internal/server/stateio/stateio.go
+++ b/go/internal/server/stateio/stateio.go
@@ -2,10 +2,14 @@
package stateio
import (
+ "errors"
"fmt"
"sync"
)
+var ErrWriterFenced = errors.New("state writer fenced by a newer server instance")
+var ErrStateRecoveryRequired = errors.New("state transaction outcome requires process restart")
+
func RequireNoEntries(store EntryStore, domain string, buckets ...string) error {
for _, bucket := range buckets {
entries, err := store.ListEntries(domain, bucket)
diff --git a/go/internal/server/transport/http.go b/go/internal/server/transport/http.go
index 0dba101..72f0a8e 100644
--- a/go/internal/server/transport/http.go
+++ b/go/internal/server/transport/http.go
@@ -15,6 +15,7 @@ import (
"bd2server/internal/server/authconfig"
"bd2server/internal/server/bootstrap"
"bd2server/internal/server/resourcepolicy"
+ "bd2server/internal/server/stateio"
)
type Envelope struct {
@@ -52,6 +53,11 @@ type RawDispatcher interface {
DispatchRaw(path string, wireBody []byte, cookie string) (RawReply, error)
}
+type Availability interface {
+ BeginRequest() (done func(), ok bool)
+ Ready() bool
+}
+
type Bootstrap struct {
Config bootstrap.Config
Now func() time.Time
@@ -59,6 +65,10 @@ type Bootstrap struct {
var ErrNotImplemented = errors.New("packet not implemented")
+// ErrAccessCredentialInvalid distinguishes a rejected LoginUser bearer
+// credential from an expired per-process game-session cookie.
+var ErrAccessCredentialInvalid = errors.New("game access credential invalid")
+
// ErrGameSessionExpired tells the HTTP adapter that a syntactically valid
// game-session cookie no longer names a live session. A dedicated status and
// header let the client distinguish this condition from a transient network
@@ -97,6 +107,8 @@ type HTTP struct {
Authentication authconfig.Config
AuthenticationHandler http.Handler
ResourcePolicy resourcepolicy.Public
+ Availability Availability
+ InstanceID string
}
func (h HTTP) Handler() http.Handler {
@@ -105,18 +117,64 @@ func (h HTTP) Handler() http.Handler {
mux.HandleFunc("/game/StateCheckInfoJson", h.stateCheck)
mux.HandleFunc("/auth/config", h.authenticationConfig)
mux.HandleFunc("/client/resources", h.clientResources)
+ mux.HandleFunc("/client/runtime", h.clientRuntime)
if h.AuthenticationHandler != nil {
- mux.Handle("/auth/", h.AuthenticationHandler)
+ mux.Handle("/auth/", h.withAvailability(h.AuthenticationHandler))
}
mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
mux.HandleFunc("/game/", h.game)
- mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
+ mux.HandleFunc("/livez", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
})
+ ready := func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ if h.Availability != nil && !h.Availability.Ready() {
+ http.Error(w, "draining", http.StatusServiceUnavailable)
+ return
+ }
+ _, _ = w.Write([]byte("ready\n"))
+ }
+ mux.HandleFunc("/readyz", ready)
+ mux.HandleFunc("/healthz", ready)
return mux
}
+func (h HTTP) withAvailability(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if h.Availability == nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+ done, ok := h.Availability.BeginRequest()
+ if !ok {
+ h.reconnect(w, "rolling-restart")
+ return
+ }
+ defer done()
+ next.ServeHTTP(w, r)
+ })
+}
+
+func (h HTTP) clientRuntime(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "GET required", http.StatusMethodNotAllowed)
+ return
+ }
+ status := "ready"
+ if h.Availability != nil && !h.Availability.Ready() {
+ status = "draining"
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ _ = json.NewEncoder(w).Encode(struct {
+ Status string `json:"status"`
+ InstanceID string `json:"instance_id"`
+ RetryAfterMS int `json:"retry_after_ms"`
+ }{Status: status, InstanceID: h.InstanceID, RetryAfterMS: 1000})
+}
+
func (h HTTP) clientResources(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "PUT required", http.StatusMethodNotAllowed)
@@ -186,6 +244,14 @@ func (h HTTP) stateCheck(w http.ResponseWriter, r *http.Request) {
func (h HTTP) game(w http.ResponseWriter, r *http.Request) {
started := time.Now()
+ if h.Availability != nil {
+ done, ok := h.Availability.BeginRequest()
+ if !ok {
+ h.reconnect(w, "rolling-restart")
+ return
+ }
+ defer done()
+ }
if r.Method != http.MethodPut {
http.Error(w, "PUT required", http.StatusMethodNotAllowed)
return
@@ -214,6 +280,16 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) {
}
reply, err := h.Raw.DispatchRaw(path, body, r.Header.Get("Cookie"))
if err != nil {
+ if errors.Is(err, stateio.ErrStateRecoveryRequired) {
+ h.logger().Error("account state requires process recovery", "path", path, "duration_ms", elapsedMilliseconds(started), "error", err)
+ h.reconnect(w, "state-recovery")
+ return
+ }
+ if errors.Is(err, stateio.ErrWriterFenced) {
+ h.logger().Warn("stale server instance fenced", "path", path, "duration_ms", elapsedMilliseconds(started))
+ h.reconnect(w, "fenced")
+ return
+ }
if errors.Is(err, ErrGameSessionExpired) {
h.logger().Info("game session expired", "path", path, "duration_ms", elapsedMilliseconds(started))
w.Header().Set("X-BD2-Session-Expired", "1")
@@ -221,6 +297,14 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) {
http.Error(w, "game session expired", http.StatusUnauthorized)
return
}
+ if errors.Is(err, ErrAccessCredentialInvalid) {
+ h.logger().Info("game access credential rejected", "path", path, "duration_ms", elapsedMilliseconds(started))
+ w.Header().Set("X-BD2-Access-Expired", "1")
+ w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, "game access credential invalid", http.StatusUnauthorized)
+ return
+ }
h.logger().Warn("session packet rejected", "path", path, "duration_ms", elapsedMilliseconds(started), "error", err)
http.Error(w, "session packet rejected", http.StatusBadRequest)
return
@@ -271,6 +355,15 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) {
}
}
+func (h HTTP) reconnect(w http.ResponseWriter, reason string) {
+ w.Header().Set("X-BD2-Reconnect", "1")
+ w.Header().Set("X-BD2-Reconnect-Reason", reason)
+ w.Header().Set("Retry-After", "1")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Connection", "close")
+ http.Error(w, "server restarting", http.StatusServiceUnavailable)
+}
+
func elapsedMilliseconds(started time.Time) float64 {
return float64(time.Since(started).Microseconds()) / 1000
}
diff --git a/go/internal/server/transport/http_test.go b/go/internal/server/transport/http_test.go
index 950248a..83e70af 100644
--- a/go/internal/server/transport/http_test.go
+++ b/go/internal/server/transport/http_test.go
@@ -12,10 +12,73 @@ import (
"bd2server/internal/server/authconfig"
"bd2server/internal/server/bootstrap"
+ "bd2server/internal/server/lifecycle"
"bd2server/internal/server/resourcepolicy"
+ "bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
+func TestDrainingServerRejectsGameRequestsWithReconnectMarker(t *testing.T) {
+ gate := lifecycle.NewGate()
+ handler := HTTP{Availability: gate, InstanceID: "instance-a"}.Handler()
+ ready := httptest.NewRecorder()
+ handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if ready.Code != http.StatusOK {
+ t.Fatalf("initial readiness=%d", ready.Code)
+ }
+ gate.Drain()
+ ready = httptest.NewRecorder()
+ handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil))
+ if ready.Code != http.StatusServiceUnavailable {
+ t.Fatalf("draining readiness=%d", ready.Code)
+ }
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
+ if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" ||
+ response.Header().Get("X-BD2-Reconnect-Reason") != "rolling-restart" ||
+ response.Header().Get("Retry-After") != "1" || response.Header().Get("Connection") != "close" {
+ t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
+ }
+ health := httptest.NewRecorder()
+ handler.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil))
+ if health.Code != http.StatusServiceUnavailable {
+ t.Fatalf("readiness alias during drain=%d", health.Code)
+ }
+ live := httptest.NewRecorder()
+ handler.ServeHTTP(live, httptest.NewRequest(http.MethodGet, "/livez", nil))
+ if live.Code != http.StatusOK {
+ t.Fatalf("liveness during drain=%d", live.Code)
+ }
+ runtime := httptest.NewRecorder()
+ handler.ServeHTTP(runtime, httptest.NewRequest(http.MethodGet, "/client/runtime", nil))
+ var status struct {
+ Status string `json:"status"`
+ ID string `json:"instance_id"`
+ }
+ if err := json.Unmarshal(runtime.Body.Bytes(), &status); err != nil || status.Status != "draining" || status.ID != "instance-a" {
+ t.Fatalf("runtime=%+v err=%v", status, err)
+ }
+}
+
+func TestDrainingServerRejectsAuthenticationMutationBeforeHandler(t *testing.T) {
+ gate := lifecycle.NewGate()
+ called := false
+ handler := HTTP{
+ Availability: gate,
+ AuthenticationHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ }.Handler()
+ gate.Drain()
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/auth/session/refresh", strings.NewReader(`{}`)))
+ if called || response.Code != http.StatusServiceUnavailable ||
+ response.Header().Get("X-BD2-Reconnect") != "1" {
+ t.Fatalf("called=%v status=%d headers=%v", called, response.Code, response.Header())
+ }
+}
+
func TestBootstrapRoundTrip(t *testing.T) {
cfg := bootstrap.Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: "test-client", BundleVer: "test-bundle"}
now := func() time.Time { return time.UnixMilli(12345) }
@@ -91,6 +154,17 @@ func TestExpiredGameSessionUsesDedicatedHTTPMarker(t *testing.T) {
}
}
+func TestInvalidGameAccessCredentialUsesDedicatedHTTPMarker(t *testing.T) {
+ h := HTTP{Raw: failedRawDispatcher{err: ErrAccessCredentialInvalid}}.Handler()
+ response := httptest.NewRecorder()
+ h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/LoginUser", strings.NewReader("encrypted")))
+ if response.Code != http.StatusUnauthorized || response.Header().Get("X-BD2-Access-Expired") != "1" ||
+ response.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` ||
+ response.Header().Get("X-BD2-Session-Expired") != "" {
+ t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
+ }
+}
+
func TestDomainFailureDoesNotUseExpiredSessionMarker(t *testing.T) {
h := HTTP{Raw: failedRawDispatcher{err: errors.New("mail seed is invalid")}}.Handler()
response := httptest.NewRecorder()
@@ -101,6 +175,26 @@ func TestDomainFailureDoesNotUseExpiredSessionMarker(t *testing.T) {
}
}
+func TestFencedWriterRequestsReconnectInsteadOfDomainFailure(t *testing.T) {
+ h := HTTP{Raw: failedRawDispatcher{err: stateio.ErrWriterFenced}}.Handler()
+ response := httptest.NewRecorder()
+ h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
+ if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" ||
+ response.Header().Get("X-BD2-Reconnect-Reason") != "fenced" {
+ t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
+ }
+}
+
+func TestUncertainStateTransactionRequestsProcessRecovery(t *testing.T) {
+ h := HTTP{Raw: failedRawDispatcher{err: stateio.ErrStateRecoveryRequired}}.Handler()
+ response := httptest.NewRecorder()
+ h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted")))
+ if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" ||
+ response.Header().Get("X-BD2-Reconnect-Reason") != "state-recovery" {
+ t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String())
+ }
+}
+
func TestOAuthGameSessionCookieIsHostOnlySecureAndGameScoped(t *testing.T) {
h := HTTP{
Raw: cookieRawDispatcher{},
diff --git a/go/internal/server/versionconfig/config.go b/go/internal/server/versionconfig/config.go
index 21a3049..ea17c44 100644
--- a/go/internal/server/versionconfig/config.go
+++ b/go/internal/server/versionconfig/config.go
@@ -1,5 +1,5 @@
-// Package versionconfig loads the repository-wide client, seed, and
-// resource version selection. The same versions.json also drives plugin builds.
+// Package versionconfig loads repository-wide game compatibility, component
+// release, seed, and resource versions. The same versions.json drives plugins.
package versionconfig
import (
@@ -18,6 +18,7 @@ const FileName = "versions.json"
var (
semanticVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
+ releaseVersion = regexp.MustCompile(`^([0-9]+\.[0-9]+\.[0-9]+)\+(client|server)\.([0-9]+\.[0-9]+\.[0-9]+)$`)
resourceVersion = regexp.MustCompile(`^[0-9]{14}$`)
currentMu sync.RWMutex
current *Config
@@ -26,7 +27,9 @@ var (
// Config is the single version selection shared by the server and plugins.
// SourcePath is populated by Load and is not part of the JSON document.
type Config struct {
+ GameVersion string `json:"game_version"`
ClientVersion string `json:"client_version"`
+ ServerVersion string `json:"server_version"`
GameDataVersion string `json:"game_data_version"`
BundleVersion string `json:"bundle_version"`
SeedDirectory string `json:"seed_directory"`
@@ -70,14 +73,15 @@ func Load(path string) (Config, error) {
return cfg, nil
}
-func Client() string { return Current().ClientVersion }
-func State() string { return Current().ClientVersion }
+func Game() string { return Current().GameVersion }
+func Server() string { return Current().ServerVersion }
+func State() string { return Current().GameVersion }
func GameData() string { return Current().GameDataVersion }
func Bundle() string { return Current().BundleVersion }
func (c Config) Validate() error {
for name, value := range map[string]string{
- "client_version": c.ClientVersion,
+ "game_version": c.GameVersion,
"plugins.local_identity": c.Plugins.LocalIdentity,
"plugins.capture_environment": c.Plugins.CaptureEnvironment,
"plugins.login_ui": c.Plugins.LoginUI,
@@ -86,6 +90,12 @@ func (c Config) Validate() error {
return fmt.Errorf("%s must be a numeric three-part version", name)
}
}
+ if err := validateReleaseVersion("client_version", c.ClientVersion, c.GameVersion, "client"); err != nil {
+ return err
+ }
+ if err := validateReleaseVersion("server_version", c.ServerVersion, c.GameVersion, "server"); err != nil {
+ return err
+ }
for name, value := range map[string]string{
"game_data_version": c.GameDataVersion, "bundle_version": c.BundleVersion,
} {
@@ -103,6 +113,14 @@ func (c Config) Validate() error {
return nil
}
+func validateReleaseVersion(name, value, game, component string) error {
+ match := releaseVersion.FindStringSubmatch(value)
+ if len(match) != 4 || match[1] != game || match[2] != component {
+ return fmt.Errorf("%s must be %s+%s.X.Y.Z", name, game, component)
+ }
+ return nil
+}
+
// Resolve interprets a configured repository asset relative to versions.json,
// rather than relative to the process working directory.
func (c Config) Resolve(relative string) string {
diff --git a/go/internal/server/versionconfig/config_test.go b/go/internal/server/versionconfig/config_test.go
index 00d4b7d..6109737 100644
--- a/go/internal/server/versionconfig/config_test.go
+++ b/go/internal/server/versionconfig/config_test.go
@@ -10,7 +10,9 @@ func TestLoadAndResolve(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, FileName)
data := []byte(`{
- "client_version":"2.35.10",
+ "game_version":"2.35.10",
+ "client_version":"2.35.10+client.0.1.0",
+ "server_version":"2.35.10+server.0.1.0",
"game_data_version":"20260923193640",
"bundle_version":"20260921135230",
"seed_directory":"go/seed/v2_35_10",
@@ -23,7 +25,7 @@ func TestLoadAndResolve(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if cfg.ClientVersion != "2.35.10" || cfg.Plugins.LoginUI != "0.1.0" {
+ if cfg.GameVersion != "2.35.10" || cfg.ClientVersion != "2.35.10+client.0.1.0" || cfg.ServerVersion != "2.35.10+server.0.1.0" || cfg.Plugins.LoginUI != "0.1.0" {
t.Fatalf("unexpected config: %+v", cfg)
}
want := filepath.Join(dir, "go", "seed", "v2_35_10")
@@ -34,8 +36,9 @@ func TestLoadAndResolve(t *testing.T) {
func TestLoadRejectsUnknownAndEscapingFields(t *testing.T) {
for name, body := range map[string]string{
- "unknown": `{"client_version":"2.35.10","unknown":true}`,
- "escape": `{"client_version":"2.35.10","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"../seed","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`,
+ "unknown": `{"game_version":"2.35.10","unknown":true}`,
+ "escape": `{"game_version":"2.35.10","client_version":"2.35.10+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"../seed","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`,
+ "client-prefix": `{"game_version":"2.35.10","client_version":"2.36.0+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"seed","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`,
} {
t.Run(name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), FileName)
@@ -53,7 +56,9 @@ func TestFindUsesEnvironmentOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, FileName)
data := []byte(`{
- "client_version":"9.8.7",
+ "game_version":"9.8.7",
+ "client_version":"9.8.7+client.1.2.3",
+ "server_version":"9.8.7+server.4.5.6",
"game_data_version":"20260102030405",
"bundle_version":"20260504030201",
"seed_directory":"seed/current",
@@ -67,7 +72,7 @@ func TestFindUsesEnvironmentOverride(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if cfg.SourcePath != path || cfg.ClientVersion != "9.8.7" || cfg.GameDataVersion != "20260102030405" {
+ if cfg.SourcePath != path || cfg.GameVersion != "9.8.7" || cfg.ClientVersion != "9.8.7+client.1.2.3" || cfg.ServerVersion != "9.8.7+server.4.5.6" || cfg.GameDataVersion != "20260102030405" {
t.Fatalf("Find() ignored environment override: %+v", cfg)
}
}
diff --git a/plugins/CaptureEnvironment/Plugin.cs b/plugins/CaptureEnvironment/Plugin.cs
index 7694fbe..2bc7874 100644
--- a/plugins/CaptureEnvironment/Plugin.cs
+++ b/plugins/CaptureEnvironment/Plugin.cs
@@ -24,7 +24,7 @@ public sealed class Plugin : BaseUnityPlugin
private const int MaxBodyBytes = 16 * 1024 * 1024;
private const int MaxQueuedRecords = 256;
private const int WriterShutdownSeconds = 30;
- private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Client + ":";
+ private const string PlayerPrefsPrefix = "BD2OfficialCapture:" + Bd2Build.Versions.Game + ":";
private static readonly object CorrelationLock = new object();
private static readonly object FailureFileLock = new object();
private static readonly Dictionary> PendingRequests =
@@ -563,7 +563,7 @@ public sealed class Plugin : BaseUnityPlugin
private static void WriteMetadata()
{
File.WriteAllText(Path.Combine(CaptureDirectory, "README.txt"),
- "BD2 " + Bd2Build.Versions.Client + " official API capture.\r\n" +
+ "BD2 " + Bd2Build.Versions.Game + " official API capture.\r\n" +
"All NetworkManager plaintext protobuf requests and responses are recorded.\r\n" +
"capture.jsonl is machine-readable; capture.log is the aligned human-readable index.\r\n" +
"If INCOMPLETE.txt exists, the writer rejected or could not flush part of the capture.\r\n" +
diff --git a/plugins/Generate-VersionSource.ps1 b/plugins/Generate-VersionSource.ps1
index 82d99be..5316113 100644
--- a/plugins/Generate-VersionSource.ps1
+++ b/plugins/Generate-VersionSource.ps1
@@ -11,7 +11,9 @@ $pluginVersion = $versions.plugins.$Plugin
$semver = '^[0-9]+\.[0-9]+\.[0-9]+$'
$resource = '^[0-9]{14}$'
foreach ($entry in @(
- @('client_version', $versions.client_version, $semver),
+ @('game_version', $versions.game_version, $semver),
+ @('client_version', $versions.client_version, [regex]'^[0-9]+\.[0-9]+\.[0-9]+\+client\.[0-9]+\.[0-9]+\.[0-9]+$'),
+ @('server_version', $versions.server_version, [regex]'^[0-9]+\.[0-9]+\.[0-9]+\+server\.[0-9]+\.[0-9]+\.[0-9]+$'),
@('game_data_version', $versions.game_data_version, $resource),
@('bundle_version', $versions.bundle_version, $resource),
@("plugins.$Plugin", $pluginVersion, $semver)
@@ -20,6 +22,10 @@ foreach ($entry in @(
throw "Invalid $($entry[0]) in $Config"
}
}
+if (-not $versions.client_version.StartsWith("$($versions.game_version)+client.", [StringComparison]::Ordinal) -or
+ -not $versions.server_version.StartsWith("$($versions.game_version)+server.", [StringComparison]::Ordinal)) {
+ throw 'client_version and server_version must use the configured game_version prefix.'
+}
$source = @"
// Generated from versions.json. Do not edit.
@@ -27,7 +33,8 @@ namespace Bd2Build
{
internal static class Versions
{
- internal const string Client = "$($versions.client_version)";
+ internal const string Game = "$($versions.game_version)";
+ internal const string ClientRelease = "$($versions.client_version)";
internal const string GameData = "$($versions.game_data_version)";
internal const string Bundle = "$($versions.bundle_version)";
internal const string Plugin = "$pluginVersion";
diff --git a/plugins/LoginUI/CredentialStores.cs b/plugins/LoginUI/CredentialStores.cs
index f152578..0e0dcab 100644
--- a/plugins/LoginUI/CredentialStores.cs
+++ b/plugins/LoginUI/CredentialStores.cs
@@ -11,24 +11,41 @@ namespace Bd2LoginUI;
internal sealed class MemoryAccessTokenStore
{
private string value;
+ private string origin;
+ private string provider;
+ private long expiresAt;
public string Get()
{
return value ?? string.Empty;
}
- public void Set(string token)
+ public void Set(string token, string tokenOrigin, string tokenProvider, long expiresInSeconds)
{
- if (string.IsNullOrEmpty(token))
+ if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(tokenOrigin) ||
+ string.IsNullOrEmpty(tokenProvider) || expiresInSeconds <= 0)
{
- throw new ArgumentException("access token is empty", nameof(token));
+ throw new ArgumentException("access token metadata is incomplete", nameof(token));
}
value = token;
+ origin = tokenOrigin;
+ provider = tokenProvider;
+ expiresAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + expiresInSeconds;
+ }
+
+ public bool IsUsable(string tokenOrigin, long safetyWindowSeconds = 30)
+ {
+ return !string.IsNullOrEmpty(value) && origin == tokenOrigin &&
+ !string.IsNullOrEmpty(provider) &&
+ expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds() + safetyWindowSeconds;
}
public void Clear()
{
value = null;
+ origin = null;
+ provider = null;
+ expiresAt = 0;
}
}
@@ -49,6 +66,8 @@ internal sealed class RefreshCredential
public string provider;
public string refresh_token;
public long expires_at;
+ public string pending_attempt_id;
+ public string pending_refresh_token;
}
internal static class PlatformRefreshCredentialStore
diff --git a/plugins/LoginUI/LoginUI.csproj b/plugins/LoginUI/LoginUI.csproj
index 366556a..807639e 100644
--- a/plugins/LoginUI/LoginUI.csproj
+++ b/plugins/LoginUI/LoginUI.csproj
@@ -49,6 +49,10 @@
$(BD2ManagedDir)\UnityEngine.UIModule.dll
false
+
+ $(BD2ManagedDir)\UnityEngine.TextRenderingModule.dll
+ false
+
diff --git a/plugins/LoginUI/Plugin.cs b/plugins/LoginUI/Plugin.cs
index 35c3cd4..f53f6d2 100644
--- a/plugins/LoginUI/Plugin.cs
+++ b/plugins/LoginUI/Plugin.cs
@@ -41,14 +41,26 @@ public sealed class Plugin : BaseUnityPlugin
private static MemoryAccessTokenStore AccessTokens;
private static IRefreshCredentialStore RefreshCredentials;
private static int SessionRecoveryInProgress;
+ private static Plugin Owner;
+ private static bool EstablishedGameSession;
+ private static GameObject RecoveryOverlay;
+ private static Text RecoveryText;
+ private static MethodInfo EnterGame;
+ private static string ServerInstanceID;
+ private static int RecoveryRestartScheduled;
+ private static int RecoveryEnterScheduled;
+ private static int RecoveryGeneration;
+ private static int RuntimeProbeFailures;
private void Awake()
{
try
{
Log = Logger;
+ Owner = this;
AccessTokens = new MemoryAccessTokenStore();
RefreshCredentials = PlatformRefreshCredentialStore.Create();
+ StartCoroutine(RuntimeMonitor());
DiscordSymbol = LoadSprite(SymbolResource, "BD2 Discord Symbol");
DiscordWordmark = LoadSprite(WordmarkResource, "BD2 Discord Wordmark");
@@ -70,6 +82,7 @@ public sealed class Plugin : BaseUnityPlugin
new[] { typeof(bool) },
null);
SetIntroState = FindSetIntroState(introUI);
+ EnterGame = introUI.GetMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
OpenPCLoginPopup = FindOpenPCLoginPopup();
MethodInfo accessTokenGetter = FindAccessTokenGetter();
MethodInfo clearPCLocalData = FindClearPCLocalData();
@@ -90,7 +103,7 @@ public sealed class Plugin : BaseUnityPlugin
BindingFlags.Instance | BindingFlags.NonPublic);
if (SendMaintenance == null || SetIntroState == null || OpenPCLoginPopup == null ||
accessTokenGetter == null || clearPCLocalData == null || disposeWebRequest == null ||
- clientNetworkError == null || exponentialBackOff == null)
+ clientNetworkError == null || exponentialBackOff == null || EnterGame == null)
{
throw new MissingMethodException("IntroUI authentication transition methods were not found (client version mismatch)");
}
@@ -114,7 +127,10 @@ public sealed class Plugin : BaseUnityPlugin
prefix: new HarmonyMethod(typeof(Plugin), nameof(SuppressNetworkErrorDuringRecovery)));
harmony.Patch(
exponentialBackOff,
- prefix: new HarmonyMethod(typeof(Plugin), nameof(SuppressNetworkErrorDuringRecovery)));
+ prefix: new HarmonyMethod(typeof(Plugin), nameof(ExponentialBackoffPrefix)));
+ harmony.Patch(
+ SetIntroState,
+ postfix: new HarmonyMethod(typeof(Plugin), nameof(SetIntroStatePostfix)));
Logger.LogInfo("Server-authoritative Discord and Google login UI patch installed");
}
catch (Exception ex)
@@ -148,15 +164,28 @@ public sealed class Plugin : BaseUnityPlugin
if (requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) &&
request.responseCode >= 200 && request.responseCode < 300)
{
- Interlocked.Exchange(ref SessionRecoveryInProgress, 0);
+ EstablishedGameSession = true;
+ SetRecoveryMessage("正在同步玩家数据……\nSynchronizing player data…");
return;
}
- if (request.responseCode != 401 ||
- !string.Equals(request.GetResponseHeader("X-BD2-Session-Expired"), "1", StringComparison.Ordinal))
+ bool accessExpired = requestUri.AbsolutePath.Equals("/game/LoginUser", StringComparison.Ordinal) &&
+ request.responseCode == 401 &&
+ string.Equals(request.GetResponseHeader("X-BD2-Access-Expired"), "1", StringComparison.Ordinal);
+ if (accessExpired)
+ {
+ AccessTokens.Clear();
+ BeginSessionRecovery("expired game access credential");
+ return;
+ }
+ bool sessionExpired = request.responseCode == 401 &&
+ string.Equals(request.GetResponseHeader("X-BD2-Session-Expired"), "1", StringComparison.Ordinal);
+ bool serverRestarting = request.responseCode == 503 &&
+ string.Equals(request.GetResponseHeader("X-BD2-Reconnect"), "1", StringComparison.Ordinal);
+ if (!sessionExpired && !serverRestarting)
{
return;
}
- RecoverExpiredGameSession();
+ BeginSessionRecovery(serverRestarting ? "server restart" : "expired game session");
}
catch (Exception ex)
{
@@ -178,35 +207,24 @@ public sealed class Plugin : BaseUnityPlugin
return true;
}
- private static void RecoverExpiredGameSession()
+ private static void BeginSessionRecovery(string reason)
{
- if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) != 0)
- {
- return;
- }
try
{
- object network = FindUnitySingleton("BDNetwork.NetworkManager");
- object app = FindUnitySingleton("AppManager");
- MethodInfo refresh = network?.GetType().GetMethod(
- "Refresh",
- BindingFlags.Instance | BindingFlags.Public,
- null,
- Type.EmptyTypes,
- null);
- MethodInfo restart = app?.GetType().GetMethod(
- "AppReStart",
- BindingFlags.Instance | BindingFlags.Public,
- null,
- Type.EmptyTypes,
- null);
- if (refresh == null || restart == null)
+ if (Owner == null || ServerRoot == null)
{
- throw new MissingMethodException("client game-session recovery methods were not found");
+ throw new InvalidOperationException("client recovery coordinator is unavailable");
}
- Log?.LogWarning("Game session expired; returning to login and creating a new session");
- refresh.Invoke(network, null);
- restart.Invoke(app, null);
+ if (Interlocked.CompareExchange(ref SessionRecoveryInProgress, 1, 0) == 0)
+ {
+ Log?.LogWarning("Starting automatic game-session recovery: " + reason);
+ ShowRecoveryOverlay("正在重新连接服务器……\nReconnecting to server…");
+ }
+ else
+ {
+ Log?.LogWarning("Restarting automatic game-session recovery: " + reason);
+ }
+ ScheduleRecoveryRestart();
}
catch
{
@@ -215,11 +233,336 @@ public sealed class Plugin : BaseUnityPlugin
}
}
+ private static bool ExponentialBackoffPrefix(object __0)
+ {
+ if (ServerRoot == null || !IsConfiguredServerFailure(__0))
+ {
+ return true;
+ }
+ if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
+ {
+ BeginSessionRecovery("transport failure during recovery");
+ return false;
+ }
+ if (!EstablishedGameSession)
+ {
+ return true;
+ }
+ BeginSessionRecovery("transport failure");
+ return false;
+ }
+
+ private static bool IsConfiguredServerFailure(object packetException)
+ {
+ if (packetException == null || ServerRoot == null)
+ {
+ return false;
+ }
+ const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+ Type exceptionType = packetException.GetType();
+ string url = exceptionType.GetProperty("Url", flags)?.GetValue(packetException, null) as string;
+ object packet = null;
+ foreach (PropertyInfo property in exceptionType.GetProperties(flags))
+ {
+ if (property.PropertyType.Name == "PacketData" && property.GetIndexParameters().Length == 0)
+ {
+ packet = property.GetValue(packetException, null);
+ break;
+ }
+ }
+ string requestServer = packet?.GetType().GetField("RequestServerURL", flags)?.GetValue(packet) as string;
+ foreach (string candidate in new[] { requestServer, url })
+ {
+ if (string.IsNullOrWhiteSpace(candidate))
+ {
+ continue;
+ }
+ if (!Uri.TryCreate(candidate, UriKind.Absolute, out Uri parsed))
+ {
+ parsed = new Uri(ServerRoot, candidate.TrimStart('/'));
+ }
+ if (SameOrigin(ServerRoot, parsed))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void ScheduleRecoveryRestart()
+ {
+ Interlocked.Increment(ref RecoveryGeneration);
+ AuthenticationLoadingOrigin = null;
+ LoginInProgress = false;
+ ContinueMaintenance = false;
+ EstablishedGameSession = false;
+ RuntimeProbeFailures = 0;
+ ServerInstanceID = null;
+ Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
+ if (Interlocked.CompareExchange(ref RecoveryRestartScheduled, 1, 0) == 0)
+ {
+ Owner.StartCoroutine(StartLatestRecoveryGeneration());
+ }
+ }
+
+ private static IEnumerator StartLatestRecoveryGeneration()
+ {
+ int generation;
+ do
+ {
+ generation = Volatile.Read(ref RecoveryGeneration);
+ yield return new WaitForSecondsRealtime(0.25f);
+ }
+ while (generation != Volatile.Read(ref RecoveryGeneration));
+ Interlocked.Exchange(ref RecoveryRestartScheduled, 0);
+ if (Volatile.Read(ref SessionRecoveryInProgress) != 0)
+ {
+ Owner.StartCoroutine(WaitForServerAndRestart(generation));
+ }
+ }
+
+ private static IEnumerator WaitForServerAndRestart(int generation)
+ {
+ float delay = 0.5f;
+ while (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation == Volatile.Read(ref RecoveryGeneration))
+ {
+ SetRecoveryMessage("等待服务器启动……\nWaiting for server…");
+ using (UnityWebRequest request = UnityWebRequest.Get(new Uri(ServerRoot, "readyz")))
+ {
+ request.timeout = 6;
+ yield return request.SendWebRequest();
+ if (request.result == UnityWebRequest.Result.Success && request.responseCode == 200)
+ {
+ break;
+ }
+ }
+ yield return new WaitForSecondsRealtime(delay);
+ delay = Math.Min(delay * 2f, 5f);
+ }
+ if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || generation != Volatile.Read(ref RecoveryGeneration))
+ {
+ yield break;
+ }
+ SetRecoveryMessage("正在恢复登录会话……\nRestoring session…");
+ if (!RestartClientForRecovery())
+ {
+ FinishRecovery(false, "client restart methods are unavailable", generation);
+ }
+ }
+
+ private static bool RestartClientForRecovery()
+ {
+ object network = FindUnitySingleton("BDNetwork.NetworkManager");
+ object app = FindUnitySingleton("AppManager");
+ MethodInfo refresh = network?.GetType().GetMethod("Refresh", BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
+ MethodInfo restart = app?.GetType().GetMethod("AppReStart", BindingFlags.Instance | BindingFlags.Public, null, Type.EmptyTypes, null);
+ if (refresh == null || restart == null)
+ {
+ return false;
+ }
+ AuthenticationLoadingOrigin = null;
+ LoginInProgress = false;
+ ContinueMaintenance = false;
+ Interlocked.Exchange(ref RecoveryEnterScheduled, 0);
+ ServerInstanceID = null;
+ refresh.Invoke(network, null);
+ restart.Invoke(app, null);
+ return true;
+ }
+
+ private static IEnumerator RuntimeMonitor()
+ {
+ WaitForSecondsRealtime interval = new WaitForSecondsRealtime(5f);
+ while (true)
+ {
+ if (ServerRoot == null || !EstablishedGameSession || Volatile.Read(ref SessionRecoveryInProgress) != 0)
+ {
+ yield return interval;
+ continue;
+ }
+ using (UnityWebRequest request = UnityWebRequest.Get(new Uri(ServerRoot, "client/runtime")))
+ {
+ request.timeout = 6;
+ yield return request.SendWebRequest();
+ if (request.result != UnityWebRequest.Result.Success)
+ {
+ RuntimeProbeFailures++;
+ if (RuntimeProbeFailures >= 3)
+ {
+ BeginSessionRecovery("runtime probe failed three consecutive times");
+ }
+ }
+ else
+ {
+ RuntimeProbeFailures = 0;
+ RuntimeStatus status = null;
+ try
+ {
+ status = JsonUtility.FromJson(request.downloadHandler.text);
+ }
+ catch (Exception ex)
+ {
+ Log?.LogWarning("Server runtime response was invalid: " + ex.Message);
+ }
+ if (status != null && !string.IsNullOrEmpty(status.instance_id))
+ {
+ if (ServerInstanceID == null)
+ {
+ ServerInstanceID = status.instance_id;
+ }
+ else if (ServerInstanceID != status.instance_id || status.status == "draining")
+ {
+ ServerInstanceID = status.instance_id;
+ BeginSessionRecovery(status.status == "draining" ? "server draining" : "server instance changed");
+ }
+ }
+ }
+ }
+ yield return interval;
+ }
+ }
+
private static bool SuppressNetworkErrorDuringRecovery()
{
return Volatile.Read(ref SessionRecoveryInProgress) == 0;
}
+ private static void SetIntroStatePostfix(object __instance, object __0)
+ {
+ if (Volatile.Read(ref SessionRecoveryInProgress) == 0 || __0 == null || Convert.ToInt32(__0) != 9 || Owner == null)
+ {
+ return;
+ }
+ if (Interlocked.CompareExchange(ref RecoveryEnterScheduled, 1, 0) != 0)
+ {
+ return;
+ }
+ Owner.StartCoroutine(EnterAfterAuthoritativeLoad(__instance, Volatile.Read(ref RecoveryGeneration)));
+ }
+
+ private static IEnumerator EnterAfterAuthoritativeLoad(object introUI, int generation)
+ {
+ SetRecoveryMessage("正在返回安全场景……\nReturning to a safe scene…");
+ yield return null;
+ if (generation != Volatile.Read(ref RecoveryGeneration))
+ {
+ yield break;
+ }
+ try
+ {
+ EnterGame.Invoke(introUI, null);
+ }
+ catch (Exception ex)
+ {
+ BeginSessionRecovery("could not enter safe scene: " + ex.Message);
+ yield break;
+ }
+ float deadline = Time.realtimeSinceStartup + 30f;
+ bool fieldLoaded = false;
+ while (Time.realtimeSinceStartup < deadline && generation == Volatile.Read(ref RecoveryGeneration))
+ {
+ object field = FindUnitySingleton("GameFieldManager");
+ PropertyInfo loaded = field?.GetType().GetProperty("IsLoadedField", BindingFlags.Instance | BindingFlags.Public);
+ if (loaded != null && loaded.GetValue(field) is bool ready && ready)
+ {
+ fieldLoaded = true;
+ break;
+ }
+ if (IsPackCollectionActive())
+ {
+ fieldLoaded = true;
+ break;
+ }
+ yield return new WaitForSecondsRealtime(0.25f);
+ }
+ if (!fieldLoaded)
+ {
+ if (generation == Volatile.Read(ref RecoveryGeneration))
+ {
+ BeginSessionRecovery("safe scene load timed out");
+ }
+ yield break;
+ }
+ FinishRecovery(true, null, generation);
+ }
+
+ private static bool IsPackCollectionActive()
+ {
+ Type uiManager = FindType("UIManager");
+ MethodInfo getUI = uiManager?.GetMethod(
+ "GetUI",
+ BindingFlags.Static | BindingFlags.Public,
+ null,
+ new[] { typeof(string) },
+ null);
+ Component collection = getUI?.Invoke(null, new object[] { "PackCollectionUI" }) as Component;
+ return collection != null && collection.gameObject != null && collection.gameObject.activeInHierarchy;
+ }
+
+ private static void ShowRecoveryOverlay(string message)
+ {
+ if (RecoveryOverlay == null)
+ {
+ RecoveryOverlay = new GameObject(
+ "BD2 Recovery Overlay",
+ typeof(RectTransform), typeof(Canvas), typeof(CanvasScaler), typeof(GraphicRaycaster), typeof(Image));
+ UnityEngine.Object.DontDestroyOnLoad(RecoveryOverlay);
+ Canvas canvas = RecoveryOverlay.GetComponent