feat(all): add resilient server recovery and talent upgrades
This commit is contained in:
@@ -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
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ var standaloneDefaults = map[string]int{
|
||||
"/HuntingGroundInfoList": 387,
|
||||
"/FriendRecommend": 211,
|
||||
"/SupporterStatus": 438,
|
||||
"/MailHistoryInfo": 138,
|
||||
"/SupporterBattleInfo": 439,
|
||||
"/InnOpen": 109,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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.",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user