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。

- + @@ -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(); + canvas.renderMode = RenderMode.ScreenSpaceOverlay; + canvas.sortingOrder = short.MaxValue; + Image background = RecoveryOverlay.GetComponent(); + background.color = new Color(0.025f, 0.035f, 0.055f, 0.94f); + RectTransform root = RecoveryOverlay.GetComponent(); + root.anchorMin = Vector2.zero; + root.anchorMax = Vector2.one; + root.offsetMin = root.offsetMax = Vector2.zero; + GameObject label = new GameObject("Status", typeof(RectTransform), typeof(Text)); + label.transform.SetParent(RecoveryOverlay.transform, false); + RecoveryText = label.GetComponent(); + RecoveryText.font = Resources.GetBuiltinResource("Arial.ttf"); + RecoveryText.fontSize = 28; + RecoveryText.alignment = TextAnchor.MiddleCenter; + RecoveryText.color = Color.white; + RectTransform rect = label.GetComponent(); + rect.anchorMin = new Vector2(0.15f, 0.35f); + rect.anchorMax = new Vector2(0.85f, 0.65f); + rect.offsetMin = rect.offsetMax = Vector2.zero; + } + RecoveryOverlay.SetActive(true); + SetRecoveryMessage(message); + } + + private static void SetRecoveryMessage(string message) + { + if (RecoveryText != null) + { + RecoveryText.text = message; + } + } + + private static void FinishRecovery(bool success, string error, int expectedGeneration = 0) + { + if (expectedGeneration != 0 && expectedGeneration != Volatile.Read(ref RecoveryGeneration)) + { + return; + } + if (!success) + { + Log?.LogError("Automatic session recovery failed: " + error); + } + if (RecoveryOverlay != null) + { + UnityEngine.Object.Destroy(RecoveryOverlay); + RecoveryOverlay = null; + RecoveryText = null; + } + Interlocked.Increment(ref RecoveryGeneration); + Interlocked.Exchange(ref SessionRecoveryInProgress, 0); + Interlocked.Exchange(ref RecoveryRestartScheduled, 0); + Interlocked.Exchange(ref RecoveryEnterScheduled, 0); + Log?.LogInfo(success ? "Automatic session recovery completed" : "Automatic session recovery stopped"); + } + private static bool AccessTokenPrefix(ref string __result) { if (Authentication != null && Authentication.mode == "oauth") @@ -234,6 +577,9 @@ public sealed class Plugin : BaseUnityPlugin private static void ClearPCLocalDataPostfix() { AccessTokens.Clear(); + EstablishedGameSession = false; + RuntimeProbeFailures = 0; + ServerInstanceID = null; PlayerPrefs.DeleteKey("AccessToken"); DeleteCurrentRefresh(); PlayerPrefs.Save(); @@ -258,10 +604,19 @@ public sealed class Plugin : BaseUnityPlugin AccessTokens.Clear(); Authentication = null; LoginInProgress = false; + EstablishedGameSession = false; + RuntimeProbeFailures = 0; + ServerInstanceID = null; ServerRoot = currentRoot; PlayerPrefs.DeleteKey("AccessToken"); PlayerPrefs.Save(); } + if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && Authentication != null && + Authentication.mode == "oauth" && AccessTokens.IsUsable(NormalizedServerOrigin())) + { + ContinueWithMaintenance(__instance, true); + return false; + } if (Authentication != null) { ApplyAuthenticationPolicy(__instance); @@ -301,6 +656,10 @@ public sealed class Plugin : BaseUnityPlugin if (request.result != UnityWebRequest.Result.Success) { Log?.LogError("Authentication policy request failed: " + request.error); + if (Volatile.Read(ref SessionRecoveryInProgress) != 0) + { + BeginSessionRecovery("authentication policy request failed"); + } yield break; } try @@ -314,6 +673,10 @@ public sealed class Plugin : BaseUnityPlugin catch (Exception ex) { Log?.LogError("Server returned an invalid authentication policy: " + ex.Message); + if (Volatile.Read(ref SessionRecoveryInProgress) != 0) + { + BeginSessionRecovery("authentication policy response was invalid"); + } } } } @@ -352,6 +715,23 @@ public sealed class Plugin : BaseUnityPlugin { return; } + if (Volatile.Read(ref SessionRecoveryInProgress) != 0) + { + if (AccessTokens.IsUsable(NormalizedServerOrigin())) + { + ContinueWithMaintenance(introUI, true); + return; + } + if (RefreshCredentials.IsSupported) + { + LoginInProgress = true; + StartIntroCoroutine(introUI, RefreshSession(introUI)); + return; + } + FinishRecovery(false, "no usable credential is available"); + ShowLoginPanel(introUI); + return; + } if (PlayerPrefs.GetInt("IsAutoLogin", 0) != 0 && PlayerPrefs.GetInt("StandaloneAutoLogin", 0) != 0 && CanAttemptAutomaticLogin()) @@ -369,6 +749,7 @@ public sealed class Plugin : BaseUnityPlugin { AccessTokens.Clear(); LoginInProgress = false; + EstablishedGameSession = false; ConfigureLoginPanel(introUI); Type stateType = SetIntroState.GetParameters()[0].ParameterType; SetIntroState.Invoke(introUI, new[] { Enum.ToObject(stateType, 1) }); @@ -658,7 +1039,7 @@ public sealed class Plugin : BaseUnityPlugin { if (!RefreshCredentials.IsSupported) { - AccessTokens.Set(result.access_token); + AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); result.access_token = null; result.refresh_token = null; PlayerPrefs.SetInt("IsAutoLogin", 0); @@ -683,7 +1064,7 @@ public sealed class Plugin : BaseUnityPlugin DeleteCurrentRefresh(); result.refresh_token = null; } - AccessTokens.Set(result.access_token); + AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); result.access_token = null; PlayerPrefs.SetInt("IsAutoLogin", autoLogin ? 1 : 0); PlayerPrefs.DeleteKey("AccessToken"); @@ -695,7 +1076,8 @@ public sealed class Plugin : BaseUnityPlugin result.access_token = null; result.refresh_token = null; Log?.LogError("Could not finish interactive login: " + ex.Message); - ClearSavedLogin(); + AccessTokens.Clear(); + LoginInProgress = false; ShowLoginPanel(introUI); } }; @@ -714,80 +1096,160 @@ public sealed class Plugin : BaseUnityPlugin private static IEnumerator RefreshSession(object introUI) { - RefreshCredential saved; - try + int generation = Volatile.Read(ref RecoveryGeneration); + RefreshCredential saved = null; + while (saved == null) { - saved = LoadRefresh(); + if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation != Volatile.Read(ref RecoveryGeneration)) + { + yield break; + } + InvalidDataException invalid = null; + Exception transient = null; + try + { + saved = PrepareRefreshAttempt(); + } + catch (InvalidDataException ex) + { + invalid = ex; + } + catch (FileNotFoundException ex) + { + invalid = new InvalidDataException("saved automatic-login credential was not found", ex); + } + catch (Exception ex) + { + transient = ex; + } + if (invalid != null) + { + Log?.LogError("Saved automatic login is invalid: " + invalid.Message); + ClearSavedLogin(); + if (Volatile.Read(ref SessionRecoveryInProgress) != 0) + { + FinishRecovery(false, "saved automatic-login credential is invalid", generation); + } + ShowLoginPanel(introUI); + yield break; + } + if (transient != null) + { + Log?.LogWarning("Secure automatic-login storage is temporarily unavailable: " + transient.Message); + if (Volatile.Read(ref SessionRecoveryInProgress) == 0) + { + LoginInProgress = false; + ShowLoginPanel(introUI); + yield break; + } + yield return new WaitForSecondsRealtime(2f); + } } - catch (Exception ex) - { - Log?.LogError("Saved automatic login could not be decrypted: " + ex.Message); - ClearSavedLogin(); - ShowLoginPanel(introUI); - yield break; - } - byte[] body = BuildRefreshRequest(saved.refresh_token); + string refreshToken = saved.pending_refresh_token; + string attemptID = saved.pending_attempt_id; saved.refresh_token = null; - using (UnityWebRequest request = JsonPost(new Uri(ServerRoot, "auth/session/refresh").AbsoluteUri, body)) + saved.pending_refresh_token = null; + saved.pending_attempt_id = null; + float retryDelay = 1f; + while (true) { - yield return request.SendWebRequest(); - Array.Clear(body, 0, body.Length); - if (request.responseCode == 401 || request.responseCode == 403) + byte[] body = BuildRefreshRequest(refreshToken, attemptID); + using (UnityWebRequest request = JsonPost(new Uri(ServerRoot, "auth/session/refresh").AbsoluteUri, body)) { - ClearSavedLogin(); - ShowLoginPanel(introUI); - yield break; + yield return request.SendWebRequest(); + Array.Clear(body, 0, body.Length); + if (Volatile.Read(ref SessionRecoveryInProgress) != 0 && generation != Volatile.Read(ref RecoveryGeneration)) + { + yield break; + } + bool credentialRejected = (request.responseCode == 401 || request.responseCode == 409) && + string.Equals(request.GetResponseHeader("X-BD2-Refresh-Invalid"), "1", StringComparison.Ordinal); + if (credentialRejected) + { + refreshToken = null; + attemptID = null; + ClearSavedLogin(); + if (Volatile.Read(ref SessionRecoveryInProgress) != 0) + { + FinishRecovery(false, "saved automatic-login credential was rejected", generation); + } + ShowLoginPanel(introUI); + yield break; + } + if (request.result != UnityWebRequest.Result.Success) + { + Log?.LogWarning("Automatic login temporarily unavailable; the same refresh attempt will be retried: " + request.error); + if (Volatile.Read(ref SessionRecoveryInProgress) == 0) + { + LoginInProgress = false; + ShowLoginPanel(introUI); + yield break; + } + } + else + { + TokenResult result = null; + try + { + result = JsonUtility.FromJson(request.downloadHandler.text); + } + catch (Exception ex) + { + Log?.LogWarning("Automatic login returned an unreadable response; the same refresh attempt will be retried: " + ex.Message); + } + if (ValidRefreshResult(result) && ProviderEnabled(result.provider)) + { + try + { + StoreRefresh(result); + if (result.access_expires_in <= 30) + { + result.access_token = null; + saved = PrepareRefreshAttempt(); + refreshToken = saved.pending_refresh_token; + attemptID = saved.pending_attempt_id; + saved.refresh_token = null; + saved.pending_refresh_token = null; + saved.pending_attempt_id = null; + retryDelay = 1f; + continue; + } + AccessTokens.Set(result.access_token, NormalizedServerOrigin(), result.provider, result.access_expires_in); + result.access_token = null; + PlayerPrefs.DeleteKey("AccessToken"); + PlayerPrefs.Save(); + refreshToken = null; + attemptID = null; + } + catch (Exception ex) + { + result.access_token = null; + result.refresh_token = null; + Log?.LogWarning("Could not persist the rotated automatic-login credential; the committed attempt will be retrieved again: " + ex.Message); + if (Volatile.Read(ref SessionRecoveryInProgress) == 0) + { + LoginInProgress = false; + ShowLoginPanel(introUI); + yield break; + } + } + if (AccessTokens.IsUsable(NormalizedServerOrigin())) + { + ContinueWithMaintenance(introUI, true); + yield break; + } + } + Log?.LogWarning("Automatic login returned incomplete credentials; the same refresh attempt will be retried"); + if (Volatile.Read(ref SessionRecoveryInProgress) == 0) + { + LoginInProgress = false; + ShowLoginPanel(introUI); + yield break; + } + } } - if (request.result != UnityWebRequest.Result.Success) - { - // Refresh tokens are single-use. A transport failure can occur - // after the server committed rotation, so retrying the saved - // token could be interpreted as replay and revoke its family. - Log?.LogError("Automatic login temporarily unavailable: " + request.error); - ClearSavedLogin(); - ShowLoginPanel(introUI); - yield break; - } - TokenResult result; - try - { - result = JsonUtility.FromJson(request.downloadHandler.text); - } - catch (Exception ex) - { - Log?.LogError("Automatic login returned invalid credentials: " + ex.Message); - ClearSavedLogin(); - ShowLoginPanel(introUI); - yield break; - } - if (!ValidTokenResult(result) || !ProviderEnabled(result.provider)) - { - Log?.LogError("Automatic login returned incomplete credentials"); - ClearSavedLogin(); - ShowLoginPanel(introUI); - yield break; - } - // Persist the rotated refresh credential before exposing the new - // access credential. The old plaintext exists only in managed - // memory until the request body is cleared above. - try - { - StoreRefresh(result); - AccessTokens.Set(result.access_token); - result.access_token = null; - PlayerPrefs.DeleteKey("AccessToken"); - PlayerPrefs.Save(); - } - catch (Exception ex) - { - result.access_token = null; - result.refresh_token = null; - Log?.LogError("Could not persist the rotated automatic-login credential: " + ex.Message); - ClearSavedLogin(); - ShowLoginPanel(introUI); - yield break; - } - ContinueWithMaintenance(introUI, true); + yield return new WaitForSecondsRealtime(retryDelay); + retryDelay = Math.Min(retryDelay * 2f, 5f); } } @@ -838,8 +1300,7 @@ public sealed class Plugin : BaseUnityPlugin } catch (Exception ex) { - Log?.LogError("Could not inspect the secure automatic-login credential: " + ex.Message); - ClearSavedLogin(); + Log?.LogWarning("Could not inspect the secure automatic-login credential: " + ex.Message); return false; } } @@ -849,7 +1310,7 @@ public sealed class Plugin : BaseUnityPlugin string origin = NormalizedServerOrigin(); RefreshCredential credential = new RefreshCredential { - version = 1, + version = 2, origin = origin, provider = result.provider, refresh_token = result.refresh_token, @@ -864,7 +1325,7 @@ public sealed class Plugin : BaseUnityPlugin { string origin = NormalizedServerOrigin(); RefreshCredential credential = RefreshCredentials.Load(origin); - if (credential == null || credential.version != 1 || credential.origin != origin || + if (credential == null || credential.version < 1 || credential.version > 2 || credential.origin != origin || !ProviderEnabled(credential.provider) || string.IsNullOrEmpty(credential.refresh_token) || credential.expires_at <= DateTimeOffset.UtcNow.ToUnixTimeSeconds()) { @@ -877,13 +1338,29 @@ public sealed class Plugin : BaseUnityPlugin return credential; } - private static byte[] BuildRefreshRequest(string token) + private static RefreshCredential PrepareRefreshAttempt() { - if (string.IsNullOrEmpty(token)) + string origin = NormalizedServerOrigin(); + RefreshCredential credential = LoadRefresh(); + bool missingAttempt = string.IsNullOrEmpty(credential.pending_attempt_id) || + string.IsNullOrEmpty(credential.pending_refresh_token); + if (missingAttempt) { - throw new InvalidDataException("refresh token is empty"); + credential.version = 2; + credential.pending_attempt_id = System.Guid.NewGuid().ToString("N"); + credential.pending_refresh_token = credential.refresh_token; + RefreshCredentials.Save(origin, credential); } - foreach (char item in token) + return credential; + } + + private static byte[] BuildRefreshRequest(string token, string attemptID) + { + if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(attemptID)) + { + throw new InvalidDataException("refresh token or attempt ID is empty"); + } + foreach (char item in token + attemptID) { bool safe = item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '-' || item == '_'; @@ -892,7 +1369,7 @@ public sealed class Plugin : BaseUnityPlugin throw new InvalidDataException("refresh token contains an unexpected character"); } } - return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\"}"); + return Encoding.UTF8.GetBytes("{\"refresh_token\":\"" + token + "\",\"attempt_id\":\"" + attemptID + "\"}"); } private static void ClearSavedLogin() @@ -928,6 +1405,13 @@ public sealed class Plugin : BaseUnityPlugin !string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0; } + private static bool ValidRefreshResult(TokenResult result) + { + return result != null && !string.IsNullOrEmpty(result.provider) && + !string.IsNullOrEmpty(result.access_token) && result.access_expires_in >= 0 && + !string.IsNullOrEmpty(result.refresh_token) && result.refresh_expires_in > 0; + } + private static string NormalizedServerOrigin() { return NormalizeOrigin(ServerRoot); @@ -1255,4 +1739,12 @@ public sealed class Plugin : BaseUnityPlugin public string mode = null; public string[] providers = null; } + + [Serializable] + private sealed class RuntimeStatus + { + public string status = null; + public string instance_id = null; + public int retry_after_ms = 0; + } } diff --git a/tools/python/README.md b/tools/python/README.md index fa20a0d..e923d2d 100644 --- a/tools/python/README.md +++ b/tools/python/README.md @@ -26,7 +26,7 @@ python .\tools\python\extract_client_proto.py ` --output .\tmp\client-proto ``` -反混淆输出根下的 `.bd2-deobfuscate-manifest.json` 记录有效映射、冲突改名、未处理警告和替换统计。映射含命名空间或路径时,工具取末段并规范为一个合法标识符;发生同名时加入稳定后缀。该镜像用于阅读与检索,并不承诺可编译。 +反混淆输出根下的 `.bd2-deobfuscate-manifest.json` 记录有效映射、冲突改名、未处理警告和替换统计。映射含命名空间或路径时,工具取末段并规范为一个合法标识符;发生同名时加入稳定后缀。反编译器在源码里留下的派生局部名也会回推:`映射名 + 数字后缀` 按最长映射前缀还原,`camelCase(映射名)`(首段大写字母整体小写)按来源唯一的逆变换还原;两者计入 `derived_identifier_replacements`。注释与字面量始终保持原样(其中的名字须回查映射表)。该镜像用于阅读与检索,并不承诺可编译。 Proto 输出根按 descriptor 原始文件名保存可读 `.proto`;`client-descriptors.pb` 是无损 `FileDescriptorSet`,用于保留文本渲染器暂未展开的复杂 options。`.bd2-proto-extract-manifest.json` 记录 package、依赖、源 Reflection、descriptor/.proto SHA-256 和渲染警告。 @@ -97,27 +97,26 @@ python .\tools\python\import_seed.py mail .\decoded\MailInfo.pb ` 无需 GameData 或第三方 Python 包的 `grant` 子命令可追加一封含多个附件的动态邮件。服务端使用 `--mail-grant-spool` 读取该 JSON,每次 `/MailInfo` 导入未发放的 identity;已发放 identity 在领取和重启后仍保持幂等。CLI 允许付费钻石 `2:0:数量`、免费钻石 `3:0:数量`、金币 `4:0:数量`、天赋神药 `12:0:数量`、金线 `20:0:数量`,以及当前 2.35.10 已核实的抽抽乐券 `8:1000:数量` 和 UR 专用装备抽抽乐券 `8:1104:数量`,每个附件数量均限制为 `1..2147483647`。例如 `python tools/python/dev_mail_grant.py grant --output data/dev/currency-grants.json --identity test-paid-and-tickets-1 --attachment 2:0:100000000 --attachment 8:1000:100000000 --attachment 8:1104:100000000 --attachment 4:0:1000000000`。两种券通过 `ItemDBInfo` 写入背包,钻石和金币直接叠加账户余额;发放前应检查同类券现有库存加附件后的总量仍在客户端 `int32` 范围内。 -启动工具时,`--mail-seed` 是只读的当前基础邮件种子;`--output` 是新生成的完整临时种子;`--settings-output` 保存与账号存档分离的开发选项。工具启动后在浏览器打开 `http://127.0.0.1:8765/`: +启动工具时,`--output` 是 `version=1` 的动态邮件 grant spool;网页只追加带稳定 identity 的待发放请求,实际 `mail_id` 由服务端统一分配,因此不会与任务补偿等动态邮件撞号。`--settings-output` 保存与账号存档分离的开发选项。页面会在一次提交完成前禁用按钮,避免浏览器双击生成重复请求。工具启动后在浏览器打开 `http://127.0.0.1:8765/`: ```powershell python .\tools\python\dev_mail_grant.py serve ` --game-data "E:\bd2\dl\GameData" ` --game-data-version "20260923193640" ` - --mail-seed .\go\seed\v2_35_10\mail.json ` - --output .\data\dev\mail-grants.json ` + --output .\data\dev\mail-grants-spool.json ` --settings-output .\data\dev\dev-tools.json ``` -工具启动时立即原子写出规范化的完整 `--output`(尚未发放也一样),因此首次启用时可先启动工具、再让本地服务端监听这个输出文件。每次发放同样原子更新该文件。`bd2server` 的邮件服务会在下一次正常 `/MailInfo` 请求检查它:网页发放后重新打开或刷新游戏邮箱即可看到新邮件,**不需要每次重启服务器**。服务仅在启动时需要加入(或替换为)以下参数: +工具首次提交及后续每次发放都会原子更新 `--output`。`bd2server` 的邮件服务会在下一次正常 `/MailInfo` 请求检查它:网页发放后重新打开或刷新游戏邮箱即可看到新邮件,**不需要每次重启服务器**。服务启动时加入: ```powershell ---mail-seed ".\data\dev\mail-grants.json" +--mail-grant-spool ".\data\dev\mail-grants-spool.json" --dev-tools-config ".\data\dev\dev-tools.json" ``` 页面下方的“无限背包容量”不会写入账号状态,也不会使用任意巨大容量;它从当前 GameData 读取客户端安全上限(2.35.10 当前为普通道具 500、装备 2000)。切换后无需重启服务端,但客户端必须重新登录以重新取得 `UserDBInfo`。普通背包、普通仓库、装备背包和装备仓库的正式扩充接口仍按 `GameDefaultTable` 的逐格阶梯价格扣除金币并持久化;开发开关不覆盖或删除已购买容量。 -客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`;每封工具邮件固定只有一个附件。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。客户端邮箱 UI 按一次请求加载最多 100 封普通邮件,现有本地服务目前回传全部未开封邮件,故大量历史未领取邮件的实际 UI 表现尚待验证。现有本地 `/MailOpen` 对同一邮件 ID 的领取由 `data/state/mail.json` 的 `opened` 集合持久化,重试不会重复发奖;工具会在完整种子中分配唯一递增邮件 ID。 +客户端 `MailDBInfo.ItemType`、`ItemId` 和 `ItemCount` 均为 `int32`,所以该工具把单附件数量限制为 `1..2147483647`。当前官方样本中单封最多观察到 5 个附件,但没有证据证明这是协议上限,因此工具不据此宣称或实施“5 件”上限。服务端统一分配动态邮件 ID,并持久化 spool identity、领取状态和领取历史;同一邮件重试不会重复发奖。 这不是“所有 GameData 表都可发放”的虚假承诺:角色(元素类型 6)、装备(10)、服装(11)和我的房间奖杯(28)在客户端 `RewardDBInfoBundle` 中分别必须使用 `CharDBInfo`、`EquipDBInfo`、`CostumeDBInfo`、`MyRoomTrophyDBInfo`,而当前本地邮件服务尚未连接相应领域存档,工具不会提供它们;直接伪装成 `ItemDBInfo` 会造成客户端状态错误。付费/普通货币之外的特殊货币亦不在当前本地钱包实现范围内。`ContentTicket`(19)仅为已核实的满月一次性专用券开放 `19:450030:1`:客户端 `CommonPacket.AddItemInfo` 将其交给 `AddContentTicketItem`,服务端按实例存入物品存档并沿用邮件领取去重;其他内容券 ID 和其他数量均拒绝。浏览器道具列表不开放内容券,`grant` 子命令可使用这一严格限定的附件。`LobbySettingItem`(25)仍未提供;`GetItemInfo` 的显示分支不足以证明可安全存储。若要补齐其余类型,需要先实现对应的服务端存储、去重及正确 reward-bundle 字段,不需要客户端 patch。 diff --git a/tools/python/deobfuscate_client_source.py b/tools/python/deobfuscate_client_source.py index ed89f00..e5a8183 100644 --- a/tools/python/deobfuscate_client_source.py +++ b/tools/python/deobfuscate_client_source.py @@ -193,6 +193,70 @@ def build_replacements(entries: Iterable[dict]) -> tuple[dict[str, str], list[di return replacements, report, warnings +def camel_case(name: str) -> str: + """Lowercase the leading run of uppercase letters. + + The decompiler names locals after their type, lowering the type's leading + uppercase run (``<>c__DisplayClass`` becomes ``c__DisplayClass``); the + result is a token the mapping never contains verbatim. + """ + index = 0 + while index < len(name) and name[index].isupper(): + index += 1 + return name[:index].lower() + name[index:] if index else name + + +class ReplacementIndex: + """Exact mappings plus the decompiler-derived forms they can appear as. + + Only tokens that contain a non-ASCII character are considered: every + mapping source is an obfuscated (non-ASCII) identifier, so ordinary code + tokens never reach the derived rules and pay nothing for them. + """ + + EXACT = "exact" + SUFFIXED = "suffixed" + CAMEL = "camel" + + def __init__(self, replacements: dict[str, str]): + self.replacements = replacements + self.derived_names: dict[str, list[str]] = {} + for source in replacements: + key = camel_case(source) + if key != source: + self.derived_names.setdefault(key, []).append(source) + + def _base_replacement(self, token: str) -> str | None: + """A mapped name's replacement, or its unambiguous camel-case form.""" + replacement = self.replacements.get(token) + if replacement is not None: + return replacement + sources = self.derived_names.get(token) + if sources is not None and len(sources) == 1: + return camel_case(self.replacements[sources[0]]) + return None + + def resolve(self, token: str) -> tuple[str | None, str | None]: + """Return ``(replacement, kind)`` for one token, or ``(None, None)``.""" + replacement = self._base_replacement(token) + if replacement is not None: + kind = self.EXACT if token in self.replacements else self.CAMEL + return replacement, kind + if token.isascii(): + return None, None + # ````: the decompiler disambiguated a local by appending + # a counter, where the base is a mapped name or its camel-case form. + # Take the longest base so mapped names ending in digits are not + # truncated. + for cut in range(len(token) - 1, 0, -1): + head, tail = token[:cut], token[cut:] + if tail.isdigit(): + base = self._base_replacement(head) + if base is not None: + return base + tail, self.SUFFIXED + return None, None + + def _consume_quoted(text: str, start: int, quote: str) -> int: """Return the index after a C# string/character/raw-string literal.""" quotes = 0 @@ -216,39 +280,51 @@ def _consume_quoted(text: str, start: int, quote: str) -> int: return len(text) -def replace_csharp_identifiers(text: str, replacements: dict[str, str]) -> tuple[str, int]: - """Replace code identifiers only; comments and literal payloads stay exact.""" +def replace_csharp_identifiers( + text: str, replacements: dict[str, str] | ReplacementIndex +) -> tuple[str, int, int]: + """Replace code identifiers only; comments and literal payloads stay exact. + + Returns ``(text, changed, derived)`` where ``derived`` counts the + replacements resolved through :class:`ReplacementIndex`'s suffix/camel + rules rather than an exact mapping source. + """ + index = replacements if isinstance(replacements, ReplacementIndex) else ReplacementIndex(replacements) output: list[str] = [] - index = changed = 0 + position = changed = derived = 0 length = len(text) - while index < length: - if text.startswith("//", index): - end = text.find("\n", index) + while position < length: + if text.startswith("//", position): + end = text.find("\n", position) end = length if end < 0 else end - output.append(text[index:end]) - index = end - elif text.startswith("/*", index): - end = text.find("*/", index + 2) + output.append(text[position:end]) + position = end + elif text.startswith("/*", position): + end = text.find("*/", position + 2) end = length if end < 0 else end + 2 - output.append(text[index:end]) - index = end - elif text[index] in "\"'": - end = _consume_quoted(text, index, text[index]) - output.append(text[index:end]) - index = end - elif is_identifier_start(text[index]): - end = index + 1 + output.append(text[position:end]) + position = end + elif text[position] in "\"'": + end = _consume_quoted(text, position, text[position]) + output.append(text[position:end]) + position = end + elif is_identifier_start(text[position]): + end = position + 1 while end < length and is_identifier_continue(text[end]): end += 1 - token = text[index:end] - replacement = replacements.get(token, token) + token = text[position:end] + replacement, kind = index.resolve(token) + if replacement is None: + replacement = token output.append(replacement) - changed += replacement != token - index = end + if replacement != token: + changed += 1 + derived += kind != ReplacementIndex.EXACT + position = end else: - output.append(text[index]) - index += 1 - return "".join(output), changed + output.append(text[position]) + position += 1 + return "".join(output), changed, derived def read_csharp(path: Path) -> tuple[str, str] | None: @@ -297,6 +373,13 @@ def deobfuscate(source: Path, mapping: Path, output: Path) -> dict: entries, warnings = parse_mapping(mapping) replacements, mapping_report, replacement_warnings = build_replacements(entries) warnings.extend(replacement_warnings) + index = ReplacementIndex(replacements) + for key, sources in index.derived_names.items(): + if len(sources) > 1: + warnings.append( + f"derived name {key!r}: ignored ambiguous camel-case sources " + f"({len(sources)} candidates)" + ) stage = prepare_stage(output) files: list[dict] = [] occupied: set[Path] = set() @@ -316,9 +399,13 @@ def deobfuscate(source: Path, mapping: Path, output: Path) -> dict: warnings.append(f"{relative}: copied without replacement (unsupported encoding)") else: text, encoding = decoded - translated, changed = replace_csharp_identifiers(text, replacements) + translated, changed, derived = replace_csharp_identifiers(text, index) write_csharp(destination_path, translated, encoding) - record.update({"action": "translated", "identifier_replacements": changed}) + record.update({ + "action": "translated", + "identifier_replacements": changed, + "derived_identifier_replacements": derived, + }) else: shutil.copy2(input_path, destination_path) record["action"] = "copied" @@ -335,10 +422,15 @@ def deobfuscate(source: Path, mapping: Path, output: Path) -> dict: "files": len(files), "csharp_files": sum(item["source"].casefold().endswith(".cs") for item in files), "identifier_replacements": sum(item.get("identifier_replacements", 0) for item in files), + "derived_identifier_replacements": sum( + item.get("derived_identifier_replacements", 0) for item in files + ), }, "limitations": [ "Only C# identifier tokens are changed; comments and literal contents are preserved.", "Qualified mapping values become their terminal identifier component.", + "Decompiler-derived locals (mapped name plus a numeric suffix, or the " + "camel-cased mapped name) are resolved back through those two derived rules.", "This is a searchable mirror, not a promise that the transformed source compiles.", ], } diff --git a/tools/python/dev_mail_grant.py b/tools/python/dev_mail_grant.py index e58af70..fe69d1a 100644 --- a/tools/python/dev_mail_grant.py +++ b/tools/python/dev_mail_grant.py @@ -8,7 +8,7 @@ by an explicitly configured local bd2server. Example: python tools/python/dev_mail_grant.py serve ` --game-data E:\\bd2\\dl\\GameData --game-data-version 20260923193640 ` - --mail-seed go\\seed\\v2_35_10\\mail.json --output data\\dev\\mail-grants.json + --output data\\dev\\mail-grants-spool.json python tools/python/dev_mail_grant.py grant ` --output data\\dev\\currency-grants.json --identity test-grant-1 ` @@ -72,6 +72,7 @@ MAIL_CURRENCY_TYPES = frozenset({2, 3, 4, 12, 20}) # ticket resources, rather than accepting arbitrary GameData item IDs. MAIL_DRAW_TICKET_IDS = frozenset({1000, 1104}) MAIL_CONTENT_TICKET_ID = 450030 +MAIL_ITEM_TYPES = frozenset({5, 7, 8, 9, 13, 14, 17, 27, 29}) def _varint(value: Any) -> int: @@ -385,34 +386,6 @@ def load_items(root: Path, version: str) -> list[dict[str, Any]]: temporary.unlink(missing_ok=True) -def load_seed(path: Path) -> dict[str, Any]: - try: - value = json.loads(path.read_text(encoding="utf-8")) - except OSError as exc: - raise ValueError(f"无法读取邮件种子 {path}: {exc}") from exc - except json.JSONDecodeError as exc: - raise ValueError(f"邮件种子不是 JSON: {exc}") from exc - if value.get("version") != VERSION or not isinstance(value.get("mails"), list): - raise ValueError(f"邮件种子必须是 version={VERSION} 且含 mails 数组") - ids: set[int] = set() - for entry in value["mails"]: - mail_id = entry.get("mail_id") - if not isinstance(mail_id, int) or mail_id <= 0 or mail_id in ids: - raise ValueError("邮件种子含零、非整数或重复的 mail_id") - ids.add(mail_id) - return value - - -def normalise_seed(seed: dict[str, Any]) -> dict[str, Any]: - """Make the server sentinel fields agree with the complete mail list.""" - result = dict(seed) - result["version"] = VERSION - result["mails"] = list(seed["mails"]) - result["mail_count"] = len(result["mails"]) + 1 - result["max_mail_id"] = max((entry["mail_id"] for entry in result["mails"]), default=0) - return result - - def atomic_json(path: Path, value: dict[str, Any]) -> None: path = path.resolve() path.parent.mkdir(parents=True, exist_ok=True) @@ -490,8 +463,8 @@ def _validate_reward(value: Any) -> dict[str, int]: elif value["type"] == 19: if value["id"] != MAIL_CONTENT_TICKET_ID or type(value["count"]) is not int or value["count"] != 1: raise ValueError("内容券附件只允许满月甄选券 type19、id450030、count1") - elif value["type"] != 8 or value["id"] not in MAIL_DRAW_TICKET_IDS: - raise ValueError("附件只允许货币类型 2、3、4、12、20 的 id0,资源类型 8 的抽抽乐券 id1000、UR 专用装备抽抽乐券 id1104,或内容券 type19、id450030、count1") + elif value["type"] not in MAIL_ITEM_TYPES or value["id"] <= 0: + raise ValueError("物品附件必须使用已支持的 ItemDBInfo 类型和正数 id") if type(value["count"]) is not int or not 1 <= value["count"] <= MAX_INT32: raise ValueError(f"附件 count 必须是 1 到 {MAX_INT32} 的整数") return dict(value) @@ -502,7 +475,12 @@ def attachment(value: str) -> dict[str, int]: parts = value.split(":") if len(parts) != 3: raise ValueError("附件格式必须是 TYPE:ID:COUNT") - return _validate_reward(dict(zip(("type", "id", "count"), map(int, parts)))) + reward = _validate_reward(dict(zip(("type", "id", "count"), map(int, parts)))) + if reward["type"] not in MAIL_CURRENCY_TYPES and not ( + reward["type"] == 8 and reward["id"] in MAIL_DRAW_TICKET_IDS + ) and not (reward["type"] == 19 and reward["id"] == MAIL_CONTENT_TICKET_ID): + raise ValueError("命令行附件只开放已审计的货币、抽抽乐券和满月甄选券") + return reward except ValueError as exc: raise argparse.ArgumentTypeError(str(exc)) from exc @@ -565,18 +543,13 @@ def grant(args: argparse.Namespace) -> int: class MailGrantStore: - def __init__(self, source: Path, output: Path, items: list[dict[str, Any]], expires_days: int): - self.source = source.resolve() + def __init__(self, output: Path, items: list[dict[str, Any]]): self.output = output.resolve() self.items = items self.item_keys = {(item["element_type"], item["id"]) for item in items} - self.expires_days = expires_days self.lock = threading.Lock() - self.seed = normalise_seed(load_seed(self.output if self.output.exists() else self.source)) - # Write the complete baseline immediately. The game server can - # therefore begin watching --output before the first browser grant. if not self.output.exists(): - atomic_json(self.output, self.seed) + atomic_json(self.output, {"version": 1, "grants": []}) def grant(self, payload: Any) -> dict[str, Any]: with self.lock: @@ -600,28 +573,19 @@ class MailGrantStore: if not title or len(title) > 500 or len(body) > 5000: raise ValueError("标题不能为空且不超过 500 字符;正文不超过 5000 字符") - current_ids = {entry["mail_id"] for entry in self.seed["mails"]} - mail_id = max(current_ids, default=13_000_000_000) + 1 - # MailDBInfo's InvenIndex is int64 in the 2.35.10 client descriptor. - if mail_id > (1 << 63) - 1: - raise ValueError("没有可用的正 int64 邮件 ID") now = int(time.time() * 1000) - expires = now + self.expires_days * 24 * 60 * 60 * 1000 - entry = { - "mail_id": mail_id, - "mail_type": 2, + entry = _validate_grant({ + "identity": str(uuid.uuid4()), "title": title, "body": body, - "expires_at": expires, - "reward_types": [element_type], - "reward_ids": [item_id], - "reward_counts": [count], "sent_at": now, - } - next_seed = normalise_seed({**self.seed, "mails": [*self.seed["mails"], entry]}) - atomic_json(self.output, next_seed) - self.seed = next_seed - return {"mail": entry, "output": str(self.output), "restart_required": False} + "rewards": [{"type": element_type, "id": item_id, "count": count}], + }) + with grant_file_lock(self.output): + value = load_grants(self.output) + value["grants"].append(entry) + atomic_json(self.output, value) + return {"grant": entry, "output": str(self.output), "restart_required": False} class DevelopmentSettingsStore: @@ -678,11 +642,11 @@ class DevelopmentSettingsStore: PAGE = """BD2 开发工具 -

BD2 开发工具

开发邮件发放

只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。货币直接叠加到钱包。提交会原子写入临时邮件种子;重新打开或刷新游戏邮箱即可热载,无需重启服务端。

+

BD2 开发工具

开发邮件发放

只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。货币直接叠加到钱包。提交会原子追加到动态邮件队列,由服务端唯一分配邮件 ID;重新打开或刷新游戏邮箱即可热载,无需重启服务端。

选择开发测试物品
ID类型名称类别/内容
-

发放一个附件

+

发放一个附件

背包容量

使用当前客户端 GameData 的安全上限,不写入账号存档。切换后无需重启服务端,但必须重新登录客户端才会生效。

-""" +""" class Handler(BaseHTTPRequestHandler): @@ -741,19 +705,17 @@ class Handler(BaseHTTPRequestHandler): def serve(args: argparse.Namespace) -> int: - if args.expires_days < 1 or args.expires_days > 3650: - raise ValueError("--expires-days 必须是 1 到 3650") items = load_items(args.game_data, args.game_data_version) - store = MailGrantStore(args.mail_seed, args.output, items, args.expires_days) + store = MailGrantStore(args.output, items) settings = DevelopmentSettingsStore(args.settings_output, load_inventory_limits(args.game_data, args.game_data_version)) Handler.store = store Handler.settings = settings server = ThreadingHTTPServer((args.listen_host, args.listen_port), Handler) print(f"已读取 {len(items)} 个可由 ItemDBInfo 领取的 GameData 物品。") print(f"浏览器打开:http://{args.listen_host}:{args.listen_port}/") - print(f"临时邮件种子:{store.output}") + print(f"动态邮件队列:{store.output}") print(f"开发工具配置:{settings.path}") - print("此服务不修改 data/state;bd2server 指向该 seed 后,每次 /MailInfo 自动热载。") + print("此服务不修改 data/state;bd2server 每次 /MailInfo 导入队列并唯一分配邮件 ID。") try: server.serve_forever() except KeyboardInterrupt: @@ -769,12 +731,10 @@ def parser() -> argparse.ArgumentParser: command = commands.add_parser("serve", help="start the loopback browser UI") command.add_argument("--game-data", type=Path, required=True, help="GameData root") command.add_argument("--game-data-version", required=True, help="validated GameData version") - command.add_argument("--mail-seed", type=Path, required=True, help="base mail seed; read only") - command.add_argument("--output", type=Path, required=True, help="generated development mail seed") + command.add_argument("--output", type=Path, required=True, help="version=1 dynamic mail grant spool") command.add_argument("--settings-output", type=Path, default=Path("data/dev/dev-tools.json"), help="development settings JSON") command.add_argument("--listen-host", default="127.0.0.1", help="loopback host (default: 127.0.0.1)") command.add_argument("--listen-port", default=8765, type=int, help="loopback port (default: 8765)") - command.add_argument("--expires-days", default=365, type=int, help="development mail validity (default: 365)") command.set_defaults(run=serve) command = commands.add_parser("grant", help="append one durable currency or draw ticket mail grant (standard library only)") command.add_argument("--output", type=Path, required=True, help="version=1 development mail grants JSON") diff --git a/tools/python/tests/test_offline_tools.py b/tools/python/tests/test_offline_tools.py index 33476b2..7d1feb1 100644 --- a/tools/python/tests/test_offline_tools.py +++ b/tools/python/tests/test_offline_tools.py @@ -219,17 +219,11 @@ class DevelopmentMailGrantToolTests(unittest.TestCase): def test_gold_currency_mail_uses_type_four_id_zero_and_requested_count(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - source = root / "mail.json" output = root / "generated.json" - source.write_text(json.dumps({ - "version": "2.35.10", "mails": [], "mail_count": 1, "max_mail_id": 0, - }), encoding="utf-8") gold = {"id": 0, "element_type": 4, "name": "金币"} - store = dev_mail_grant.MailGrantStore(source, output, [gold], 365) + store = dev_mail_grant.MailGrantStore(output, [gold]) result = store.grant({"item_id": 0, "element_type": 4, "count": 123456789}) - self.assertEqual(result["mail"]["reward_types"], [4]) - self.assertEqual(result["mail"]["reward_ids"], [0]) - self.assertEqual(result["mail"]["reward_counts"], [123456789]) + self.assertEqual(result["grant"]["rewards"], [{"type": 4, "id": 0, "count": 123456789}]) def test_packed_varints_accepts_repeated_and_packed_fields(self): self.assertEqual(dev_mail_grant.packed_varints({4: [3, b"\x80\x01\x02"]}, 4), [3, 128, 2]) @@ -278,40 +272,33 @@ class DevelopmentMailGrantToolTests(unittest.TestCase): self.assertIn("重新登录客户端后生效", dev_mail_grant.PAGE) self.assertIn("/api/settings/inventory", dev_mail_grant.PAGE) - def test_grant_writes_complete_seed_without_state_mutation(self): + def test_browser_grant_appends_spool_without_allocating_mail_id(self): with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - source = root / "source-mail.json" - output = root / "generated-mail.json" - source.write_text(json.dumps({ - "version": "2.35.10", - "mails": [{ - "mail_id": 100, "mail_type": 2, "title": "base", "body": "base", - "expires_at": 200, "reward_types": [8], "reward_ids": [7], - "reward_counts": [1], "sent_at": 100, - }], - "mail_count": 2, "max_mail_id": 100, - }), encoding="utf-8") - original_source = source.read_text(encoding="utf-8") - store = dev_mail_grant.MailGrantStore(source, output, [{ + output = root / "mail-grants-spool.json" + store = dev_mail_grant.MailGrantStore(output, [{ "id": 9, "element_type": 8, "name": "slime", - }], 365) + }]) self.assertTrue(output.is_file()) - self.assertEqual(json.loads(output.read_text(encoding="utf-8"))["mail_count"], 2) + self.assertEqual(json.loads(output.read_text(encoding="utf-8")), {"version": 1, "grants": []}) result = store.grant({"item_id": 9, "element_type": 8, "count": 123, "title": "test", "body": "body"}) written = json.loads(output.read_text(encoding="utf-8")) - self.assertEqual(source.read_text(encoding="utf-8"), original_source) - self.assertEqual(written["mail_count"], 3) - self.assertEqual(written["max_mail_id"], 101) - self.assertEqual(written["mails"][-1]["reward_types"], [8]) - self.assertEqual(written["mails"][-1]["reward_ids"], [9]) - self.assertEqual(written["mails"][-1]["reward_counts"], [123]) + self.assertEqual(written["version"], 1) + self.assertEqual(len(written["grants"]), 1) + self.assertEqual(written["grants"][0]["rewards"], [{"type": 8, "id": 9, "count": 123}]) + self.assertNotIn("mail_id", written["grants"][0]) + self.assertEqual(result["grant"], written["grants"][0]) self.assertFalse(result["restart_required"]) with self.assertRaises(ValueError): store.grant({"item_id": 999, "element_type": 8, "count": 1}) with self.assertRaises(ValueError): store.grant({"item_id": 9, "element_type": 8, "count": dev_mail_grant.MAX_INT32 + 1}) + def test_browser_form_blocks_duplicate_submit_until_response(self): + self.assertIn("if(submitting)return", dev_mail_grant.PAGE) + self.assertIn("$('grant-submit').disabled=true", dev_mail_grant.PAGE) + self.assertIn("x.grant.identity", dev_mail_grant.PAGE) + class ClientSourceToolTests(unittest.TestCase): def test_generated_tree_staging_uses_parent_inheriting_directory_mode(self): diff --git a/versions.json b/versions.json index f7fb1c8..eaa5755 100644 --- a/versions.json +++ b/versions.json @@ -1,11 +1,13 @@ { - "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", "plugins": { "local_identity": "0.6.1", "capture_environment": "0.2.0", - "login_ui": "0.1.4" + "login_ui": "0.2.0" } }