feat(all): add resilient server recovery and talent upgrades

This commit is contained in:
2026-10-03 15:09:31 +08:00
parent db1eab764e
commit ce03fe7289
70 changed files with 3664 additions and 419 deletions
+2 -2
View File
@@ -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 及其权利人无隶属或授权关系。游戏名称、商标、客户端、游戏资源和数据归各自权利人所有;本项目的许可不覆盖它们,也不变更第三方依赖的许可证。项目按现状提供,无正确性、安全性、可用性或不侵权保证。使用者须自行确认适用法律、相关协议和第三方权利要求,并自行承担使用、客户端修改及本地服务运行风险。
本项目是非官方开发与研究项目,与 Brown Dust II 及其权利人无隶属或授权关系。游戏名称、商标、客户端、游戏资源和数据归各自权利人所有;本项目的许可不覆盖它们,也不变更第三方依赖的许可证。项目按现状提供,无正确性、安全性、可用性或不侵权保证。使用者须自行确认适用法律、相关协议和第三方权利要求,并自行承担使用、客户端修改及本地服务运行风险。
+2 -2
View File
@@ -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)。
+8 -2
View File
@@ -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)) {
+1
View File
@@ -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 {
+98 -12
View File
@@ -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]
+123
View File
@@ -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)
}
+4 -2
View File
@@ -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
})
}
+4 -4
View File
@@ -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
File diff suppressed because one or more lines are too long
+19 -5
View File
@@ -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 {
+5 -4
View File
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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",
}
}
@@ -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
}
@@ -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)
}
}
+74 -15
View File
@@ -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) {
@@ -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 {
@@ -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
+29 -2
View File
@@ -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)
}
}
@@ -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
}
@@ -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
}
+116 -5
View File
@@ -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) {
+84 -4
View File
@@ -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()
+26 -4
View File
@@ -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
}
+39
View File
@@ -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))
+80 -31
View File
@@ -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
}
+54
View File
@@ -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")
}
}
@@ -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",
}
+3
View File
@@ -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{}
}
-1
View File
@@ -27,7 +27,6 @@ var standaloneDefaults = map[string]int{
"/HuntingGroundInfoList": 387,
"/FriendRecommend": 211,
"/SupporterStatus": 438,
"/MailHistoryInfo": 138,
"/SupporterBattleInfo": 439,
"/InnOpen": 109,
}
+56
View File
@@ -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()
+75
View File
@@ -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}
+39
View File
@@ -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"`
+29
View File
@@ -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 {
+28 -3
View File
@@ -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
@@ -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
}
+85
View File
@@ -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()
}
}
+31
View File
@@ -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)
}
}
+7
View File
@@ -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() }
@@ -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}
}
@@ -0,0 +1,7 @@
//go:build windows
package lifecycle
import "os"
func shutdownSignals() []os.Signal { return []os.Signal{os.Interrupt} }
+2 -1
View File
@@ -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)
}
+304 -18
View File
@@ -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.
+264
View File
@@ -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
+50
View File
@@ -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")
+5 -2
View File
@@ -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)) {
+61 -5
View File
@@ -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()
+6 -1
View File
@@ -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
@@ -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{}
}
+3
View File
@@ -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)
}
+1
View File
@@ -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"`
+51 -8
View File
@@ -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
}
@@ -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) {
+1 -1
View File
@@ -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")
+15 -11
View File
@@ -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)
}
+4
View File
@@ -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)
+95 -2
View File
@@ -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
}
+94
View File
@@ -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{},
+23 -5
View File
@@ -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 {
@@ -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)
}
}
+2 -2
View File
@@ -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<string, Queue<PendingRequest>> 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" +
+9 -2
View File
@@ -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";
+22 -3
View File
@@ -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
+4
View File
@@ -49,6 +49,10 @@
<HintPath>$(BD2ManagedDir)\UnityEngine.UIModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.TextRenderingModule">
<HintPath>$(BD2ManagedDir)\UnityEngine.TextRenderingModule.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Assets\Discord-Symbol.png" LogicalName="Bd2LoginUI.Assets.Discord-Symbol.png" />
+602 -110
View File
@@ -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<RuntimeStatus>(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>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = short.MaxValue;
Image background = RecoveryOverlay.GetComponent<Image>();
background.color = new Color(0.025f, 0.035f, 0.055f, 0.94f);
RectTransform root = RecoveryOverlay.GetComponent<RectTransform>();
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<Text>();
RecoveryText.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
RecoveryText.fontSize = 28;
RecoveryText.alignment = TextAnchor.MiddleCenter;
RecoveryText.color = Color.white;
RectTransform rect = label.GetComponent<RectTransform>();
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<TokenResult>(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<TokenResult>(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;
}
}
+6 -7
View File
@@ -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。
+119 -27
View File
@@ -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
# ``<base><digits>``: 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.",
],
}
+28 -68
View File
@@ -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 = """<!doctype html><meta charset=utf-8><title>BD2 开发工具</title>
<style>body{font:14px system-ui;max-width:1060px;margin:2rem auto;padding:0 1rem}input,textarea,button{font:inherit;padding:.4rem}input{width:100%}input[type=checkbox]{width:auto;transform:scale(1.2);margin-right:.5rem}section{border-top:1px solid #ddd;margin-top:2rem;padding-top:1rem}table{border-collapse:collapse;width:100%;margin:0}th,td{border:1px solid #ccc;padding:.4rem;text-align:left}tr:hover{background:#f5f5f5}#status,#inventory-status{white-space:pre-wrap;margin:1rem 0}.small{color:#555}.pick{white-space:nowrap}#item-picker{margin:1rem 0;border:1px solid #ccc;border-radius:.35rem;padding:.55rem}#item-picker summary{cursor:pointer;font-weight:600}#item-picker[open] summary{margin-bottom:.75rem}.item-list{max-height:min(40vh,28rem);overflow:auto;border:1px solid #ccc;margin-top:1rem}.item-list thead th{position:sticky;top:0;background:#fff}.item-list table{min-width:760px}</style>
<h1>BD2 开发工具</h1><section><h2>开发邮件发放</h2><p class=small>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。货币直接叠加到钱包。提交会原子写入临时邮件种子;重新打开或刷新游戏邮箱即可热载,无需重启服务端。</p>
<h1>BD2 开发工具</h1><section><h2>开发邮件发放</h2><p class=small>只列出可由当前邮件链路直接领取的安全物品和货币。固定内容随机箱已映射成真实内容物;其他随机箱与“遗失物品”等内部哨兵不会显示。货币直接叠加到钱包。提交会原子追加到动态邮件队列,由服务端唯一分配邮件 ID;重新打开或刷新游戏邮箱即可热载,无需重启服务端。</p>
<details id=item-picker><summary>选择开发测试物品 <span id=count class=small></span></summary><label>搜索(ID、名称、类别、固定箱映射)<input id=q></label><div class=item-list><table><thead><tr><th>ID</th><th>类型</th><th>名称</th><th>类别/内容</th><th></th></tr></thead><tbody id=items></tbody></table></div></details>
<h3>发放一个附件</h3><form id=form><label>物品 ID<input id=item_id required readonly></label><input id=element_type required readonly type=hidden><label>数量(1–2147483647)<input id=quantity type=number min=1 max=2147483647 value=1 required></label><label>邮件标题<input id=title value="开发测试物品" required maxlength=500></label><label>正文<textarea id=body maxlength=5000>由本地开发工具发放。</textarea></label><p><button>写入临时邮件种子</button></p></form><pre id=status></pre></section>
<h3>发放一个附件</h3><form id=form><label>物品 ID<input id=item_id required readonly></label><input id=element_type required readonly type=hidden><label>数量(1–2147483647)<input id=quantity type=number min=1 max=2147483647 value=1 required></label><label>邮件标题<input id=title value="开发测试物品" required maxlength=500></label><label>正文<textarea id=body maxlength=5000>由本地开发工具发放。</textarea></label><p><button id=grant-submit>加入动态邮件队列</button></p></form><pre id=status></pre></section>
<section><h2>背包容量</h2><label><input id=unlimited-inventory type=checkbox>无限背包容量</label><p id=inventory-limits class=small></p><p class=small>使用当前客户端 GameData 的安全上限,不写入账号存档。切换后无需重启服务端,但必须重新登录客户端才会生效。</p><pre id=inventory-status></pre></section>
<script>let all=[];const $=id=>document.getElementById(id);function render(){let q=$('q').value.toLowerCase();let matches=all.filter(x=>(x.id+' '+x.element_type+' '+x.name+' '+x.category+' '+(x.aliases||[]).join(' ')+' '+(x.details||'')).toLowerCase().includes(q));let rows=matches.slice(0,500);$('count').textContent=`(匹配 ${matches.length} / ${all.length} 项;显示前 ${rows.length} 项)`; $('items').innerHTML=rows.map(x=>`<tr><td>${x.id}</td><td>${x.element_type}</td><td>${esc(x.name)}</td><td>${esc(x.category+(x.details?':'+x.details:''))}</td><td class=pick><button onclick="pick(${x.element_type},${x.id})">选择</button></td></tr>`).join('')}function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}function pick(t,id){$('item_id').value=id;$('element_type').value=t;$('item-picker').open=false;$('quantity').focus();$('form').scrollIntoView({block:'nearest',behavior:'smooth'})}function showSettings(x){$('unlimited-inventory').checked=x.inventory.unlimited;$('inventory-limits').textContent=`关闭时:道具 ${x.inventory.baseline.items}、装备 ${x.inventory.baseline.equipment};开启时:道具 ${x.inventory.enabled_limits.items}、装备 ${x.inventory.enabled_limits.equipment}`}$('q').oninput=render;$('form').onsubmit=async e=>{e.preventDefault();let r=await fetch('/api/grants',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({item_id:+$('item_id').value,element_type:+$('element_type').value,count:+$('quantity').value,title:$('title').value,body:$('body').value})});let x=await r.json();$('status').textContent=r.ok?`已写入邮件 #${x.mail.mail_id}。\n重新打开或刷新游戏邮箱即可看到并领取;服务端无需重启。\n货币会直接叠加,固定箱映射会直接发放内容物。\n热载文件:${x.output}`:x.error};$('unlimited-inventory').onchange=async e=>{let box=e.target,old=!box.checked;box.disabled=true;let r=await fetch('/api/settings/inventory',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({unlimited:box.checked})});let x=await r.json();box.disabled=false;if(r.ok){showSettings(x);$('inventory-status').textContent='设置已保存。无需重启服务端;请重新登录客户端后生效。'}else{box.checked=old;$('inventory-status').textContent=x.error}};Promise.all([fetch('/api/items').then(r=>r.json()),fetch('/api/settings').then(r=>r.json())]).then(([x,s])=>{all=x.items;render();showSettings(s)});</script>"""
<script>let all=[],submitting=false;const $=id=>document.getElementById(id);function render(){let q=$('q').value.toLowerCase();let matches=all.filter(x=>(x.id+' '+x.element_type+' '+x.name+' '+x.category+' '+(x.aliases||[]).join(' ')+' '+(x.details||'')).toLowerCase().includes(q));let rows=matches.slice(0,500);$('count').textContent=`(匹配 ${matches.length} / ${all.length} 项;显示前 ${rows.length} 项)`; $('items').innerHTML=rows.map(x=>`<tr><td>${x.id}</td><td>${x.element_type}</td><td>${esc(x.name)}</td><td>${esc(x.category+(x.details?':'+x.details:''))}</td><td class=pick><button onclick="pick(${x.element_type},${x.id})">选择</button></td></tr>`).join('')}function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML}function pick(t,id){$('item_id').value=id;$('element_type').value=t;$('item-picker').open=false;$('quantity').focus();$('form').scrollIntoView({block:'nearest',behavior:'smooth'})}function showSettings(x){$('unlimited-inventory').checked=x.inventory.unlimited;$('inventory-limits').textContent=`关闭时:道具 ${x.inventory.baseline.items}、装备 ${x.inventory.baseline.equipment};开启时:道具 ${x.inventory.enabled_limits.items}、装备 ${x.inventory.enabled_limits.equipment}`}$('q').oninput=render;$('form').onsubmit=async e=>{e.preventDefault();if(submitting)return;submitting=true;$('grant-submit').disabled=true;let r=await fetch('/api/grants',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({item_id:+$('item_id').value,element_type:+$('element_type').value,count:+$('quantity').value,title:$('title').value,body:$('body').value})});let x=await r.json();$('status').textContent=r.ok?`已加入动态邮件队列,标识 ${x.grant.identity}。\n重新打开或刷新游戏邮箱即可看到并领取;服务端无需重启。\n货币会直接叠加,固定箱映射会直接发放内容物。\n队列文件:${x.output}`:x.error;submitting=false;$('grant-submit').disabled=false};$('unlimited-inventory').onchange=async e=>{let box=e.target,old=!box.checked;box.disabled=true;let r=await fetch('/api/settings/inventory',{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({unlimited:box.checked})});let x=await r.json();box.disabled=false;if(r.ok){showSettings(x);$('inventory-status').textContent='设置已保存。无需重启服务端;请重新登录客户端后生效。'}else{box.checked=old;$('inventory-status').textContent=x.error}};Promise.all([fetch('/api/items').then(r=>r.json()),fetch('/api/settings').then(r=>r.json())]).then(([x,s])=>{all=x.items;render();showSettings(s)});</script>"""
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")
+17 -30
View File
@@ -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):
+4 -2
View File
@@ -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"
}
}