refactor(all): reorganize packages, establish player ownership and isolate accounts

This commit is contained in:
2026-10-07 22:42:16 +08:00
parent 8e8ddeddb6
commit 06a83ada28
850 changed files with 42335 additions and 79891 deletions
+47
View File
@@ -0,0 +1,47 @@
name = "test-reviewer"
description = "Reviews newly written or modified tests for meaningful behavioral coverage and AI-generated test smells."
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
You are a strict test-quality reviewer.
Review only tests added or modified by the parent task.
Your job is NOT to maximize test coverage and NOT to suggest tests merely
because code is uncovered.
Reject or flag tests that:
- test trivial getters, setters, field types, constructors, or language behavior;
- assert implementation details rather than externally meaningful behavior;
- freeze incidental fixture data that is not an intentional contract;
- assert facts merely because they happen to be true in the current fixture;
- duplicate behavior already adequately covered;
- combine unrelated business invariants into one large test;
- have names claiming behavior that the test does not actually exercise;
- create requests, fixtures, mocks, or values that never participate in an assertion;
- contain dead setup or ceremonial assertions;
- depend on ordering or representation that is not part of the contract;
- mirror the current implementation instead of testing requirements;
- add maintenance burden without a plausible regression they would catch.
For every test, ask:
"If this assertion fails after a legitimate implementation or data change,
would that necessarily indicate a bug?"
If the answer is no, flag it.
Also ask:
"What realistic regression does this test catch?"
If there is no concrete answer, recommend deleting the test.
Do not edit files.
Return findings ordered by severity, with exact test names and concise reasons.
If all modified tests are meaningful, explicitly say PASS.
"""
+1 -1
View File
@@ -18,7 +18,7 @@
客户端窗口在插件准备完成后打开。首次生成完整游戏源码或者在 SDK / 游戏更新后可能等待数分钟,后续启动复用共享缓存。
将 `go/config.example.json` 复制为 `go/config.json`,将 `game_directory` 改为本机 Brown Dust II 安装目录。仍可在 `run` 后使用 `--game-dir` 临时覆盖。
将 `go/config.example.json` 改名为 `go/config.json`,将 `game_directory` 改为本机 Brown Dust II 安装目录。仍可在 `run` 后使用 `--game-dir` 临时覆盖。
发布脚本统一使用 `-tags release` 编译。
+1 -1
View File
@@ -20,7 +20,7 @@
| 14 | 32 | payload 的 SHA-256 原始摘要 |
| 46 | payload 长度 | 按固定顺序编码的记录 |
定义在 `go/internal/server/calendar/records.go` ,自行查看。
定义在 `go/internal/server/domain/events/calendar/protocol_adapter.go` ,自行查看。
## 更新和验证
-3
View File
@@ -52,9 +52,6 @@ func (t task) sdkPack(args []string) (result error) {
if err = command("pack", filepath.Join(t.root, "plugins", "GameNames", "GameNames.csproj"), "-c", "Release", "--nologo", "-o", o.outputDirectory, "-p:Version="+version, "-p:GameNamesTable="+table, "-p:BaseIntermediateOutputPath="+runtimeObj, "-p:OutputPath="+runtimeBin); err != nil {
return err
}
if err = command(toolDLL, "verify-runtime", table, filepath.Join(runtimeBin, "BD2.GameNames.dll")); err != nil {
return err
}
project := filepath.Join(t.root, "plugins", "GameSdk", "Package", "BD2.GameSdk.Package.csproj")
config := filepath.Join(workspace, "NuGet.Config")
if err = sdkNugetConfig(config, o.outputDirectory, ""); err != nil {
-4
View File
@@ -124,12 +124,8 @@ func (t task) sdkVerify(args []string) (result error) {
return err
}
command := func(args ...string) error { _, err := t.sdkCommand(workspace, nil, false, args...); return err }
runtime := filepath.Join(output, "BD2.GameNames.dll")
plugin := filepath.Join(output, "ExamplePlugin.dll")
assembly := filepath.Join(managed, "Assembly-CSharp.dll")
if err = command(tool, "verify-runtime", table, runtime); err != nil {
return err
}
if err = command(tool, "verify-navigation", sdk); err != nil {
return err
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func versionSourceFixture(t *testing.T) map[string]any {
t.Helper()
return map[string]any{
"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": map[string]any{"local_identity": "0.6.1", "capture_environment": "0.2.0", "login_ui": "0.2.0", "cash_shop": "0.1.2"},
}
}
func writeVersionFixture(t *testing.T, path string, config map[string]any) {
t.Helper()
raw, err := json.Marshal(config)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
}
func TestGenerateVersionSourcePreservesUnchangedFile(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, "versions with spaces.json")
output := filepath.Join(dir, "generated with spaces", "Version.cs")
writeVersionFixture(t, config, versionSourceFixture(t))
args := []string{"--config", config, "--output", output, "--plugin", "cash_shop"}
if err := (task{root: dir}).generateVersionSource(args); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `Plugin = "0.1.2";`) || !strings.Contains(string(raw), `ClientRelease = "2.35.10+client.0.1.0";`) {
t.Fatalf("incorrect generated versions: %s", raw)
}
stamp := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
if err := os.Chtimes(output, stamp, stamp); err != nil {
t.Fatal(err)
}
if err := (task{root: dir}).generateVersionSource(args); err != nil {
t.Fatal(err)
}
info, err := os.Stat(output)
if err != nil || !info.ModTime().Equal(stamp) {
t.Fatalf("unchanged source rewritten: info=%v err=%v", info, err)
}
changed := versionSourceFixture(t)
changed["plugins"].(map[string]any)["cash_shop"] = "0.1.3"
writeVersionFixture(t, config, changed)
if err := (task{root: dir}).generateVersionSource(args); err != nil {
t.Fatal(err)
}
raw, err = os.ReadFile(output)
if err != nil || !strings.Contains(string(raw), `Plugin = "0.1.3";`) {
t.Fatalf("changed source not installed: %s err=%v", raw, err)
}
}
func TestGenerateVersionSourceRejectsInvalidConfigWithoutWriting(t *testing.T) {
for _, test := range []struct {
name string
change func(map[string]any)
plugin string
}{
{"cross-game-client", func(c map[string]any) { c["client_version"] = "2.34.13+client.0.1.0" }, "login_ui"},
{"wrong-server-component", func(c map[string]any) { c["server_version"] = "2.35.10+client.0.1.0" }, "login_ui"},
{"invalid-resource", func(c map[string]any) { c["bundle_version"] = "2026092113523" }, "login_ui"},
{"injected-plugin-source", func(c map[string]any) { c["plugins"].(map[string]any)["cash_shop"] = "1.0.0\";" }, "cash_shop"},
{"invalid-unselected-plugin", func(c map[string]any) { c["plugins"].(map[string]any)["cash_shop"] = "invalid" }, "login_ui"},
{"unknown-field", func(c map[string]any) { c["game_versoin"] = "2.35.10" }, "login_ui"},
{"escaping-seed", func(c map[string]any) { c["seed_directory"] = `..\outside` }, "login_ui"},
{"unknown-plugin", func(map[string]any) {}, "game_names"},
} {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, "versions.json")
output := filepath.Join(dir, "Version.cs")
c := versionSourceFixture(t)
test.change(c)
writeVersionFixture(t, config, c)
prior := []byte("existing generated source")
if err := os.WriteFile(output, prior, 0o600); err != nil {
t.Fatal(err)
}
if err := (task{}).generateVersionSource([]string{"--config", config, "--output", output, "--plugin", test.plugin}); err == nil {
t.Fatal("invalid configuration accepted")
}
raw, err := os.ReadFile(output)
if err != nil || string(raw) != string(prior) {
t.Fatalf("validation failure modified output=%s err=%v", raw, err)
}
})
}
}
@@ -1,15 +0,0 @@
//go:build release
package main
import "testing"
func TestReleaseBuildRejectsDevelopmentFlag(t *testing.T) {
args, options, err := developmentRunOptions([]string{"--dev", "run"})
if err != nil {
t.Fatal(err)
}
if err := runClient(args, options); err == nil {
t.Fatal("release build unexpectedly accepted --dev run")
}
}
-144
View File
@@ -1,144 +0,0 @@
//go:build !release
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestClientDevelopmentGameDirectory(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
}{
{name: "separate", args: []string{"--game-dir", filepath.Join("some", "game")}, want: filepath.Join("some", "game")},
{name: "equals", args: []string{"--game-dir=" + filepath.Join("other", "game")}, want: filepath.Join("other", "game")},
{name: "absent", args: nil},
} {
t.Run(test.name, func(t *testing.T) {
got, err := clientDevelopmentGameDirectory(test.args)
if err != nil {
t.Fatal(err)
}
if got != test.want {
t.Fatalf("game directory = %q, want %q", got, test.want)
}
})
}
}
func TestClientDevelopmentGameDirectoryRequiresValue(t *testing.T) {
for _, args := range [][]string{{"--game-dir"}, {"--game-dir="}} {
if _, err := clientDevelopmentGameDirectory(args); err == nil {
t.Fatalf("args %v unexpectedly succeeded", args)
}
}
}
func TestLoadClientDevelopmentConfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
gameDir := filepath.Join(dir, "BrownDust II")
quoted, err := json.Marshal(gameDir)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`{"schema_version":1,"game_directory":`+string(quoted)+`}`), 0o600); err != nil {
t.Fatal(err)
}
config, err := loadClientDevelopmentConfig(path)
if err != nil {
t.Fatal(err)
}
want, _ := filepath.Abs(gameDir)
if config.SchemaVersion != 1 || config.GameDirectory != want {
t.Fatalf("config=%+v want directory %q", config, want)
}
if _, err := loadClientDevelopmentConfig(filepath.Join(dir, "missing.json")); err == nil {
t.Fatal("missing development config accepted")
}
for name, body := range map[string]string{
"unknown": `{"schema_version":1,"game_directory":"x","extra":true}`,
"version": `{"schema_version":2,"game_directory":"x"}`,
"empty": `{"schema_version":1,"game_directory":""}`,
"trailing": `{"schema_version":1,"game_directory":"x"}{}`,
} {
t.Run(name, func(t *testing.T) {
bad := filepath.Join(dir, name+".json")
if err := os.WriteFile(bad, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadClientDevelopmentConfig(bad); err == nil {
t.Fatal("invalid development config accepted")
}
})
}
}
func TestDevelopmentRunOptionsUsesRepositoryFiles(t *testing.T) {
gameDir := t.TempDir()
args, options, err := developmentRunOptions([]string{"--dev", "run", "--game-dir", gameDir})
if err != nil {
t.Fatal(err)
}
if len(args) != 2 || args[0] != "--game-dir" || args[1] != gameDir {
t.Fatalf("client args = %v", args)
}
for name, path := range map[string]string{
"versions": options.versionConfigPath,
"log executable": options.logExecutablePath,
"local identity": options.localIdentityPlugin,
"login UI": options.loginUIPlugin,
"cash shop": options.cashShopPlugin,
} {
if !filepath.IsAbs(path) {
t.Errorf("%s path is not absolute: %q", name, path)
}
}
}
func TestDevelopmentRunOptionsRequiresRun(t *testing.T) {
if _, _, err := developmentRunOptions([]string{"--dev"}); err == nil {
t.Fatal("development command without run unexpectedly succeeded")
}
}
func TestDevelopmentRunRelaunchesWithWailsProductionHost(t *testing.T) {
if wailsDevelopmentBuild {
t.Skip("test exercises the untagged bootstrap process")
}
previous := runDevelopmentChild
t.Cleanup(func() { runDevelopmentChild = previous })
var gotRoot string
var gotArgs []string
runDevelopmentChild = func(root string, args []string) error {
gotRoot = root
gotArgs = append([]string(nil), args...)
return nil
}
relaunched, err := relaunchDevelopmentIfNeeded([]string{"--dev", "run", "--game-dir", "example"})
if err != nil || !relaunched {
t.Fatalf("relaunched=%v err=%v", relaunched, err)
}
if !filepath.IsAbs(gotRoot) {
t.Fatalf("root is not absolute: %q", gotRoot)
}
want := []string{"run", "-tags", "production", "./cmd/bd2client", "--dev", "run", "--game-dir", "example"}
if !reflect.DeepEqual(gotArgs, want) {
t.Fatalf("child args=%v want=%v", gotArgs, want)
}
}
func TestDevelopmentRunDoesNotRelaunchOtherCommands(t *testing.T) {
for _, args := range [][]string{nil, {"--game-dir", "example"}, {"--dev"}, {"--dev", "other"}} {
relaunched, err := relaunchDevelopmentIfNeeded(args)
if err != nil || relaunched {
t.Fatalf("args=%v relaunched=%v err=%v", args, relaunched, err)
}
}
}
@@ -1,28 +0,0 @@
//go:build !release
package main
import (
"os"
"testing"
)
// Opt in on a developer machine with the game installed. Exercise the exact
// --dev run preparation path without starting the interactive Wails window.
func TestDevelopmentRunBuildsPluginsWithEmbeddedNames(t *testing.T) {
if os.Getenv("BD2_TEST_DEVELOPMENT_BUILD") != "1" {
t.Skip("set BD2_TEST_DEVELOPMENT_BUILD=1 to build against go/config.json")
}
args, options, err := developmentRunOptions([]string{"--dev", "run"})
if err != nil {
t.Fatal(err)
}
if len(args) != 0 {
t.Fatalf("unexpected runtime arguments: %v", args)
}
for _, path := range []string{options.localIdentityPlugin, options.loginUIPlugin, options.cashShopPlugin} {
if !clientDevelopmentFile(path) {
t.Fatalf("development plugin output missing: %s", path)
}
}
}
+3 -2
View File
@@ -3,6 +3,7 @@
package main
import (
"bd2server/internal/server/app"
"errors"
"fmt"
"os"
@@ -27,8 +28,8 @@ func runDevelopmentCommand(args []string) (bool, error) {
serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, ".build", "config", "resources.json"))
serveArgs = appendDefaultFlag(serveArgs, "--game-config", filepath.Join(root, ".build", "config", "game.json"))
serveArgs = appendDefaultFlag(serveArgs, "--data-dir", filepath.Join(root, "data"))
serveArgs = appendDefaultFlag(serveArgs, "--state", filepath.Join(root, "data", "state", "state.db"))
return true, serve(serveArgs)
serveArgs = appendDefaultFlag(serveArgs, "--state-dir", filepath.Join(root, "data", "state"))
return true, app.Serve(serveArgs)
}
func findDevelopmentRoot() (string, error) {
@@ -1,15 +0,0 @@
//go:build release
package main
import "testing"
func TestReleaseBuildDoesNotHandleDevelopmentCommand(t *testing.T) {
handled, err := runDevelopmentCommand([]string{"--dev", "run"})
if err != nil {
t.Fatal(err)
}
if handled {
t.Fatal("release build unexpectedly handled --dev run")
}
}
-40
View File
@@ -1,40 +0,0 @@
//go:build !release
package main
import (
"os"
"path/filepath"
"testing"
)
func TestFindDevelopmentRootWithoutRuntimeConfigurations(t *testing.T) {
root := t.TempDir()
child := filepath.Join(root, "go", "cmd")
if err := os.MkdirAll(child, 0755); err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(root, "versions.json"), filepath.Join(root, "go", "go.mod")} {
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
}
t.Chdir(child)
got, err := findDevelopmentRoot()
if err != nil || got != root {
t.Fatalf("development root = %q, %v", got, err)
}
}
func TestAppendDefaultFlagPreservesExplicitOverride(t *testing.T) {
for _, args := range [][]string{{"--data-dir", "custom"}, {"--data-dir=custom"}} {
got := appendDefaultFlag(append([]string(nil), args...), "--data-dir", "default")
if len(got) != len(args) {
t.Fatalf("args=%v got=%v", args, got)
}
}
got := appendDefaultFlag(nil, "--data-dir", "default")
if len(got) != 2 || got[0] != "--data-dir" || got[1] != "default" {
t.Fatalf("default args=%v", got)
}
}
@@ -1,17 +0,0 @@
package main
import (
"path/filepath"
"testing"
)
func TestDevelopmentSettingsDefaultFollowsServerDataDirectory(t *testing.T) {
dataDir := filepath.Join(t.TempDir(), "custom-data")
if got := resolveDevelopmentSettingsPath(dataDir, ""); got != filepath.Join(dataDir, "dev-tools.json") {
t.Fatalf("development settings path = %q", got)
}
override := filepath.Join(t.TempDir(), "operator-settings.json")
if got := resolveDevelopmentSettingsPath(dataDir, override); got != override {
t.Fatalf("explicit development settings path = %q, want %q", got, override)
}
}
-35
View File
@@ -1,35 +0,0 @@
package main
import (
"bytes"
"log/slog"
"strings"
"testing"
"bd2server/internal/server/logging"
)
func TestServeLoggingOverridesEnvironmentAndEnablesTrace(t *testing.T) {
previous := slog.Default()
t.Cleanup(func() { slog.SetDefault(previous) })
t.Setenv("BD2_LOG_LEVEL", "warn")
t.Setenv("BD2_LOG_COLOR", "never")
var output bytes.Buffer
if err := configureLogging(&output, "trace", "always"); err != nil {
t.Fatal(err)
}
logging.Trace("trace enabled")
slog.Info("existing callers use new handler")
if !strings.Contains(output.String(), "\x1b[90mTRACE\x1b[0m") || !strings.Contains(output.String(), "\x1b[32mINFO\x1b[0m") {
t.Fatalf("log output=%q", output.String())
}
output.Reset()
if err := configureLogging(&output, "", ""); err != nil {
t.Fatal(err)
}
slog.Info("filtered")
slog.Warn("plain warning")
if strings.Contains(output.String(), "filtered") || strings.Contains(output.String(), "\x1b[") || !strings.Contains(output.String(), "level=WARN") {
t.Fatalf("env log output=%q", output.String())
}
}
+6 -1438
View File
File diff suppressed because it is too large Load Diff
-182
View File
@@ -1,182 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"bd2server/internal/server/account"
"bd2server/internal/server/authconfig"
"bd2server/internal/server/calendar"
"bd2server/internal/server/deck"
"bd2server/internal/server/gameconfig"
"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")
gamePath := fs.String("game-config", "", "game.json server gameplay configuration 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 *gamePath == "" {
*gamePath, err = gameconfig.BesideExecutable()
if err != nil {
return err
}
}
gameRules, err := gameconfig.Load(*gamePath)
if err != nil {
return err
}
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
}
defaults, err := readonly.Load(filepath.Join(seedRoot, "readonly.json"))
if err != nil {
return err
}
calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion)
if err != nil {
return err
}
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
return fmt.Errorf("project calendars require regular content and monster hunt schedules")
}
if _, err := calendars.ApplyReadonly(defaults); err != nil {
return err
}
if err := calendars.ValidateDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight project calendar GameData references: %w", err)
}
schedule := calendars.GachaSeed
var groups, steps []uint64
for _, window := range schedule.Schedules {
groups = append(groups, window.GroupID)
}
for _, window := range schedule.StepUps {
steps = append(steps, window.GroupID)
}
_, equipment, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps)
if err != nil {
return err
}
if gameRules.Gacha.IncludeCollaborationURWeapons {
if err := equipment.IncludeCollaborationURWeapons(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight collaboration UR weapon game rule: %w", err)
}
}
if _, err := gamedata.LoadFirstGacha(gameData, versions.GameDataVersion); err != nil {
return err
}
if _, err := gamedata.LoadLimitedCostumes(gameData, versions.GameDataVersion); err != nil {
return err
}
if _, err := gamedata.LoadCostumeBurstDesign(gameData, versions.GameDataVersion); err != nil {
return err
}
if _, err := gamedata.LoadCostumePotentialDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight costume potential design: %w", err)
}
if _, err := gamedata.LoadTalentUseDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight talent skill design: %w", err)
}
if _, err := gamedata.LoadFieldSettingsDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight field character settings: %w", err)
}
if _, err := gamedata.LoadItemCraftDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight item craft design: %w", err)
}
if _, err := gamedata.LoadTalentDispatchDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight talent dispatch design: %w", err)
}
if _, err := gamedata.LoadFieldBuffDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight field damage design: %w", err)
}
if _, err := gamedata.LoadPackRecoveryPolicy(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight automatic recovery policy: %w", err)
}
if _, err := gamedata.LoadSkyWayOverwhelm(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight overwhelm design: %w", err)
}
if _, err := gamedata.LoadNPCShopDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight NPC shop design: %w", 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)
}
-208
View File
@@ -1,208 +0,0 @@
package app
import (
"context"
"errors"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
clientconfig "bd2server/internal/client/config"
)
func TestEmbeddedStudioUsesNativeBindings(t *testing.T) {
page, err := fs.ReadFile(webFS, "web/index.html")
if err != nil {
t.Fatal(err)
}
text := string(page)
for _, marker := range []string{
"BD2 Client Studio", "window.go.app.Studio", "bridge().Initialize()",
"(()=>{", "async function navigateScene(next)",
`id="directoryScene"`, `id="serverScene"`, `id="deskScene"`,
`id="patch"`, `id="install"`, `id="launch"`,
`value="official"`, `value="local"`, `value="server"`,
"prefers-reduced-motion",
"opening-curtain", "is-entering", "@keyframes reveal", "bridge().Quit()",
"Asia/Shanghai", "Asia/Hong_Kong", "Asia/Macau", "Asia/Taipei",
} {
if !strings.Contains(text, marker) {
t.Errorf("embedded desktop interface lacks %q", marker)
}
}
for _, obsolete := range []string{"fetch(", "/api/", "bd2-session", "X-BD2-Session", "{{.", "window_darwin.js", "function go("} {
if strings.Contains(text, obsolete) {
t.Errorf("embedded desktop interface retains obsolete browser bridge %q", obsolete)
}
}
}
func TestStudioInitializeLoadsSavedSettings(t *testing.T) {
dir := makeTestClient(t)
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")
}
if _, err := clientconfig.Save(dir, clientconfig.Settings{ServerOrigin: "https://play.example.com", CDNMode: clientconfig.CDNOfficial}); err != nil {
t.Fatal(err)
}
state := studio.Initialize()
if !state.AutoOpen || state.GameDirectory != dir || state.ServerOrigin != "https://play.example.com" || 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{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}
result, err := studio.Save(input)
if err != nil || !result.OK || remembered != dir {
t.Fatalf("result=%+v remembered=%q err=%v", result, remembered, err)
}
remembered = ""
input.ServerOrigin = "http://untrusted.example.com"
if _, err := studio.Save(input); err == nil || remembered != "" {
t.Fatalf("invalid settings remembered=%q err=%v", remembered, err)
}
if _, err := studio.Launch(Request{GameDirectory: dir}); err == nil {
t.Fatal("unpatched game launch accepted")
}
}
func TestStudioContextCancellationAndPickerCancellation(t *testing.T) {
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) { return "", nil }})
for _, browse := range []func(Request) (Response, error){studio.Browse, studio.BrowseResources} {
result, err := browse(Request{})
if err != nil || !result.OK || result.Message != "Selection cancelled" || result.Data != nil {
t.Fatalf("result=%+v err=%v", result, err)
}
}
ctx, cancel := context.WithCancel(context.Background())
studio.Startup(ctx)
cancel()
if _, err := studio.Resources(Request{ServerOrigin: "http://127.0.0.1:8080", CDNMode: clientconfig.CDNOfficial}); !errors.Is(err, context.Canceled) {
t.Fatalf("err=%v", err)
}
}
func TestStudioShutdownCancelsOwnedContext(t *testing.T) {
studio := NewStudio(Options{}, NativeHost{})
studio.Startup(context.Background())
owned := studio.context()
studio.Shutdown()
if !errors.Is(owned.Err(), context.Canceled) {
t.Fatalf("owned context err=%v", owned.Err())
}
}
func TestStudioInitializeDefaultsWithoutGameDirectory(t *testing.T) {
studio := NewStudio(Options{LogPath: "test.log"}, NativeHost{})
studio.Startup(context.Background())
state := studio.Initialize()
if state.Platform == "" || state.LogPath != "test.log" || state.GameDirectory != "" {
t.Fatalf("state=%+v", state)
}
if state.ServerOrigin != "http://127.0.0.1:8080" || state.CDNMode != clientconfig.CDNOfficial || state.AutoOpen {
t.Fatalf("defaults=%+v", state)
}
}
func TestStudioBrowseUsesNativeDirectoryPicker(t *testing.T) {
dir := makeTestClient(t)
var title string
studio := NewStudio(Options{Versions: clientconfig.ReleaseVersions{GameVersion: "2.35.10"}}, NativeHost{
BrowseDirectory: func(_ context.Context, requested string) (string, error) {
title = requested
return dir, nil
},
})
studio.Startup(context.Background())
result, err := studio.Browse(Request{UILanguage: "zh-CN"})
if err != nil {
t.Fatal(err)
}
if !result.OK || result.Data == nil || title != "选择 Brown Dust II 安装目录" {
t.Fatalf("result=%+v title=%q", result, title)
}
}
func TestStudioQuitUsesNativeLifecycleOnce(t *testing.T) {
quits := 0
studio := NewStudio(Options{}, NativeHost{Quit: func(context.Context) { quits++ }})
studio.Startup(context.Background())
studio.Quit()
studio.Quit()
if quits != 1 {
t.Fatalf("quits=%d", quits)
}
}
func TestStudioPropagatesOperationErrors(t *testing.T) {
want := errors.New("picker failed")
studio := NewStudio(Options{}, NativeHost{BrowseDirectory: func(context.Context, string) (string, error) {
return "", want
}})
studio.Startup(context.Background())
if _, err := studio.Browse(Request{}); !errors.Is(err, want) {
t.Fatalf("err=%v", err)
}
}
func TestDesktopSecurityHeaders(t *testing.T) {
called := false
handler := desktopSecurityHeaders(httpHandlerFunc(func(header map[string]string) {
called = true
if header["Cache-Control"] != "no-store" || header["Content-Security-Policy"] == "" {
t.Fatalf("headers=%v", header)
}
}))
response := &headerRecorder{header: make(map[string][]string)}
handler.ServeHTTP(response, nil)
if !called {
t.Fatal("asset middleware did not call the next handler")
}
}
// Small local adapters keep this middleware test independent of httptest's
// network-shaped helpers.
type httpHandlerFunc func(map[string]string)
func (f httpHandlerFunc) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
headers := make(map[string]string)
for name, values := range w.Header() {
if len(values) != 0 {
headers[name] = values[0]
}
}
f(headers)
}
type headerRecorder struct{ header http.Header }
func (r *headerRecorder) Header() http.Header { return r.header }
func (*headerRecorder) Write([]byte) (int, error) { return 0, nil }
func (*headerRecorder) WriteHeader(int) {}
func makeTestClient(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("not a real Unity file"),
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
return dir
}
+2 -2
View File
@@ -31,6 +31,7 @@ func Run(runOptions Options) error {
studio.log().Info("starting native client window", "windows_engine", "WebView2", "macos_engine", "WKWebView")
err = wails.Run(&options.App{
Title: "BD2 Client Studio",
Frameless: true,
Width: 1080,
Height: 720,
MinWidth: 860,
@@ -52,13 +53,12 @@ func Run(runOptions Options) error {
},
Windows: &windows.Options{
Theme: windows.Light,
BackdropType: windows.Mica,
DisablePinchZoom: true,
IsZoomControlEnabled: false,
EnableSwipeGestures: false,
},
Mac: &mac.Options{
TitleBar: mac.TitleBarDefault(),
TitleBar: &mac.TitleBar{HideTitle: true, HideTitleBar: true, FullSizeContent: true, TitlebarAppearsTransparent: true},
Appearance: mac.NSAppearanceNameAqua,
DisableZoom: true,
},
-13
View File
@@ -28,16 +28,3 @@ func gameProxyEnvironment(environment []string, proxyURL string) []string {
}
return result
}
// LaunchServices needs explicit empty values when the player selects direct.
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
args := []string{target}
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
key, _, found := strings.Cut(entry, "=")
if found && isGameProxyEnvironmentKey(key) {
args = append(args, "--env", entry)
}
}
args = append(args, "--args")
return append(args, gameLaunchArguments()...)
}
-71
View File
@@ -1,71 +0,0 @@
package app
import (
"reflect"
"strings"
"testing"
)
func TestGameProxyEnvironmentAuthoritative(t *testing.T) {
input := []string{"PATH=kept", "UNITY_PROXYSERVER=http://stale:8080", "uNiTy_NoPrOxY=*", "http_proxy=http://stale:8080", "HTTPS_PROXY=http://stale:8080", "All_Proxy=http://stale:8080", "NO_PROXY=*", "bd2_client_proxy_url=http://stale:8080"}
original := append([]string(nil), input...)
for _, proxy := range []string{"", "http://127.0.0.1:12451"} {
got := gameProxyEnvironment(input, proxy)
if !reflect.DeepEqual(got, gameProxyEnvironment(got, proxy)) {
t.Fatal("environment is not idempotent")
}
if got[0] != "PATH=kept" || !reflect.DeepEqual(input, original) {
t.Fatal("unrelated environment or input changed")
}
seen := map[string]string{}
for _, entry := range got[1:] {
key, value, _ := strings.Cut(entry, "=")
if !isGameProxyEnvironmentKey(key) || strings.Contains(value, "stale") {
t.Fatal("stale proxy survived")
}
seen[key] = value
}
for _, key := range []string{"UNITY_PROXYSERVER", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy", "BD2_CLIENT_PROXY_URL"} {
if value, ok := seen[key]; !ok || value != proxy {
t.Errorf("missing authoritative %s override", key)
}
}
for _, key := range []string{"UNITY_NOPROXY", "NO_PROXY", "no_proxy"} {
if seen[key] != "localhost,127.0.0.1,::1" {
t.Errorf("loopback bypass missing for %s", key)
}
}
}
}
func TestGameProxyEnvironmentKeys(t *testing.T) {
for _, key := range []string{"UNITY_PROXYSERVER", "unity_noproxy", "http_proxy", "HTTPS_PROXY", "All_Proxy", "NO_PROXY", "bd2_client_proxy_url"} {
if !isGameProxyEnvironmentKey(key) {
t.Errorf("proxy key %q not recognized", key)
}
}
for _, key := range []string{"PATH", "SECRET", "HTTP_PROXY_PASSWORD", "NO_PROXY_EXTRA"} {
if isGameProxyEnvironmentKey(key) {
t.Errorf("unrelated key %q recognized", key)
}
}
}
func TestGameOpenArgumentsOverridesLaunchServicesProxy(t *testing.T) {
args := gameOpenArguments("/Applications/BrownDust II.app", []string{"HTTP_PROXY=http://stale:80", "PATH=private"}, "")
overrides := map[string]bool{}
for index := 1; index < len(args) && args[index] != "--args"; index += 2 {
if args[index] != "--env" {
t.Fatal("missing explicit LaunchServices environment flag")
}
overrides[args[index+1]] = true
}
for _, entry := range []string{"HTTP_PROXY=", "http_proxy=", "HTTPS_PROXY=", "ALL_PROXY=", "UNITY_PROXYSERVER=", "BD2_CLIENT_PROXY_URL=", "UNITY_NOPROXY=localhost,127.0.0.1,::1"} {
if !overrides[entry] {
t.Errorf("LaunchServices override missing: %s", entry)
}
}
if overrides["PATH=private"] || overrides["HTTP_PROXY=http://stale:80"] {
t.Fatal("unrelated or stale environment forwarded")
}
}
-81
View File
@@ -1,81 +0,0 @@
package app
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"unicode"
"unicode/utf8"
)
func TestOpenPersistentLoggerUsesExecutableLogDirectory(t *testing.T) {
root := t.TempDir()
executable := filepath.Join(root, "bd2client.exe")
logger, closer, logPath, err := OpenPersistentLogger(executable)
if err != nil {
t.Fatal(err)
}
logger.Info("test entry")
if err := closer.Close(); err != nil {
t.Fatal(err)
}
wantPath := filepath.Join(root, "logs", clientLogName)
if logPath != wantPath {
t.Fatalf("log path=%q want=%q", logPath, wantPath)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("test entry")) {
t.Fatalf("log does not contain test entry: %s", data)
}
}
func TestPersistentOperationalLogsUseEnglish(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewTextHandler(&output, nil))
studio := NewStudio(Options{Logger: logger}, NativeHost{})
studio.Startup(context.Background())
_, _ = studio.Inspect(Request{GameDirectory: `Z:\missing`, UILanguage: "zh-CN"})
for len(output.Bytes()) > 0 {
r, size := utf8.DecodeRune(output.Bytes())
if unicode.Is(unicode.Han, r) {
t.Fatalf("operational log contains Han character %q: %s", r, output.String())
}
output.Next(size)
}
}
func TestRollingLogRetainsOneBackup(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, clientLogName)
backup := filepath.Join(root, clientLogBackupName)
writer, err := openRollingLog(path, backup, 8)
if err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte("first")); err != nil {
t.Fatal(err)
}
if _, err := writer.Write([]byte("second")); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
active, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
previous, err := os.ReadFile(backup)
if err != nil {
t.Fatal(err)
}
if string(active) != "second" || string(previous) != "first" {
t.Fatalf("active=%q backup=%q", active, previous)
}
}
+13
View File
@@ -29,3 +29,16 @@ func launchGame(target, proxyURL string) error {
args := gameOpenArguments(target, os.Environ(), proxyURL)
return exec.Command("open", args...).Start()
}
// LaunchServices needs explicit empty values when the player selects direct.
func gameOpenArguments(target string, environment []string, proxyURL string) []string {
args := []string{target}
for _, entry := range gameProxyEnvironment(environment, proxyURL) {
key, _, found := strings.Cut(entry, "=")
if found && isGameProxyEnvironmentKey(key) {
args = append(args, "--env", entry)
}
}
args = append(args, "--args")
return append(args, gameLaunchArguments()...)
}
+1 -11
View File
@@ -54,18 +54,8 @@ func ShowFatalError(err error) {
// despite inheriting no console from the parent process.
const createNoWindow = 0x08000000
func hiddenCommand(name string, args ...string) *exec.Cmd {
command := exec.Command(name, args...)
command.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: createNoWindow,
}
return command
}
// visibleCommand suppresses a console allocation without hiding the GUI
// window created by the child process. It must be used for the game itself;
// hiddenCommand is reserved for background helper processes.
// window created by the child process.
func visibleCommand(name string, args ...string) *exec.Cmd {
command := exec.Command(name, args...)
command.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNoWindow}
@@ -1,42 +0,0 @@
//go:build windows
package app
import (
"reflect"
"testing"
)
func TestHiddenCommandNeverAllocatesVisibleConsole(t *testing.T) {
command := hiddenCommand("powershell.exe", "-NoProfile")
if command.SysProcAttr == nil {
t.Fatal("hidden command has no Windows process attributes")
}
if !command.SysProcAttr.HideWindow {
t.Fatal("hidden command does not request a hidden window")
}
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
t.Fatalf("hidden command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
}
}
func TestVisibleCommandDoesNotHideGUIWindow(t *testing.T) {
command := visibleCommand("Brown Dust II.exe", gameLaunchArguments()...)
if command.SysProcAttr == nil {
t.Fatal("visible command has no Windows process attributes")
}
if command.SysProcAttr.HideWindow {
t.Fatal("visible game command requests a hidden window")
}
if command.SysProcAttr.CreationFlags&createNoWindow == 0 {
t.Fatalf("visible command creation flags %#x omit CREATE_NO_WINDOW", command.SysProcAttr.CreationFlags)
}
want := []string{
"Brown Dust II.exe",
"-screen-fullscreen", "1",
"-window-mode", "borderless",
}
if !reflect.DeepEqual(command.Args, want) {
t.Fatalf("visible game command args = %#v, want %#v", command.Args, want)
}
}
File diff suppressed because one or more lines are too long
-133
View File
@@ -1,133 +0,0 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNormalize(t *testing.T) {
got, err := Normalize(Settings{ServerOrigin: " https://example.com:8443/ ", CDNMode: CDNServer})
if err != nil {
t.Fatal(err)
}
if got.SchemaVersion != SchemaVersion || got.ServerOrigin != "https://example.com:8443" || got.CDNMode != CDNServer {
t.Fatalf("normalized=%+v", got)
}
for _, bad := range []string{"example.com", "ftp://example.com", "http://192.168.1.8:8080", "https://u:p@example.com", "https://example.com/game/", "https://example.com?q=1"} {
if _, err := Normalize(Settings{ServerOrigin: bad, CDNMode: CDNOfficial}); err == nil {
t.Errorf("accepted origin %q", bad)
}
}
localRoot := t.TempDir()
local, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: localRoot})
if err != nil || local.LocalResourceDirectory != localRoot {
t.Fatalf("local=%+v err=%v", local, err)
}
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal}); err == nil {
t.Fatal("accepted local mode without a resource directory")
}
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, LocalResourceDirectory: localRoot}); err == nil {
t.Fatal("accepted a local resource directory in official mode")
}
}
func TestSaveLoad(t *testing.T) {
dir := t.TempDir()
want := Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: t.TempDir()}
if _, err := Save(dir, want); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if got.ServerOrigin != want.ServerOrigin || got.CDNMode != want.CDNMode || got.SchemaVersion != SchemaVersion || got.LocalResourceDirectory != want.LocalResourceDirectory {
t.Fatalf("loaded=%+v", got)
}
data, err := os.ReadFile(Path(dir))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "secret") || strings.Contains(string(data), "token") {
t.Fatalf("client config unexpectedly stores a credential: %s", data)
}
updated := Settings{ServerOrigin: "https://friends.example:8443", CDNMode: CDNServer}
if _, err := Save(dir, updated); err != nil {
t.Fatalf("replace config: %v", err)
}
got, err = Load(dir)
if err != nil || got.ServerOrigin != updated.ServerOrigin || got.CDNMode != updated.CDNMode {
t.Fatalf("replaced=%+v err=%v", got, err)
}
}
func TestSaveOmitsLocalDirectoryOutsideLocalMode(t *testing.T) {
dir := t.TempDir()
if _, err := Save(dir, Settings{ServerOrigin: "https://example.com", CDNMode: CDNOfficial}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(Path(dir))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "local_resource_directory") {
t.Fatalf("official config contains local directory field: %s", data)
}
}
func TestPathUsesMacAppSiblingBepInEx(t *testing.T) {
parent := t.TempDir()
app := filepath.Join(parent, "BrownDust II.app")
for _, path := range []string{
filepath.Join(app, "Contents", "MacOS", "BrownDust II"),
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
t.Fatal(err)
}
}
want := filepath.Join(parent, "BepInEx", "config", FileName)
if got := Path(app); got != want {
t.Fatalf("Path()=%q want=%q", got, want)
}
}
func TestExplicitProxyNormalization(t *testing.T) {
for _, test := range []struct{ raw, want string }{
{"", ""}, {" ", ""}, {" http://127.0.0.1:12451/ ", "http://127.0.0.1:12451"}, {"http://[::1]:8080", "http://[::1]:8080"}, {"http://proxy.example:08080", "http://proxy.example:8080"},
} {
got, err := NormalizeProxyURL(test.raw)
if err != nil || got != test.want {
t.Errorf("normalize proxy: got %q, %v", got, err)
}
}
for _, bad := range []string{"http://proxy", "https://proxy:443", "socks5://proxy:1080", "http://user:secret@proxy:8080", "http://proxy:0", "http://proxy:65536", "http://proxy:http", "http://proxy:+80", "http://proxy:8080/path", "http://proxy:8080?secret", "http://proxy:8080?", "http://proxy:8080#", "http://proxy:8080#secret", "http://:8080", "http://[::1%25zone]:8080"} {
if _, err := NormalizeProxyURL(bad); err == nil {
t.Errorf("invalid proxy accepted: %q", bad)
}
}
}
func TestProxySettingsPersistence(t *testing.T) {
dir := t.TempDir()
saved, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, ProxyURL: "http://localhost:12451/"})
if err != nil {
t.Fatal(err)
}
loaded, err := Load(dir)
if err != nil || loaded.ProxyURL != "http://localhost:12451" || saved != loaded {
t.Fatalf("proxy not persisted: %#v, %v", loaded, err)
}
if _, err := Save(dir, Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial}); err != nil {
t.Fatal(err)
}
loaded, err = Load(dir)
if err != nil || loaded.ProxyURL != "" {
t.Fatal("omitted proxy should use direct connection")
}
}
@@ -1,31 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestPreferencesRoundTrip(t *testing.T) {
root := t.TempDir()
t.Setenv("APPDATA", root)
game := filepath.Join(root, "game")
if err := SavePreferences(game); err != nil {
t.Fatal(err)
}
got, err := LoadPreferences()
if err != nil {
t.Fatal(err)
}
want, _ := filepath.Abs(game)
if got.SchemaVersion != preferencesSchemaVersion || got.GameDirectory != want {
t.Fatalf("preferences=%+v", got)
}
path, err := PreferencesPath()
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatal(err)
}
}
-40
View File
@@ -1,40 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadReleaseVersions(t *testing.T) {
path := filepath.Join(t.TempDir(), ReleaseFileName)
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)
}
got, err := LoadReleaseVersions(path)
if err != nil {
t.Fatal(err)
}
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": `{"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)
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadReleaseVersions(path); err == nil {
t.Fatal("accepted invalid release versions")
}
})
}
}
-130
View File
@@ -1,130 +0,0 @@
package introdb
import (
"bytes"
"os"
"path/filepath"
"testing"
)
func referenceClientDir(t *testing.T) string {
t.Helper()
dir := os.Getenv("BD2_TEST_CLIENT_DIR")
if dir == "" {
t.Skip("set BD2_TEST_CLIENT_DIR to enable read-only client integration tests")
}
return dir
}
func TestPagesRoundTrip(t *testing.T) {
p := make([]byte, PageSize*2)
copy(p, salt)
for i := 16; i < len(p); i++ {
p[i] = byte(i * 31)
}
c, err := EncryptPages(p)
if err != nil {
t.Fatal(err)
}
got, err := DecryptPages(c)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, p) {
t.Fatal("page cipher did not round-trip")
}
}
func TestPagesRejectPartialPage(t *testing.T) {
if _, err := DecryptPages(make([]byte, PageSize-1)); err == nil {
t.Fatal("accepted partial page")
}
}
func TestValidateRequiresUniqueURL(t *testing.T) {
p := append([]byte{}, salt...)
p = append(p, []byte(" ServerURLTable LIVE_URL "+oldURL)...)
if err := validateIntroDB(p, oldURL); err != nil {
t.Fatal(err)
}
if err := validateIntroDB(append(p, []byte(oldURL)...), oldURL); err == nil {
t.Fatal("accepted duplicated URL")
}
}
// This is deliberately read-only. When the reference client is present, it
// proves the Unity metadata parser and crypto parameters against the real file.
func TestKnownClientVerify(t *testing.T) {
knownClientDir := referenceClientDir(t)
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
t.Skip("reference client is not available")
}
v, err := VerifyClient(knownClientDir)
if err != nil {
t.Fatal(err)
}
if v.URL != oldURL && v.URL != "http://127.0.0.1:8080/game/" {
t.Fatalf("unexpected LIVE_URL=%q", v.URL)
}
}
// The transaction test copies the reference asset into a test directory, never
// mutating the installed client. It covers Unity object lookup, backup,
// encryption, atomic replacement, and the post-patch diagnostic together.
func TestPatchClientTransaction(t *testing.T) {
knownClientDir := referenceClientDir(t)
if _, err := os.Stat(knownClientDir); os.IsNotExist(err) {
t.Skip("reference client is not available")
}
tmp := t.TempDir()
dir := filepath.Join(tmp, "BrownDust II_Data")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
src, err := ResourcesPath(knownClientDir)
if err != nil {
t.Fatal(err)
}
if current, err := VerifyClient(knownClientDir); err == nil && current.URL != oldURL {
// The installed research client is normally patched. Exercise the
// transaction against its immutable pre-patch backup in that case.
if _, err := os.Stat(src + ".bak"); err != nil {
t.Skipf("official pre-patch asset is unavailable: %v", err)
}
src += ".bak"
}
dst := filepath.Join(dir, "resources.assets")
in, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, in, 0o600); err != nil {
t.Fatal(err)
}
const local = "http://127.0.0.1:8080/bd2x/"
r, err := PatchClient(tmp, local)
if err != nil {
t.Fatal(err)
}
if r.OldURL != oldURL || r.NewURL != local || r.BackupPath != dst+".bak" {
t.Fatalf("unexpected patch result: %#v", r)
}
if !r.Changed {
t.Fatal("first patch was not reported as changed")
}
if _, err := os.Stat(r.BackupPath); err != nil {
t.Fatalf("backup missing: %v", err)
}
v, err := VerifyClient(tmp)
if err != nil {
t.Fatal(err)
}
if v.URL != local {
t.Fatalf("LIVE_URL=%q, want %q", v.URL, local)
}
repeated, err := PatchClient(tmp, local)
if err != nil {
t.Fatalf("idempotent patch: %v", err)
}
if repeated.Changed {
t.Fatal("idempotent patch was reported as changed")
}
}
-40
View File
@@ -1,40 +0,0 @@
package layout
import (
"os"
"path/filepath"
"testing"
)
func writeFile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
t.Fatal(err)
}
}
func TestResolveWindows(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "BrownDust II.exe"))
writeFile(t, filepath.Join(root, "BrownDust II_Data", "resources.assets"))
got, err := Resolve(root)
if err != nil || got.Kind != Windows || got.Resources != filepath.Join(root, "BrownDust II_Data", "resources.assets") || got.Plugins != filepath.Join(root, "BepInEx", "plugins") {
t.Fatalf("layout=%+v err=%v", got, err)
}
}
func TestResolveMacAppAndParent(t *testing.T) {
parent := t.TempDir()
app := filepath.Join(parent, "BrownDust II.app")
writeFile(t, filepath.Join(app, "Contents", "MacOS", "BrownDust II"))
writeFile(t, filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"))
for _, selected := range []string{app, parent} {
got, err := Resolve(selected)
if err != nil || got.Kind != MacOS || got.Root != app || got.BepInEx != filepath.Join(parent, "BepInEx") || got.LaunchTarget() != app {
t.Fatalf("selected=%q layout=%+v err=%v", selected, got, err)
}
}
}
-162
View File
@@ -1,162 +0,0 @@
package plugin
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
t.Run(spec.FileName(), func(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(source, []byte("plugin"), 0o600); err != nil {
t.Fatal(err)
}
_, err := Install(spec, gameDir, source)
if err == nil || !strings.Contains(err.Error(), BepInExReleasesURL) {
t.Fatalf("missing BepInEx error=%v", err)
}
if _, statErr := os.Stat(filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())); !os.IsNotExist(statErr) {
t.Fatalf("plugin was copied without BepInEx: %v", statErr)
}
})
}
}
func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
t.Run(spec.FileName(), func(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(source, []byte("v1"), 0o600); err != nil {
t.Fatal(err)
}
first, err := Install(spec, gameDir, source)
if err != nil || !first.Changed {
t.Fatalf("first install=%+v err=%v", first, err)
}
second, err := Install(spec, gameDir, source)
if err != nil || second.Changed {
t.Fatalf("idempotent install=%+v err=%v", second, err)
}
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.FileName())
if err := os.WriteFile(runtimeSource, []byte("names-v2"), 0o600); err != nil {
t.Fatal(err)
}
runtimeUpdate, err := Install(spec, gameDir, source)
if err != nil || !runtimeUpdate.Changed {
t.Fatalf("runtime-only update=%+v err=%v", runtimeUpdate, err)
}
installedRuntime, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", GameNames.FileName()))
if err != nil || string(installedRuntime) != "names-v2" {
t.Fatalf("installed runtime=%q err=%v", installedRuntime, err)
}
if err := os.WriteFile(source, []byte("v2"), 0o600); err != nil {
t.Fatal(err)
}
third, err := Install(spec, gameDir, source)
if err != nil || !third.Changed {
t.Fatalf("update=%+v err=%v", third, err)
}
got, err := os.ReadFile(third.Destination)
if err != nil || string(got) != "v2" {
t.Fatalf("installed=%q err=%v", got, err)
}
})
}
}
func TestInstallMissingSharedRuntimeDoesNotChangePlugin(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()): []byte("old-plugin"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
source := filepath.Join(t.TempDir(), LocalIdentity.FileName())
if err := os.WriteFile(source, []byte("new-plugin"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Install(LocalIdentity, gameDir, source); err == nil || !strings.Contains(err.Error(), GameNames.FileName()) {
t.Fatalf("missing runtime error=%v", err)
}
installed, err := os.ReadFile(filepath.Join(gameDir, "BepInEx", "plugins", LocalIdentity.FileName()))
if err != nil || string(installed) != "old-plugin" {
t.Fatalf("installed=%q err=%v", installed, err)
}
}
func TestInstallKeepsPluginsSeparate(t *testing.T) {
gameDir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(gameDir, "BrownDust II.exe"): []byte("game"),
filepath.Join(gameDir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(gameDir, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
source := filepath.Join(t.TempDir(), spec.FileName())
if err := os.WriteFile(filepath.Join(filepath.Dir(source), GameNames.FileName()), []byte("names-v1"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(source, []byte(spec.FileName()), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Install(spec, gameDir, source); err != nil {
t.Fatal(err)
}
}
for _, spec := range []Spec{LocalIdentity, LoginUI, CashShop} {
path := filepath.Join(gameDir, "BepInEx", "plugins", spec.FileName())
data, err := os.ReadFile(path)
if err != nil || string(data) != spec.FileName() {
t.Fatalf("%s=%q err=%v", spec.FileName(), data, err)
}
}
}
-30
View File
@@ -137,11 +137,6 @@ func readCatalog(path string) ([]byte, error) {
return raw, nil
}
func validateLocalCatalog(raw []byte, release string) error {
_, err := localizeCatalogIDs(raw, release)
return err
}
func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
document, ids, err := decodeCatalog(raw)
if err != nil {
@@ -185,31 +180,6 @@ func localizeCatalog(raw []byte, release, aa string) ([]byte, error) {
return localized, nil
}
func localizeCatalogIDs(raw []byte, release string) ([]string, error) {
_, ids, err := decodeCatalog(raw)
if err != nil {
return nil, err
}
remoteCount := 0
for _, internalID := range ids {
if !strings.HasPrefix(internalID, remoteCatalogPrefix) {
continue
}
relative, err := catalogBundlePath(internalID)
if err != nil {
return nil, err
}
if info, err := os.Stat(filepath.Join(release, relative)); err != nil || !info.Mode().IsRegular() {
return nil, fmt.Errorf("local ServerData catalog references a missing bundle: %s", relative)
}
remoteCount++
}
if remoteCount == 0 {
return nil, errors.New("local ServerData catalog has no CDN bundle entries")
}
return ids, nil
}
func decodeCatalog(raw []byte) (map[string]json.RawMessage, []string, error) {
var document map[string]json.RawMessage
decoder := json.NewDecoder(bytes.NewReader(raw))
@@ -1,133 +0,0 @@
package setup
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestValidateLocalCatalogRequiresEveryRemoteBundle(t *testing.T) {
release := t.TempDir()
path := filepath.Join("nested", "current.bundle")
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(release, path), []byte("bundle"), 0o600); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{"m_InternalIds": []string{
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
}})
if err != nil {
t.Fatal(err)
}
if err := validateLocalCatalog(raw, release); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(release, path)); err != nil {
t.Fatal(err)
}
if err := validateLocalCatalog(raw, release); err == nil {
t.Fatal("catalog with missing bundle unexpectedly passed")
}
}
func TestCatalogBundlePathRejectsTraversal(t *testing.T) {
if _, err := catalogBundlePath(remoteCatalogPrefix + `StandaloneWindows64\HD\version\..\escape.bundle`); err == nil {
t.Fatal("catalog traversal path unexpectedly passed")
}
}
func TestReplaceCatalogReplacesExistingFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "catalog.json")
if err := os.WriteFile(path, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := replaceCatalog(path, []byte("new")); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != "new" {
t.Fatalf("catalog=%q", got)
}
}
func TestLocalizeCatalogPreservesMetadataAndCreatesHardLink(t *testing.T) {
release := t.TempDir()
aa := t.TempDir()
relative := filepath.Join("nested", "current.bundle")
if err := os.MkdirAll(filepath.Join(release, "nested"), 0o755); err != nil {
t.Fatal(err)
}
source := filepath.Join(release, relative)
if err := os.WriteFile(source, []byte("bundle"), 0o600); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{
"m_BuildResultHash": "current-metadata",
"m_InternalIds": []string{
remoteCatalogPrefix + `StandaloneWindows64\HD\version\nested/current.bundle`,
},
})
if err != nil {
t.Fatal(err)
}
localized, err := localizeCatalog(raw, release, aa)
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(localized, []byte(`"m_BuildResultHash":"current-metadata"`)) ||
!bytes.Contains(localized, []byte(`Addressables.RuntimePath`)) {
t.Fatalf("localized catalog=%s", localized)
}
sourceInfo, err := os.Stat(source)
if err != nil {
t.Fatal(err)
}
destinationInfo, err := os.Stat(filepath.Join(aa, relative))
if err != nil {
t.Fatal(err)
}
if !os.SameFile(sourceInfo, destinationInfo) {
t.Fatal("localized bundle is not a hard link to the selected release")
}
}
func TestSynchronizePersistentCatalogBacksUpAndIsIdempotent(t *testing.T) {
cache := t.TempDir()
oldCatalog := []byte("old catalog")
oldHash := []byte("old hash")
if err := os.WriteFile(filepath.Join(cache, "catalog_alpha.json"), oldCatalog, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cache, "catalog_alpha.hash"), oldHash, 0o600); err != nil {
t.Fatal(err)
}
newCatalog := []byte("new catalog")
newHash := []byte("new hash")
if err := synchronizePersistentCatalog(cache, newCatalog, newHash); err != nil {
t.Fatal(err)
}
if err := synchronizePersistentCatalog(cache, newCatalog, newHash); err != nil {
t.Fatal(err)
}
for name, want := range map[string][]byte{
"catalog_alpha.json": newCatalog,
"catalog_alpha.hash": newHash,
"catalog_alpha.json.bd2-before-local-sync": oldCatalog,
"catalog_alpha.hash.bd2-before-local-sync": oldHash,
} {
got, err := os.ReadFile(filepath.Join(cache, name))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, want) {
t.Fatalf("%s=%q, want %q", name, got, want)
}
}
}
-214
View File
@@ -1,214 +0,0 @@
package setup
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
clientconfig "bd2server/internal/client/config"
)
func testVersions() clientconfig.ReleaseVersions {
return clientconfig.ReleaseVersions{
GameVersion: "2.35.10", ClientVersion: "2.35.10+client.0.1.0", ServerVersion: "2.35.10+server.0.1.0", BundleVersion: "20260921135230", GameDataVersion: "20260923193640",
}
}
func TestInspectRequiresGameFiles(t *testing.T) {
dir := t.TempDir()
if _, err := Inspect(dir, testVersions()); err == nil {
t.Fatal("accepted empty directory")
}
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
status, err := Inspect(dir, testVersions())
if err != nil {
t.Fatal(err)
}
if !status.Executable || !status.Resources || status.BepInEx {
t.Fatalf("status=%+v", status)
}
}
func TestInspectRejectsUnsupportedClientVersion(t *testing.T) {
dir := t.TempDir()
for path, data := range map[string][]byte{
filepath.Join(dir, "BrownDust II.exe"): []byte("exe"),
filepath.Join(dir, "BrownDust II_Data", "resources.assets"): []byte("assets"),
filepath.Join(dir, "BrownDust II_Data", "globalgamemanagers"): []byte("\x002.36.0\x00"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
status, err := Inspect(dir, testVersions())
if err == nil || status.ClientVersion != "2.36.0" {
t.Fatalf("status=%+v err=%v", status, err)
}
}
func TestInspectMacApp(t *testing.T) {
parent := t.TempDir()
app := filepath.Join(parent, "BrownDust II.app")
for path, data := range map[string][]byte{
filepath.Join(app, "Contents", "MacOS", "BrownDust II"): []byte("binary"),
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"): []byte("assets"),
filepath.Join(app, "Contents", "Resources", "Data", "globalgamemanagers"): []byte("\x002.35.10\x00"),
filepath.Join(parent, "BepInEx", "core", "BepInEx.dll"): []byte("bepinex"),
} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o700); err != nil {
t.Fatal(err)
}
}
status, err := Inspect(app, testVersions())
if err != nil || status.GameDirectory != app || status.ClientVersion != "2.35.10" || !status.BepInEx {
t.Fatalf("status=%+v err=%v", status, err)
}
}
func TestFetchResourcePolicyRejectsServerVersionMismatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(ResourcePolicy{
Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData",
GameDataURL: "https://cdn.example/GameData", BundleVersion: "wrong", GameDataVersion: "wrong",
})
}))
defer server.Close()
_, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer}, testVersions())
if err == nil {
t.Fatal("accepted mismatched server resource versions")
}
}
func TestFetchResourcePolicy(t *testing.T) {
var method string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
if request["cdn_mode"] != "server" {
t.Errorf("request=%v", request)
}
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(ResourcePolicy{
Mode: clientconfig.CDNServer,
ServerDataURL: "https://cdn.example/ServerData",
GameDataURL: "https://cdn.example/GameData",
BundleVersion: "20260921135230",
GameDataVersion: "20260923193640",
})
}))
defer server.Close()
policy, err := FetchResourcePolicy(context.Background(), server.Client(), clientconfig.Settings{
ServerOrigin: server.URL,
CDNMode: clientconfig.CDNServer,
}, testVersions())
if err != nil {
t.Fatal(err)
}
if method != http.MethodPut || policy.Mode != clientconfig.CDNServer || policy.BundleVersion != "20260921135230" {
t.Fatalf("method=%s policy=%+v", method, policy)
}
}
func TestLocalResourcesDoNotContactServer(t *testing.T) {
root := t.TempDir()
for _, relative := range []string{
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.json"),
filepath.Join("ServerData", "StandaloneWindows64", "HD", "20260921135230", "catalog_alpha.hash"),
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.info"),
filepath.Join("GameData", "20260923193640", "release", "common-dbdata.bin"),
} {
path := filepath.Join(root, relative)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("resource"), 0o600); err != nil {
t.Fatal(err)
}
}
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
ServerOrigin: "http://127.0.0.1:8080",
CDNMode: clientconfig.CDNLocal,
LocalResourceDirectory: root,
}, testVersions())
if err != nil {
t.Fatal(err)
}
if policy.Mode != clientconfig.CDNLocal || policy.LocalDirectory != root || policy.ServerDataURL == "" || policy.GameDataURL == "" {
t.Fatalf("policy=%+v", policy)
}
}
func TestOfficialDoesNotContactServer(t *testing.T) {
policy, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{
ServerOrigin: "https://example.com",
CDNMode: clientconfig.CDNOfficial,
}, testVersions())
if err != nil || policy.Mode != clientconfig.CDNOfficial {
t.Fatalf("policy=%+v err=%v", policy, err)
}
}
func TestResourcePolicyLoopbackBypassesPlayerAndEnvironmentProxy(t *testing.T) {
var proxyCalls atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyCalls.Add(1)
w.WriteHeader(http.StatusBadGateway)
}))
defer proxy.Close()
t.Setenv("HTTP_PROXY", proxy.URL)
t.Setenv("HTTPS_PROXY", proxy.URL)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(ResourcePolicy{Mode: clientconfig.CDNServer, ServerDataURL: "https://cdn.example/ServerData", GameDataURL: "https://cdn.example/GameData", BundleVersion: testVersions().BundleVersion, GameDataVersion: testVersions().GameDataVersion})
}))
defer server.Close()
for _, configured := range []string{"", proxy.URL} {
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: server.URL, CDNMode: clientconfig.CDNServer, ProxyURL: configured}, testVersions())
if err != nil {
t.Fatal(err)
}
}
if proxyCalls.Load() != 0 {
t.Fatal("loopback policy request used proxy")
}
}
func TestResourcePolicyUsesExplicitProxyForRemoteOrigin(t *testing.T) {
var proxyCalls atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect || r.Host != "remote.invalid:443" {
t.Errorf("unexpected proxy request: %s %s", r.Method, r.Host)
}
proxyCalls.Add(1)
w.WriteHeader(http.StatusBadGateway)
}))
defer proxy.Close()
_, err := FetchResourcePolicy(context.Background(), nil, clientconfig.Settings{ServerOrigin: "https://remote.invalid", CDNMode: clientconfig.CDNServer, ProxyURL: proxy.URL}, testVersions())
if err == nil || proxyCalls.Load() != 1 {
t.Fatal("remote policy request did not use explicit HTTP proxy")
}
}
@@ -1,42 +0,0 @@
package account
import (
"errors"
"math"
"testing"
"bd2server/internal/server/wire"
)
type testAchievementExperience struct {
value uint64
err error
}
func (s *testAchievementExperience) AchievementExperience() (uint64, error) { return s.value, s.err }
func TestLoginReadsPersistedAchievementExperienceEachTime(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 12, 999)}
source := &testAchievementExperience{value: 7}
if err := seed.AttachAchievementExperience(source); err != nil {
t.Fatal(err)
}
for _, value := range []uint64{7, 16, 0} {
source.value = value
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(response, 1)
exp, found, err := wire.Varint(user, 12)
if err != nil || !found || exp != value {
t.Fatalf("exp=%d want=%d err=%v", exp, value, err)
}
}
for _, invalid := range []testAchievementExperience{{value: math.MaxInt32 + 1}, {err: errors.New("read failed")}} {
*source = invalid
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("invalid experience accepted")
}
}
}
@@ -1,38 +0,0 @@
package account
import (
"bd2server/internal/server/wire"
"fmt"
)
type AutoReviveSettingsProvider interface{ AutoReviveSettings() (bool, uint64, error) }
func (s *LoginSeed) AttachAutoReviveSettings(p AutoReviveSettingsProvider) error {
if p == nil {
return fmt.Errorf("account: nil automatic recovery settings")
}
s.autoReviveSettings = p
return nil
}
func (s *LoginSeed) projectAutoRevive(user []byte) ([]byte, error) {
if s.autoReviveSettings == nil {
return user, nil
}
on, index, e := s.autoReviveSettings.AutoReviveSettings()
if e != nil {
return nil, e
}
if index > 9223372036854775807 {
return nil, fmt.Errorf("account: automatic recovery caster overflow")
}
n := uint64(0)
if on {
n = 1
}
user, _, e = wire.ReplaceVarint(user, 49, n)
if e != nil {
return nil, e
}
user, _, e = wire.ReplaceVarint(user, 50, index)
return user, e
}
@@ -1,36 +0,0 @@
package account
import (
"bd2server/internal/server/wire"
"testing"
)
type autoSettingsFake struct {
on bool
index uint64
}
func (p *autoSettingsFake) AutoReviveSettings() (bool, uint64, error) { return p.on, p.index, nil }
func TestLoginProjectsCurrentRecoverySettingsEachTime(t *testing.T) {
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 42)}
p := &autoSettingsFake{true, 199}
if e := s.AttachAutoReviveSettings(p); e != nil {
t.Fatal(e)
}
for _, on := range []bool{true, false} {
p.on = on
if !on {
p.index = 0
}
b, e := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if e != nil {
t.Fatal(e)
}
user, _, _ := wire.Bytes(b, 1)
v, _, _ := wire.Varint(user, 49)
index, _, _ := wire.Varint(user, 50)
if (v != 0) != on || index != p.index {
t.Fatal("seed value leaked", v, index)
}
}
}
@@ -1,42 +0,0 @@
package account
import (
"errors"
"testing"
"bd2server/internal/server/wire"
)
type friendshipAPFixture struct {
remaining uint64
err error
}
func (f *friendshipAPFixture) FriendshipAP() (uint64, error) { return f.remaining, f.err }
func TestLoginReadsCurrentFriendshipAPInsteadOfSeed(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 69, 3)
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 70, 99)
provider := &friendshipAPFixture{remaining: 2}
if err := seed.AttachFriendshipAP(provider); err != nil {
t.Fatal(err)
}
for _, remaining := range []uint64{2, 0, 3} {
provider.remaining = remaining
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
free, _, _ := wire.Varint(user, 69)
stack, _, _ := wire.Varint(user, 70)
if free != remaining || stack != 0 {
t.Fatalf("friendship points free=%d stack=%d, want %d/0", free, stack, remaining)
}
}
provider.err = errors.New("unavailable")
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("login ignored friendship state error")
}
}
@@ -1,39 +0,0 @@
package account
import (
"errors"
"testing"
"bd2server/internal/server/wire"
)
type lastPackFixture struct {
pack uint64
err error
}
func (f *lastPackFixture) LastPlayedPackID() (uint64, error) { return f.pack, f.err }
func TestLoginUsesSavedPackAndPreservesSeedOnlyForNewAccount(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 4, 21)}
provider := &lastPackFixture{}
if err := seed.AttachLastPlayedPack(provider); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ saved, want uint64 }{{0, 21}, {3001, 3001}, {22, 22}} {
provider.pack = tc.saved
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
pack, _, _ := wire.Varint(user, 4)
if pack != tc.want {
t.Fatalf("saved=%d login pack=%d want=%d", tc.saved, pack, tc.want)
}
}
provider.err = errors.New("position unavailable")
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("ignored saved position error")
}
}
@@ -1,42 +0,0 @@
package account
import (
"testing"
"bd2server/internal/server/wire"
)
type mutableFirstGachaStatus struct{ completed bool }
func (p *mutableFirstGachaStatus) FirstGachaCompleted() bool { return p.completed }
func TestLoginReadsFirstGachaCompletionAtEachLogin(t *testing.T) {
// An old immutable seed value must be replaced by authoritative account
// state, then immediately change on the next login after confirmation.
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 27, 1)}
status := &mutableFirstGachaStatus{}
if err := seed.AttachFirstGacha(status); err != nil {
t.Fatal(err)
}
for _, completed := range []bool{false, true, false} {
status.completed = completed
body, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(body, 1)
if err != nil || !found {
t.Fatalf("user found=%v err=%v", found, err)
}
value, _, err := wire.Varint(user, 27)
if err != nil || (value == 1) != completed {
t.Fatalf("first gacha=%d want completed=%v err=%v", value, completed, err)
}
}
}
func TestLoginRejectsMissingFirstGachaProvider(t *testing.T) {
if err := (&LoginSeed{}).AttachFirstGacha(nil); err == nil {
t.Fatal("missing provider accepted")
}
}
-267
View File
@@ -1,267 +0,0 @@
package account
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"bd2server/internal/server/cryptox"
"bd2server/internal/server/wire"
)
func TestEncodeUsesFreshLocalKey(t *testing.T) {
user := wire.AppendVarint(nil, 1, 42)
user = wire.AppendString(user, 2, "Guest_42")
user = wire.AppendVarint(user, 5, 100)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: user}
const local = "0123456789abcdef0123456789abcdef"
body, err := seed.Encode(local, time.UnixMilli(1234))
if err != nil {
t.Fatal(err)
}
var envelope struct {
PacketCode int `json:"packetCode"`
Length int `json:"length"`
Data string `json:"data"`
ServerNowTime int64 `json:"serverNowTime"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
if envelope.PacketCode != 11 || envelope.ServerNowTime != 1234 {
t.Fatalf("envelope=%+v", envelope)
}
proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key())
if err != nil {
t.Fatal(err)
}
responseUser, found, err := wire.Bytes(proto, 1)
if err != nil || !found {
t.Fatalf("response UserInfo: found=%v err=%v", found, err)
}
gotKey, found, err := wire.Bytes(responseUser, 3)
if err != nil || !found || string(gotKey) != local {
t.Fatalf("local key=%q found=%v err=%v", gotKey, found, err)
}
}
func TestLoginValidatesEncryptedRequest(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
if _, err := seed.Login([]byte("not protobuf"), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("Login accepted invalid protobuf request")
}
}
func TestLoadRejectsSeedWithUserKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}
if err := seed.Write(path); err == nil {
t.Fatal("Write accepted user_key")
}
if err := os.WriteFile(path, []byte(`{"version":"2.35.10","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("Load accepted user_key")
}
}
func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) {
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "login_user.json"))
if err != nil {
t.Fatal(err)
}
proto, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(proto, 1)
if err != nil || !found {
t.Fatalf("missing UserInfo: found=%v err=%v", found, err)
}
key, found, err := wire.Bytes(user, 3)
if err != nil || !found || string(key) != "0123456789abcdef0123456789abcdef" {
t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err)
}
}
type loginCurrencyFixture struct{}
func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) {
return 1, 2, 3, 4
}
func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 }
func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: %v", err)
}
if mileage, found, err := wire.Varint(user, 67); err != nil || !found || mileage != 17 {
t.Fatalf("equipment mileage=%d found=%v err=%v", mileage, found, err)
}
if gauge, found, err := wire.Varint(user, 68); err != nil || !found || gauge != 845 {
t.Fatalf("equipment mileage gauge=%d found=%v err=%v", gauge, found, err)
}
}
type loginPurchaseCountFixture struct {
infos [][]byte
}
func (f *loginPurchaseCountFixture) PurchaseCountDBInfos() [][]byte { return f.infos }
func TestLoginReplacesSeedPurchaseCountsFromProvider(t *testing.T) {
stale := wire.AppendVarint(nil, 1, 999)
userTemplate := wire.AppendVarint(nil, 1, 1)
userTemplate = wire.AppendBytes(userTemplate, 26, stale)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
current := wire.AppendVarint(nil, 1, 1100001)
current = wire.AppendVarint(current, 2, 9100033)
current = wire.AppendVarint(current, 4, 1)
provider := &loginPurchaseCountFixture{infos: [][]byte{current}}
if err := seed.AttachPurchaseCounts(provider); err != nil {
t.Fatal(err)
}
login := func() []byte {
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: found=%v err=%v", found, err)
}
return user
}
counts := byteFields(login(), 26)
if len(counts) != 1 || string(counts[0]) != string(current) {
t.Fatalf("purchase counts=%x want=%x", counts, current)
}
// The provider is consulted on every LoginUser response. An empty current
// state must also remove any stale count captured in the seed template.
provider.infos = nil
if counts = byteFields(login(), 26); len(counts) != 0 {
t.Fatalf("empty current state retained purchase counts: %x", counts)
}
}
func TestAttachPurchaseCountsRejectsNil(t *testing.T) {
seed := &LoginSeed{}
if err := seed.AttachPurchaseCounts(nil); err == nil {
t.Fatal("AttachPurchaseCounts accepted nil provider")
}
}
type loginPresetSlotFixture struct{ count uint64 }
func (f *loginPresetSlotFixture) PresetSlotCount() uint64 { return f.count }
func TestLoginReplacesSeedPresetSlotFromProvider(t *testing.T) {
userTemplate := wire.AppendVarint(nil, 1, 1)
userTemplate = wire.AppendVarint(userTemplate, 28, 6)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
provider := &loginPresetSlotFixture{count: 9}
if err := seed.AttachPresetSlots(provider); err != nil {
t.Fatal(err)
}
login := func() []byte {
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: found=%v err=%v", found, err)
}
return user
}
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 9 {
t.Fatalf("preset slots=%d found=%v err=%v", count, found, err)
}
provider.count = 12
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 12 {
t.Fatalf("updated preset slots=%d found=%v err=%v", count, found, err)
}
}
func TestAttachPresetSlotsRejectsNil(t *testing.T) {
seed := &LoginSeed{}
if err := seed.AttachPresetSlots(nil); err == nil {
t.Fatal("AttachPresetSlots accepted nil provider")
}
}
type loginInventorySlotFixture struct {
items, storage, equipment, equipmentStorage uint64
err error
}
func (f *loginInventorySlotFixture) UserInventorySlots() (uint64, uint64, uint64, uint64, error) {
return f.items, f.storage, f.equipment, f.equipmentStorage, f.err
}
func TestLoginReplacesAllInventorySlotFieldsFromProvider(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 100, 10: 500, 15: 100} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
provider := &loginInventorySlotFixture{items: 500, storage: 100, equipment: 2000, equipmentStorage: 100}
if err := seed.AttachInventorySlots(provider); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
result, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing UserInfo: %v", err)
}
for field, want := range map[int]uint64{5: 500, 6: 100, 10: 2000, 15: 100} {
if got, found, err := wire.Varint(result, field); err != nil || !found || got != want {
t.Fatalf("field %d=%d found=%t err=%v want=%d", field, got, found, err, want)
}
}
}
func TestSeedInventorySlotsReadsUserInfoFields(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 101, 10: 500, 15: 102} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
items, storage, equipment, equipmentStorage, err := seed.SeedInventorySlots()
if err != nil || items != 100 || storage != 101 || equipment != 500 || equipmentStorage != 102 {
t.Fatalf("slots=%d/%d/%d/%d err=%v", items, storage, equipment, equipmentStorage, err)
}
}
func byteFields(data []byte, number int) [][]byte {
var result [][]byte
_ = wire.Walk(data, func(field wire.Field) error {
if field.Number == number && field.Type == 2 {
result = append(result, append([]byte(nil), field.Value...))
}
return nil
})
return result
}
@@ -1,58 +0,0 @@
package account
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/deck"
"bd2server/internal/server/wire"
"path/filepath"
"testing"
)
func TestLoginPortraitUsesSQLiteSelectionAfterRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
r, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
seed, err := deck.LoadSeed("../../../seed/v2_35_10/decks.json")
if err != nil {
t.Fatal(err)
}
d, err := deck.OpenStore(r, seed)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3601)
if _, _, _, err = d.Handle("/UserPortraitChange", request); err != nil {
t.Fatal(err)
}
if err = r.Close(); err != nil {
t.Fatal(err)
}
r, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
d, err = deck.OpenStore(r, seed)
if err != nil {
t.Fatal(err)
}
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 14, 3501)}
if err = s.AttachPortrait(d); err != nil {
t.Fatal(err)
}
body, err := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
portrait, _, _ := wire.Varint(user, 14)
if portrait != 3601 {
t.Fatalf("reconnected portrait%d, want saved3601", portrait)
}
}
@@ -1,58 +0,0 @@
package account
import (
"errors"
"math"
"testing"
"bd2server/internal/server/wire"
)
type rewardStateSource struct {
claimed, free, bonus uint64
err error
}
func (p *rewardStateSource) LevelRewardCount() (uint64, error) { return p.claimed, p.err }
func (p *rewardStateSource) HuntingAP() (uint64, uint64, error) { return p.free, p.bonus, p.err }
func TestLoginReadsMutableRewardAndHuntingState(t *testing.T) {
user := wire.AppendVarint(nil, 1, 42)
for _, field := range []int{13, 20, 21} {
user = wire.AppendVarint(user, field, 999)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
p := &rewardStateSource{claimed: 2, free: 40, bonus: 7}
if err := seed.AttachLevelReward(p); err != nil {
t.Fatal(err)
}
if err := seed.AttachHuntingAP(p); err != nil {
t.Fatal(err)
}
for _, state := range []rewardStateSource{*p, {claimed: 3, free: 20, bonus: 0}, {}} {
*p = state
response, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
actual, _, _ := wire.Bytes(response, 1)
for field, want := range map[int]uint64{13: p.claimed, 20: p.free, 21: p.bonus} {
got, _, err := wire.Varint(actual, field)
if err != nil || got != want {
t.Fatalf("field %d=%d want %d: %v", field, got, want, err)
}
}
}
for _, bad := range []rewardStateSource{{claimed: math.MaxInt32 + 1}, {free: math.MaxInt32 + 1}, {bonus: math.MaxInt32 + 1}, {err: errors.New("storage failed")}} {
*p = bad
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("invalid state accepted")
}
}
if err := seed.AttachLevelReward(nil); err == nil {
t.Fatal("nil reward provider accepted")
}
if err := seed.AttachHuntingAP(nil); err == nil {
t.Fatal("nil AP provider accepted")
}
}
@@ -1,53 +0,0 @@
package accountstate
import (
"context"
"fmt"
"bd2server/internal/server/stateio"
)
var _ stateio.AtomicEntryStore = (*Repository)(nil)
// SaveWithEntries writes a domain's bounded core and its changed entry rows
// together. Calls inside a request join that transaction; direct calls create
// their own transaction so neither half can become visible alone.
func (r *Repository) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
if domain == "" {
return fmt.Errorf("accountstate: empty domain name")
}
r.activeMu.RLock()
if r.active != nil {
err := saveWithEntries(r.active, domain, core, changes)
r.activeMu.RUnlock()
return err
}
r.activeMu.RUnlock()
tx, err := r.Begin(context.Background())
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := saveWithEntries(tx, domain, core, changes); err != nil {
return err
}
return tx.Commit()
}
func saveWithEntries(tx *Tx, domain string, core []byte, changes []stateio.EntryMutation) error {
if core != nil {
if _, err := tx.Save(domain, core); err != nil {
return err
}
}
for _, change := range changes {
if change.Delete {
if _, err := tx.DeleteEntry(domain, change.Bucket, change.Key); err != nil {
return err
}
} else if err := tx.PutEntry(domain, change.Bucket, change.Key, change.Payload); err != nil {
return err
}
}
return nil
}
@@ -1,48 +0,0 @@
package accountstate
import (
"bytes"
"context"
"testing"
"bd2server/internal/server/stateio"
)
func TestSaveWithEntriesAtomicAndEntryOnly(t *testing.T) {
r, _ := openTestRepository(t)
change := stateio.EntryMutation{Bucket: "granted", Key: "quest:1", Payload: []byte("true")}
if err := r.SaveWithEntries("wallet", []byte(`{"gold":10}`), []stateio.EntryMutation{change}); err != nil {
t.Fatal(err)
}
core, generation, found, err := r.LoadContext(context.Background(), "wallet")
if err != nil || !found || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
t.Fatalf("core=%q generation=%d found=%t err=%v", core, generation, found, err)
}
if err := r.SaveWithEntries("wallet", nil, []stateio.EntryMutation{{Bucket: "granted", Key: "quest:2", Payload: []byte("true")}}); err != nil {
t.Fatal(err)
}
if err := r.SaveWithEntries("wallet", []byte(`{"gold":99}`), []stateio.EntryMutation{{Bucket: "granted", Key: ""}}); err == nil {
t.Fatal("accepted invalid entry mutation")
}
core, generation, _, err = r.LoadContext(context.Background(), "wallet")
if err != nil || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
t.Fatalf("entry-only/failed write changed core: %q generation %d, %v", core, generation, err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.SaveWithEntries("wallet", []byte(`{"gold":20}`), []stateio.EntryMutation{{Bucket: "granted", Key: "quest:3", Payload: []byte("true")}}); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty rollback did not fail stop")
}
var count int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry WHERE domain_name='wallet' AND bucket='granted'`).Scan(&count); err != nil || count != 2 {
t.Fatalf("entries after rollback=%d: %v", count, err)
}
if err := r.db.QueryRow(`SELECT generation FROM domain_state WHERE name='wallet'`).Scan(&generation); err != nil || generation != 1 {
t.Fatalf("generation after rollback=%d: %v", generation, err)
}
}
@@ -1,109 +0,0 @@
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 func() {
if err := repository.Close(); err != nil {
t.Error(err)
}
}()
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 func() {
if err := repository.Close(); err != nil {
t.Error(err)
}
}()
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 func() {
if err := repository.Close(); err != nil {
t.Error(err)
}
}()
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)
}
}
@@ -1,163 +0,0 @@
package accountstate
import (
"bytes"
"context"
"database/sql"
"errors"
"testing"
)
func TestEntriesShareRequestTransaction(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.SaveContext(context.Background(), "collection", []byte("unchanged")); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.PutEntry("collection", "grants", "draw:1", []byte(`{"id":1}`)); err != nil {
t.Fatal(err)
}
if err := r.PutEntry("collection", "applied", "draw:1", nil); err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("charged")); err != nil {
t.Fatal(err)
}
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte(`{"id":1}`)) {
t.Fatalf("read own entry write: %q, %t, %v", got, found, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
requireState(t, r, "collection", []byte("unchanged"), 1)
requireState(t, r, "wallet", []byte("charged"), 1)
entries, err := r.ListEntries("collection", "grants")
if err != nil || len(entries) != 1 || !bytes.Equal(entries["draw:1"], []byte(`{"id":1}`)) {
t.Fatalf("grant entries: %#v, %v", entries, err)
}
if got, found, err := r.LoadEntry("collection", "applied", "draw:1"); err != nil || !found || len(got) != 0 {
t.Fatalf("empty marker: %q, %t, %v", got, found, err)
}
}
func TestEntryRollbackAndDelete(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.PutEntry("collection", "grants", "draw:1", []byte("before")); err != nil {
t.Fatal(err)
}
tx, err := r.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
if err := tx.PutEntry("collection", "grants", "draw:1", []byte("after")); err != nil {
t.Fatal(err)
}
if err := tx.PutEntry("collection", "grants", "draw:2", []byte("new")); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte("before")) {
t.Fatalf("rolled-back replacement: %q, %t, %v", got, found, err)
}
if _, found, err := r.LoadEntry("collection", "grants", "draw:2"); err != nil || found {
t.Fatalf("rolled-back insertion: %t, %v", found, err)
}
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || !deleted {
t.Fatalf("delete existing entry: %t, %v", deleted, err)
}
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || deleted {
t.Fatalf("delete missing entry: %t, %v", deleted, err)
}
if _, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || found {
t.Fatalf("deleted entry: %t, %v", found, err)
}
}
func TestEntryWriteMarksRequestRollbackDirty(t *testing.T) {
r, _ := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.PutEntry("collection", "grants", "draw:1", []byte("pending")); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty entry rollback did not require restart")
}
if err := r.Check(); err == nil {
t.Fatal("repository accepted requests after dirty entry rollback")
}
var count int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry`).Scan(&count); err != nil || count != 0 {
t.Fatalf("entry survived rollback: count %d, error %v", count, err)
}
}
func TestEntryScopeAndClosedTransaction(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.PutEntry("", "grants", "key", nil); err == nil {
t.Fatal("accepted empty domain")
}
if err := r.PutEntry("collection", "", "key", nil); err == nil {
t.Fatal("accepted empty bucket")
}
if err := r.PutEntry("collection", "grants", "", nil); err == nil {
t.Fatal("accepted empty key")
}
tx, err := r.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := tx.PutEntry("collection", "grants", "key", nil); !errors.Is(err, ErrClosed) {
t.Fatalf("put after commit: %v", err)
}
if _, err := tx.ListEntries("collection", "grants"); !errors.Is(err, ErrClosed) {
t.Fatalf("list after commit: %v", err)
}
}
func TestOldSchemaIsRejectedWithoutMutation(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.SaveContext(context.Background(), "collection", []byte("legacy snapshot")); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`UPDATE metadata SET value = '0' WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("opened unsupported schema version")
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
var version string
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "0" {
t.Fatalf("changed unsupported version %q: %v", version, err)
}
var entryTables int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 0 {
t.Fatalf("created table during failed open: %d, %v", entryTables, err)
}
}
@@ -1,366 +0,0 @@
package accountstate
import (
"bytes"
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)
func openTestRepository(t *testing.T) (*Repository, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "state.db")
r, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
return r, path
}
func requireState(t *testing.T, r *Repository, name string, want []byte, wantGeneration int64) {
t.Helper()
got, generation, found, err := r.LoadContext(context.Background(), name)
if err != nil {
t.Fatal(err)
}
if !found || !bytes.Equal(got, want) || generation != wantGeneration {
t.Fatalf("%s: got (%q, %d, %t), want (%q, %d, true)", name, got, generation, found, want, wantGeneration)
}
}
func TestTransactionAtomicityAndGeneration(t *testing.T) {
r, _ := openTestRepository(t)
ctx := context.Background()
tx, err := r.Begin(ctx)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"wallet", "deck", "progress"} {
if generation, err := tx.Save(name, []byte(name)); err != nil || generation != 1 {
t.Fatalf("save %s: generation %d, error %v", name, generation, err)
}
}
if generation, err := tx.Save("wallet", []byte("updated")); err != nil || generation != 2 {
t.Fatalf("second save: generation %d, error %v", generation, err)
}
if data, generation, found, err := tx.Load("wallet"); err != nil || !found || !bytes.Equal(data, []byte("updated")) || generation != 2 {
t.Fatalf("read own write: %q, %d, %t, %v", data, generation, found, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if _, _, _, err := tx.Load("wallet"); !errors.Is(err, ErrClosed) {
t.Fatalf("load after commit: %v", err)
}
requireState(t, r, "wallet", []byte("updated"), 2)
requireState(t, r, "deck", []byte("deck"), 1)
requireState(t, r, "progress", []byte("progress"), 1)
}
func TestRollbackAndReopen(t *testing.T) {
r, path := openTestRepository(t)
ctx := context.Background()
if _, err := r.SaveContext(ctx, "wallet", []byte("before")); err != nil {
t.Fatal(err)
}
tx, err := r.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := tx.Save("wallet", []byte("after")); err != nil {
t.Fatal(err)
}
if _, err := tx.Save("deck", []byte("new")); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("before"), 1)
if _, _, found, err := r.LoadContext(ctx, "deck"); err != nil || found {
t.Fatalf("rolled-back domain: found=%t err=%v", found, err)
}
if _, err := r.SaveContext(ctx, "wallet", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
reopened, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := reopened.Close(); err != nil {
t.Error(err)
}
}()
requireState(t, reopened, "wallet", []byte("committed"), 2)
var mode string
if err := reopened.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" {
t.Fatalf("journal mode %q: %v", mode, err)
}
var synchronous int
if err := reopened.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil || synchronous != 2 {
t.Fatalf("synchronous %d: %v", synchronous, err)
}
}
func TestConcurrentTransactionsSerialize(t *testing.T) {
r, _ := openTestRepository(t)
first, err := r.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
finished := make(chan error, 1)
go func() {
close(entered)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
tx, err := r.Begin(ctx)
if err != nil {
finished <- err
return
}
_, err = tx.Save("wallet", []byte("second"))
if err == nil {
err = tx.Commit()
} else {
_ = tx.Rollback()
}
finished <- err
}()
<-entered
select {
case err := <-finished:
t.Fatalf("second transaction finished before first released connection: %v", err)
case <-time.After(20 * time.Millisecond):
}
if _, err := first.Save("wallet", []byte("first")); err != nil {
t.Fatal(err)
}
if err := first.Commit(); err != nil {
t.Fatal(err)
}
if err := <-finished; err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("second"), 2)
}
func TestOperationRoutesStoreWritesAcrossDomains(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.Save("wallet", []byte("before")); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("after")); err != nil {
t.Fatal(err)
}
if err := r.Save("deck", []byte("new")); err != nil {
t.Fatal(err)
}
if data, err := r.Load("wallet"); err != nil || !bytes.Equal(data, []byte("after")) {
t.Fatalf("read active write: %q, %v", data, err)
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty request rollback did not require restart")
}
if err := r.Check(); err == nil {
t.Fatal("repository accepted requests after dirty rollback")
}
if _, err := r.BeginOperation(); err == nil {
t.Fatal("began request after dirty rollback")
}
// The durable rows are rolled back even though domain memory now needs reload.
var payload []byte
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'wallet'`).Scan(&payload); err != nil || !bytes.Equal(payload, []byte("before")) {
t.Fatalf("wallet after rollback: %q, %v", payload, err)
}
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'deck'`).Scan(&payload); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("deck after rollback: %q, %v", payload, err)
}
}
func TestOperationCommitAndCleanRollback(t *testing.T) {
r, _ := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err != nil {
t.Fatal(err)
}
if err := r.Check(); err != nil {
t.Fatal(err)
}
op, err = r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := r.Save("deck", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("committed"), 1)
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 func() {
if err := second.Close(); err != nil {
t.Error(err)
}
}()
}
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 = '4' WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("opened unknown schema version")
}
}
func TestPopulatedDatabaseWithoutSchemaVersionRejected(t *testing.T) {
r, path := openTestRepository(t)
if err := r.Save("wallet", []byte("existing")); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`DELETE FROM metadata WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("stamped current schema onto populated unversioned database")
}
}
func TestCurrentVersionWithMissingTableRejected(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("recreated a missing table in an existing current-version database")
}
}
func TestRequireDomainsRejectsPartialAccount(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.RequireDomains("wallet"); err == nil {
t.Fatal("accepted empty database as a complete account")
}
if err := r.Save("wallet", []byte(`{}`)); err != nil {
t.Fatal(err)
}
if err := r.RequireDomains("wallet"); err != nil {
t.Fatal(err)
}
if err := r.RequireDomains("wallet", "items"); err == nil {
t.Fatal("accepted a missing account domain")
}
}
func TestInvalidNamesAndEmptyBlob(t *testing.T) {
r, _ := openTestRepository(t)
ctx := context.Background()
if _, err := r.SaveContext(ctx, "", []byte("bad")); err == nil {
t.Fatal("empty name accepted")
}
if _, err := r.SaveContext(ctx, "empty", nil); err != nil {
t.Fatal(err)
}
requireState(t, r, "empty", []byte{}, 1)
}
func BenchmarkRequestTransaction(b *testing.B) {
path := filepath.Join(b.TempDir(), "state.db")
r, err := Open(path)
if err != nil {
b.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
b.Error(err)
}
}()
ctx := context.Background()
payload := bytes.Repeat([]byte("x"), 4096)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tx, err := r.Begin(ctx)
if err != nil {
b.Fatal(err)
}
for _, name := range []string{"wallet", "deck", "progress"} {
if _, err := tx.Save(name, payload); err != nil {
b.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
b.Fatal(err)
}
}
}
@@ -1,222 +0,0 @@
package accountstate
import (
"context"
"database/sql"
"errors"
"path/filepath"
"strings"
"testing"
)
func createV1Database(t *testing.T, path string, domain, payload string) {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if err := createV1(context.Background(), tx); err != nil {
t.Fatal(err)
}
if domain != "" {
if _, err := tx.Exec(`INSERT INTO domain_state(name, payload, generation) VALUES (?, ?, 1)`, domain, []byte(payload)); err != nil {
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
func TestFreshDatabaseRunsEveryMigration(t *testing.T) {
r, _ := openTestRepository(t)
version, err := r.SchemaVersion()
if err != nil || version != schemaVersion {
t.Fatalf("schema version %d, error %v", version, err)
}
var entryTables int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 1 {
t.Fatalf("domain_entry tables %d, error %v", entryTables, err)
}
}
func TestMigrationV1ToV2IsRepeatSafe(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 func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
for range 2 {
if err := migrateV1ToV2(context.Background(), tx); err != nil {
t.Fatal(err)
}
}
if err := validateSchemaTables(tx, 2); err != nil {
t.Fatal(err)
}
}
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 func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = 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)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
version, err := r.SchemaVersion()
if err != nil || version != schemaVersion {
t.Fatalf("schema version %d, error %v", version, err)
}
}
func TestMigrationsRejectMissingAndNonAdjacentSteps(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 func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
for _, test := range []struct {
name string
steps []migration
want string
}{
{name: "missing", want: "missing migration 1->2"},
{name: "skip", steps: []migration{{from: 1, to: 3, up: func(context.Context, *sql.Tx) error { return nil }}}, want: "not adjacent"},
} {
t.Run(test.name, func(t *testing.T) {
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
err = runMigrations(context.Background(), tx, 1, 3, test.steps)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error %v, want %q", err, test.want)
}
})
}
}
func TestValidationFailureRollsBackMigrationAndVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "progress", `{"quests":{"0:1":{"QuestID":1,"PackID":0}},"cleared_quests":{}}`)
if r, err := Open(path); err == nil {
if err := r.Close(); err != nil {
t.Error(err)
}
t.Fatal("opened state rejected by final validation")
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
var version string
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "1" {
t.Fatalf("schema version %q after rollback: %v", version, err)
}
var generationColumns int
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('domain_entry') WHERE name = 'generation'`).Scan(&generationColumns); err != nil || generationColumns != 0 {
t.Fatalf("migration column survived rollback: %d, %v", generationColumns, err)
}
}
func TestMigrationFailureRollsBackEarlierStepWork(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 func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
steps := []migration{{from: 1, to: 2, up: func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `CREATE TABLE partial_write(value INTEGER)`); err != nil {
return err
}
return errors.New("injected migration failure")
}}}
if err := runMigrations(context.Background(), tx, 1, 2, steps); err == nil {
t.Fatal("migration unexpectedly succeeded")
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
var tables int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'partial_write'`).Scan(&tables); err != nil || tables != 0 {
t.Fatalf("partial migration survived rollback: %d, %v", tables, err)
}
}
@@ -1,144 +0,0 @@
package accountstate
import (
"context"
"database/sql"
"errors"
"testing"
)
func TestStartingPackPolicyPersistsAndRejectsChanges(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.LockStartingPack(21, true); err == nil {
t.Fatal("accepted policy outside startup operation")
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if got, err := r.LockStartingPack(21, true); err != nil || got != 21 {
t.Fatalf("lock=%d: %v", got, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
r, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
op, err = r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if got, err := r.LockStartingPack(21, false); err != nil || got != 21 {
t.Fatalf("reopened lock=%d: %v", got, err)
}
if _, err := r.LockStartingPack(1, true); err == nil {
t.Fatal("accepted changed policy")
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
var raw string
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); err != nil || raw != "21" {
t.Fatalf("policy=%q: %v", raw, err)
}
}
func TestStartingPackPolicyRollbackAllowsAnotherChoice(t *testing.T) {
r, path := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, err := r.LockStartingPack(21, true); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err != nil {
t.Fatal(err)
}
var raw string
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("rolled back policy survived: %q, %v", raw, err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
r, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
op, err = r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if got, err := r.LockStartingPack(1, true); err != nil || got != 1 {
t.Fatalf("retry lock=%d: %v", got, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
}
func TestStartingPackPolicyMissingExistingRejectsInitialization(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.SaveContext(context.Background(), "progress", []byte("{}")); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
defer func() { _ = op.Rollback() }()
for _, initialize := range []bool{false, true} {
if _, err := r.LockStartingPack(1, initialize); err == nil {
t.Fatalf("accepted missing existing policy initialize=%v", initialize)
}
}
}
func TestStartingPackPolicyRejectsInvalidValues(t *testing.T) {
for _, raw := range []string{"0", "2", "abc", "01", "21 "} {
t.Run(raw, func(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.db.Exec(`INSERT INTO metadata(key,value) VALUES(?,?)`, startingPackMetadataKey, raw); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
defer func() { _ = op.Rollback() }()
if _, err := r.LockStartingPack(1, false); err == nil {
t.Fatal("accepted invalid stored value")
}
})
}
r, _ := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
defer func() { _ = op.Rollback() }()
for _, configured := range []int{0, 2, -1} {
if _, err := r.LockStartingPack(configured, true); err == nil {
t.Fatalf("accepted invalid configured value %d", configured)
}
}
if _, err := r.LockStartingPack(1, false); err == nil {
t.Fatal("initialized empty DB without authorization")
}
}
@@ -1,117 +0,0 @@
package accountstate
import (
"slices"
"testing"
)
func validValidationSnapshot() validationSnapshot {
return validationSnapshot{
formatVersion: 1, clientVersion: "2.35.10", gameDataVersion: "20260924000000",
itemNextIndex: 11, equipmentNextIndex: 21, characterNextIndex: 31, costumeNextIndex: 41,
itemIndices: []uint64{10}, equipmentIndices: []uint64{20}, characterIndices: []uint64{30}, costumeIndices: []uint64{40},
collectionCharacterIndices: []uint64{30},
equipmentGrants: []namedIndex{{identity: "quest:test", index: 20}},
equipmentUsers: []namedIndex{{identity: "20", index: 30}},
quests: []questKey{{pack: 1, quest: 2}}, cleared: []questKey{{pack: 1, quest: 2}},
granted: map[string]bool{"mail:test": true},
itemGrants: []indexedGrant{{identity: "mail:test", indices: []uint64{9}}},
}
}
func TestTimedCompanionIdentityIsSeparateFromCollectionAllocator(t *testing.T) {
s := validValidationSnapshot()
companion := uint64(1<<59) + 100
s.characterIndices = append(s.characterIndices, companion)
s.equipmentUsers[0].index = companion
if p := validateSnapshot(s); len(p) != 0 {
t.Fatalf("temporary owner rejected: %+v", p)
}
s.collectionCharacterIndices = append(s.collectionCharacterIndices, s.characterNextIndex)
if !slices.Contains(problemCodes(validateSnapshot(s)), "collection.next_character_index") {
t.Fatal("permanent allocator invariant lost")
}
}
func problemCodes(problems []Problem) []string {
codes := make([]string, 0, len(problems))
for _, problem := range problems {
codes = append(codes, problem.Code)
}
return codes
}
func TestValidateSnapshotAcceptsConsumedGrantedItem(t *testing.T) {
snapshot := validValidationSnapshot()
// Index 9 is intentionally absent from itemIndices: grant_items is an
// issuance ledger and remains valid after the stack is consumed.
if problems := validateSnapshot(snapshot); len(problems) != 0 {
t.Fatalf("unexpected problems: %#v", problems)
}
}
func TestValidateSnapshotAcceptsDismantledGrantedEquipment(t *testing.T) {
snapshot := validValidationSnapshot()
snapshot.equipmentIndices = nil
// Index 20 was issued below next_index and its grant marker must survive
// dismantling so a retried grant cannot create a duplicate instance.
if problems := validateSnapshot(snapshot); len(problems) != 0 {
t.Fatalf("unexpected problems: %#v", problems)
}
}
func TestValidateSnapshotPortsEveryExternalValidatorRule(t *testing.T) {
snapshot := validValidationSnapshot()
snapshot.formatVersion = 2
snapshot.clientVersion = ""
snapshot.gameDataVersion = ""
snapshot.itemIndices = []uint64{10, 10}
snapshot.equipmentIndices = []uint64{20, 20}
snapshot.characterIndices = []uint64{30, 30}
snapshot.costumeIndices = []uint64{40, 40}
snapshot.itemNextIndex = 10
snapshot.equipmentNextIndex = 20
snapshot.characterNextIndex = 30
snapshot.costumeNextIndex = 40
snapshot.equipmentGrants = []namedIndex{{identity: "missing", index: 999}}
snapshot.equipmentUsers = []namedIndex{{identity: "20", index: 999}}
snapshot.quests = []questKey{{}, {pack: 2, quest: 3}, {pack: 2, quest: 3}}
snapshot.cleared = []questKey{{pack: 1}, {pack: 4, quest: 5}, {pack: 4, quest: 5}}
snapshot.granted = map[string]bool{}
snapshot.itemGrants = []indexedGrant{{identity: "unmarked", indices: []uint64{0, 10, 10}}}
want := []string{
"snapshot.format_version", "snapshot.client_version", "snapshot.game_data_version",
"inventory.duplicate_index", "equipment.duplicate_index", "characters.duplicate_index", "costumes.duplicate_index",
"inventory.next_index", "equipment.next_index", "collection.next_character_index", "collection.next_costume_index",
"equipment.grant_missing_equipment", "equipment.unknown_user",
"progress.invalid_quest_key", "progress.invalid_cleared_key",
"progress.duplicate_quest_key", "progress.duplicate_cleared_key",
"inventory.grant_without_marker", "inventory.grant_invalid_index", "inventory.grant_invalid_index", "inventory.grant_invalid_index",
"inventory.grant_duplicate_index",
}
got := problemCodes(validateSnapshot(snapshot))
if !slices.Equal(got, want) {
t.Fatalf("problem codes:\n got %v\nwant %v", got, want)
}
}
func TestValidateSnapshotGrantAndOwnershipRules(t *testing.T) {
for _, test := range []struct {
name string
edit func(*validationSnapshot)
code string
}{
{name: "unissued item index", edit: func(s *validationSnapshot) { s.itemGrants[0].indices = []uint64{s.itemNextIndex} }, code: "inventory.grant_invalid_index"},
{name: "missing equipment", edit: func(s *validationSnapshot) { s.equipmentGrants[0].index = 999 }, code: "equipment.grant_missing_equipment"},
{name: "unknown equipped character", edit: func(s *validationSnapshot) { s.equipmentUsers[0].index = 999 }, code: "equipment.unknown_user"},
} {
t.Run(test.name, func(t *testing.T) {
snapshot := validValidationSnapshot()
test.edit(&snapshot)
if got := problemCodes(validateSnapshot(snapshot)); !slices.Contains(got, test.code) {
t.Fatalf("codes %v do not contain %s", got, test.code)
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
package app
import (
"bd2server/internal/server/design/gameconfig"
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/events/calendar"
"bd2server/internal/server/gateway/authconfig"
"bd2server/internal/server/gateway/bootstrap"
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/resources/policy"
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
)
type configuration struct {
logLevel string
logColor string
versionConfigPath string
authConfigPath string
resourceConfigPath string
gameConfigPath string
listen string
dataDir string
gameDataVersion string
gameDataOrigin string
accountSeed string
playerSeed string
readonlySeed string
mailSeed string
mailGrantSpool string
stateDirectory string
deckSeed string
worldSeed string
devToolsConfig string
versions versionconfig.Config
calendars *calendar.Set
gameRules gameconfig.Config
authentication authconfig.Config
authRuntime authconfig.Runtime
publicResources resourcepolicy.Public
bootstrap bootstrap.Config
gameData string
verifiedGameData gamedata.Result
}
func loadConfiguration(args []string) (result *configuration, loadErr error) {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
logLevel := fs.String("log-level", "", "log threshold: trace, debug, info, warn, error (default BD2_LOG_LEVEL or info)")
logColor := fs.String("log-color", "", "level colors: auto, always, never (default BD2_LOG_COLOR or auto)")
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
authConfigPath := fs.String("authentication-config", "", "authentication.json override for development")
resourceConfigPath := fs.String("resource-config", "", "resources.json override for development")
gameConfigPath := fs.String("game-config", "", "game.json server gameplay configuration override")
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
dataDir := fs.String("data-dir", "", "server data directory (defaults beside the executable)")
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (defaults to versions.json)")
gameDataOrigin := fs.String("game-data-origin", resourcepolicy.OfficialGameDataURL, "official GameData repair source override for development")
accountSeed := fs.String("account-seed", "", "versioned local account seed")
playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters")
readonlySeed := fs.String("readonly-seed", "", "versioned static protocol defaults")
mailSeed := fs.String("mail-seed", "", "versioned starter mailbox")
mailGrantSpool := fs.String("mail-grant-spool", "", "optional local JSON spool for idempotent dynamic system mail")
stateDirectory := fs.String("state-dir", "", "player and shared state directory")
deckSeed := fs.String("deck-seed", "", "versioned starter deck")
worldSeed := fs.String("world-seed", "", "versioned starter world")
devToolsConfig := fs.String("dev-tools-config", "", "development-tool settings JSON (defaults to DATA_DIR/dev-tools.json)")
if err := fs.Parse(args); err != nil {
return nil, err
}
if err := ConfigureLogging(os.Stderr, *logLevel, *logColor); err != nil {
return nil, err
}
var versions versionconfig.Config
var err error
if *versionConfigPath == "" {
versions, err = versionconfig.Find()
} else {
versions, err = versionconfig.Load(*versionConfigPath)
}
if err != nil {
return nil, err
}
if *gameDataVersion == "" {
*gameDataVersion = versions.GameDataVersion
} else {
// Preserve the development override as part of the effective process
// configuration so state snapshots describe the GameData actually used.
versions.GameDataVersion = *gameDataVersion
if err := versions.Validate(); err != nil {
return nil, fmt.Errorf("effective version config: %w", err)
}
}
versionconfig.Use(versions)
calendarDirectory := versions.Resolve("schedules")
calendars, err := calendar.LoadDirectory(calendarDirectory, versions.GameVersion, versions.GameDataVersion)
if err != nil {
return nil, fmt.Errorf("load project calendars: %w", err)
}
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
return nil, errors.New("project calendars require regular content and monster hunt schedules")
}
slog.Info("project calendars loaded", "directory", calendarDirectory, "revisions", calendars.Revisions, "events", len(calendars.Events))
if *gameConfigPath == "" {
*gameConfigPath, err = gameconfig.BesideExecutable()
if err != nil {
return nil, err
}
}
gameRules, err := gameconfig.Load(*gameConfigPath)
if err != nil {
return nil, err
}
if *authConfigPath == "" {
*authConfigPath, err = authconfig.BesideExecutable()
if err != nil {
return nil, err
}
}
authentication, err := authconfig.Load(*authConfigPath)
if err != nil {
return nil, err
}
authRuntime, err := authentication.ResolveEnvironment()
if err != nil {
return nil, err
}
defer func() {
if result == nil {
clear(authRuntime.MasterKey)
}
}()
if *resourceConfigPath == "" {
*resourceConfigPath, err = resourcepolicy.BesideExecutable()
if err != nil {
return nil, err
}
}
resources, err := resourcepolicy.Load(*resourceConfigPath)
if err != nil {
return nil, err
}
if *dataDir == "" {
executable, executableErr := os.Executable()
if executableErr != nil {
return nil, fmt.Errorf("resolve server data directory: %w", executableErr)
}
*dataDir = filepath.Join(filepath.Dir(executable), "data")
}
*dataDir, err = filepath.Abs(filepath.Clean(*dataDir))
if err != nil {
return nil, fmt.Errorf("resolve server data directory: %w", err)
}
*devToolsConfig = resolveDevelopmentSettingsPath(*dataDir, *devToolsConfig)
gameData := filepath.Join(*dataDir, "resources", "GameData")
if *stateDirectory == "" {
*stateDirectory = filepath.Join(*dataDir, "state")
}
seedRoot := versions.Resolve(versions.SeedDirectory)
for target, name := range map[*string]string{
accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json",
mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json",
} {
if *target == "" {
*target = filepath.Join(seedRoot, name)
}
}
clientOrigin := "http://" + *listen
if authentication.Mode == "oauth" {
clientOrigin = strings.TrimSuffix(authentication.PublicURL, "/")
}
base := clientOrigin + "/game/"
publicResources := resources.Public(versions.BundleVersion, *gameDataVersion)
cfg := bootstrap.Config{
BaseURL: base,
CDNURL: publicResources.ServerDataURL,
Version: versions.GameVersion,
BundleVer: versions.BundleVersion,
GameDataURL: publicResources.GameDataURL,
GameDataVer: *gameDataVersion,
}
if err := cfg.Validate(); err != nil {
return nil, err
}
verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, gameData, *gameDataVersion, *gameDataOrigin)
if err != nil {
return nil, fmt.Errorf("refuse to advertise unavailable or unverified GameData: %w", err)
}
if downloaded {
slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount)
}
if err := calendars.ValidateDesign(gameData, *gameDataVersion); err != nil {
return nil, fmt.Errorf("validate project calendar GameData references: %w", err)
}
return &configuration{
logLevel: *logLevel,
logColor: *logColor,
versionConfigPath: *versionConfigPath,
authConfigPath: *authConfigPath,
resourceConfigPath: *resourceConfigPath,
gameConfigPath: *gameConfigPath,
listen: *listen,
dataDir: *dataDir,
gameDataVersion: *gameDataVersion,
gameDataOrigin: *gameDataOrigin,
accountSeed: *accountSeed,
playerSeed: *playerSeed,
readonlySeed: *readonlySeed,
mailSeed: *mailSeed,
mailGrantSpool: *mailGrantSpool,
stateDirectory: *stateDirectory,
deckSeed: *deckSeed,
worldSeed: *worldSeed,
devToolsConfig: *devToolsConfig,
versions: versions, calendars: calendars, gameRules: gameRules, authentication: authentication, authRuntime: authRuntime, publicResources: publicResources, bootstrap: cfg, gameData: gameData, verifiedGameData: verifiedGameData,
}, nil
}
+370
View File
@@ -0,0 +1,370 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/domain/commerce"
"fmt"
)
type designCatalog struct {
cashCatalog *commerce.Catalog
source *gamedata.Source
world *gamedata.WorldRules
fieldReset gamedata.FieldResetSchedule
fieldBuffs map[uint64]gamedata.FieldBuffDesign
recovery *gamedata.PackRecoveryPolicy
researchCharacters map[uint64]bool
overwhelmSky []gamedata.SkyWayOverwhelmRule
eventPlay *gamedata.EventPlayCatalog
monsterHunt *monsterhunt.Rules
presetDesign *gamedata.PresetDesign
recipeDesign *gamedata.CookingRecipeDesign
randomBoxes *gamedata.RandomBoxDesign
itemStacks *gamedata.ItemStackDesign
slotDesign *gamedata.InventorySlotDesign
contentTickets *gamedata.GachaContentTicketDesign
missionDesign *gamedata.MissionDesign
levelDesign *gamedata.AchievementLevelDesign
equipmentSlots map[uint64]uint64
equipmentUpgrade *gamedata.EquipmentUpgradeDesign
equipmentCraft *gamedata.EquipmentCraftDesign
talentGrowth *gamedata.TalentGrowthDesign
equipmentSmelting *gamedata.EquipmentSmeltingDesign
equipmentOptionReroll *gamedata.EquipmentOptionRerollDesign
infiniteGacha *gamedata.InfiniteGachaDesign
fieldSettingsDesign *gamedata.FieldSettingsDesign
pictorialDesign *gamedata.PictorialDesign
equipmentStatDesign *gamedata.EquipmentStatDesign
charAwakeDesign *gamedata.CharAwakeDesign
immortalDesign *gamedata.ImmortalDesign
costumePotentialDesign *gamedata.CostumePotentialDesign
costumeBurstDesign *gamedata.CostumeBurstDesign
friendshipDesign gamedata.FriendshipDesign
contentOpeningDesign *gamedata.ContentOpeningDesign
huntingAPDesign gamedata.HuntingAPDesign
rewardGraph *gamedata.RewardGraph
rewardEquipment *gamedata.EquipmentGachaCatalog
rewardCostumes *gamedata.RegularGachaCatalog
talentUseDesign *gamedata.TalentUseDesign
dispatchDesign map[uint64]gamedata.TalentDispatchDesign
itemCraftDesign *gamedata.ItemCraftDesign
npcShopDesign gamedata.NPCShopDesign
commissionDesign *gamedata.TodayQuestCatalog
prestigeCatalog *gamedata.PrestigeSkinCatalog
ownedEventItems map[uint64]map[uint64]bool
avatarRewards *gamedata.AvatarRewardDesign
buffDesign map[uint64]gamedata.PictorialBuffStat
eventAPCaps map[uint64]uint64
eventAPReset gamedata.HuntingAPDesign
cashDesign *gamedata.CashCatalog
cashEntitlementDesign *gamedata.CashEntitlementDesign
cashRewards *gamedata.CashRewardResolver
cashMailTemplates map[uint64]bool
clearPackageDesign *gamedata.ClearPackageCatalog
cashBonusDesign *gamedata.CashBonusCatalog
eventTasksDesign *gamedata.EventTasksDesign
loginPassDesign *gamedata.LoginPassCatalog
eventExchangeDesign *gamedata.EventExchangeCatalog
eventBattleChallenges gamedata.EventBattleChallenges
eventActionsDesign *gamedata.EventActionsDesign
miniContent *gamedata.MiniContentDesign
recruitDesign gamedata.RecruitDesign
foodDesign *gamedata.FoodDesign
achievementCounterDesign *gamedata.AchievementCounterDesign
achievementGrades gamedata.GameplayAchievementGrades
regularGacha *gamedata.RegularGachaCatalog
equipmentGacha *gamedata.EquipmentGachaCatalog
limitedCostumes *gamedata.LimitedCostumeCatalog
firstGacha *gamedata.FirstGachaDesign
}
func loadDesign(c *configuration, seeds *seedCatalog) (*designCatalog, error) {
gameData, gameDataVersion := c.gameData, c.gameDataVersion
calendars, gameRules := c.calendars, c.gameRules
d := &designCatalog{source: gamedata.NewSource(gameData, gameDataVersion)}
var err error
gachaSchedule := calendars.GachaSeed
var scheduleGroupIDs, stepUpGroupIDs []uint64
for _, window := range gachaSchedule.Schedules {
scheduleGroupIDs = append(scheduleGroupIDs, window.GroupID)
}
for _, window := range gachaSchedule.StepUps {
stepUpGroupIDs = append(stepUpGroupIDs, window.GroupID)
}
d.regularGacha, d.equipmentGacha, err = gamedata.LoadActiveGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs, stepUpGroupIDs)
if err != nil {
return nil, fmt.Errorf("load active gacha GameData: %w", err)
}
if gameRules.Gacha.IncludeCollaborationURWeapons {
if err := d.equipmentGacha.IncludeCollaborationURWeapons(gameData, gameDataVersion); err != nil {
return nil, fmt.Errorf("apply collaboration UR weapon game rule: %w", err)
}
}
d.presetDesign, err = gamedata.LoadPresetDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load party preset GameData: %w", err)
}
d.recipeDesign, err = gamedata.LoadCookingRecipeDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cooking recipes: %w", err)
}
d.randomBoxes, err = gamedata.LoadRandomBoxDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load deterministic random-box GameData: %w", err)
}
d.itemStacks, err = gamedata.LoadItemStackDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load item stack GameData: %w", err)
}
d.slotDesign, err = gamedata.LoadInventorySlotDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load inventory slot GameData: %w", err)
}
d.contentTickets, err = gamedata.LoadGachaContentTicketDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load gacha content tickets: %w", err)
}
d.missionDesign, err = gamedata.LoadMissionDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load mission GameData: %w", err)
}
d.levelDesign, err = gamedata.LoadAchievementLevelDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load user level rewards: %w", err)
}
d.equipmentSlots, err = gamedata.LoadEquipmentSlots(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment slot GameData: %w", err)
}
d.equipmentUpgrade, err = gamedata.LoadEquipmentUpgradeDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment upgrade GameData: %w", err)
}
d.equipmentCraft, err = gamedata.LoadEquipmentCraftDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment crafting GameData: %w", err)
}
d.talentGrowth, err = gamedata.LoadTalentGrowthDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load talent growth GameData: %w", err)
}
d.equipmentSmelting, err = gamedata.LoadEquipmentSmeltingDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment smelting GameData: %w", err)
}
d.equipmentOptionReroll, err = gamedata.LoadEquipmentOptionRerollDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment option reroll GameData: %w", err)
}
d.infiniteGacha, err = gamedata.LoadInfiniteGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs)
if err != nil {
return nil, fmt.Errorf("load infinite gacha GameData: %w", err)
}
d.fieldSettingsDesign, err = gamedata.LoadFieldSettingsDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load field character settings: %w", err)
}
d.pictorialDesign, err = gamedata.LoadPictorialDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load pictorial GameData: %w", err)
}
d.equipmentStatDesign, err = gamedata.LoadEquipmentStatDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment stat GameData: %w", err)
}
d.charAwakeDesign, err = gamedata.LoadCharAwakeDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load character awakening GameData: %w", err)
}
d.immortalDesign, err = gamedata.LoadImmortalDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load immortal talent GameData: %w", err)
}
d.costumePotentialDesign, err = gamedata.LoadCostumePotentialDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load costume potential GameData: %w", err)
}
d.costumeBurstDesign, err = gamedata.LoadCostumeBurstDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load costume burst GameData: %w", err)
}
d.friendshipDesign, err = gamedata.LoadFriendshipDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load friendship GameData: %w", err)
}
d.contentOpeningDesign, err = gamedata.LoadContentOpeningDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load content opening GameData: %w", err)
}
d.huntingAPDesign, err = gamedata.LoadHuntingAPDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load hunting AP reset: %w", err)
}
d.rewardGraph, err = gamedata.LoadRewardGraph(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event reward graph: %w", err)
}
d.rewardEquipment, err = gamedata.LoadRewardEquipmentCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load reward equipment: %w", err)
}
d.rewardCostumes, err = gamedata.LoadRewardCostumeCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load reward costumes: %w", err)
}
d.talentUseDesign, err = gamedata.LoadTalentUseDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load field talent skills: %w", err)
}
d.dispatchDesign, err = gamedata.LoadTalentDispatchDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load talent dispatch design: %w", err)
}
d.itemCraftDesign, err = gamedata.LoadItemCraftDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load item crafting design: %w", err)
}
d.npcShopDesign, err = gamedata.LoadNPCShopDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load NPC shop design: %w", err)
}
d.commissionDesign, err = gamedata.LoadTodayQuests(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load NPC commission design: %w", err)
}
d.prestigeCatalog, err = gamedata.LoadPrestigeSkinCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load reward prestige skins: %w", err)
}
d.ownedEventItems, err = gamedata.LoadOwnedEventItemDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event inventory design: %w", err)
}
d.avatarRewards, err = gamedata.LoadAvatarRewardDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load avatar rewards: %w", err)
}
d.buffDesign, err = gamedata.LoadBuffRewardDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load permanent buff rewards: %w", err)
}
d.eventAPCaps, d.eventAPReset, err = gamedata.LoadEventAPDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event AP reset: %w", err)
}
d.cashDesign, err = gamedata.LoadCashCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash products: %w", err)
}
d.cashEntitlementDesign, err = gamedata.LoadCashEntitlementDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash entitlement design: %w", err)
}
d.cashRewards, err = gamedata.LoadCashRewardResolver(gameData, gameDataVersion, d.rewardGraph)
if err != nil {
return nil, fmt.Errorf("load cash product rewards: %w", err)
}
d.cashMailTemplates, err = gamedata.LoadCashMailTemplates(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash mail templates: %w", err)
}
d.clearPackageDesign, err = gamedata.LoadClearPackageCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load clear-package rewards: %w", err)
}
d.cashBonusDesign, err = gamedata.LoadCashBonusCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash bonus design: %w", err)
}
d.eventTasksDesign, err = gamedata.LoadEventTasksDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event tasks design: %w", err)
}
d.loginPassDesign, err = gamedata.LoadLoginPassCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load login-pass rewards: %w", err)
}
d.eventExchangeDesign, err = gamedata.LoadEventExchangeCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event exchange design: %w", err)
}
d.eventBattleChallenges, err = gamedata.LoadEventBattleChallenges(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event battle challenges: %w", err)
}
d.eventActionsDesign, err = gamedata.LoadEventActionsDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event action design: %w", err)
}
d.miniContent, err = gamedata.LoadMiniContentDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load mini event content: %w", err)
}
d.recruitDesign, err = gamedata.LoadRecruitDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load recruitment GameData: %w", err)
}
d.foodDesign, err = gamedata.LoadFoodDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load food GameData: %w", err)
}
d.achievementCounterDesign, err = gamedata.LoadAchievementCounterDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load achievement counters: %w", err)
}
d.achievementGrades, err = gamedata.LoadGameplayAchievementGrades(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load achievement gameplay grades: %w", err)
}
d.limitedCostumes, err = gamedata.LoadLimitedCostumes(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load limited costume GameData: %w", err)
}
d.firstGacha, err = gamedata.LoadFirstGacha(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load first gacha GameData: %w", err)
}
d.world, err = gamedata.LoadWorldRules(gameData, gameDataVersion, seeds.world.PlaceholderCostumeID)
if err != nil {
return nil, err
}
d.fieldReset, err = gamedata.LoadFieldResetSchedule(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.fieldBuffs, err = gamedata.LoadFieldBuffDesign(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.recovery, err = gamedata.LoadPackRecoveryPolicy(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.researchCharacters, err = gamedata.LoadResearchCharacters(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.overwhelmSky, err = gamedata.LoadSkyWayOverwhelm(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.eventPlay, err = gamedata.LoadEventPlayCatalog(gameData, gameDataVersion)
if err != nil {
return nil, err
}
presets, err := gamedata.LoadMonsterHuntPresetDesign(gameData, gameDataVersion)
if err != nil {
return nil, err
}
seasons := monsterHuntSeasons(seeds.defaults)
d.monsterHunt, err = monsterhunt.NewRules(seasons, seeds.defaults, presets, d.source.MonsterHunt)
if err != nil {
return nil, err
}
d.cashCatalog, err = commerce.NewCatalog(c.versions.GameVersion, d.cashDesign, c.gameRules.Purchases)
if err != nil {
return nil, err
}
return d, nil
}
@@ -1,4 +1,4 @@
package main
package app
import "path/filepath"
@@ -0,0 +1,304 @@
//go:build integration
package app
import (
"bytes"
"context"
"fmt"
"testing"
"time"
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/protocol/wire"
"bd2server/internal/server/runtime/player"
accountstate "bd2server/internal/server/storage/account"
)
type trapPlayerScenario struct {
factory *PlayerFactory
owner *playerInstance
account string
leader, other uint64
maximum uint64
commands int
}
func newTrapPlayerScenario(t *testing.T, pack int) *trapPlayerScenario {
t.Helper()
factory, accounts := newIntegrationFactory(t)
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
s := &trapPlayerScenario{factory: factory, owner: owner, account: accounts[0]}
t.Cleanup(func() {
if s.owner != nil {
if err := s.owner.Close(); err != nil {
t.Error(err)
}
}
})
s.run(t, true, func(ctx command.Context, p *playerAssembly) {
if _, err := p.collection.GrantCostumes(ctx, "trap-party-owned-costumes", []uint64{101, 201}, p.design.rewardCostumes); err != nil {
t.Fatal(err)
}
if rule := p.design.world.Story.Packs[pack].Open; rule != nil && rule.TicketID != 0 {
if _, err := p.ownedItems.GrantOnce(ctx, "trap-scenario-ticket", []gamedata.BattleReward{{Type: 19, ID: rule.TicketID, Count: 1}}); err != nil {
t.Fatal(err)
}
}
if _, _, _, err := p.worldService.Handle(ctx, "/PackInGameInfo", trapRequest(100, uint64(pack))); err != nil {
t.Fatal(err)
}
var selected []roster.Character
for _, c := range p.worldService.CharacterService().RawAll() {
if !roster.IsStoryCharacter(c) && !roster.IsCharmCharacter(c) && !roster.CharacterExpired(c, time.Now()) {
worn, owned := p.collection.CostumeByIndex(c.UseCostume)
if !owned || worn.UseChar != c.InvenIndex {
continue
}
connectedOwned := c.ConnectPotentialCostume == 0
for _, costume := range p.collection.Costumes() {
if costume.ID == c.ConnectPotentialCostume && p.design.costumePotentialDesign.CostumeUnique[costume.ID] == p.design.costumePotentialDesign.CharacterUnique[c.ID] {
connectedOwned = true
break
}
}
if !connectedOwned {
continue
}
maximum, err := p.worldService.CharacterService().MaxHealth(ctx, c.InvenIndex)
if err != nil {
t.Fatal(err)
}
if maximum == 0 || len(selected) == 0 && maximum <= 150 {
continue
}
selected = append(selected, c)
if len(selected) == 2 {
break
}
}
}
if len(selected) < 2 {
t.Fatal("player setup requires two owned permanent characters")
}
s.leader = selected[0].InvenIndex
s.other = selected[1].InvenIndex
s.maximum, err = p.worldService.CharacterService().MaxHealth(ctx, s.leader)
if err != nil {
t.Fatal(err)
}
for _, c := range selected {
max, err := p.worldService.CharacterService().MaxHealth(ctx, c.InvenIndex)
if err != nil {
t.Fatal(err)
}
if err := p.worldService.CharacterService().SetCurrentHealth(ctx, c.InvenIndex, max); err != nil {
t.Fatal(err)
}
}
req := wire.AppendVarint(nil, 1, 1)
for i, c := range selected {
row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(i+1)), 2, c.InvenIndex), 3, c.UseCostume)
req = wire.AppendBytes(req, 2, row)
}
if _, _, _, err := p.deckStateStore.Handle(ctx, "/FieldDeckSave", req); err != nil {
t.Fatal(err)
}
if _, _, _, err := p.deckStateStore.Handle(ctx, "/SaveFieldCharControlDeckType", wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1)); err != nil {
t.Fatal(err)
}
})
return s
}
func (s *trapPlayerScenario) run(t *testing.T, commit bool, action func(command.Context, *playerAssembly)) {
t.Helper()
tx, err := s.owner.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
ctx := command.Context{Identity: command.Identity{AccountID: s.account, SessionID: "trap-client"}, State: tx}
action(ctx, s.owner.assembly)
problems, err := tx.Validate()
if err != nil || len(problems) != 0 {
t.Fatalf("complete player validation failed: %v %v", problems, err)
}
if commit {
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
}
func (s *trapPlayerScenario) reopen(t *testing.T) {
t.Helper()
status, err := s.owner.repository.InitializationState("characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet")
if err != nil || status != accountstate.InitializationComplete {
t.Fatalf("trap operations changed the nine-domain account schema: status=%v err=%v", status, err)
}
if err := s.owner.repository.RequireDomains("bootstrap", "characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"); err != nil {
t.Fatal(err)
}
problems, err := s.owner.repository.Validate()
if err != nil || len(problems) != 0 {
t.Fatalf("persisted player validation: %v %v", problems, err)
}
if err := s.owner.Close(); err != nil {
t.Fatal(err)
}
s.owner = nil
s.owner, err = s.factory.open(s.account)
if err != nil {
t.Fatalf("reopen complete player after trap operation: %v", err)
}
}
func trapRequest(seq, id uint64) []byte {
return wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id)
}
func trapHealth(t *testing.T, ctx command.Context, p *playerAssembly, index uint64) uint64 {
t.Helper()
hp, err := p.worldService.CharacterService().CurrentHealth(ctx, index)
if err != nil {
t.Fatal(err)
}
return hp
}
func (s *trapPlayerScenario) execute(path string, request []byte) (player.Reply, error) {
c := registryCommand(s.account, fmt.Sprintf("trap-command-%d", s.commands), path, request)
s.commands++
c.Identity.SessionID = "trap-client"
return s.owner.Execute(context.Background(), c)
}
func (s *trapPlayerScenario) protocol(t *testing.T, path string, request []byte) []byte {
t.Helper()
reply, err := s.execute(path, request)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
if len(reply.Responses) != 1 {
t.Fatalf("%s missing response", path)
}
return reply.Responses[0].Body
}
func (s *trapPlayerScenario) health(t *testing.T, index uint64) uint64 {
t.Helper()
var hp uint64
s.run(t, false, func(ctx command.Context, p *playerAssembly) { hp = trapHealth(t, ctx, p, index) })
return hp
}
func (s *trapPlayerScenario) position(t *testing.T, pack, mapID int) {
t.Helper()
raw := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(s.commands+1)), 2, uint64(pack)), 3, []byte(fmt.Sprintf(`{"MapId":%d}`, mapID)))
s.protocol(t, "/SaveUserPosition", raw)
}
// The real player's leader takes spike damage; retries, failed transactions,
// and reassembly must preserve HP and the account's initialized domain set.
func TestPlayerTrapDamagePersistsHealthAndReplaysAcrossTransactions(t *testing.T) {
s := newTrapPlayerScenario(t, 2)
request := trapRequest(1, 11)
s.position(t, 2, 22)
otherHP := s.health(t, s.other)
reply := s.protocol(t, "/TrapDamage", request)
rows := appRows(reply, 1)
if len(rows) != 1 || appValue(rows[0], 1) != s.leader || appValue(rows[0], 3) != s.maximum-50 {
t.Fatalf("spikes must return the actual leader's reduced HP: %x", reply)
}
if s.health(t, s.leader) != s.maximum-50 || s.health(t, s.other) != otherHP {
t.Fatal("spikes changed the wrong party member's health")
}
s.reopen(t)
if retry := s.protocol(t, "/TrapDamage", request); !bytes.Equal(retry, reply) {
t.Fatal("reopened player changed the one-hit retry")
}
if s.health(t, s.leader) != s.maximum-50 {
t.Fatal("retry deducted HP again")
}
if _, err := s.execute("/TrapDamage", trapRequest(1, 21)); err == nil {
t.Fatal("changed replay accepted")
}
s.position(t, 2, 23)
s.protocol(t, "/TrapDamage", trapRequest(2, 11))
if s.health(t, s.leader) != s.maximum-100 {
t.Fatal("shared trap ID in its second scene did not apply damage")
}
s.position(t, 2, 21)
before := s.health(t, s.leader)
if _, err := s.execute("/TrapDamage", trapRequest(3, 11)); err == nil {
t.Fatal("wrong-scene trap request accepted")
}
if s.health(t, s.leader) != before {
t.Fatal("wrong-scene trap request changed HP")
}
s.position(t, 2, 22)
failed := registryCommand(s.account, "trap-then-rejected-batch", "/TrapDamage", trapRequest(4, 11))
failed.Identity.SessionID = "trap-client"
failed.Requests = append(failed.Requests, player.Request{Path: "/TrapDamage", Body: trapRequest(5, 999999)})
if _, err := s.owner.Execute(context.Background(), failed); err == nil {
t.Fatal("invalid batch committed its preceding trap hit")
}
s.reopen(t)
if s.health(t, s.leader) != s.maximum-100 {
t.Fatal("rolled-back HP was persisted")
}
s.protocol(t, "/TrapDamage", trapRequest(4, 11))
if s.health(t, s.leader) != s.maximum-150 {
t.Fatal("rolled-back receipt blocked the next real hit")
}
s.reopen(t)
}
// A saved Preserve override controls what GetInitialActiveState restores. Its
// map filter and disabled damage behavior must survive a complete player reopen.
func TestPlayerTrapOverrideRestoresAcrossReopenAndFiltersMaps(t *testing.T) {
s := newTrapPlayerScenario(t, 2)
query := func(mapID uint64) []byte {
t.Helper()
return s.protocol(t, "/FieldTrapInfo", wire.AppendVarint(trapRequest(10, 2), 3, mapID))
}
s.position(t, 2, 22)
if len(appRows(query(0), 1)) != 0 {
t.Fatal("new player received an override without saving one")
}
s.protocol(t, "/TrapDamage", trapRequest(11, 11))
if s.health(t, s.leader) != s.maximum-50 {
t.Fatal("new player did not use the trap's enabled default")
}
s.run(t, true, func(ctx command.Context, p *playerAssembly) {
// The setup is a legal saved state for the real Preserve trap 11. It
// does not change GameData or invent a currently absent switch chain.
payload := []byte(`{"States":{"2/22/11":{"Pack":2,"Map":22,"Trap":11,"Enabled":false,"Partial":null},"2/23/11":{"Pack":2,"Map":23,"Trap":11,"Enabled":true,"Partial":null}},"Hits":{},"Requests":{}}`)
if err := p.gameplayStore.Save(ctx.State, "field_traps", payload); err != nil {
t.Fatal(err)
}
})
s.reopen(t)
rows := appRows(query(22), 1)
if len(rows) != 1 || appValue(rows[0], 1) != 2 || appValue(rows[0], 2) != 22 || appValue(rows[0], 3) != 11 || appValue(rows[0], 4) != 0 {
t.Fatalf("disabled trap override not restored in the requested map: %x", rows)
}
if reply := s.protocol(t, "/TrapDamage", trapRequest(12, 11)); len(reply) != 0 || s.health(t, s.leader) != s.maximum-50 {
t.Fatal("restored disabled trap caused damage")
}
rows = appRows(query(23), 1)
if len(rows) != 1 || appValue(rows[0], 2) != 23 || appValue(rows[0], 3) != 11 || appValue(rows[0], 4) != 1 {
t.Fatalf("second-map enabled override not restored: %x", rows)
}
if len(appRows(query(0), 1)) != 2 {
t.Fatal("whole-pack query lost a persisted map override")
}
s.position(t, 2, 23)
s.protocol(t, "/TrapDamage", trapRequest(13, 11))
if s.health(t, s.leader) != s.maximum-100 {
t.Fatal("enabled override in the other map failed to damage the leader")
}
s.reopen(t)
}
@@ -1,12 +1,12 @@
package main
package app
import (
"io"
"bd2server/internal/server/logging"
"bd2server/internal/server/platform/logging"
)
func configureLogging(writer io.Writer, levelOverride, colorOverride string) error {
func ConfigureLogging(writer io.Writer, levelOverride, colorOverride string) error {
options, err := logging.OptionsFromEnv()
if err != nil {
return err
@@ -0,0 +1,41 @@
package app
import (
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/protocol/staticdata"
)
func monsterHuntSeasons(seed *readonly.Seed) []monsterhunt.Season {
var seasons []monsterhunt.Season
for _, field := range seed.Responses["/MonsterHuntScheduleInfo"].Fields {
if field.Number != 1 || field.Type != 2 {
continue
}
var season monsterhunt.Season
for _, value := range field.Fields {
switch value.Number {
case 1:
for _, nested := range value.Fields {
switch nested.Number {
case 1:
season.ID = nested.Varint
case 2:
season.Start = nested.Varint
case 3:
season.End = nested.Varint
}
}
case 2:
season.Hunt = value.Varint
case 4:
season.Calculate = value.Varint
case 6:
season.Independent = value.Varint != 0
case 7:
season.RankGroup = value.Varint
}
}
seasons = append(seasons, season)
}
return seasons
}
+140
View File
@@ -0,0 +1,140 @@
package app
import (
"bd2server/internal/server/domain/command"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/domain/mail"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/roster/deck"
"bd2server/internal/server/domain/world/progress"
"bd2server/internal/server/storage/account"
"fmt"
"path/filepath"
)
func (p *playerAssembly) assets(ctx command.Context) error {
var err error
p.startingPackID, err = ctx.State.(*accountstate.CommandStore).LockStartingPack(p.options.gameRules.Story.StartPackID, p.initializeAccount)
if err != nil {
return fmt.Errorf("server starting chapter policy: %w", err)
}
p.progressState, err = progress.OpenStore(ctx, p.scope)
if err != nil {
return err
}
p.deckStateStore, err = deck.OpenStore(ctx, p.scope, p.deckSeed, *p.design.presetDesign)
if err != nil {
return fmt.Errorf("load deck state: %w", err)
}
if err := p.login.AttachPresetSlots(p.deckStateStore); err != nil {
return fmt.Errorf("attach preset slots to login: %w", err)
}
if err := p.login.AttachPortrait(p.deckStateStore); err != nil {
return fmt.Errorf("attach persisted portrait to login: %w", err)
}
p.ownedItems, err = assets.OpenInventory(ctx, p.scope, p.starter.Items)
if err != nil {
return fmt.Errorf("load owned inventory: %w", err)
}
if err := p.ownedItems.AttachItemStacks(p.design.itemStacks); err != nil {
return err
}
p.recipeService, err = assets.NewRecipeService(p.design.recipeDesign, p.starter.CookingRecipes, p.ownedItems)
if err != nil {
return fmt.Errorf("load learned recipes: %w", err)
}
if err := p.ownedItems.AttachRandomBoxes(ctx, p.design.randomBoxes); err != nil {
return fmt.Errorf("attach random-box GameData: %w", err)
}
gold, freeJewelry, jewelry, mileage, err := p.login.SeedCurrencies()
if err != nil {
return fmt.Errorf("read account seed currency: %w", err)
}
hopePowder, err := p.login.SeedHopePowder()
if err != nil {
return fmt.Errorf("read account seed hope powder: %w", err)
}
catalyst, err := p.login.SeedCatalyst()
if err != nil {
return fmt.Errorf("read account seed catalyst: %w", err)
}
equipMileage, equipMileageExchangeGage, err := p.login.SeedEquipmentMileage()
if err != nil {
return fmt.Errorf("read account seed equipment mileage: %w", err)
}
p.wallet, err = assets.OpenWallet(ctx, p.scope, assets.Currency{
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Catalyst: catalyst, Mileage: mileage, HopePowder: hopePowder,
EquipMileage: equipMileage, EquipMileageExchangeGage: equipMileageExchangeGage,
})
if err != nil {
return fmt.Errorf("load wallet state: %w", err)
}
if err := p.login.AttachCurrencies(p.wallet); err != nil {
return fmt.Errorf("attach wallet to login: %w", err)
}
itemSlots, storageSlots, equipmentInventorySlots, equipmentStorageSlots, err := p.login.SeedInventorySlots()
if err != nil {
return fmt.Errorf("read account seed inventory slots: %w", err)
}
p.inventorySlots, err = assets.OpenInventorySlots(ctx, p.scope, p.design.slotDesign, assets.InventorySlotCounts{
Items: itemSlots, Storage: storageSlots, Equipment: equipmentInventorySlots, EquipmentStorage: equipmentStorageSlots,
}, p.wallet)
if err != nil {
return fmt.Errorf("load inventory slot state: %w", err)
}
p.inventorySlots.AttachDevelopmentSettings(p.options.devToolsConfig)
if err := p.login.AttachInventorySlots(p.inventorySlots); err != nil {
return fmt.Errorf("attach inventory slots to login: %w", err)
}
p.mailService, err = mail.OpenService(ctx, p.scope, p.mailbox, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load mail state: %w", err)
}
if err := p.mailService.AttachContentTickets(ctx, p.design.contentTickets); err != nil {
return fmt.Errorf("attach mailbox content tickets: %w", err)
}
if err := p.mailService.AttachSeedPath(ctx, filepath.Clean(p.options.mailSeed)); err != nil {
return fmt.Errorf("watch mail seed: %w", err)
}
if p.options.mailGrantSpool != "" {
if err := p.mailService.AttachGrantSpoolPath(ctx, p.options.mailGrantSpool); err != nil {
return fmt.Errorf("attach mail grant spool: %w", err)
}
}
p.missionService, err = missions.Open(ctx, p.scope, p.design.missionDesign, p.ownedItems)
if err != nil {
return fmt.Errorf("load mission state: %w", err)
}
if err := p.missionService.AttachWallet(ctx, p.wallet); err != nil {
return fmt.Errorf("attach mission wallet: %w", err)
}
if err := p.missionService.AttachUserLevelRewards(ctx, p.design.levelDesign); err != nil {
return fmt.Errorf("attach user level rewards: %w", err)
}
if err := p.login.AttachLevelReward(p.missionService); err != nil {
return fmt.Errorf("attach persisted user level reward: %w", err)
}
if err := p.missionService.AttachMail(ctx, p.mailService); err != nil {
return fmt.Errorf("attach mission compensation mailbox: %w", err)
}
p.ownedEquipment, err = assets.OpenEquipmentInventory(ctx, p.scope)
if err != nil {
return fmt.Errorf("load owned equipment: %w", err)
}
if err := p.ownedEquipment.AttachSlots(ctx, p.design.equipmentSlots); err != nil {
return fmt.Errorf("attach equipment slot GameData: %w", err)
}
if err := p.ownedEquipment.AttachUpgrade(ctx, p.design.equipmentUpgrade, p.wallet, p.ownedItems); err != nil {
return fmt.Errorf("attach equipment upgrade GameData: %w", err)
}
if err := p.ownedEquipment.AttachCraft(ctx, p.design.equipmentCraft); err != nil {
return fmt.Errorf("attach equipment crafting GameData: %w", err)
}
if err := p.ownedEquipment.AttachSmelting(ctx, p.design.equipmentSmelting, p.wallet, p.ownedItems); err != nil {
return fmt.Errorf("attach equipment smelting GameData: %w", err)
}
if err := p.ownedEquipment.AttachOptionReroll(ctx, p.design.equipmentOptionReroll, p.wallet, p.ownedItems); err != nil {
return fmt.Errorf("attach equipment option reroll GameData: %w", err)
}
return nil
}
+133
View File
@@ -0,0 +1,133 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce"
"bd2server/internal/server/domain/events/tasks"
"bd2server/internal/server/protocol/wire"
"fmt"
"time"
)
func (p *playerAssembly) commerce(ctx command.Context) error {
var err error
p.cashCatalog = p.design.cashCatalog
p.cashEconomy, err = commerce.NewEntitlementEconomy(ctx, p.gameplayStore, p.eventEconomy, p.design.cashRewards, p.ownedItems, p.design.cashEntitlementDesign)
if err != nil {
return fmt.Errorf("load cash entitlements: %w", err)
}
p.cashEconomy.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
if err := p.mailService.AttachCashRewards(ctx, p.cashEconomy, p.design.cashMailTemplates); err != nil {
return err
}
if err := p.cashEconomy.AttachCashMail(p.mailService); err != nil {
return err
}
p.cashService, err = commerce.NewService(ctx, p.cashCatalog, p.gameplayStore, p.cashEconomy)
if err != nil {
return fmt.Errorf("load cash purchase state: %w", err)
}
p.clearPackages, err = commerce.NewClearPackages(ctx, p.gameplayStore, p.design.clearPackageDesign, p.cashEconomy, p.ownedItems)
if err != nil {
return fmt.Errorf("load clear-package claims: %w", err)
}
p.clearPackages.AttachProgress(p.worldService.CashPackagePackCleared, nil)
p.cashService.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
if err := p.cashService.AttachPackageRules(p.design.cashDesign.Packages); err != nil {
return fmt.Errorf("attach cash package progression: %w", err)
}
if err := p.cashService.AttachShopSeed(ctx, p.seeds.defaults); err != nil {
return fmt.Errorf("attach cash product availability: %w", err)
}
if err := p.cashService.AttachEventShopSchedules(p.design.cashDesign, p.options.calendars.Events); err != nil {
return fmt.Errorf("attach event shop availability: %w", err)
}
p.cashBonuses, err = commerce.NewCashBonuses(ctx, p.gameplayStore, p.cashEconomy, p.cashService, p.design.cashBonusDesign, p.design.cashDesign.Packages)
if err != nil {
return fmt.Errorf("load cash bonus claims: %w", err)
}
p.cashService.AttachLegacyCounts(p.gachaService)
cashSpecialProducts := []gamedata.CashProductKey{{GroupID: p.design.infiniteGacha.ProductGroupID, ProductID: p.design.infiniteGacha.ProductID, SaleGroup: p.design.infiniteGacha.SaleGroup}}
for _, group := range p.design.regularGacha.Groups() {
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
cashSpecialProducts = append(cashSpecialProducts, gamedata.CashProductKey{GroupID: group.CashProductGroupID, ProductID: group.CashProductID, SaleGroup: group.CashSalesGroup})
}
}
if err := p.cashService.AttachSpecialProducts(cashSpecialProducts); err != nil {
return fmt.Errorf("attach special cash products: %w", err)
}
p.cashService.AttachDelegate(func(ctx command.Context, key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
known := key.GroupID == p.design.infiniteGacha.ProductGroupID && key.ProductID == p.design.infiniteGacha.ProductID && key.SaleGroup == p.design.infiniteGacha.SaleGroup
for _, group := range p.design.regularGacha.Groups() {
if key.GroupID == group.CashProductGroupID && key.ProductID == group.CashProductID && key.SaleGroup == group.CashSalesGroup {
known = true
break
}
}
if !known {
return nil, false, nil
}
_, response, handled, err := p.gachaService.Handle(ctx, "/CashShopBuy", request)
if err != nil || !handled {
return nil, handled, err
}
bundle, _, err := wire.Bytes(response, 1)
return bundle, true, err
})
if err := p.login.AttachPurchaseCounts(p.cashService); err != nil {
return fmt.Errorf("attach cash purchase counts: %w", err)
}
p.eventTasksService, err = eventtasks.Open(ctx, p.gameplayStore, p.design.eventTasksDesign, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event tasks state: %w", err)
}
if err := p.mailService.AttachAttendanceRewardEconomy(ctx, p.eventEconomy); err != nil {
return fmt.Errorf("attach attendance mail rewards: %w", err)
}
p.eventTasksService.AttachAttendanceMail(p.mailService)
newbieStep, _, err := wire.Varint(p.login.UserInfo, 39)
if err != nil {
return err
}
if err = p.eventTasksService.SetNewbieStep(ctx, newbieStep); err != nil {
return err
}
if err = p.login.AttachNewbieStep(p.eventTasksService); err != nil {
return err
}
p.eventTasksService.AttachCashAuthorization(func(ctx command.Context, passID, buyType uint64) bool {
for _, buy := range p.design.eventTasksDesign.PassBuys[passID] {
if buy.Type == buyType && buy.CashID != 0 {
return p.cashService.ConsumeEntitlement(ctx, gamedata.CashProductKey{GroupID: buy.CashGroup, ProductID: buy.CashID, SaleGroup: buy.CashSales})
}
}
return false
})
p.eventTasksService.AttachAttendancePremium(func(ctx command.Context, ticket uint64) bool {
for _, item := range p.ownedItems.All(ctx) {
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(time.Now().UnixMilli())) {
return true
}
}
return false
})
p.loginPasses, err = commerce.NewLoginPasses(ctx, p.gameplayStore, p.design.loginPassDesign, p.cashEconomy, p.ownedItems, func(ctx command.Context, group uint64) bool {
for _, pack := range p.design.cashDesign.Packages {
if pack.PackageType == 7 && pack.ID == group && p.cashService.IsAvailable(ctx, gamedata.CashProductKey{GroupID: pack.GroupID, ProductID: pack.ID, SaleGroup: pack.SaleGroup}) {
return true
}
}
return false
})
if err != nil {
return fmt.Errorf("load login-pass progress: %w", err)
}
p.loginPasses.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
p.eventTasksService.AttachUnlockResolver(p.worldService.MissionsUnlocked)
if err = p.missionService.AttachEventHandler(ctx, p.eventTasksService); err != nil {
return fmt.Errorf("attach mission event handler: %w", err)
}
return nil
}
+184
View File
@@ -0,0 +1,184 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/events"
"bd2server/internal/server/domain/events/actions"
"bd2server/internal/server/domain/events/exchange"
"bd2server/internal/server/domain/events/games"
"bd2server/internal/server/domain/events/play"
"bd2server/internal/server/domain/events/tasks"
"bd2server/internal/server/domain/progression/achievements"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/roster"
"fmt"
"log/slog"
"path/filepath"
)
func (p *playerAssembly) events(ctx command.Context) error {
var err error
p.eventGamesService, err = eventgames.Open(ctx, p.gameplayStore, p.design.source.EventGame, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event games state: %w", err)
}
p.eventExchangeService, err = eventexchange.Open(ctx, p.gameplayStore, p.design.eventExchangeDesign, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event exchange state: %w", err)
}
p.boxService, err = events.OpenBoxes(p.gameplayStore, p.ownedItems, p.eventEconomy)
if err != nil {
return fmt.Errorf("load random box state: %w", err)
}
p.eventPlayService, err = eventplay.Open(ctx, p.gameplayStore, p.design.eventPlay, func(id uint64) (*gamedata.EventField, error) {
return p.design.source.EventField(p.design.eventPlay, id)
}, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event play state: %w", err)
}
p.eventPlayService.AttachBattleChallenges(p.design.eventBattleChallenges)
p.eventPlayService.AttachHubCalendars(p.seeds.defaults)
if err := p.eventPlayService.AttachFieldBindingsFile(filepath.Join(filepath.Dir(p.options.worldSeed), "event_field_bindings.json")); err != nil {
return fmt.Errorf("attach hidden field bindings: %w", err)
}
if err := p.worldService.AttachEventFieldPacks(p.eventPlayService); err != nil {
return fmt.Errorf("attach event field packs: %w", err)
}
p.battleService.AttachEventBattle(p.eventPlayService)
p.eventActionsService, err = eventactions.Open(ctx, p.gameplayStore, p.design.eventActionsDesign, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event action state: %w", err)
}
if err = p.eventActionsService.AttachMiniContent(ctx, p.eventPlayService, p.design.miniContent); err != nil {
return fmt.Errorf("attach mini event content: %w", err)
}
p.eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 {
for _, entry := range p.collection.FriendshipEntries() {
if entry.State != nil && entry.State.CostumeID == id {
return entry.State.Level
}
}
return 0
})
p.eventActionsService.AttachChargeInfo(func(ctx command.Context) ([]byte, error) {
rows, err := p.eventEconomy.ChargeInfo(ctx)
if err != nil {
return nil, err
}
huntingRows, err := p.huntingService.APChargeInfo(ctx)
if err != nil {
return nil, err
}
return append(rows, huntingRows...), nil
})
p.eventActionsService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
if err := p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked); err != nil {
return err
}
return p.eventTasksService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
})
p.eventPlayService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
return p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
})
p.eventTasksService.AttachAssociatedMissionGroup(func(schedule events.Schedule) uint64 {
if group := p.eventActionsService.AssociatedMissionGroup(schedule); group != 0 {
return group
}
group, err := p.eventPlayService.AssociatedMissionGroup(schedule)
if err != nil {
slog.Error("event mission design unavailable", "event_uid", schedule.UID, "event_id", schedule.ID, "error", err)
}
return group
})
p.battleService.AttachEventBattle(p.eventActionsService)
p.battleService.AttachCurrentDifficulty(p.worldService.CurrentQuestDifficulty)
p.battleService.AttachQuestBattleValidation(p.worldService.ValidateQuestBattle)
p.battleService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
})
if err := p.worldService.AttachBattleActive(p.battleService.Active); err != nil {
return fmt.Errorf("attach world battle guard: %w", err)
}
p.monsterHuntService, err = monsterhunt.Open(ctx, p.gameplayStore, p.design.monsterHunt, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load monster hunt state: %w", err)
}
if err := p.monsterHuntService.AttachPresetRuntime(ctx, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
return fmt.Errorf("attach monster hunt preset ownership: %w", err)
}
if err := p.login.AttachMonsterHuntSlots(p.monsterHuntService); err != nil {
return fmt.Errorf("attach monster hunt preset slots: %w", err)
}
p.battleService.AttachMonsterHunt(p.monsterHuntService)
p.monsterHuntService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
})
p.recruitService, err = roster.NewRecruitService(&p.design.recruitDesign, &p.design.recruitDesign, p.collection, p.ownedItems, p.wallet,
func(ctx command.Context, npcID uint64) (uint64, error) {
return p.worldService.ResolveRecruitNPC(ctx, npcID, p.design.source, &p.design.recruitDesign)
})
if err != nil {
return fmt.Errorf("load recruitment service: %w", err)
}
p.battleService.AttachCommittedHealth(func(ctx command.Context, health map[uint64]uint64) error {
for index, hp := range health {
maximum, err := p.worldService.CharacterService().MaxHealth(ctx, index)
if err != nil {
return fmt.Errorf("invalid completed battle health for character %d: %w", index, err)
}
if hp > maximum {
// Battle-only HP buffs are not persisted into field health.
// This is our settlement policy, not an inferred provider rule.
health[index] = maximum
}
}
for index, hp := range health {
if err := p.worldService.CharacterService().SetCurrentHealth(ctx, index, hp); err != nil {
return err
}
}
return nil
})
p.foodService, err = roster.OpenFoodService(ctx, p.scope, p.design.foodDesign, p.ownedItems, p.worldService.CharacterService())
if err != nil {
return fmt.Errorf("load food state: %w", err)
}
if err := p.foodService.AttachContext(ctx, p.worldService.CurrentPackID, p.battleService.Active); err != nil {
return err
}
p.battleService.AttachMonsterWinMission(func(ctx command.Context) error {
return p.missionService.CompleteSingleTargetEvent(ctx, missions.ConditionMonsterKill, p.worldService.MissionsUnlocked)
})
p.battleService.AttachPictorialBuffs(func(ctx command.Context) ([]gamedata.PictorialBuffStat, error) {
_, buffs, err := p.pictorialService.Snapshot(ctx)
return buffs, err
})
p.achievementCounters, err = achievements.NewAchievementService(p.design.achievementCounterDesign, p.scope, p.missionService)
if err != nil {
return fmt.Errorf("load achievement counter state: %w", err)
}
if err := p.missionService.AttachAchievementProgress(ctx, p.achievementCounters); err != nil {
return fmt.Errorf("attach achievement completion validation: %w", err)
}
p.commissionService.CompleteAchievement = func(ctx command.Context, identity string) error {
_, err := p.achievementCounters.RecordEvent(ctx, identity, 17, 0, 1)
return err
}
if err := p.login.AttachAchievementExperience(p.missionService); err != nil {
return fmt.Errorf("attach persisted achievement experience: %w", err)
}
achievementProvider := p.worldService.GameplayAchievementProvider(ctx, p.design.achievementCounterDesign, p.design.achievementGrades)
achievementProvider.StateVersion = p.stateRepository.ObservationVersion
p.achievementObserver, err = achievements.NewGameplayAchievementObserver(p.achievementCounters, achievementProvider)
if err != nil {
return fmt.Errorf("initialize achievement gameplay observer: %w", err)
}
if err := p.achievementObserver.SyncRecordedHistory(ctx); err != nil {
return fmt.Errorf("restore recorded achievement history: %w", err)
}
p.eventTasksService.AttachInventoryProvider(&eventtasks.InventoryProjection{Items: p.ownedItems, Equipment: p.ownedEquipment, Costumes: p.collection, StateVersion: p.stateRepository.ObservationVersion})
return nil
}
+211
View File
@@ -0,0 +1,211 @@
package app
import (
"bd2server/internal/server/domain/command"
"bd2server/internal/server/runtime/player"
accountstate "bd2server/internal/server/storage/account"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"path/filepath"
"time"
)
type commandReceipt struct {
Digest string `json:"digest"`
Responses []player.Response `json:"responses"`
}
func (p *playerInstance) Execute(ctx context.Context, request player.Command) (reply player.Reply, result error) {
if request.Identity.AccountID != p.accountID || p.assembly == nil {
return reply, player.Failure{Cause: player.ErrUnavailable, RecoveryRequired: true}
}
started := time.Now()
tx, err := p.repository.BeginCommand(ctx)
reply.Timing.Begin = time.Since(started)
if err != nil {
return reply, player.Failure{Cause: err, RecoveryRequired: true}
}
finished := false
defer func() {
if !finished {
started := time.Now()
result = errors.Join(result, tx.Rollback())
reply.Timing.Rollback += time.Since(started)
p.assembly = nil
if result != nil {
result = player.Failure{Cause: result, RecoveryRequired: true}
}
}
}()
identity := command.Context{Identity: request.Identity, Cancellation: request.Cancellation, State: tx}
isLogin := len(request.Requests) == 1 && request.Requests[0].Path == "/LoginUser"
receiptKey := request.Identity.SessionID + "/" + request.Identity.RequestID
if !isLogin {
raw, found, err := tx.LoadEntry("missions", "command_receipts", receiptKey)
if err != nil {
return reply, err
}
if found {
var receipt commandReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
return reply, player.Failure{Cause: fmt.Errorf("invalid command receipt: %w", err), RecoveryRequired: true}
}
if receipt.Digest != hex.EncodeToString(request.Digest[:]) {
finished = true
if err := tx.Rollback(); err != nil {
p.assembly = nil
return reply, player.Failure{Cause: errors.Join(player.ErrIdentityConflict, err), RecoveryRequired: true}
}
return reply, player.ErrIdentityConflict
}
reply.Responses = receipt.Responses
finished = true
if err := tx.Rollback(); err != nil {
p.assembly = nil
return reply, player.Failure{Cause: err, RecoveryRequired: true}
}
return reply, nil
}
}
assembly := p.assembly
transientBefore := assembly.transientVersion()
for _, item := range request.Requests {
var response player.Response
if isLogin {
started := time.Now()
err = assembly.beginLogin(identity)
if err == nil {
response.Body, err = assembly.login.Login(identity, item.Body, request.LoginSessionKey)
response.PacketCode = 3
}
reply.Timing.Execute += time.Since(started)
} else {
started := time.Now()
for _, observer := range assembly.observers {
if err = observer.BeforeDispatch(identity, item.Path, item.Body); err != nil {
break
}
}
reply.Timing.Observer += time.Since(started)
if err == nil {
started = time.Now()
response.PacketCode, response.Body, err = assembly.dispatch(identity, item.Path, item.Body)
reply.Timing.Execute += time.Since(started)
}
if err == nil {
started = time.Now()
for _, observer := range assembly.observers {
var notify []byte
notify, err = observer.AfterDispatch(identity, item.Path, item.Body, response.Body)
if err != nil {
break
}
response.Notification = append(response.Notification, notify...)
}
reply.Timing.Observer += time.Since(started)
}
}
if err != nil {
changed := tx.Dirty() || assembly.transientVersion() != transientBefore
started := time.Now()
rollbackErr := tx.Rollback()
reply.Timing.Rollback += time.Since(started)
finished = true
if rollbackErr != nil {
p.assembly = nil
return reply, player.Failure{Cause: errors.Join(err, rollbackErr), RecoveryRequired: true}
}
if !changed {
return reply, err
}
p.assembly = nil
if recoveryErr := p.Recover(context.WithoutCancel(ctx)); recoveryErr != nil {
return reply, player.Failure{Cause: errors.Join(err, recoveryErr), RecoveryRequired: true}
}
return reply, player.Failure{Cause: err, RecoveryRequired: true, AlreadyRecovered: true}
}
reply.Responses = append(reply.Responses, response)
}
if tx.Dirty() && !isLogin {
raw, err := json.Marshal(commandReceipt{Digest: hex.EncodeToString(request.Digest[:]), Responses: reply.Responses})
if err != nil {
return reply, err
}
if err := tx.PutEntry("missions", "command_receipts", receiptKey, raw); err != nil {
return reply, err
}
}
started = time.Now()
err = tx.Commit()
reply.Timing.Commit += time.Since(started)
finished = true
if err != nil {
p.assembly = nil
return reply, player.Failure{Cause: err, RecoveryRequired: true}
}
return reply, nil
}
func (p *playerAssembly) dispatch(ctx command.Context, path string, request []byte) (int, []byte, error) {
for _, handler := range p.handlers {
code, body, handled, err := handler.Handle(ctx, path, request)
if handled || err != nil {
return code, body, err
}
}
return 0, nil, fmt.Errorf("packet not implemented: %s", path)
}
func (p *playerAssembly) beginLogin(ctx command.Context) error {
for _, observer := range p.observers {
if hook, ok := observer.(interface{ BeginLogin(command.Context) }); ok {
hook.BeginLogin(ctx)
}
}
for _, handler := range p.handlers {
if hook, ok := handler.(interface{ BeginLogin(command.Context) }); ok {
hook.BeginLogin(ctx)
}
}
return p.missionService.RecordLogin(ctx, p.worldService.MissionsUnlocked)
}
func (p *playerInstance) Recover(ctx context.Context) error {
if err := p.repository.Check(); err != nil {
if errors.Is(err, accountstate.ErrFenced) {
return err
}
if closeErr := p.repository.Close(); closeErr != nil {
return errors.Join(err, closeErr)
}
path := filepath.Join(p.factory.options.stateDirectory, "accounts", accountDirectoryName(p.accountID), "state.db")
repository, openErr := accountstate.Open(path)
if openErr != nil {
return errors.Join(err, openErr)
}
p.repository = repository
}
assembly, err := p.factory.assemble(ctx, p.accountID, p.repository)
if err != nil {
return err
}
p.assembly = assembly
slog.Warn("player state recovered from committed snapshot", "account_id", p.accountID)
return nil
}
func (p *playerInstance) Close() error {
p.assembly = nil
return p.repository.Close()
}
func (p *playerAssembly) transientVersion() [4]uint64 {
return [4]uint64{p.battleService.TransientVersion(), p.monsterHuntService.TransientVersion(), p.gachaService.TransientVersion(), p.worldService.TransientVersion()}
}
+218
View File
@@ -0,0 +1,218 @@
package app
import (
"bd2server/internal/server/domain/battle"
"bd2server/internal/server/domain/battle/hunting"
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce"
"bd2server/internal/server/domain/commerce/gacha"
"bd2server/internal/server/domain/commerce/npcinn"
"bd2server/internal/server/domain/commerce/npcshop"
"bd2server/internal/server/domain/events"
"bd2server/internal/server/domain/events/actions"
"bd2server/internal/server/domain/events/exchange"
"bd2server/internal/server/domain/events/games"
"bd2server/internal/server/domain/events/play"
"bd2server/internal/server/domain/events/tasks"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/domain/mail"
"bd2server/internal/server/domain/progression/achievements"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/progression/pictorial"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/roster/deck"
"bd2server/internal/server/domain/world"
"bd2server/internal/server/domain/world/progress"
"bd2server/internal/server/domain/world/todayquest"
"bd2server/internal/server/gateway/session"
loginprotocol "bd2server/internal/server/protocol/login"
"bd2server/internal/server/protocol/wire"
"bd2server/internal/server/storage/account"
identitystore "bd2server/internal/server/storage/identity"
"bd2server/internal/server/storage/stateio"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
)
type PlayerFactory struct {
options *configuration
design *designCatalog
seeds *seedCatalog
profiles interface {
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
}
}
type playerAssembly struct {
worldSeed world.Seed
mailbox *mail.Starter
deckSeed deck.Seed
*PlayerFactory
progressState *progress.Store
deckStateStore *deck.Store
ownedItems *assets.Inventory
recipeService *assets.RecipeService
wallet *assets.Wallet
inventorySlots *assets.InventorySlots
mailService *mail.Service
missionService *missions.Service
ownedEquipment *assets.EquipmentInventory
worldService *world.Service
collection *roster.CollectionStore
gachaService *gacha.Service
pictorialService *pictorial.Service
charAwakeService *roster.CharAwakeService
costumePotentialService *roster.CostumePotentialService
costumeBurstService *roster.CostumeBurstService
friendshipService *roster.FriendshipService
masterTitleService *roster.MasterTitleService
battleService *battle.Service
gameplayStore stateio.EntrySnapshotStore
contentOpenService *assets.ContentOpenService
huntingService *hunting.Service
eventRegistry *events.Registry
eventEconomy *events.Economy
talentUseService *roster.TalentUseService
dispatchService *roster.TalentDispatchService
itemCraftService *roster.ItemCraftService
innService *npcinn.Service
npcShopService *npcshop.Service
commissionService *todayquest.Service
buffRewards *events.BuffRewards
cashCatalog *commerce.Catalog
cashEconomy *commerce.EntitlementEconomy
cashService *commerce.Service
clearPackages *commerce.ClearPackages
cashBonuses *commerce.CashBonuses
eventTasksService *eventtasks.Service
loginPasses *commerce.LoginPasses
eventGamesService *eventgames.Service
eventExchangeService *eventexchange.Service
boxService *events.BoxService
eventPlayService *eventplay.Service
eventActionsService *eventactions.Service
monsterHuntService *monsterhunt.Service
recruitService *roster.RecruitService
foodService *roster.FoodService
achievementCounters *achievements.AchievementService
achievementObserver *achievements.GameplayAchievementObserver
handlers []session.Handler
observers []session.ResponseObserver
stateRepository *accountstate.Repository
scope stateio.RootStore
startingPackID int
initializeAccount bool
login *loginprotocol.LoginSeed
starter *roster.Starter
}
type playerInstance struct {
accountID string
factory *PlayerFactory
repository *accountstate.Repository
assembly *playerAssembly
}
func (f *PlayerFactory) open(accountID string) (instance *playerInstance, openErr error) {
statePath := filepath.Join(f.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
return nil, err
}
repository, err := accountstate.Open(statePath)
if err != nil {
return nil, err
}
defer func() {
if recovered := recover(); recovered != nil {
openErr = fmt.Errorf("initialize player panic: %v", recovered)
instance = nil
}
if openErr != nil {
openErr = errors.Join(openErr, repository.Close())
}
}()
assembly, err := f.assemble(context.Background(), accountID, repository)
if err != nil {
return nil, err
}
return &playerInstance{accountID: accountID, factory: f, repository: repository, assembly: assembly}, nil
}
func (f *PlayerFactory) assemble(ctx context.Context, accountID string, repository *accountstate.Repository) (_ *playerAssembly, openErr error) {
login := &loginprotocol.LoginSeed{Version: f.seeds.login.Version, PacketCode: f.seeds.login.PacketCode,
UserInfo: slices.Clone(f.seeds.login.UserInfo), ResponseFields: slices.Clone(f.seeds.login.ResponseFields)}
if f.profiles != nil {
profile, err := f.profiles.GameIdentity(ctx, accountID)
if err != nil {
return nil, err
}
login.UserInfo, _, err = wire.ReplaceVarint(login.UserInfo, 1, uint64(profile.OwnerIndex))
if err != nil {
return nil, err
}
login.UserInfo, _, err = wire.ReplaceBytes(login.UserInfo, 2, []byte(profile.UserID))
if err != nil {
return nil, err
}
}
starter, err := cloneSeed(f.seeds.starter)
if err != nil {
return nil, err
}
mailbox, err := cloneSeed(f.seeds.mailbox)
if err != nil {
return nil, err
}
deckSeed, err := cloneSeed(&f.seeds.deck)
if err != nil {
return nil, err
}
worldSeed, err := cloneSeed(&f.seeds.world)
if err != nil {
return nil, err
}
p := &playerAssembly{PlayerFactory: f, stateRepository: repository, login: login, starter: starter, mailbox: mailbox, deckSeed: *deckSeed, worldSeed: *worldSeed}
accountDomains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"}
initializationState, err := p.stateRepository.InitializationState(accountDomains...)
if err != nil {
return nil, fmt.Errorf("reject incomplete account database: %w", err)
}
if initializationState == accountstate.InitializationCorrupt {
return nil, errors.New("reject incomplete account database: corrupt initialization state")
}
p.initializeAccount = initializationState == accountstate.InitializationPending
startupTransaction, err := p.stateRepository.BeginCommand(ctx)
if err != nil {
return nil, fmt.Errorf("begin startup state transaction: %w", err)
}
startupCommitted := false
defer func() {
if startupCommitted {
return
}
if rollbackErr := startupTransaction.Rollback(); rollbackErr != nil {
openErr = errors.Join(openErr, rollbackErr)
}
}()
identity := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "startup", RequestID: "initialize"}, Cancellation: ctx, State: startupTransaction}
for _, assemble := range []func(command.Context) error{p.assets, p.worldRoster, p.gameplay, p.commerce, p.events, p.session} {
if err := assemble(identity); err != nil {
return nil, err
}
}
if err := startupTransaction.Commit(); err != nil {
return nil, err
}
startupCommitted = true
if err := repository.Check(); err != nil {
return nil, err
}
return p, nil
}
+166
View File
@@ -0,0 +1,166 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/battle"
"bd2server/internal/server/domain/battle/hunting"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce/npcinn"
"bd2server/internal/server/domain/commerce/npcshop"
"bd2server/internal/server/domain/events"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/world/todayquest"
"bd2server/internal/server/protocol/wire"
"bd2server/internal/server/storage/stateio"
"fmt"
)
func (p *playerAssembly) gameplay(ctx command.Context) error {
var err error
p.battleService = battle.NewService(p.options.gameData, p.options.gameDataVersion, p.worldService.CurrentPackID)
freeHuntingAP, bonusHuntingAP, err := p.login.SeedHuntingAP()
if err != nil {
return fmt.Errorf("read initial hunting AP: %w", err)
}
p.gameplayStore = stateio.EntrySnapshotStore{Domain: "missions", Bucket: "gameplay"}
if err := p.costumePotentialService.AttachConnectStore(p.gameplayStore); err != nil {
return fmt.Errorf("attach costume potential connection state: %w", err)
}
if err := p.worldService.AttachFieldMonsterState(p.gameplayStore); err != nil {
return fmt.Errorf("attach field monster state: %w", err)
}
p.battleService.AttachFieldMonsters(p.worldService)
p.battleService.AttachFieldBuffConsume(p.worldService.ConsumeFieldBattleBuff)
if err := p.worldService.AttachFieldBuffRuntime(p.design.fieldBuffs); err != nil {
return fmt.Errorf("attach field monster damage: %w", err)
}
p.contentOpenService, err = assets.NewContentOpenService(ctx, p.design.contentOpeningDesign, p.ownedItems, p.gameplayStore, func() (uint64, error) {
experience, err := p.missionService.AchievementExperience()
if err != nil {
return 0, err
}
return p.design.levelDesign.Level(experience), nil
})
if err != nil {
return fmt.Errorf("load content opening state: %w", err)
}
p.huntingService, err = hunting.Open(ctx, p.gameplayStore, p.design.source, p.ownedItems, p.wallet,
p.worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP)
if err != nil {
return fmt.Errorf("load hunting state: %w", err)
}
if err := p.login.AttachHuntingAP(p.huntingService); err != nil {
return fmt.Errorf("attach persisted hunting AP: %w", err)
}
if err = p.huntingService.AttachAPRefresh(p.design.huntingAPDesign); err != nil {
return err
}
p.battleService.AttachHunting(p.huntingService)
p.huntingService.AttachEligibility(p.worldService.HuntingEligibility)
if err := p.worldService.AttachHuntingGround(p.huntingService); err != nil {
return err
}
p.eventRegistry = events.NewRegistry()
if err := p.eventRegistry.Replace(p.options.calendars.Events); err != nil {
return err
}
initialEventCurrency := map[uint64]uint64{}
for itemType, field := range events.AdditionalCurrencyFields {
value, _, readErr := wire.Varint(p.login.UserInfo, field)
if readErr != nil {
return readErr
}
initialEventCurrency[itemType] = value
}
p.eventEconomy, err = events.NewEconomy(ctx, p.gameplayStore, p.ownedItems, p.wallet, p.collection, p.ownedEquipment, p.design.rewardCostumes, p.design.rewardEquipment, p.design.rewardGraph, initialEventCurrency)
if err != nil {
return fmt.Errorf("load event economy: %w", err)
}
p.eventEconomy.AttachHuntingAP(p.huntingService)
p.talentUseService, err = roster.NewTalentUseService(p.design.talentUseDesign, p.gameplayStore, p.worldService.CharacterService(), p.ownedItems, p.wallet, p.eventEconomy)
if err != nil {
return fmt.Errorf("load field talent state: %w", err)
}
p.talentUseService.AttachContext(ctx, p.worldService.TalentFieldContext)
if err := p.worldService.AttachAutoRecoveryPolicy(p.design.recovery); err != nil {
return fmt.Errorf("attach automatic recovery policy: %w", err)
}
p.deckStateStore.AttachAutoRecoveryAllowed(p.worldService.AutoRecoveryAllowed)
p.deckStateStore.AttachAutoRecovery(p.talentUseService.AutoRecover)
p.worldService.AttachTalentPackInfo(p.talentUseService.PackInfo)
p.worldService.AttachOverwhelmAuthorization(p.talentUseService.ConsumeOverwhelm)
p.worldService.AttachOverwhelmHunting(p.huntingService)
if err := p.worldService.AttachOverwhelmDesign(p.design.source, p.design.overwhelmSky); err != nil {
return fmt.Errorf("attach overwhelm design: %w", err)
}
p.talentUseService.AttachEffect(4, p.worldService.ApplyTalentFieldAbsorb)
p.talentUseService.AttachEffect(20, p.worldService.ApplyTalentMonsterSummon)
p.dispatchService, err = roster.OpenTalentDispatch(p.gameplayStore, p.design.dispatchDesign, p.eventEconomy)
if err != nil {
return fmt.Errorf("load talent dispatch state: %w", err)
}
p.talentUseService.AttachEffect(18, p.dispatchService.Start)
p.itemCraftService, err = roster.NewItemCraftService(p.design.itemCraftDesign, p.design.talentUseDesign, p.gameplayStore, p.ownedItems, p.worldService.CharacterService(), p.wallet, p.recipeService.Knows)
if err != nil {
return fmt.Errorf("load item crafting state: %w", err)
}
p.itemCraftService.AttachContext(ctx, func(ctx command.Context) (int, bool, error) {
pack, err := p.worldService.CurrentPackID(ctx)
return pack, p.battleService.Active(ctx), err
})
if err := p.worldService.ConfigureNPCRuntime(p.design.source, p.gameplayStore); err != nil {
return fmt.Errorf("configure NPC world runtime: %w", err)
}
p.innService, err = npcinn.New(p.gameplayStore, p.worldService.CharacterService(), p.wallet, p.worldService.InnContext,
func(ctx command.Context) (uint64, error) {
experience, err := p.missionService.AchievementExperience()
if err != nil {
return 0, err
}
return p.design.levelDesign.Level(experience), nil
}, p.battleService.Active)
if err != nil {
return fmt.Errorf("load inn recovery: %w", err)
}
p.npcShopService, err = npcshop.New(ctx, p.design.npcShopDesign, p.gameplayStore, p.eventEconomy, p.ownedItems, p.worldService.PackAvailable)
if err != nil {
return fmt.Errorf("load NPC shop state: %w", err)
}
p.npcShopService.SetReputationSource(p.worldService.NPCShopReputation)
p.npcShopService.SetTalentDiscountSource(p.talentUseService.ShopDiscount)
p.commissionService, err = todayquest.Open(p.gameplayStore, p.design.commissionDesign, p.eventEconomy, p.ownedItems, p.worldService.CommissionPackUnlocked)
if err != nil {
return fmt.Errorf("load NPC commission state: %w", err)
}
p.commissionService.CompleteReputation = p.worldService.CompleteNPCReputation
if err := p.worldService.AttachTodayQuests(p.commissionService); err != nil {
return fmt.Errorf("attach NPC commissions: %w", err)
}
if err = p.worldService.AttachResearchRuntime(p.design.source, p.design.researchCharacters, p.eventEconomy); err != nil {
return fmt.Errorf("attach field research: %w", err)
}
p.eventEconomy.AttachPrestigeSkins(p.design.prestigeCatalog.Skins)
p.eventEconomy.AttachPrestigePortrait(p.deckStateStore.PortraitCostume)
if err := p.worldService.AttachPrestigeSelections(ctx, p.eventEconomy.PrestigeSkinSelections); err != nil {
return fmt.Errorf("attach prestige skin selections: %w", err)
}
p.eventEconomy.AttachOwnedItemDesign(p.design.ownedEventItems)
p.eventEconomy.AttachAvatarRewards(p.design.avatarRewards)
p.buffRewards, err = events.OpenBuffRewards(ctx, p.gameplayStore, p.design.buffDesign)
if err != nil {
return fmt.Errorf("load permanent buff ownership: %w", err)
}
p.eventEconomy.AttachBuffRewards(p.buffRewards)
p.pictorialService.AttachPermanentBuffs(p.buffRewards.SnapshotBuffs)
if err = p.eventEconomy.AttachAPRefresh(p.design.eventAPCaps, p.design.eventAPReset); err != nil {
return err
}
if err = p.login.AttachAdditionalCurrencies(p.eventEconomy); err != nil {
return err
}
p.huntingService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
})
return nil
}
@@ -0,0 +1,598 @@
//go:build integration
package app
import (
"bd2server/internal/server/design/gameconfig"
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/events/calendar"
"bd2server/internal/server/domain/identity"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/runtime/player"
identitystore "bd2server/internal/server/storage/identity"
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"path/filepath"
"slices"
"testing"
"time"
)
// This integration uses the selected repository GameData and seeds. Missing
// resources are a setup failure, never a silently skipped asset-isolation test.
// Network fields are independently taken from the 2.35.10 client classes:
// DeckSaveRequest/DeckDBInfo, EquipUseRequest, SaveUserPositionRequest,
// MailOpenRequest/RewardDBInfoBundle, CostumeDBInfo and QuestUpdateRequest.
func newIntegrationFactory(t *testing.T) (*PlayerFactory, []string) {
t.Helper()
versions, err := versionconfig.Find()
if err != nil {
t.Fatal(err)
}
versionconfig.Use(versions)
resources := versions.Resolve("data/resources/GameData")
if _, err = gamedata.Validate(resources, versions.GameDataVersion); err != nil {
t.Fatalf("selected GameData required; fetch repository resources before integration tests: %v", err)
}
calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion)
if err != nil {
t.Fatal(err)
}
if err = calendars.ValidateDesign(resources, versions.GameDataVersion); err != nil {
t.Fatal(err)
}
seed := versions.Resolve(versions.SeedDirectory)
root := t.TempDir()
config := &configuration{versions: versions, calendars: calendars, gameRules: gameconfig.Default(), gameData: resources, gameDataVersion: versions.GameDataVersion, stateDirectory: filepath.Join(root, "state"), devToolsConfig: filepath.Join(root, "development.json"), accountSeed: filepath.Join(seed, "login_user.json"), playerSeed: filepath.Join(seed, "starter_player.json"), readonlySeed: filepath.Join(seed, "readonly.json"), mailSeed: filepath.Join(seed, "mail.json"), deckSeed: filepath.Join(seed, "decks.json"), worldSeed: filepath.Join(seed, "world.json")}
if err = lockServerState(config.stateDirectory, config.gameRules.Story.StartPackID); err != nil {
t.Fatal(err)
}
seeds, err := loadSeeds(config)
if err != nil {
t.Fatal(err)
}
design, err := loadDesign(config, seeds)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := gamedata.CloseDatabaseCache(); err != nil {
t.Error(err)
}
})
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
profileStore, err := identitystore.Open(filepath.Join(root, "identity.db"), key)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := profileStore.Close(); err != nil {
t.Error(err)
}
})
registration, err := identity.New(identity.Config{Providers: map[string]string{"discord": "integration-client"}, DeviceTTL: time.Minute, AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour}, profileStore)
if err != nil {
t.Fatal(err)
}
accounts := []string{}
for _, subject := range []string{"11111111", "22222222"} {
device, err := registration.CreateDevice("discord", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
authorization, err := registration.Start("discord", device.ID, device.StartTicket)
if err != nil {
t.Fatal(err)
}
if err = registration.CompleteDevice(authorization.ID, "discord", identity.ProviderIdentity{Issuer: "https://discord.com", Subject: subject}); err != nil {
t.Fatal(err)
}
result, err := registration.Poll(device.ID, device.Secret)
if err != nil {
t.Fatal(err)
}
id, err := registration.ValidateAccess(result.Tokens.AccessToken)
if err != nil {
t.Fatal(err)
}
accounts = append(accounts, id)
}
return &PlayerFactory{options: config, seeds: seeds, design: design, profiles: profileStore}, accounts
}
func appScalar(field int, value uint64) []byte {
return binary.AppendUvarint(binary.AppendUvarint(nil, uint64(field<<3)), value)
}
func appMessage(field int, value []byte) []byte {
p := binary.AppendUvarint(nil, uint64(field<<3|2))
p = binary.AppendUvarint(p, uint64(len(value)))
return append(p, value...)
}
func appRows(body []byte, wanted int) [][]byte {
var rows [][]byte
for len(body) > 0 {
k, n := binary.Uvarint(body)
if n <= 0 {
return nil
}
body = body[n:]
if k&7 == 0 {
_, n = binary.Uvarint(body)
if n <= 0 {
return nil
}
body = body[n:]
continue
}
if k&7 != 2 {
return nil
}
size, n := binary.Uvarint(body)
if n <= 0 || size > uint64(len(body)-n) {
return nil
}
value := body[n : n+int(size)]
if int(k>>3) == wanted {
rows = append(rows, value)
}
body = body[n+int(size):]
}
return rows
}
func appValue(body []byte, wanted int) uint64 {
for len(body) > 0 {
k, n := binary.Uvarint(body)
if n <= 0 {
return 0
}
body = body[n:]
if k&7 == 0 {
v, m := binary.Uvarint(body)
if m <= 0 {
return 0
}
if int(k>>3) == wanted {
return v
}
body = body[m:]
continue
}
if k&7 != 2 {
return 0
}
size, m := binary.Uvarint(body)
if m <= 0 || size > uint64(len(body)-m) {
return 0
}
body = body[m+int(size):]
}
return 0
}
func appPacked(body []byte, field int) []uint64 {
var values []uint64
for _, row := range appRows(body, field) {
for len(row) > 0 {
v, n := binary.Uvarint(row)
if n <= 0 {
return nil
}
values = append(values, v)
row = row[n:]
}
}
return values
}
func appItemTotals(body []byte) map[[2]uint64]uint64 {
totals := map[[2]uint64]uint64{}
for _, row := range appRows(body, 1) {
totals[[2]uint64{appValue(row, 3), appValue(row, 2)}] += appValue(row, 4)
}
return totals
}
func TestTwoRealPlayerBundlesKeepEquipmentFormationAndWorldSeparate(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
aID, bID := accounts[0], accounts[1]
instances := map[string]*playerInstance{}
runtimes := map[string]*player.Runtime{}
var equipmentID uint64
ids := make([]uint64, 0, len(factory.design.equipmentSlots))
for id, slot := range factory.design.equipmentSlots {
if slot == 1 {
ids = append(ids, id)
}
}
slices.Sort(ids)
if len(ids) == 0 {
t.Fatal("real GameData has no weapon slot")
}
equipmentID = ids[0]
equipmentIndex := map[string]uint64{}
characterIndex := map[string]uint64{}
for _, accountID := range accounts {
instance, err := factory.open(accountID)
if err != nil {
t.Fatal("assemble actual account", accountID, err)
}
instances[accountID] = instance
// Establish one owned weapon as an explicit test grant before the actor is
// exposed. Both players may legitimately use equal inventory numbers.
tx, err := instance.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
ctx := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "fixture", RequestID: "weapon"}, State: tx}
equipment, err := instance.assembly.ownedEquipment.GrantOnce(ctx, "test-owned-weapon", equipmentID)
if err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
equipmentIndex[accountID] = equipment.InvenIndex
for _, c := range instance.assembly.worldService.CharacterService().RawAll() {
if !roster.IsStoryCharacter(c) && !roster.IsCharmCharacter(c) && !roster.CharacterExpired(c, time.Now()) {
characterIndex[accountID] = c.InvenIndex
break
}
}
if characterIndex[accountID] == 0 {
t.Fatal("seed has no permanent character")
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
runtime, err := player.New(accountID, instance, player.Limits{CompletedReceipts: 1})
if err != nil {
t.Fatal(err)
}
runtimes[accountID] = runtime
}
t.Cleanup(func() {
for _, runtime := range runtimes {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := runtime.Close(ctx); err != nil {
t.Error(err)
}
cancel()
}
})
makeCommand := func(accountID, identity string, requests []player.Request) player.Command {
content := []byte{}
for _, r := range requests {
content = append(content, []byte(r.Path)...)
content = append(content, r.Body...)
}
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "client-session", RequestID: identity}, Digest: sha256.Sum256(content), Requests: requests}
}
run := func(c player.Command) (player.Reply, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
future, err := runtimes[c.Identity.AccountID].Submit(ctx, c)
if err != nil {
return player.Reply{}, err
}
return future.Wait(ctx)
}
query := func(accountID, path string, sequence uint64) []byte {
t.Helper()
reply, err := run(makeCommand(accountID, fmt.Sprint("query-", path, "-", sequence), []player.Request{{Path: path, Body: appScalar(1, sequence)}}))
if err != nil {
t.Fatal(accountID, path, err)
}
return reply.Responses[0].Body
}
// New-player ownership is instantiated separately even though versioned seeds
// contain the same public design IDs. Mail claims must affect only the claimant.
beforeBCostumes := query(bID, "/CostumeInfo", 100)
beforeBItems := appItemTotals(query(bID, "/ItemInfo", 101))
beforeAItems := appItemTotals(query(aID, "/ItemInfo", 102))
beforeACostumes := query(aID, "/CostumeInfo", 103)
if len(appRows(beforeACostumes, 1)) == 0 || len(appRows(beforeBCostumes, 1)) == 0 {
t.Fatal("new player received no starter costume ownership")
}
owned := map[uint64]bool{}
for _, row := range appRows(beforeACostumes, 1) {
owned[appValue(row, 2)] = true
}
mailbox := query(aID, "/MailInfo", 104)
var costumeMail uint64
expectedCostumes := map[uint64]uint64{}
for _, row := range appRows(mailbox, 1) {
types, ids, counts := appPacked(row, 8), appPacked(row, 9), appPacked(row, 10)
if len(types) != len(ids) || len(types) != len(counts) {
t.Fatal("client mail reward arrays differ in length")
}
candidate := map[uint64]uint64{}
valid := len(types) > 0
for i, kind := range types {
if kind != 11 || counts[i] != 6 || owned[ids[i]] {
valid = false
break
}
candidate[ids[i]] = counts[i] - 1
}
if valid {
costumeMail = appValue(row, 1)
expectedCostumes = candidate
break
}
}
if costumeMail == 0 {
t.Fatal("versioned new-player entitlement has no unowned six-copy costume mail")
}
claim := func(id, seq uint64) player.Command {
return makeCommand(aID, fmt.Sprint("mail-claim-", seq), []player.Request{{Path: "/MailOpen", Body: append(appScalar(1, seq), appScalar(2, id)...)}})
}
costumeClaim := claim(costumeMail, 105)
costumeReply, err := run(costumeClaim)
if err != nil {
t.Fatal("claim real costume entitlement", err)
}
bundles := appRows(costumeReply.Responses[0].Body, 1)
if len(bundles) != 1 {
t.Fatal("mail claim omitted reward bundle")
}
// During the introductory chapter CostumeInfo intentionally shows the story
// roster. MailOpen's RewardDBInfoBundle is the client's ownership update.
for id, level := range expectedCostumes {
found := false
for _, row := range appRows(bundles[0], 3) {
if appValue(row, 2) == id {
found = true
if appValue(row, 3) != level {
t.Fatal("six copies did not yield acquisition plus five enhancements", id, appValue(row, 3), level)
}
}
}
if !found {
t.Fatal("costume reward message omitted listed costume", id)
}
}
collection := instances[aID].assembly.collection.Costumes()
for id, level := range expectedCostumes {
found := false
for _, entry := range collection {
if entry.ID == id {
found = true
if entry.Level != level {
t.Fatal("durable costume level differs from gift")
}
}
}
if !found {
t.Fatal("costume ownership not committed", id)
}
}
if !bytes.Equal(beforeBCostumes, query(bID, "/CostumeInfo", 107)) {
t.Fatal("A costume entitlement granted to B")
}
var starterMail uint64
expectedItems := map[[2]uint64]uint64{}
for _, entry := range factory.seeds.mailbox.Mails {
valid := len(entry.RewardTypes) > 0
for i, kind := range entry.RewardTypes {
if kind != 8 || entry.RewardIDs[i] == 0 {
valid = false
}
}
if valid && entry.ExpiresAt > uint64(time.Now().UnixMilli()) {
starterMail = entry.MailID
for i, kind := range entry.RewardTypes {
expectedItems[[2]uint64{kind, entry.RewardIDs[i]}] += entry.RewardCounts[i]
}
break
}
}
if starterMail == 0 {
t.Fatal("versioned starter mail requires a current resource gift")
}
resourceClaim := claim(starterMail, 108)
if _, err := run(resourceClaim); err != nil {
t.Fatal("claim versioned starter resources", err)
}
actualItems := appItemTotals(query(aID, "/ItemInfo", 109))
for key, gift := range expectedItems {
if actualItems[key] != beforeAItems[key]+gift {
t.Fatal("mail claim did not deliver listed resource count", key, actualItems[key], gift)
}
}
for key, old := range beforeBItems {
if appItemTotals(query(bID, "/ItemInfo", 110))[key] != old {
t.Fatal("starter mail changed unrelated player's inventory")
}
}
// Memo is one item; query eviction forces the next claim retry to use SQL.
if _, err := run(resourceClaim); err != nil {
t.Fatal("retry starter claim", err)
}
afterRetry := appItemTotals(query(aID, "/ItemInfo", 111))
for key, value := range actualItems {
if afterRetry[key] != value {
t.Fatal("starter claim retry issued duplicate resources")
}
}
// QuestUpdate stores the client's counters without granting QuestClear's
// GameData rewards. The selected starting quest is a genuine active quest.
selection, selected := instances[aID].assembly.progressState.Selection(factory.options.gameRules.Story.StartPackID)
if !selected || selection.QuestID <= 0 {
t.Fatal("new-player active quest missing")
}
beforeGold := instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold
questBody := append(appScalar(1, 120), appScalar(2, uint64(selection.QuestID))...)
questBody = append(questBody, appScalar(3, uint64(factory.options.gameRules.Story.StartPackID))...)
questBody = append(questBody, appScalar(4, 1)...)
if _, err := run(makeCommand(aID, "quest-counter", []player.Request{{Path: "/QuestUpdate", Body: questBody}})); err != nil {
t.Fatal("actual quest progress", err)
}
current, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
if !found || len(current.Values) != 1 || current.Values[0] != 1 {
t.Fatal("quest update did not retain player counter")
}
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
t.Fatal("quest counter leaked to B")
}
if instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold {
t.Fatal("quest update incorrectly granted clear reward")
}
beforeBDeck := query(bID, "/DeckInfo", 10)
makeDeck := func(index, position, sequence uint64) []byte {
entry := append(appScalar(1, index), appScalar(2, position)...)
entry = append(entry, appScalar(3, 1)...)
return append(appScalar(1, sequence), appMessage(2, entry)...)
}
equip := append(appScalar(1, 11), appScalar(2, equipmentIndex[aID])...)
equip = append(equip, appScalar(3, characterIndex[aID])...)
position := []byte(`{"MapId":1,"PlayerPosition":{"x":12,"y":3,"z":4},"ColleaguePositions":[]}`)
save := append(appScalar(1, 13), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
save = append(save, appMessage(3, position)...)
changes := makeCommand(aID, "asset-batch", []player.Request{{Path: "/EquipUse", Body: equip}, {Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 5, 12)}, {Path: "/SaveUserPosition", Body: save}})
if _, err := run(changes); err != nil {
t.Fatal("actual asset batch", err)
}
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 20)) {
t.Fatal("another account's formation changed")
}
bEquipment := appRows(query(bID, "/EquipInfo", 21), 1)
if len(bEquipment) != 1 || appValue(bEquipment[0], 2) != 0 {
t.Fatal("A equipped B's equal-index weapon")
}
aEquipment := appRows(query(aID, "/EquipInfo", 22), 1)
if len(aEquipment) != 1 || appValue(aEquipment[0], 2) != characterIndex[aID] {
t.Fatal("owner weapon not bound to owner's character")
}
aDeck := query(aID, "/DeckInfo", 23)
entries := appRows(aDeck, 1)
if len(entries) != 1 || appValue(entries[0], 1) != characterIndex[aID] || appValue(entries[0], 2) != 5 {
t.Fatal("ordinary deck did not preserve selected battle-grid position")
}
if _, found := instances[bID].assembly.progressState.Position(); found {
t.Fatal("world position leaked to unrelated account")
}
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
t.Fatal("owner world position missing")
}
// Evict the small in-memory memo before replaying an asset operation. The
// committed database receipt must preserve the exact response and ownership.
replay, err := run(changes)
if err != nil || len(replay.Responses) != 3 {
t.Fatal("durable asset retry failed", err)
}
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 24)) {
t.Fatal("retry modified formation")
}
// A later invalid equipment member must roll back an earlier valid deck
// replacement in the same client batch and recover only this player's state.
invalid := append(appScalar(1, 31), appScalar(2, ^uint64(0))...)
invalid = append(invalid, appScalar(3, characterIndex[aID])...)
failed := makeCommand(aID, "rejected-batch", []player.Request{{Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 8, 30)}, {Path: "/EquipUse", Body: invalid}})
if _, err = run(failed); err == nil {
t.Fatal("invalid equipment accepted")
}
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 32)) {
t.Fatal("failed batch published uncommitted formation")
}
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 33)) {
t.Fatal("A recovery replaced B state")
}
// Unload and recreate real bundles from their separate SQLite databases.
for _, accountID := range accounts {
if err := runtimes[accountID].Close(context.Background()); err != nil {
t.Fatal(err)
}
instance, err := factory.open(accountID)
if err != nil {
t.Fatal("reopen account", err)
}
instances[accountID] = instance
runtime, err := player.New(accountID, instance, player.Limits{})
if err != nil {
t.Fatal(err)
}
runtimes[accountID] = runtime
}
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 40)) || !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 41)) {
t.Fatal("reopen combined player formations")
}
if _, found := instances[bID].assembly.progressState.Position(); found {
t.Fatal("reopen mixed world progress")
}
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
t.Fatal("owner progress lost on reopen")
}
retainedQuest, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
if !found || len(retainedQuest.Values) != 1 || retainedQuest.Values[0] != 1 {
t.Fatal("owner task counter lost on reopen")
}
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
t.Fatal("reopen mixed task counters")
}
for id, level := range expectedCostumes {
found := false
for _, entry := range instances[aID].assembly.collection.Costumes() {
if entry.ID == id {
found = true
if entry.Level != level {
t.Fatal("reopen changed costume enhancement")
}
}
}
if !found {
t.Fatal("reopen lost claimed costume", id)
}
}
bProfile, err := factory.profiles.GameIdentity(context.Background(), bID)
if err != nil {
t.Fatal(err)
}
aProfile, err := factory.profiles.GameIdentity(context.Background(), aID)
if err != nil {
t.Fatal(err)
}
aLogin := makeCommand(aID, "fresh-login-A", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
aLogin.LoginSessionKey = []byte("0123456789abcdef0123456789abcdef")
bLogin := makeCommand(bID, "fresh-login-B", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
bLogin.LoginSessionKey = []byte("fedcba9876543210fedcba9876543210")
aReply, err := run(aLogin)
if err != nil {
t.Fatal(err)
}
bReply, err := run(bLogin)
if err != nil {
t.Fatal(err)
}
aUser, bUser := appRows(aReply.Responses[0].Body, 1), appRows(bReply.Responses[0].Body, 1)
if len(aUser) != 1 || len(bUser) != 1 || appValue(aUser[0], 1) != uint64(aProfile.OwnerIndex) || appValue(bUser[0], 1) != uint64(bProfile.OwnerIndex) || appValue(aUser[0], 1) == appValue(bUser[0], 1) {
t.Fatal("actual login responses share player numeric identity")
}
aUserID, bUserID := appRows(aUser[0], 2), appRows(bUser[0], 2)
if len(aUserID) != 1 || len(bUserID) != 1 || string(aUserID[0]) != aProfile.UserID || string(bUserID[0]) != bProfile.UserID || bytes.Equal(aUserID[0], bUserID[0]) {
t.Fatal("actual login responses share player string identity")
}
for _, instance := range instances {
tx, err := instance.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
records, err := tx.ListEntries("missions", "command_receipts")
if err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
for _, raw := range records {
if bytes.Contains(raw, aLogin.LoginSessionKey) || bytes.Contains(raw, bLogin.LoginSessionKey) {
_ = tx.Rollback()
t.Fatal("ephemeral login encryption key persisted as command receipt")
}
}
if err = tx.Rollback(); err != nil {
t.Fatal(err)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
package app
import (
"bd2server/internal/server/runtime/player"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"sync"
"time"
)
const (
playerLoadWorkers = 4
playerLoadQueue = 128
maxResidentPlayers = 1024
)
var errPlayerLoadCapacity = errors.New("player load capacity exhausted")
type playerSlot struct {
ready chan struct{}
closed chan struct{}
runtime *player.Runtime
err error
leases int
lastUsed time.Time
retiring bool
retiredErr error
retireOnce sync.Once
}
type playerLoad struct {
accountID string
slot *playerSlot
}
type playerRegistry struct {
factory *PlayerFactory
mu sync.Mutex
players map[string]*playerSlot
closing bool
stop chan struct{}
done chan struct{}
loadDone chan struct{}
closeDone chan struct{}
loads chan playerLoad
closeErr error
idle time.Duration
}
func newPlayerRegistry(factory *PlayerFactory, idle time.Duration) *playerRegistry {
r := &playerRegistry{factory: factory, players: make(map[string]*playerSlot), stop: make(chan struct{}), done: make(chan struct{}), loadDone: make(chan struct{}), closeDone: make(chan struct{}), loads: make(chan playerLoad, playerLoadQueue), idle: idle}
var workers sync.WaitGroup
for range playerLoadWorkers {
workers.Go(func() {
for load := range r.loads {
r.load(load.accountID, load.slot)
}
})
}
go func() { workers.Wait(); close(r.loadDone) }()
go r.sweep()
return r
}
func accountDirectoryName(accountID string) string {
digest := sha256.Sum256([]byte(accountID))
return hex.EncodeToString(digest[:])
}
func (r *playerRegistry) Acquire(ctx context.Context, accountID string) (*player.Runtime, func(), error) {
if accountID == "" {
return nil, nil, errors.New("player account identity is empty")
}
for {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
r.mu.Lock()
if r.closing {
r.mu.Unlock()
return nil, nil, player.ErrClosed
}
slot := r.players[accountID]
if slot != nil && slot.retiring {
if slot.retiredErr != nil {
err := slot.retiredErr
r.mu.Unlock()
return nil, nil, errors.Join(player.ErrUnavailable, err)
}
r.mu.Unlock()
select {
case <-slot.closed:
continue
case <-ctx.Done():
return nil, nil, ctx.Err()
}
}
if slot == nil {
if len(r.players) >= maxResidentPlayers {
r.mu.Unlock()
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
}
slot = &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
select {
case r.loads <- playerLoad{accountID: accountID, slot: slot}:
r.players[accountID] = slot
default:
r.mu.Unlock()
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
}
}
slot.leases++
r.mu.Unlock()
var once sync.Once
release := func() { once.Do(func() { r.mu.Lock(); slot.leases--; slot.lastUsed = time.Now(); r.mu.Unlock() }) }
select {
case <-slot.ready:
r.mu.Lock()
err := slot.err
if ctx.Err() != nil {
err = errors.Join(err, ctx.Err())
}
if r.closing {
err = errors.Join(player.ErrClosed, err)
}
runtime := slot.runtime
r.mu.Unlock()
if err != nil {
release()
return nil, nil, err
}
return runtime, release, nil
case <-ctx.Done():
release()
return nil, nil, ctx.Err()
}
}
}
func (r *playerRegistry) load(accountID string, slot *playerSlot) {
r.mu.Lock()
if r.closing {
slot.err = player.ErrClosed
close(slot.ready)
delete(r.players, accountID)
r.mu.Unlock()
return
}
r.mu.Unlock()
runtime, err := r.open(accountID)
r.mu.Lock()
slot.runtime, slot.err = runtime, err
close(slot.ready)
if err != nil && r.players[accountID] == slot {
delete(r.players, accountID)
}
r.mu.Unlock()
if err != nil {
slog.Error("player load failed", "account_id", accountID, "error", err)
}
}
func (r *playerRegistry) open(accountID string) (runtime *player.Runtime, err error) {
defer func() {
if value := recover(); value != nil {
err = fmt.Errorf("load player panic: %v", value)
}
}()
owner, err := r.factory.open(accountID)
if err != nil {
return nil, err
}
runtime, err = player.New(accountID, owner, player.Limits{})
if err != nil {
err = errors.Join(err, owner.Close())
}
return runtime, err
}
func (r *playerRegistry) sweep() {
defer close(r.done)
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case now := <-ticker.C:
r.expire(now)
case <-r.stop:
return
}
}
}
func (r *playerRegistry) expire(now time.Time) {
r.mu.Lock()
var expired []playerLoad
if !r.closing {
for accountID, slot := range r.players {
select {
case <-slot.ready:
if !slot.retiring && slot.leases == 0 && now.Sub(slot.lastUsed) >= r.idle && slot.runtime != nil {
slot.retiring = true
expired = append(expired, playerLoad{accountID: accountID, slot: slot})
}
default:
}
}
}
r.mu.Unlock()
for _, load := range expired {
r.retire(load.accountID, load.slot)
}
}
func (r *playerRegistry) retire(accountID string, slot *playerSlot) {
slot.retireOnce.Do(func() {
err := slot.runtime.Close(context.Background())
r.mu.Lock()
slot.retiredErr = err
if err == nil && r.players[accountID] == slot {
delete(r.players, accountID)
}
r.closeErr = errors.Join(r.closeErr, err)
close(slot.closed)
r.mu.Unlock()
if err != nil {
slog.Error("player close failed", "account_id", accountID, "error", err)
}
})
}
func (r *playerRegistry) shutdown() {
defer close(r.closeDone)
<-r.loadDone
<-r.done
r.mu.Lock()
var loaded []playerLoad
for accountID, slot := range r.players {
if slot.runtime != nil {
slot.retiring = true
loaded = append(loaded, playerLoad{accountID: accountID, slot: slot})
}
}
r.mu.Unlock()
for _, load := range loaded {
r.retire(load.accountID, load.slot)
}
}
func (r *playerRegistry) Close(ctx context.Context) error {
r.mu.Lock()
if !r.closing {
r.closing = true
close(r.stop)
close(r.loads)
go r.shutdown()
}
r.mu.Unlock()
select {
case <-r.closeDone:
return r.closeErr
case <-ctx.Done():
return fmt.Errorf("player registry shutdown: %w", ctx.Err())
}
}
@@ -0,0 +1,301 @@
//go:build integration
package app
import (
"bd2server/internal/server/domain/command"
"bd2server/internal/server/runtime/player"
accountstate "bd2server/internal/server/storage/account"
identitystore "bd2server/internal/server/storage/identity"
"context"
"crypto/sha256"
"errors"
"path/filepath"
"sync"
"testing"
"time"
)
type gatedPlayerExecutor struct {
owner *playerInstance
entered chan struct{}
release chan struct{}
closed chan struct{}
once sync.Once
}
type gatedIdentityProfile struct {
source interface {
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
}
accountID string
entered chan struct{}
release chan struct{}
once sync.Once
}
func (g *gatedIdentityProfile) GameIdentity(ctx context.Context, accountID string) (identitystore.GameProfile, error) {
if accountID == g.accountID {
g.once.Do(func() { close(g.entered); <-g.release })
}
return g.source.GameIdentity(ctx, accountID)
}
func (g *gatedPlayerExecutor) Execute(ctx context.Context, c player.Command) (player.Reply, error) {
g.once.Do(func() { close(g.entered); <-g.release })
return g.owner.Execute(ctx, c)
}
func (g *gatedPlayerExecutor) Recover(ctx context.Context) error { return g.owner.Recover(ctx) }
func (g *gatedPlayerExecutor) Close() error { err := g.owner.Close(); close(g.closed); return err }
func registryCommand(accountID, identity, path string, body []byte) player.Command {
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "registry-client", RequestID: identity}, Digest: sha256.Sum256(append([]byte(path), body...)), Requests: []player.Request{{Path: path, Body: body}}}
}
func registryExecute(t *testing.T, runtime *player.Runtime, c player.Command) player.Reply {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
future, err := runtime.Submit(ctx, c)
if err != nil {
t.Fatal(err)
}
reply, err := future.Wait(ctx)
if err != nil {
t.Fatal(err)
}
return reply
}
func TestRegistryKeepsOnePlayerOwnerUntilAcceptedProgressDrains(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
gate := &gatedPlayerExecutor{owner: owner, entered: make(chan struct{}), release: make(chan struct{}), closed: make(chan struct{})}
old, err := player.New(accounts[0], gate, player.Limits{})
if err != nil {
t.Fatal(err)
}
registry := newPlayerRegistry(factory, time.Second)
t.Cleanup(func() {
if err := registry.Close(context.Background()); err != nil {
t.Error(err)
}
})
defer func() {
select {
case <-gate.release:
default:
close(gate.release)
}
}()
ready := make(chan struct{})
close(ready)
registry.mu.Lock()
registry.players[accounts[0]] = &playerSlot{ready: ready, closed: make(chan struct{}), runtime: old, lastUsed: time.Now()}
registry.mu.Unlock()
leased, release, err := registry.Acquire(context.Background(), accounts[0])
if err != nil {
t.Fatal(err)
}
registry.expire(time.Now().Add(time.Hour))
registry.mu.Lock()
held := registry.players[accounts[0]]
registry.mu.Unlock()
if held == nil || held.retiring {
t.Fatal("idle unload closed a leased player")
}
position := []byte(`{"MapId":1,"PlayerPosition":{"x":61,"y":2,"z":3},"ColleaguePositions":[]}`)
body := append(appScalar(1, 7), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
body = append(body, appMessage(3, position)...)
ctx, cancelWait := context.WithCancel(context.Background())
future, err := leased.Submit(ctx, registryCommand(accounts[0], "save-position-before-idle", "/SaveUserPosition", body))
if err != nil {
t.Fatal(err)
}
select {
case <-gate.entered:
case <-time.After(20 * time.Second):
t.Fatal("accepted progress did not enter owner")
}
cancelWait()
release()
release()
expired := make(chan struct{})
go func() { registry.expire(time.Now().Add(time.Hour)); close(expired) }()
deadline := time.Now().Add(5 * time.Second)
for {
registry.mu.Lock()
retiring := registry.players[accounts[0]].retiring
registry.mu.Unlock()
if retiring {
break
}
if time.Now().After(deadline) {
t.Fatal("idle player never entered retirement")
}
time.Sleep(time.Millisecond)
}
// The same account must wait for the old writer's accepted save to settle.
short, stop := context.WithTimeout(context.Background(), 30*time.Millisecond)
if _, _, err := registry.Acquire(short, accounts[0]); !errors.Is(err, context.DeadlineExceeded) {
stop()
t.Fatalf("second writer opened during drain: %v", err)
}
stop()
// Another account can load and process independently during A's retirement.
type acquiredPlayer struct {
runtime *player.Runtime
release func()
err error
}
loaded := make(chan acquiredPlayer, 8)
var callers sync.WaitGroup
for range cap(loaded) {
callers.Go(func() {
owner, release, err := registry.Acquire(context.Background(), accounts[1])
loaded <- acquiredPlayer{owner, release, err}
})
}
callers.Wait()
close(loaded)
var other *player.Runtime
var releaseOther []func()
defer func() {
for _, release := range releaseOther {
release()
}
}()
for acquired := range loaded {
if acquired.err != nil {
t.Fatal(acquired.err)
}
if other != nil && other != acquired.runtime {
t.Fatal("simultaneous same-account loads created different owners")
}
other = acquired.runtime
releaseOther = append(releaseOther, acquired.release)
}
registryExecute(t, other, registryCommand(accounts[1], "independent-equipment", "/EquipInfo", appScalar(1, 8)))
close(gate.release)
settlement, stop := context.WithTimeout(context.Background(), 20*time.Second)
defer stop()
if _, err := future.Wait(settlement); err != nil {
t.Fatal("cancelled caller lost accepted progress", err)
}
select {
case <-expired:
case <-settlement.Done():
t.Fatal(settlement.Err())
}
select {
case <-gate.closed:
default:
t.Fatal("retirement completed before writer close")
}
_, releaseReloaded, err := registry.Acquire(settlement, accounts[0])
if err != nil {
t.Fatal(err)
}
releaseReloaded()
if err := registry.Close(settlement); err != nil {
t.Fatal(err)
}
reopened, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
defer func() {
if err := reopened.Close(); err != nil {
t.Error(err)
}
}()
if saved, exists := reopened.assembly.progressState.Position(); !exists || saved.Position.PlayerPosition.X != 61 {
t.Fatal("idle drain discarded accepted world progress")
}
otherReopened, err := factory.open(accounts[1])
if err != nil {
t.Fatal(err)
}
defer func() {
if err := otherReopened.Close(); err != nil {
t.Error(err)
}
}()
if _, exists := otherReopened.assembly.progressState.Position(); exists {
t.Fatal("world progress crossed account retirement")
}
}
func TestRegistryShutdownClosesLoadingAndLeasedAccountWriters(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
gate := &gatedIdentityProfile{source: factory.profiles, accountID: accounts[0], entered: make(chan struct{}), release: make(chan struct{})}
factory.profiles = gate
registry := newPlayerRegistry(factory, time.Minute)
t.Cleanup(func() {
if err := registry.Close(context.Background()); err != nil {
t.Error(err)
}
})
defer func() {
select {
case <-gate.release:
default:
close(gate.release)
}
}()
registry.mu.Lock()
slots := make([]*playerSlot, len(accounts))
for i, accountID := range accounts {
slot := &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
registry.players[accountID] = slot
registry.loads <- playerLoad{accountID: accountID, slot: slot}
slots[i] = slot
}
registry.mu.Unlock()
select {
case <-gate.entered:
case <-time.After(20 * time.Second):
t.Fatal("account did not begin real player loading")
}
// Another complete account remains leased when shutdown begins.
ctx, stop := context.WithTimeout(context.Background(), 30*time.Second)
defer stop()
leased, releaseLease, err := registry.Acquire(ctx, accounts[1])
if err != nil {
t.Fatal(err)
}
registryExecute(t, leased, registryCommand(accounts[1], "query-before-close", "/EquipInfo", appScalar(1, 1)))
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := registry.Close(cancelled); err != nil && !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
close(gate.release)
if err := registry.Close(ctx); err != nil {
t.Fatal(err)
}
releaseLease()
for _, slot := range slots {
select {
case <-slot.ready:
default:
t.Fatal("shutdown forgot a loading account waiter")
}
}
for _, accountID := range accounts {
if _, _, err := registry.Acquire(ctx, accountID); !errors.Is(err, player.ErrClosed) {
t.Fatalf("registry accepted after shutdown: %v", err)
}
path := filepath.Join(factory.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
repository, err := accountstate.Open(path)
if err != nil {
t.Fatal("shutdown left an account writer locked", err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce"
"bd2server/internal/server/domain/commerce/gacha"
"bd2server/internal/server/domain/events"
"bd2server/internal/server/gateway/session"
"bd2server/internal/server/protocol/defaults"
scheduleadapter "bd2server/internal/server/protocol/schedule"
"bd2server/internal/server/protocol/staticdata"
"bd2server/internal/server/storage/account"
"fmt"
"time"
)
func (p *playerAssembly) session(ctx command.Context) error {
p.handlers = []session.Handler{
p.progressState,
p.cashService,
p.cashBonuses,
p.clearPackages,
commerce.PackInfoHandler{World: p.worldService, Claims: p.clearPackages},
commerce.AttendanceHandler{Events: p.eventTasksService, Economy: p.cashEconomy, LoginPasses: p.loginPasses, Store: p.gameplayStore},
p.eventRegistry,
p.eventGamesService,
p.eventExchangeService,
p.boxService,
p.eventPlayService,
p.eventActionsService,
p.npcShopService,
p.innService,
events.SkinHandler{Economy: p.eventEconomy},
p.battleService,
p.huntingService,
p.monsterHuntService,
p.worldService,
p.worldService.CharacterService(),
p.progressState,
p.deckStateStore,
p.ownedItems,
p.ownedEquipment,
p.inventorySlots,
p.charAwakeService,
p.costumePotentialService,
p.costumeBurstService,
p.friendshipService,
p.contentOpenService,
p.masterTitleService,
p.recruitService,
p.foodService,
p.talentUseService,
p.dispatchService,
p.itemCraftService,
p.recipeService,
p.starter,
p.mailService,
p.gachaService,
p.achievementCounters,
p.missionService,
p.eventTasksService,
p.pictorialService,
&scheduleadapter.Service{Schedule: p.options.calendars.RegularService},
readonly.Service{Seed: p.seeds.defaults},
feature.Service{},
}
p.observers = []session.ResponseObserver{p.achievementObserver, p.eventTasksService, p.mailService}
featured := gacha.ActivePickupCostumes(p.design.regularGacha, p.options.calendars.GachaSeed, uint64(time.Now().UTC().UnixMilli()))
limitedIDs := p.design.limitedCostumes.Excluding(featured)
if len(limitedIDs) != 0 {
if err := p.mailService.EnsureStarterLimitedCostumes(ctx, limitedIDs, time.Now().UTC()); err != nil {
return fmt.Errorf("ensure account limited-costume entitlement: %w", err)
}
}
prestigeIDs := p.design.prestigeCatalog.Giftable(func(key gamedata.CashProductKey) bool { return p.cashService.IsAvailable(ctx, key) })
if len(prestigeIDs) != 0 {
if err := p.mailService.EnsureStarterPrestigeSkins(ctx, prestigeIDs, time.Now().UTC()); err != nil {
return fmt.Errorf("ensure account prestige-skin entitlement: %w", err)
}
}
if p.initializeAccount {
if err := ensureAccountStateInitialized(ctx,
p.progressState, p.deckStateStore, p.ownedItems, p.ownedEquipment,
p.worldService.CharacterService(), p.collection, p.wallet, p.inventorySlots, p.mailService, p.missionService,
); err != nil {
return fmt.Errorf("initialize complete account state generation: %w", err)
}
if err := p.worldService.EnsureInitialPackPurchase(ctx); err != nil {
return fmt.Errorf("grant initial pack purchase rewards: %w", err)
}
if err := ctx.State.(*accountstate.CommandStore).MarkInitializationComplete(); err != nil {
return fmt.Errorf("mark account initialization complete: %w", err)
}
}
if err := p.masterTitleService.EnsurePersisted(ctx); err != nil {
return fmt.Errorf("persist master title: %w", err)
}
problems, err := ctx.State.(*accountstate.CommandStore).Validate()
if err != nil {
return fmt.Errorf("validate account state database: %w", err)
}
if len(problems) != 0 {
return stateProblemsError("account state database rejected", problems)
}
return nil
}
@@ -0,0 +1,72 @@
//go:build integration
package app
import (
"bd2server/internal/server/domain/battle"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/gateway/session"
"bd2server/internal/server/runtime/player"
"context"
"testing"
"time"
)
// Independently verified in selected 2.35.10 GameData using gamedata_db.py:
// pack22 FieldMonsterTable id8 has phase group1; PhaseBattleTable group1 rows
// id1/id2 use decks8/9. BattleEnter/Start/PhaseChange request fields are from
// the original generated Proto.Net classes, not the server's response writer.
func TestFailedBatchDiscardsRealBattlePhaseBeforeBattleCanRestart(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
// Select the verified pack as the battle's immutable scenario resolver.
// The production battle service loads phases from real GameData; only this
// scenario selection avoids progressing a full story campaign in this test.
owner.assembly.battleService = battle.NewService(factory.options.gameData, factory.options.gameDataVersion, func(command.Context) (int, error) { return 22, nil })
owner.assembly.handlers = []session.Handler{owner.assembly.battleService}
runtime, err := player.New(accounts[0], owner, player.Limits{})
if err != nil {
_ = owner.Close()
t.Fatal(err)
}
t.Cleanup(func() {
if err := runtime.Close(context.Background()); err != nil {
t.Error(err)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
run := func(c player.Command) (player.Reply, error) {
future, err := runtime.Submit(ctx, c)
if err != nil {
return player.Reply{}, err
}
return future.Wait(ctx)
}
enter := append(appScalar(1, 1), appScalar(3, 8)...)
enter = append(enter, appScalar(4, 8)...)
enter = append(enter, appScalar(5, 1)...)
if _, err := run(registryCommand(accounts[0], "enter-phase-battle", "/BattleEnter", enter)); err != nil {
t.Fatal(err)
}
start := append(appScalar(1, 2), appScalar(2, 8)...)
if _, err := run(registryCommand(accounts[0], "start-phase-one", "/BattleStart", start)); err != nil {
t.Fatal(err)
}
batch := registryCommand(accounts[0], "phase-then-invalid-member", "/BattlePhaseChange", appScalar(1, 3))
batch.Requests = append(batch.Requests, player.Request{Path: "/BattleStart", Body: append(appScalar(1, 4), appScalar(2, 999999)...)})
batch.Digest[0] ^= 1
if _, err := run(batch); err == nil {
t.Fatal("invalid next phase start committed its preceding phase transition")
}
identity := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "registry-client", RequestID: "inspect-recovered-battle"}}
if owner.assembly.battleService.Active(identity) {
t.Fatal("recovered account retained uncommitted in-battle ownership")
}
if _, err := run(registryCommand(accounts[0], "invalid-before-enter", "/BattleStart", append(appScalar(1, 5), appScalar(2, 9)...))); err == nil {
t.Fatal("post-recovery battle start bypassed required enter")
}
}
@@ -0,0 +1,22 @@
package app
import (
"bd2server/internal/server/domain/command"
"errors"
)
type accountStateInitializer interface {
EnsurePersisted(command.Context) error
}
func ensureAccountStateInitialized(ctx command.Context, stores ...accountStateInitializer) error {
for _, store := range stores {
if store == nil {
return errors.New("nil account state initializer")
}
if err := store.EnsurePersisted(ctx); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,159 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce/gacha"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/progression/pictorial"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/world"
"bd2server/internal/server/protocol/wire"
"errors"
"fmt"
)
func (p *playerAssembly) worldRoster(ctx command.Context) error {
var err error
p.worldService, err = world.New(ctx, p.worldSeed, p.design.world, p.design.source,
p.scope, p.progressState, p.starter, p.ownedEquipment, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load world state: %w", err)
}
if err := p.worldService.ConfigureStartPack(ctx, p.startingPackID, p.initializeAccount); err != nil {
return fmt.Errorf("configure account starting chapter: %w", err)
}
if err := p.login.AttachLastPlayedPack(p.worldService); err != nil {
return fmt.Errorf("attach persisted login destination: %w", err)
}
// Restore all earned seed ownership before validating persisted upgrades.
// A quest costume is not a p.collection entry; attaching it after opening
// p.collection would reject its otherwise valid burst ledger on restart.
baseCostumes := append([]roster.Costume(nil), p.starter.Costumes...)
if reward, earned := p.worldService.EarnedQuestCostume(); earned {
baseCostumes = append(baseCostumes, reward)
}
p.collection, err = roster.OpenCollectionStore(ctx, p.scope, baseCostumes)
if err != nil {
return fmt.Errorf("load owned collection: %w", err)
}
p.gachaService, err = gacha.NewService(p.design.infiniteGacha, p.design.regularGacha, p.collection, p.wallet)
if err != nil {
return err
}
if err := p.login.AttachPurchaseCounts(p.gachaService); err != nil {
return fmt.Errorf("attach cash purchase counts to login: %w", err)
}
if err := p.gachaService.AttachSchedule(ctx, p.options.calendars.GachaSeed); err != nil {
return fmt.Errorf("attach gacha schedule: %w", err)
}
previewEventIndex, err := p.seeds.defaults.CashProductEventIndex(p.design.infiniteGacha.ProductGroupID, p.design.infiniteGacha.ProductID)
if err != nil {
return fmt.Errorf("load infinite preview event: %w", err)
}
if err := p.gachaService.AttachPreviewEventIndex(ctx, previewEventIndex); err != nil {
return fmt.Errorf("attach infinite preview event: %w", err)
}
// The mapped client property is IsDoneFirstGachaPick. Its authoritative
// local state is the explicit GachaSubType=3 completion marker.
if err := p.login.AttachFirstGacha(p.gachaService); err != nil {
return fmt.Errorf("attach first gacha status to login: %w", err)
}
if err := p.gachaService.AttachFirstGacha(ctx, p.design.firstGacha); err != nil {
return fmt.Errorf("attach first gacha GameData: %w", err)
}
p.gachaService.AttachInventory(p.ownedItems)
p.gachaService.AttachEquipmentGacha(p.design.equipmentGacha, p.ownedEquipment)
p.gachaService.AttachDrawMission(func(ctx command.Context, count uint64) error {
return p.missionService.RecordEvent(ctx, missions.ConditionGachaBuy, 0, count, p.worldService.MissionsUnlocked)
})
var permanentBaseCharacters []roster.Character
for _, c := range p.worldService.CharacterService().RawAll() {
if !roster.IsCharmCharacter(c) {
permanentBaseCharacters = append(permanentBaseCharacters, c)
}
}
if err := p.collection.BindBaseCharacters(ctx, permanentBaseCharacters); err != nil {
return fmt.Errorf("bind base collection characters: %w", err)
}
if err := p.mailService.AttachCostumeRewards(ctx, p.collection, p.design.limitedCostumes); err != nil {
return fmt.Errorf("attach limited costume mail rewards: %w", err)
}
if err := p.worldService.AttachCollection(ctx, p.collection); err != nil {
return fmt.Errorf("attach gacha collection state: %w", err)
}
if err := p.worldService.AttachDecks(ctx, p.deckStateStore); err != nil {
return fmt.Errorf("attach world deck state: %w", err)
}
if err := p.worldService.AttachWaypointRuntime(ctx, p.design.source); err != nil {
return fmt.Errorf("attach waypoint runtime: %w", err)
}
if err := p.worldService.AttachFieldObjectRuntime(p.design.source, p.design.fieldReset); err != nil {
return fmt.Errorf("attach field object runtime: %w", err)
}
if err := p.ownedEquipment.AttachCharacters(ctx, p.worldService.CharacterService()); err != nil {
return fmt.Errorf("attach equipment character state: %w", err)
}
if err := p.deckStateStore.AttachPresetRuntime(ctx, p.wallet, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
return fmt.Errorf("attach ordinary preset runtime: %w", err)
}
if err := p.deckStateStore.AttachFieldSettingsPack(p.worldService.CurrentPackID); err != nil {
return err
}
if err := p.deckStateStore.AttachFieldSettings(ctx, p.design.fieldSettingsDesign); err != nil {
return fmt.Errorf("attach field character settings: %w", err)
}
if err := p.login.AttachAutoReviveSettings(p.deckStateStore); err != nil {
return fmt.Errorf("attach automatic revival settings: %w", err)
}
p.pictorialService = &pictorial.Service{Design: p.design.pictorialDesign, Owned: p.worldService}
if err := p.ownedEquipment.AttachStatDesign(ctx, p.design.equipmentStatDesign); err != nil {
return err
}
p.pictorialService.EquipmentContributions = func(ctx command.Context, character roster.Character) ([]gamedata.StatContribution, error) {
return p.ownedEquipment.StatContributions(ctx, character.InvenIndex)
}
p.charAwakeService, err = roster.NewCharAwakeService(p.design.charAwakeDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
if err != nil {
return err
}
p.pictorialService.AwakeContributions = p.charAwakeService.Contributions
if err := p.worldService.CharacterService().AttachMaxHealth(ctx, p.pictorialService.MaxHealth); err != nil {
return fmt.Errorf("attach pictorial character stats: %w", err)
}
if err := p.worldService.CharacterService().AttachWallet(ctx, p.wallet); err != nil {
return fmt.Errorf("attach character promotion wallet: %w", err)
}
if err := p.worldService.CharacterService().AttachTalentGrowth(ctx, p.design.talentGrowth); err != nil {
return fmt.Errorf("attach character talent growth: %w", err)
}
if err := p.worldService.CharacterService().AttachImmortalDesign(ctx, p.design.immortalDesign); err != nil {
return fmt.Errorf("attach immortal talent GameData: %w", err)
}
p.costumePotentialService, err = roster.NewCostumePotentialService(p.design.costumePotentialDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
if err != nil {
return err
}
p.pictorialService.PotentialContributions = p.costumePotentialService.Contributions
p.costumeBurstService, err = roster.NewCostumeBurstService(p.design.costumeBurstDesign, p.collection, p.ownedItems, p.wallet)
if err != nil {
return err
}
p.friendshipService, err = roster.NewFriendshipService(&p.design.friendshipDesign, p.design.charAwakeDesign, p.design.costumePotentialDesign, p.collection, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load friendship state: %w", err)
}
if err := p.login.AttachFriendshipAP(p.friendshipService); err != nil {
return err
}
accountName, found, err := wire.Bytes(p.login.UserInfo, 2)
if err != nil || !found || len(accountName) == 0 {
return errors.New("account seed requires its existing display name for master title")
}
p.masterTitleService, err = roster.OpenMasterTitleService(ctx, p.scope, string(accountName))
if err != nil {
return fmt.Errorf("load master title: %w", err)
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"errors"
"fmt"
)
// Preflight validates the same shared rules as Serve without opening player or authentication databases.
func Preflight(args []string) (preflightErr error) {
defer func() { preflightErr = errors.Join(preflightErr, gamedata.CloseDatabaseCache()) }()
config, err := loadConfiguration(args)
if err != nil {
return err
}
defer clear(config.authRuntime.MasterKey)
seeds, err := loadSeeds(config)
if err != nil {
return err
}
_, err = loadDesign(config, seeds)
return err
}
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)
}
@@ -0,0 +1,146 @@
//go:build integration
package app
import (
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/world/progress"
"bd2server/internal/server/runtime/player"
"bytes"
"context"
"maps"
"testing"
)
// Verified with gamedata_db.py in 20260923193640: pack1 monster2001 belongs
// to side quest103 and deck301 grants gold350 (type4, id0); monster1 belongs
// to main quest9 and decks1/200001 grant resource7 x2. These expected values
// are independent of the resolver and reward loader under test.
func TestQuestBattleUsesItsOwnQuestDifficultyAndRewardOwner(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
for _, scenario := range []struct {
name string
monster, deck, selected uint64
difficulty, activeQuest int
gold uint64
}{
{"normal-main", 1, 1, 1, 0, 9, 0},
{"very-hard-main", 1, 1, 200001, 2, 9, 0},
{"side-with-very-hard-main-selected", 2001, 301, 301, 2, 103, 350},
} {
t.Run(scenario.name, func(t *testing.T) {
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
tx, err := owner.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
ctx := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "fixture"}, State: tx}
state := owner.assembly.progressState
if err := state.SetActivePackID(ctx, 1); err != nil {
t.Fatal(err)
}
if err := state.SelectQuest(ctx, 1, progress.QuestSelection{Difficulty: scenario.difficulty}); err != nil {
t.Fatal(err)
}
if scenario.activeQuest == 103 {
for _, id := range []int{101, 102} {
if err := state.ClearQuest(ctx, id, 1, 0); err != nil {
t.Fatal(err)
}
}
if err := state.AcceptQuest(ctx, 103, 1, 0); err != nil {
t.Fatal(err)
}
} else {
for id := 1; id < scenario.activeQuest; id++ {
if err := state.ClearQuest(ctx, id, 1, scenario.difficulty); err != nil {
t.Fatal(err)
}
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := owner.Recover(context.Background()); err != nil {
t.Fatal(err)
}
runtime, err := player.New(accounts[0], owner, player.Limits{CompletedReceipts: 1})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := runtime.Close(context.Background()); err != nil {
t.Error(err)
}
})
run := func(identity, path string, body []byte) []byte {
t.Helper()
reply := registryExecute(t, runtime, registryCommand(accounts[0], scenario.name+identity, path, body))
return reply.Responses[0].Body
}
beforeGold := owner.assembly.wallet.Snapshot(command.Context{}).Gold
beforeItems := appItemTotals(run("items-before", "/ItemInfo", appScalar(1, 1)))
invalid := append(appScalar(1, 99), appScalar(3, scenario.monster)...)
invalid = append(invalid, appScalar(4, 303)...)
invalid = append(invalid, appScalar(5, 1)...)
future, err := runtime.Submit(context.Background(), registryCommand(accounts[0], scenario.name+"wrong-monster-deck", "/BattleEnter", invalid))
if err != nil {
t.Fatal(err)
}
if _, err := future.Wait(context.Background()); err == nil {
t.Fatal("quest battle accepted another monster's authored deck")
}
if owner.assembly.battleService.Active(command.Context{Identity: command.Identity{SessionID: "registry-client"}}) {
t.Fatal("rejected deck installed an active battle")
}
if owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold || !maps.Equal(appItemTotals(run("items-rejected", "/ItemInfo", appScalar(1, 100))), beforeItems) {
t.Fatal("rejected deck changed owned rewards")
}
enter := append(appScalar(1, 2), appScalar(3, scenario.monster)...)
enter = append(enter, appScalar(4, scenario.deck)...)
enter = append(enter, appScalar(5, 1)...)
response := run("enter", "/BattleEnter", enter)
if appValue(response, 2) != scenario.selected {
t.Fatalf("client receives wrong enemy deck: %d, want %d", appValue(response, 2), scenario.selected)
}
run("start", "/BattleStart", append(appScalar(1, 3), appScalar(2, scenario.selected)...))
end := append(appScalar(1, 4), appScalar(2, 1)...)
response = run("end", "/BattleEnd", end)
bundles := appRows(response, 5)
if len(bundles) != 1 {
t.Fatal("victory omitted client reward bundle")
}
rewards := appItemTotals(bundles[0])
key, count := [2]uint64{8, 7}, uint64(2)
if scenario.gold > 0 {
key, count = [2]uint64{4, 0}, scenario.gold
}
if len(rewards) != 1 || rewards[key] != count {
t.Fatalf("wrong authored victory reward: %v, want %v x%d", rewards, key, count)
}
if gold := owner.assembly.wallet.Snapshot(command.Context{}).Gold; gold-beforeGold != scenario.gold {
t.Fatalf("gold did not settle in wallet: before=%d after=%d want delta=%d", beforeGold, gold, scenario.gold)
}
afterItems := appItemTotals(run("items-after", "/ItemInfo", appScalar(1, 5)))
if scenario.gold > 0 {
if afterItems[[2]uint64{4, 0}] != 0 {
t.Fatal("wallet currency leaked into owned inventory")
}
} else if afterItems[key]-beforeItems[key] != 2 {
t.Fatal("main battle resource did not settle in owned inventory")
}
// ItemInfo evicts the actor's single completed-command receipt. Retrying
// BattleEnd must still be idempotent through the battle settlement.
retry := run("end-retry", "/BattleEnd", end)
if !bytes.Equal(response, retry) || owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold+scenario.gold {
t.Fatal("settlement retry changed its response or credited gold twice")
}
if totals := appItemTotals(run("items-retry", "/ItemInfo", appScalar(1, 6))); !maps.Equal(totals, afterItems) {
t.Fatal("settlement retry credited owned items twice")
}
})
}
}
+46
View File
@@ -0,0 +1,46 @@
package app
import (
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/resources/fetch"
"context"
"errors"
"flag"
"log/slog"
)
func Resources(args []string) error {
if len(args) == 0 || args[0] != "fetch" {
return errors.New("resources requires the fetch subcommand")
}
fs := flag.NewFlagSet("resources fetch", flag.ContinueOnError)
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
output := fs.String("output", "", "resource mirror output directory (required)")
platform := fs.String("platform", "StandaloneWindows64", "official ServerData platform")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *output == "" {
return errors.New("resources fetch requires --output")
}
var versions versionconfig.Config
var err error
if *versionConfigPath == "" {
versions, err = versionconfig.Find()
} else {
versions, err = versionconfig.Load(*versionConfigPath)
}
if err != nil {
return err
}
manifest, err := resourcefetch.Fetch(context.Background(), resourcefetch.Options{
OutputRoot: *output, Platform: *platform, BundleVersion: versions.BundleVersion,
GameDataVersion: versions.GameDataVersion,
Progress: func(message string) { slog.Info(message) },
})
if err != nil {
return err
}
slog.Info("official resource mirror complete", "output", *output, "bundles", manifest.ServerData.Bundles, "bytes", manifest.ServerData.Bytes)
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package app
import (
"bd2server/internal/server/domain/mail"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/roster/deck"
"bd2server/internal/server/domain/world"
calendaradapter "bd2server/internal/server/protocol/calendar"
loginprotocol "bd2server/internal/server/protocol/login"
"bd2server/internal/server/protocol/staticdata"
"encoding/json"
)
type seedCatalog struct {
login *loginprotocol.LoginSeed
starter *roster.Starter
defaults *readonly.Seed
mailbox *mail.Starter
deck deck.Seed
world world.Seed
}
func loadSeeds(c *configuration) (*seedCatalog, error) {
login, err := loginprotocol.Load(c.accountSeed)
if err != nil {
return nil, err
}
starter, err := roster.Load(c.playerSeed)
if err != nil {
return nil, err
}
if login.Version != c.versions.GameVersion || starter.Version != c.versions.GameVersion {
return nil, errorsVersionMismatch(c.versions.GameVersion, login.Version, starter.Version)
}
defaults, err := readonly.Load(c.readonlySeed)
if err != nil {
return nil, err
}
defaults, err = calendaradapter.ApplyStaticData(c.calendars, defaults)
if err != nil {
return nil, err
}
mailbox, err := mail.Load(c.mailSeed)
if err != nil {
return nil, err
}
deckSeed, err := deck.LoadSeed(c.deckSeed)
if err != nil {
return nil, err
}
worldSeed, err := world.LoadSeed(c.worldSeed)
if err != nil {
return nil, err
}
return &seedCatalog{login: login, starter: starter, defaults: defaults, mailbox: mailbox, deck: deckSeed, world: worldSeed}, nil
}
func cloneSeed[T any](source *T) (*T, error) {
data, err := json.Marshal(source)
if err != nil {
return nil, err
}
var result T
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return &result, nil
}
+124
View File
@@ -0,0 +1,124 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/gateway/auth"
"bd2server/internal/server/gateway/session"
"bd2server/internal/server/gateway/transport"
"bd2server/internal/server/platform/lifecycle"
identitystore "bd2server/internal/server/storage/identity"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"time"
)
func Serve(args []string) (serveErr error) {
defer func() { serveErr = errors.Join(serveErr, gamedata.CloseDatabaseCache()) }()
c, err := loadConfiguration(args)
if err != nil {
return err
}
defer clear(c.authRuntime.MasterKey)
seeds, err := loadSeeds(c)
if err != nil {
return err
}
design, err := loadDesign(c, seeds)
if err != nil {
return err
}
var authService *auth.Service
var authStore *identitystore.Store
if c.authentication.Mode == "oauth" {
authStore, err = identitystore.Open(filepath.Join(c.stateDirectory, "auth.db"), c.authRuntime.MasterKey)
if err != nil {
return fmt.Errorf("open authentication database: %w", err)
}
defer func() { serveErr = errors.Join(serveErr, authStore.Close()) }()
authService, err = auth.New(c.authRuntime, authStore)
if err != nil {
return err
}
}
if err := lockServerState(c.stateDirectory, c.gameRules.Story.StartPackID); err != nil {
return err
}
factory := &PlayerFactory{options: c, design: design, seeds: seeds}
if authStore != nil {
factory.profiles = authStore
}
registry := newPlayerRegistry(factory, 15*time.Minute)
defer func() { serveErr = errors.Join(serveErr, registry.Close(context.Background())) }()
var authenticator session.LoginAuthenticator
if authService != nil {
authenticator = authService
}
game, err := session.NewServer(registry, authenticator)
if err != nil {
return err
}
dispatcher := transport.Bootstrap{Config: c.bootstrap}
var authHandler http.Handler
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: c.authentication,
AuthenticationHandler: authHandler, ResourcePolicy: c.publicResources,
CommerceManifest: func() any { return design.cashCatalog.Manifest() }, Availability: availability, InstanceID: instanceID,
}.Handler()
server := &http.Server{
Addr: c.listen,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 60 * time.Second,
}
slog.Info("BD2 server listening", "address", c.listen, "instance_id", instanceID, "server_version", c.versions.ServerVersion, "game_version", c.bootstrap.Version, "bundle", c.bootstrap.BundleVer, "resourceMode", c.publicResources.Mode, "gameData", c.verifiedGameData.ArchivePath, "gameDataEntries", c.verifiedGameData.EntryCount, "accountSeed", c.accountSeed)
serveResult := make(chan error, 1)
go func() { serveResult <- server.ListenAndServe() }()
signals := make(chan os.Signal, 1)
signal.Notify(signals, lifecycle.ShutdownSignals()...)
defer signal.Stop(signals)
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())
}
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 nil
}
+64
View File
@@ -0,0 +1,64 @@
package app
import (
"bd2server/internal/server/storage/stateio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
type serverState struct {
Version int `json:"version"`
StartPackID int `json:"start_pack_id"`
}
func lockServerState(directory string, configured int) error {
if err := os.MkdirAll(directory, 0o700); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(directory, "state.db")); err == nil {
return errors.New("single-account state layout requires explicit offline migration")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
path := filepath.Join(directory, "server.json")
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
if entries, readErr := os.ReadDir(filepath.Join(directory, "accounts")); readErr == nil && len(entries) != 0 {
return errors.New("server policy is missing for existing player databases; explicit repair required")
} else if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return readErr
}
raw, err = json.Marshal(serverState{Version: 1, StartPackID: configured})
if err != nil {
return err
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, writeErr := file.Write(raw)
syncErr := file.Sync()
closeErr := file.Close()
return errors.Join(writeErr, syncErr, closeErr)
}
if err != nil {
return err
}
if err := stateio.RequireExactJSONObject(raw, "version", "start_pack_id"); err != nil {
return err
}
var state serverState
if err := json.Unmarshal(raw, &state); err != nil {
return err
}
if state.Version != 1 || (state.StartPackID != 1 && state.StartPackID != 21) {
return errors.New("invalid permanent server story policy")
}
if state.StartPackID != configured {
return fmt.Errorf("story.start_pack_id %d conflicts with permanent server policy %d", configured, state.StartPackID)
}
return nil
}
@@ -1,4 +1,4 @@
package main
package app
import (
"errors"
@@ -6,10 +6,10 @@ import (
"fmt"
"path/filepath"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/storage/account"
)
func stateCommand(args []string) error {
func State(args []string) error {
if len(args) == 0 || args[0] != "check" {
return errors.New("usage: bd2server state check [options]")
}
@@ -18,10 +18,13 @@ func stateCommand(args []string) error {
func stateCheckCommand(args []string) error {
fs := flag.NewFlagSet("state check", flag.ContinueOnError)
stateDB := fs.String("state", filepath.FromSlash("../data/state/state.db"), "account SQLite database")
stateDB := fs.String("state", "", "account SQLite database (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *stateDB == "" {
return errors.New("state check requires --state with the selected account database")
}
repository, err := accountstate.Open(filepath.Clean(*stateDB))
if err != nil {
return err
@@ -1,10 +1,10 @@
package main
package app
import (
"fmt"
"strings"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/storage/account"
)
func stateProblemsError(prefix string, problems []accountstate.Problem) error {
File diff suppressed because it is too large Load Diff
-672
View File
@@ -1,672 +0,0 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"bd2server/internal/server/authconfig"
)
const testNowUnix = int64(1_800_000_000)
func testService(t *testing.T) (*Service, *Store) {
t.Helper()
master := bytes.Repeat([]byte{0x42}, 32)
store, err := Open(filepath.Join(t.TempDir(), "auth.db"), master)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
store.now = func() time.Time { return time.Unix(testNowUnix, 0) }
public, _ := url.Parse("https://login.example.test")
runtime := authconfig.Runtime{
Config: authconfig.Config{
Mode: "oauth",
PublicURL: "https://login.example.test",
Providers: map[string]authconfig.ProviderConfig{
"discord": {ClientID: "discord-client", ClientSecretEnv: "DISCORD_SECRET"},
"google": {ClientID: "google-client", ClientSecretEnv: "GOOGLE_SECRET"},
},
},
PublicURLParsed: public,
MasterKey: master,
ProviderSecrets: map[string]string{"discord": "discord-secret", "google": "google-secret"},
AccessTTL: 15 * time.Minute,
RefreshTTL: 30 * 24 * time.Hour,
DeviceTTL: 10 * time.Minute,
}
service, err := New(runtime, store)
if err != nil {
t.Fatal(err)
}
if service.config.MasterKey != nil {
t.Fatal("service retained the authentication master key")
}
for i, value := range master {
if value != 0 {
t.Fatalf("master key byte %d was not cleared", i)
}
}
return service, store
}
func insertAuthorizingDevice(t *testing.T, store *Store, id, provider string) {
t.Helper()
_, err := store.db.Exec(`INSERT INTO devices(id,client_hash,secret_hash,start_hash,provider,status,created_at,expires_at)
VALUES(?,?,?,?,?,'authorizing',?,?)`, id, store.digest("client-ip", "192.0.2.1"), store.digest("device-secret", "device-secret"), store.digest("start-ticket", "start-ticket"), provider, testNowUnix, testNowUnix+600)
if err != nil {
t.Fatal(err)
}
}
func completeAndPoll(t *testing.T, service *Service, store *Store, deviceID, provider, issuer, subject string) deviceResult {
t.Helper()
insertAuthorizingDevice(t, store, deviceID, provider)
if err := service.completeDevice(deviceID, provider, providerIdentity{issuer: issuer, subject: subject}); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/auth/device/"+deviceID+"/poll", nil)
request.Header.Set("Authorization", "Device device-secret")
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("poll status=%d body=%q", response.Code, response.Body.String())
}
var result deviceResult
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
func postJSON(handler http.Handler, path string, value any) *httptest.ResponseRecorder {
body, _ := json.Marshal(value)
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func TestCompleteDeviceRequiresAuthorizingTransition(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "already-consumed", "discord")
if _, err := store.db.Exec(`UPDATE devices SET status='consumed' WHERE id='already-consumed'`); err != nil {
t.Fatal(err)
}
err := service.completeDevice("already-consumed", "discord", providerIdentity{issuer: "https://discord.com", subject: "123456789"})
if !errors.Is(err, ErrConsumed) {
t.Fatalf("completeDevice error=%v, want ErrConsumed", err)
}
for _, table := range []string{"accounts", "identities", "families", "access_tokens", "refresh_tokens"} {
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("%s has %d rows after rejected completion", table, count)
}
}
}
func TestPollConsumesEncryptedResultExactlyOnce(t *testing.T) {
service, store := testService(t)
result := completeAndPoll(t, service, store, "poll-once", "discord", "https://discord.com", "123456789")
if result.AccessToken == "" || result.RefreshToken == "" {
t.Fatal("poll omitted issued tokens")
}
request := httptest.NewRequest(http.MethodPost, "/auth/device/poll-once/poll", nil)
request.Header.Set("Authorization", "Device device-secret")
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusGone {
t.Fatalf("second poll status=%d body=%q", response.Code, response.Body.String())
}
var status string
var cipher []byte
if err := store.db.QueryRow(`SELECT status,COALESCE(result_cipher,X'') FROM devices WHERE id='poll-once'`).Scan(&status, &cipher); err != nil {
t.Fatal(err)
}
if status != "consumed" || len(cipher) != 0 {
t.Fatalf("device status=%q result bytes=%d", status, len(cipher))
}
}
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, "attempt_id": "rotation-attempt-0001"})
if response.Code != http.StatusOK {
t.Fatalf("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 rotated.RefreshToken == "" || rotated.RefreshToken == first.RefreshToken || rotated.AccessToken == first.AccessToken {
t.Fatal("refresh did not rotate both credentials")
}
if _, err := service.ValidateAccess(first.AccessToken); err == nil {
t.Fatal("old access token survived refresh rotation")
}
if _, err := service.ValidateAccess(rotated.AccessToken); err != nil {
t.Fatalf("new access token rejected: %v", err)
}
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, "attempt_id": "after-replay-attempt-3"})
if next.Code != http.StatusUnauthorized {
t.Fatalf("family refresh after replay status=%d", next.Code)
}
}
func TestRevokeInvalidatesAccessAndRefreshFamily(t *testing.T) {
service, store := testService(t)
tokens := completeAndPoll(t, service, store, "revoke-device", "discord", "https://discord.com", "123456789")
request := httptest.NewRequest(http.MethodPost, "/auth/session/revoke", nil)
request.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("revoke status=%d body=%q", response.Code, response.Body.String())
}
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, "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()
created := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%q", created.Code, created.Body.String())
}
var device struct {
ID string `json:"transaction_id"`
Secret string `json:"device_secret"`
StartURL string `json:"start_url"`
}
if err := json.Unmarshal(created.Body.Bytes(), &device); err != nil {
t.Fatal(err)
}
startURL, _ := url.Parse(device.StartURL)
ticket := startURL.Query().Get("ticket")
start := httptest.NewRequest(http.MethodGet, startURL.RequestURI(), nil)
started := httptest.NewRecorder()
handler.ServeHTTP(started, start)
if started.Code != http.StatusFound {
t.Fatalf("start status=%d body=%q", started.Code, started.Body.String())
}
authorize, _ := url.Parse(started.Header().Get("Location"))
state := authorize.Query().Get("state")
var secretHash, startHash, stateHash, verifierCipher, nonceCipher []byte
if err := store.db.QueryRow(`SELECT secret_hash,start_hash,state_hash,verifier_cipher,nonce_cipher FROM devices WHERE id=?`, device.ID).
Scan(&secretHash, &startHash, &stateHash, &verifierCipher, &nonceCipher); err != nil {
t.Fatal(err)
}
verifier, err := store.open(device.ID, "pkce", verifierCipher)
if err != nil {
t.Fatal(err)
}
nonce, err := store.open(device.ID, "nonce", nonceCipher)
if err != nil {
t.Fatal(err)
}
for name, pair := range map[string]struct{ stored, raw []byte }{
"device secret": {secretHash, []byte(device.Secret)},
"start ticket": {startHash, []byte(ticket)},
"oauth state": {stateHash, []byte(state)},
"pkce verifier": {verifierCipher, verifier},
"oidc nonce": {nonceCipher, nonce},
} {
if bytes.Equal(pair.stored, pair.raw) || bytes.Contains(pair.stored, pair.raw) {
t.Fatalf("%s was stored in plaintext", name)
}
}
const providerSubject = "987654321012345678"
if err := service.completeDevice(device.ID, "discord", providerIdentity{issuer: "https://discord.com", subject: providerSubject}); err != nil {
t.Fatal(err)
}
var subjectHash, sealedResult []byte
if err := store.db.QueryRow(`SELECT subject_hash FROM identities`).Scan(&subjectHash); err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow(`SELECT result_cipher FROM devices WHERE id=?`, device.ID).Scan(&sealedResult); err != nil {
t.Fatal(err)
}
plainResult, err := store.open(device.ID, "result", sealedResult)
if err != nil {
t.Fatal(err)
}
var issued deviceResult
if err := json.Unmarshal(plainResult, &issued); err != nil {
t.Fatal(err)
}
if bytes.Contains(subjectHash, []byte(providerSubject)) {
t.Fatal("provider subject was stored in plaintext")
}
for name, raw := range map[string]string{"access token": issued.AccessToken, "refresh token": issued.RefreshToken} {
if bytes.Contains(sealedResult, []byte(raw)) {
t.Fatalf("pending %s was stored outside AES-GCM ciphertext", name)
}
var count int
table := "access_tokens"
if name == "refresh token" {
table = "refresh_tokens"
}
if err := store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE token_hash=?`, []byte(raw)).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("%s was stored in plaintext", name)
}
}
}
func TestJSONLimitsAndSecurityHeaders(t *testing.T) {
service, _ := testService(t)
handler := service.Handler()
for name, body := range map[string]struct {
body string
want int
}{
"trailing": {`{"provider":"discord"}{}`, http.StatusBadRequest},
"oversize": {`{"provider":"discord","padding":"` + strings.Repeat("x", 17<<10) + `"}`, http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/auth/device", strings.NewReader(body.body))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != body.want {
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
}
for header, want := range map[string]string{
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
} {
if got := response.Header().Get(header); got != want {
t.Fatalf("%s=%q want %q", header, got, want)
}
}
})
}
}
func TestCreateDeviceLimitsPendingTransactionsPerClient(t *testing.T) {
service, store := testService(t)
handler := service.Handler()
for i := range 5 {
response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
if response.Code != http.StatusCreated {
t.Fatalf("create %d status=%d body=%q", i, response.Code, response.Body.String())
}
}
response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
if response.Code != http.StatusTooManyRequests {
t.Fatalf("pending limit status=%d body=%q", response.Code, response.Body.String())
}
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM devices`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 5 {
t.Fatalf("device count=%d want 5", count)
}
}
func TestDecodeProviderJSONRejectsOversizeAndTrailingValues(t *testing.T) {
var target map[string]any
if err := decodeProviderJSON(strings.NewReader(`{"id":"1"}{}`), &target); err == nil {
t.Fatal("accepted provider response with trailing JSON")
}
oversize := `{"padding":"` + strings.Repeat("x", 1<<20) + `"}`
if err := decodeProviderJSON(strings.NewReader(oversize), &target); err == nil {
t.Fatal("accepted oversized provider response")
}
}
func TestRequestLimiterIsBoundedAndExpiresWindows(t *testing.T) {
limiter := requestLimiter{windows: make(map[string]limitWindow)}
now := time.Unix(testNowUnix, 0)
for i := range 4096 {
if !limiter.allow(strconv.Itoa(i), now, time.Minute, 1) {
t.Fatalf("rejected window %d before capacity", i)
}
}
if limiter.allow("overflow", now, time.Minute, 1) {
t.Fatal("accepted a limiter key beyond its bounded capacity")
}
if !limiter.allow("after-expiry", now.Add(time.Minute), time.Minute, 1) {
t.Fatal("did not clean expired limiter windows")
}
}
func TestCleanupRetainsUsedRefreshForReplayUntilFamilyExpiry(t *testing.T) {
service, store := testService(t)
tokens := completeAndPoll(t, service, store, "cleanup-device", "discord", "https://discord.com", "123456789")
if _, err := store.db.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=?`, testNowUnix, store.digest("refresh-token", tokens.RefreshToken)); err != nil {
t.Fatal(err)
}
tx, err := store.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := cleanupExpired(tx, testNowUnix+int64((29*24*time.Hour).Seconds())); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE used_at IS NOT NULL`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("used refresh token was removed before family expiry")
}
tx, err = store.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := cleanupExpired(tx, testNowUnix+int64((31*24*time.Hour).Seconds())); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("expired family refresh token was not cleaned")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func jsonResponse(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
func TestProviderIdentityVerification(t *testing.T) {
service, _ := testService(t)
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch request.URL.Host + request.URL.Path {
case "discord.com/api/v10/oauth2/token":
return jsonResponse(http.StatusOK, `{"access_token":"provider-access"}`), nil
case "discord.com/api/v10/users/@me":
if request.Header.Get("Authorization") != "Bearer provider-access" {
t.Fatal("Discord bearer token missing")
}
return jsonResponse(http.StatusOK, `{"id":"123456789"}`), nil
case "oauth2.googleapis.com/token":
return jsonResponse(http.StatusOK, `{"access_token":"provider-access","id_token":"signed-id-token"}`), nil
case "oauth2.googleapis.com/tokeninfo":
return jsonResponse(http.StatusOK, `{"iss":"https://accounts.google.com","aud":"google-client","sub":"google-subject","nonce":"expected-nonce","exp":"1900000000"}`), nil
default:
t.Fatalf("unexpected provider request %s", request.URL)
return nil, nil
}
})}
discord, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
if err != nil || discord.issuer != "https://discord.com" || discord.subject != "123456789" {
t.Fatalf("Discord identity=%+v err=%v", discord, err)
}
google, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "expected-nonce")
if err != nil || google.issuer != "https://accounts.google.com" || google.subject != "google-subject" {
t.Fatalf("Google identity=%+v err=%v", google, err)
}
if _, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "wrong-nonce"); err == nil {
t.Fatal("Google identity accepted the wrong OIDC nonce")
}
}
func TestProviderFailureIsStructuredAndSanitized(t *testing.T) {
service, _ := testService(t)
secretDescription := "provider leaked secret sentinel"
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusUnauthorized, `{"error":"invalid_client","error_description":"`+secretDescription+`"}`), nil
})}
_, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
var failure *providerFailure
if !errors.As(err, &failure) {
t.Fatalf("error %T does not expose provider failure", err)
}
if failure.Provider != "discord" || failure.Stage != "token_exchange" || failure.Reason != "http_rejected" ||
failure.HTTPStatus != http.StatusUnauthorized || failure.OAuthError != "invalid_client" {
t.Fatalf("failure=%+v", failure)
}
if strings.Contains(err.Error(), secretDescription) {
t.Fatal("provider error description leaked through diagnostic error")
}
}
func TestProviderFailureRejectsUntrustedOAuthError(t *testing.T) {
service, _ := testService(t)
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusBadRequest, `{"error":"access-token-sentinel"}`), nil
})}
_, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
var failure *providerFailure
if !errors.As(err, &failure) || failure.OAuthError != "unknown" {
t.Fatalf("failure=%+v err=%v", failure, err)
}
if strings.Contains(err.Error(), "access-token-sentinel") {
t.Fatal("untrusted provider error leaked through diagnostic error")
}
}
func TestGoogleProviderNetworkFailureDoesNotLeakIDTokenURL(t *testing.T) {
service, _ := testService(t)
idToken := "signed-id-token-sentinel"
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path == "/token" {
return jsonResponse(http.StatusOK, `{"access_token":"provider-access","id_token":"`+idToken+`"}`), nil
}
return nil, &url.Error{Op: "Get", URL: "https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken, Err: errors.New("transport sentinel")}
})}
_, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "nonce")
var failure *providerFailure
if !errors.As(err, &failure) || failure.Provider != "google" || failure.Stage != "id_token_verify" || failure.Reason != "network_error" {
t.Fatalf("failure=%+v err=%v", failure, err)
}
if strings.Contains(err.Error(), idToken) || strings.Contains(err.Error(), "transport sentinel") {
t.Fatal("Google ID token URL or transport details leaked through diagnostic error")
}
}
func TestCallbackFailureClearsShortLivedOAuthMaterial(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "failed-device", "discord")
state := "failed-state"
verifier, err := store.seal("failed-device", "pkce", []byte("verifier"))
if err != nil {
t.Fatal(err)
}
nonce, err := store.seal("failed-device", "nonce", []byte("nonce"))
if err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`UPDATE devices SET state_hash=?,verifier_cipher=?,nonce_cipher=? WHERE id='failed-device'`, store.digest("oauth-state", state), verifier, nonce); err != nil {
t.Fatal(err)
}
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusUnauthorized, `{"error":"invalid_client"}`), nil
})}
request := httptest.NewRequest(http.MethodGet, "/auth/discord/callback?code=failed-code&state="+url.QueryEscape(state), nil)
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadGateway || !strings.Contains(response.Body.String(), "server OAuth configuration is invalid") {
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
}
var status string
var stateHash, verifierCipher, nonceCipher []byte
if err := store.db.QueryRow(`SELECT status,COALESCE(state_hash,X''),COALESCE(verifier_cipher,X''),COALESCE(nonce_cipher,X'') FROM devices WHERE id='failed-device'`).Scan(&status, &stateHash, &verifierCipher, &nonceCipher); err != nil {
t.Fatal(err)
}
if status != "failed" || len(stateHash) != 0 || len(verifierCipher) != 0 || len(nonceCipher) != 0 {
t.Fatalf("status=%q state=%d verifier=%d nonce=%d", status, len(stateHash), len(verifierCipher), len(nonceCipher))
}
}
func TestProviderScopesUseLeastPrivilege(t *testing.T) {
if got := providerScope("discord"); got != "identify" {
t.Fatalf("Discord scope=%q, want identify", got)
}
if got := providerScope("google"); got != "openid" {
t.Fatalf("Google scope=%q, want openid", got)
}
}
func TestProviderErrorConsumesAuthorizationStateWithoutExchange(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "cancelled-device", "discord")
state := "cancelled-oauth-state"
if _, err := store.db.Exec(`UPDATE devices SET state_hash=? WHERE id='cancelled-device'`, store.digest("oauth-state", state)); err != nil {
t.Fatal(err)
}
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
t.Fatal("provider error callback attempted a token exchange")
return nil, nil
})}
request := httptest.NewRequest(http.MethodGet, "/auth/discord/callback?error=access_denied&state="+url.QueryEscape(state), nil)
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
}
var status, errorCode string
var stateHash, verifier, nonce []byte
if err := store.db.QueryRow(`SELECT status,error_code,COALESCE(state_hash,X''),COALESCE(verifier_cipher,X''),COALESCE(nonce_cipher,X'') FROM devices WHERE id='cancelled-device'`).
Scan(&status, &errorCode, &stateHash, &verifier, &nonce); err != nil {
t.Fatal(err)
}
if status != "failed" || errorCode != "provider_cancelled" || len(stateHash) != 0 || len(verifier) != 0 || len(nonce) != 0 {
t.Fatalf("cancelled device status=%q error=%q state=%d verifier=%d nonce=%d", status, errorCode, len(stateHash), len(verifier), len(nonce))
}
}
func TestCompleteDeviceRejectsMalformedProviderIdentity(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "bad-identity", "discord")
if err := service.completeDevice("bad-identity", "discord", providerIdentity{issuer: "https://discord.com", subject: "not-a-snowflake"}); err == nil {
t.Fatal("accepted malformed Discord identity")
}
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM identities`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("malformed identity was persisted")
}
}
-97
View File
@@ -1,97 +0,0 @@
package auth
import (
"bytes"
"database/sql"
"path/filepath"
"testing"
)
func TestStoreRequiresAndClearsExactMasterKey(t *testing.T) {
if _, err := Open(filepath.Join(t.TempDir(), "short.db"), make([]byte, 31)); err == nil {
t.Fatal("store accepted a non-256-bit master key")
}
key := bytes.Repeat([]byte{0x7a}, 32)
store, err := Open(filepath.Join(t.TempDir(), "auth.db"), key)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := store.Close(); err != nil {
t.Error(err)
}
}()
for index, value := range key {
if value != 0 {
t.Fatalf("master key byte %d was retained by the caller buffer", index)
}
}
}
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 {
if err := db.Close(); err != nil {
t.Error(err)
}
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 func() {
if err := reopened.Close(); err != nil {
t.Error(err)
}
}()
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))
if err != nil {
t.Fatal(err)
}
var version int
if err := store.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != schemaVersion {
t.Fatalf("schema_version=%d, want %d", version, schemaVersion)
}
if _, err := store.db.Exec(`UPDATE metadata SET value='999' WHERE key='schema_version'`); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
if _, err := Open(path, bytes.Repeat([]byte{0x35}, 32)); err == nil {
t.Fatal("store accepted an unknown schema version")
}
}
@@ -1,99 +0,0 @@
package authconfig
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), FileName)
if err := os.WriteFile(path, []byte(`{"mode":"oauth","public_url":"https://example.com","master_key_env":"MASTER","providers":{"discord":{"client_id":"d","client_secret_env":"DS"},"google":{"client_id":"g","client_secret_env":"GS"}}}`), 0o600); err != nil {
t.Fatal(err)
}
config, err := Load(path)
if err != nil {
t.Fatal(err)
}
if config.Mode != "oauth" || len(config.Providers) != 2 || config.Providers["discord"].ClientID != "d" || config.Providers["google"].ClientID != "g" {
t.Fatalf("unexpected config: %+v", config)
}
}
func TestValidateRejectsUnsafePolicies(t *testing.T) {
for name, config := range map[string]Config{
"unknown mode": {Mode: "disabled"},
"local providers": {Mode: "local", Providers: map[string]ProviderConfig{"discord": {ClientID: "d", ClientSecretEnv: "DS"}}},
"empty oauth": {Mode: "oauth"},
"unknown provider": {Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", Providers: map[string]ProviderConfig{"github": {ClientID: "g", ClientSecretEnv: "GS"}}},
} {
t.Run(name, func(t *testing.T) {
if err := config.Validate(); err == nil {
t.Fatal("accepted invalid authentication policy")
}
})
}
}
func TestResolveEnvironmentKeepsSecretsOutOfPublicView(t *testing.T) {
master := make([]byte, 32)
for i := range master {
master[i] = byte(i + 1)
}
t.Setenv("AUTH_MASTER", base64.StdEncoding.EncodeToString(master))
t.Setenv("DISCORD_SECRET", "private-discord-secret")
config := Config{
Mode: "oauth",
PublicURL: "https://example.com",
MasterKeyEnv: "AUTH_MASTER",
Providers: map[string]ProviderConfig{
"discord": {ClientID: "public-client-id", ClientSecretEnv: "DISCORD_SECRET"},
},
}
runtime, err := config.ResolveEnvironment()
if err != nil {
t.Fatal(err)
}
if len(runtime.MasterKey) != 32 || runtime.ProviderSecrets["discord"] != "private-discord-secret" {
t.Fatal("runtime did not resolve authentication secrets")
}
publicJSON, err := json.Marshal(config.Public())
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"AUTH_MASTER", "DISCORD_SECRET", "private-discord-secret", "public-client-id"} {
if strings.Contains(string(publicJSON), forbidden) {
t.Fatalf("public authentication view leaked %q: %s", forbidden, publicJSON)
}
}
}
func TestValidateRejectsUnsafePublicURLsAndTTLs(t *testing.T) {
base := Config{
Mode: "oauth",
PublicURL: "https://example.com",
MasterKeyEnv: "MASTER",
Providers: map[string]ProviderConfig{
"discord": {ClientID: "d", ClientSecretEnv: "DS"},
},
}
for name, mutate := range map[string]func(*Config){
"http public": func(c *Config) { c.PublicURL = "http://example.com" },
"path": func(c *Config) { c.PublicURL = "https://example.com/auth" },
"query": func(c *Config) { c.PublicURL = "https://example.com?x=y" },
"missing secret": func(c *Config) { c.Providers["discord"] = ProviderConfig{ClientID: "d"} },
"invalid ttl": func(c *Config) { c.Session.AccessTTL = "0s" },
} {
t.Run(name, func(t *testing.T) {
candidate := base
candidate.Providers = map[string]ProviderConfig{"discord": base.Providers["discord"]}
mutate(&candidate)
if err := candidate.Validate(); err == nil {
t.Fatal("accepted unsafe authentication configuration")
}
})
}
}
-258
View File
@@ -1,258 +0,0 @@
package battle
import (
"fmt"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
func TestQuestBattleSelectsAndRetainsDifficultyDeck(t *testing.T) {
s := NewService("design", "version", nil, func() (int, error) { return 1, nil })
s.AttachCurrentDifficulty(func() (uint64, error) { return 2, nil })
s.loadDifficultyDeck = func(_, _ string, pack int, deck, difficulty uint64) (uint64, error) {
if pack != 1 || deck != 1 || difficulty != 2 {
t.Fatalf("selection %d/%d/%d", pack, deck, difficulty)
}
return 200001, nil
}
s.loadPhases = func(_, _ string, pack int, monster, deck uint64) ([]gamedata.BattlePhase, error) {
if pack != 1 || deck != 200001 {
t.Fatalf("phase selection %d/%d", pack, deck)
}
return nil, nil
}
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 1), 5, 1)
_, response, _, err := s.Handle("/BattleEnter", enter)
if err != nil {
t.Fatal(err)
}
deck, _, _ := wire.Varint(response, 2)
if deck != 200001 {
t.Fatalf("deck %d", deck)
}
start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 200001), 5, wire.AppendVarint(nil, 2, 101))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
_, response, _, err = s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 200001))
if err != nil {
t.Fatal(err)
}
deck, _, _ = wire.Varint(response, 3)
if deck != 200001 {
t.Fatalf("retry deck %d", deck)
}
}
func TestLocalBattleLifecycle(t *testing.T) {
s := &Service{}
enter := request(1)
enter = wire.AppendVarint(enter, 4, 1)
enter = wire.AppendVarint(enter, 5, 1)
code, response, ok, err := s.Handle("/BattleEnter", enter)
if err != nil || !ok || code != 52 {
t.Fatalf("enter: %d %v %v", code, ok, err)
}
engine, found, _ := wire.Varint(response, 6)
if !found || engine != 1 {
t.Fatalf("engine=%d/%v", engine, found)
}
red := wire.AppendVarint(nil, 1, 1)
blue := wire.AppendVarint(nil, 1, 6)
start := request(2)
start = wire.AppendVarint(start, 2, 1)
start = wire.AppendBytes(start, 4, red)
start = wire.AppendBytes(start, 5, blue)
code, response, _, err = s.Handle("/BattleStart", start)
if err != nil || code != 14 {
t.Fatalf("start: %d %v", code, err)
}
if _, found, _ := wire.Bytes(response, 1); !found {
t.Fatal("red state not echoed")
}
if _, found, _ := wire.Bytes(response, 2); !found {
t.Fatal("blue state not echoed")
}
code, response, _, err = s.Handle("/BattleVerifyState", request(3))
state, found, _ := wire.Varint(response, 1)
if err != nil || code != 142 || !found || state != 3 {
t.Fatalf("verify: code=%d state=%d/%v err=%v", code, state, found, err)
}
end := request(4)
end = wire.AppendVarint(end, 2, 2)
end = wire.AppendBytes(end, 3, blue)
code, response, _, err = s.Handle("/BattleEnd", end)
result, found, _ := wire.Varint(response, 1)
if err != nil || code != 15 || !found || result != 2 {
t.Fatalf("end: code=%d result=%d/%v err=%v", code, result, found, err)
}
}
func TestBattleOrdering(t *testing.T) {
s := &Service{}
start := request(1)
start = wire.AppendVarint(start, 2, 1)
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
t.Fatal("start without enter accepted")
}
}
func TestBattleRetryRestoresFirstSubmittedBlueTeam(t *testing.T) {
s := &Service{}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 4), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
first := wire.AppendVarint(wire.AppendVarint(nil, 2, 101), 4, 513)
second := wire.AppendVarint(wire.AppendVarint(nil, 2, 102), 4, 200)
start := wire.AppendVarint(request(2), 2, 77)
start = wire.AppendBytes(start, 5, first)
start = wire.AppendBytes(start, 5, second)
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
retry := wire.AppendVarint(request(3), 2, 77)
code, response, handled, err := s.Handle("/BattleRetry", retry)
if err != nil || !handled || code != 58 {
t.Fatalf("retry code=%d handled=%v err=%v", code, handled, err)
}
index, found, err := wire.Varint(response, 3)
if err != nil || !found || index != 77 {
t.Fatalf("retry battle index=%d found=%v err=%v", index, found, err)
}
var restored [][]byte
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 2 {
restored = append(restored, field.Value)
}
return nil
}); err != nil || len(restored) != 2 || string(restored[0]) != string(first) || string(restored[1]) != string(second) {
t.Fatalf("retry team=%x err=%v", restored, err)
}
}
func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) {
s := &Service{}
s.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) {
return []gamedata.PictorialBuffStat{{StatType: 2, Value: .0175}, {StatType: 4, Value: .01}}, nil
})
enter := wire.AppendVarint(wire.AppendVarint(request(9), 4, 1), 5, 1)
code, response, handled, err := s.Handle("/BattleEnter", enter)
if err != nil || !handled || code != 52 {
t.Fatalf("battle entry code=%d handled=%v err=%v", code, handled, err)
}
var count int
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 4 {
count++
stat, found, err := wire.Varint(field.Value, 1)
if err != nil || !found || (stat != 2 && stat != 4) {
t.Fatalf("invalid battle buff stat=%d found=%v err=%v", stat, found, err)
}
}
return nil
}); err != nil || count != 2 {
t.Fatalf("battle buffs count=%d err=%v", count, err)
}
}
func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) {
storage := stateio.NewMemory()
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
currentPack := 22
s := NewService("test-root", "test-version", inventory, func() (int, error) {
return currentPack, nil
})
var loadedPack int
var loadedDeck uint64
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) { return nil, nil }
s.loadRewards = func(_, _ string, packID int, deckID uint64) ([]gamedata.BattleReward, error) {
loadedPack, loadedDeck = packID, deckID
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}, nil
}
enter := wire.AppendVarint(request(1), 3, 7)
enter = wire.AppendVarint(enter, 4, 9)
enter = wire.AppendVarint(enter, 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
// A later world transition must not change the identity of an in-flight
// battle; the pack is captured at BattleEnter.
currentPack = 21
end := wire.AppendVarint(request(2), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
t.Fatal(err)
}
if loadedPack != 22 || loadedDeck != 9 {
t.Fatalf("reward lookup pack/deck=%d/%d, want 22/9", loadedPack, loadedDeck)
}
if got := inventory.GrantedItems("pack22:monster7:deck9"); len(got) != 1 || got[0].ID != 8 || got[0].Count != 3 {
t.Fatalf("pack22 reward grant=%+v", got)
}
if got := inventory.GrantedItems("pack21:monster7:deck9"); len(got) != 0 {
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 := range 1024 {
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")
}
}
-72
View File
@@ -1,72 +0,0 @@
package battle
import (
"bytes"
"fmt"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
type fakeEventBattle struct {
enter, end string
rejected bool
}
func (*fakeEventBattle) HandlesBattle(mode uint64) bool { return mode == 17 }
func (e *fakeEventBattle) EnterBattle(_ []byte, receipt string) ([]byte, error) {
e.enter = receipt
if e.rejected {
return nil, fmt.Errorf("stage locked")
}
return wire.AppendVarint(nil, 1, 7), nil
}
func (e *fakeEventBattle) CompleteBattle(_ []byte, receipt string) ([]byte, error) {
e.end = receipt
return wire.AppendBytes(nil, 16, wire.AppendVarint(nil, 3, 77)), nil
}
func TestEventBattleOwnsSettlementAndAvoidsWorldDeck(t *testing.T) {
s := NewService("unused", "unused", nil, func() (int, error) { return 21, nil })
s.BeginSession("event-session")
e := &fakeEventBattle{}
s.AttachEventBattle(e)
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
t.Fatal("event read ordinary world deck")
return nil, nil
}
s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("event altered world health"); return nil })
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 73), 5, 17)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(2), 2, 73)); err != nil {
t.Fatal(err)
}
code, out, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
if err != nil || code != 15 {
t.Fatalf("settlement: %d %v", code, err)
}
if e.enter != "event-session:1" || e.end != e.enter {
t.Fatalf("receipt %q/%q", e.enter, e.end)
}
row, _, _ := wire.Bytes(out, 16)
if !bytes.Equal(row, wire.AppendVarint(nil, 3, 77)) {
t.Fatal("event progress lost")
}
if s.Active() {
t.Fatal("event battle remained active")
}
_, retry, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
if err != nil || !bytes.Equal(retry, out) {
t.Fatal("settlement retry lost reply")
}
if _, _, _, err = s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 2)); err == nil {
t.Fatal("changed settlement retry accepted")
}
e.rejected = true
if _, _, _, err = s.Handle("/BattleEnter", enter); err == nil {
t.Fatal("locked stage entered")
}
}
@@ -1,56 +0,0 @@
package battle
import (
"bd2server/internal/server/wire"
"errors"
"testing"
)
func TestFieldBuffConsumptionAfterValidationAndBeforeBattleActivation(t *testing.T) {
s := NewService("", "", nil, nil)
s.BeginSession("first-session")
remaining := 3
used := map[string]bool{}
fail := false
s.AttachFieldBuffConsume(func(identity string) error {
if fail {
return errors.New("field buff persistence failed")
}
if !used[identity] {
remaining--
used[identity] = true
}
return nil
})
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", request(1)); err == nil || remaining != 3 {
t.Fatal("invalid battle consumed buff")
}
fail = true
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
t.Fatal("persistence failure activated battle")
}
fail = false
for range 2 {
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
}
if remaining != 2 {
t.Fatal("same enter receipt consumed twice")
}
start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 1), 5, wire.AppendVarint(nil, 1, 6))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 1)); err != nil {
t.Fatal(err)
}
if remaining != 2 {
t.Fatal("retry consumed extra field buff")
}
s.BeginSession("second-session")
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil || remaining != 1 {
t.Fatal("new battle did not consume once", err)
}
}
-132
View File
@@ -1,132 +0,0 @@
package battle
import (
"strconv"
"testing"
"bd2server/internal/server/wire"
)
func TestBattleDefeatAndRunCommitZeroAndInjuredHealth(t *testing.T) {
// EBattleResultType: RED_WIN=2, CONDITION_FAIL=3, RUN=4. A dead
// CharDBInfo omits hp because proto3's default is zero; it must be committed
// rather than ignored and replaced with an earlier full-health value.
for _, result := range []uint64{2, 3, 4} {
t.Run(strconv.FormatUint(result, 10), func(t *testing.T) {
s := &Service{}
calls := 0
var saved map[uint64]uint64
s.AttachCommittedHealth(func(health map[uint64]uint64) error { calls++; saved = health; return nil })
s.BeginSession("settlement")
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
start := wire.AppendVarint(request(2), 2, 11)
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 100))
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 200), 4, 100))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
end := wire.AppendVarint(request(3), 2, result)
end = wire.AppendBytes(end, 3, wire.AppendVarint(nil, 1, 100))
end = wire.AppendBytes(end, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 200), 3, 21))
_, response, _, err := s.Handle("/BattleEnd", end)
if err != nil {
t.Fatal(err)
}
if calls != 1 || len(saved) != 2 || saved[100] != 0 || saved[200] != 21 {
t.Fatalf("result=%d saved health=%v calls=%d", result, saved, calls)
}
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 3 {
index, _, _ := wire.Varint(field.Value, 1)
hp, _, _ := wire.Varint(field.Value, 3)
if hp != saved[index] {
t.Fatal("response differs from settled HP")
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleExit", request(4)); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatal("exit wrote health after settlement")
}
})
}
}
func TestBattleHealthCommitsOnlyAtEndAndChecksParticipants(t *testing.T) {
s := &Service{}
calls := 0
var health map[uint64]uint64
s.AttachCommittedHealth(func(values map[uint64]uint64) error {
calls++
for index, hp := range values {
values[index] = min(hp, 50)
}
health = values
return nil
})
s.BeginSession("first")
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
start := wire.AppendVarint(request(2), 2, 11)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 60)
start = wire.AppendBytes(start, 5, blue)
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
if calls != 0 || !s.Active() {
t.Fatal("round state was committed or battle was not active")
}
invalid := wire.AppendVarint(request(3), 2, 1)
invalid = wire.AppendBytes(invalid, 3, wire.AppendVarint(nil, 1, 999))
if _, _, _, err := s.Handle("/BattleEnd", invalid); err == nil || calls != 0 {
t.Fatal("accepted health for a character outside the blue team")
}
result := wire.AppendVarint(request(4), 2, 1)
character := wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 17)
result = wire.AppendBytes(result, 3, character)
if _, _, _, err := s.Handle("/BattleEnd", result); err != nil {
t.Fatal(err)
}
if calls != 1 || health[100] != 17 || s.Active() {
t.Fatalf("health=%v calls=%d active=%t", health, calls, s.Active())
}
// A second battle interrupted by a login never commits its round health.
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
s.BeginSession("reconnected")
if calls != 1 || s.Active() {
t.Fatal("reconnect persisted unfinished battle health")
}
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
result = wire.AppendVarint(request(5), 2, 1)
character = wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 999)
result = wire.AppendBytes(result, 3, character)
_, body, _, err := s.Handle("/BattleEnd", result)
if err != nil {
t.Fatal(err)
}
returned, _, _ := wire.Bytes(body, 3)
hp, _, _ := wire.Varint(returned, 3)
if health[100] != 50 || hp != 50 {
t.Fatalf("committed and returned HP differ: saved=%v response=%d", health, hp)
}
}
-69
View File
@@ -1,69 +0,0 @@
package battle
import (
"bytes"
"errors"
"testing"
"bd2server/internal/server/wire"
)
type huntingFixture struct {
validationError error
settlementError error
pack int
mode, monster, deck uint64
receipt string
settlements int
}
func (h *huntingFixture) ValidateBattle(pack int, mode, monster, deck uint64) error {
h.pack, h.mode, h.monster, h.deck = pack, mode, monster, deck
return h.validationError
}
func (h *huntingFixture) CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) {
h.pack, h.mode, h.monster, h.deck, h.receipt = pack, mode, monster, deck, receipt
h.settlements++
return wire.AppendVarint(nil, 2, 7), [][]byte{wire.AppendVarint(nil, 1, monster)}, h.settlementError
}
func TestHuntingBattleUsesLockedEncounterAndSessionReceipt(t *testing.T) {
pack := 1
s := NewService("", "", nil, func() (int, error) { return pack, nil })
h := &huntingFixture{}
s.AttachHunting(h)
s.BeginSession("login-A")
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 11), 4, 12), 5, huntingGroundMode)
h.validationError = errors.New("locked difficulty")
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
t.Fatal("invalid encounter accepted")
}
h.validationError = nil
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
pack = 2
h.settlementError = errors.New("not enough AP")
end := wire.AppendVarint(request(2), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() {
t.Fatal("failed settlement ended battle")
}
h.settlementError = nil
code, response, _, err := s.Handle("/BattleEnd", end)
if err != nil || code != 15 {
t.Fatalf("settle: %d %v", code, err)
}
if h.pack != 1 || h.mode != huntingGroundMode || h.monster != 11 || h.deck != 12 || h.receipt != "login-A:2" {
t.Fatalf("settled wrong encounter: %+v", h)
}
if _, found, _ := wire.Bytes(response, 4); !found {
t.Fatal("missing monster progress")
}
if bundle, found, _ := wire.Bytes(response, 5); !found || len(bundle) == 0 {
t.Fatal("missing hunting reward")
}
settlements := h.settlements
if _, retry, _, err := s.Handle("/BattleEnd", end); err != nil || !bytes.Equal(retry, response) || h.settlements != settlements {
t.Fatal("completed battle retry settled twice or lost response")
}
}
@@ -1,72 +0,0 @@
package battle
import (
"errors"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
type monsterHuntFixture struct {
enter, end []byte
enterReceipt, endReceipt string
err error
}
func (h *monsterHuntFixture) EnterBattle(request []byte, receipt string) ([]byte, error) {
h.enter, h.enterReceipt = append([]byte(nil), request...), receipt
return wire.AppendBytes(nil, 5, wire.AppendVarint(nil, 1, 11)), h.err
}
func (h *monsterHuntFixture) CompleteBattle(request []byte, receipt string) ([]byte, error) {
h.end, h.endReceipt = append([]byte(nil), request...), receipt
return wire.AppendBytes(nil, 13, wire.AppendVarint(nil, 1, 11)), h.err
}
func TestMonsterHuntBattleUsesSpecialRuntimeAndLeavesFieldHealthAlone(t *testing.T) {
for _, mode := range []uint64{8, 24} {
s := NewService("", "", nil, func() (int, error) { return 1, nil })
h := &monsterHuntFixture{}
s.AttachMonsterHunt(h)
s.BeginSession("login-A")
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
t.Fatal("monster hunt used pack phases")
return nil, nil
}
s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("monster hunt changed field HP"); return nil })
s.AttachMonsterWinMission(func() error { t.Fatal("monster hunt awarded field monster mission"); return nil })
s.AttachTutorialWin(func() error { t.Fatal("monster hunt awarded tutorial progress"); return nil })
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 4, 12), 5, mode), 6, 91)
h.err = errors.New("hunt unavailable")
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
t.Fatal("rejected hunt entered")
}
h.err = nil
code, response, _, err := s.Handle("/BattleEnter", enter)
if err != nil || code != 52 {
t.Fatalf("enter: %d %v", code, err)
}
if _, found, _ := wire.Bytes(response, 5); !found {
t.Fatal("missing hunt user info at enter")
}
end := wire.AppendVarint(wire.AppendVarint(request(2), 2, 1), 7, 1234)
h.err = errors.New("invalid hunt settlement")
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() {
t.Fatal("failed hunt settlement finished battle")
}
h.err = nil
code, response, _, err = s.Handle("/BattleEnd", end)
if err != nil || code != 15 || h.enterReceipt != "login-A:1" || h.endReceipt != h.enterReceipt {
t.Fatalf("end: %d %v %+v", code, err, h)
}
if _, found, _ := wire.Bytes(response, 13); !found {
t.Fatal("missing hunt settlement progress")
}
if _, found, _ := wire.Bytes(response, 5); found {
t.Fatal("hunt used ordinary pack reward bundle")
}
if s.Active() {
t.Fatal("hunt battle still active")
}
}
}
-208
View File
@@ -1,208 +0,0 @@
package battle
import (
"bytes"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func phaseService(t *testing.T) *Service {
t.Helper()
s := &Service{loadPhases: func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
return []gamedata.BattlePhase{{GroupID: 70, ID: 101, DeckID: 11}, {GroupID: 70, ID: 205, DeckID: 12}, {GroupID: 70, ID: 309, DeckID: 13}}, nil
}}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
return s
}
func phaseStart(t *testing.T, s *Service, seq, deck uint64, blue []byte) []byte {
t.Helper()
start := wire.AppendVarint(request(seq), 2, deck)
start = wire.AppendBytes(start, 5, blue)
code, reply, _, err := s.Handle("/BattleStart", start)
if err != nil || code != 14 {
t.Fatalf("start %d: code=%d err=%v", deck, code, err)
}
return reply
}
func TestPhaseLifecyclePreservesClientStateAndRejectsSkipping(t *testing.T) {
s := phaseService(t)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 1234)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(2)); err == nil {
t.Fatal("phase advanced before start")
}
phaseStart(t, s, 3, 11, blue)
commits := 0
s.AttachCommittedHealth(func(map[uint64]uint64) error { commits++; return nil })
win := wire.AppendVarint(request(4), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("early victory accepted")
}
if commits != 0 {
t.Fatal("early victory committed health")
}
code, reply, _, err := s.Handle("/BattlePhaseChange", request(5))
if err != nil || code != 632 {
t.Fatalf("phase response=%d %v", code, err)
}
for field, want := range map[int]uint64{1: 70, 2: 205, 8: 12} {
got, found, err := wire.Varint(reply, field)
if err != nil || !found || got != want {
t.Fatalf("field %d = %d/%v: %v", field, got, found, err)
}
}
if result, found, _ := wire.Bytes(reply, 3); found && len(result) != 0 {
t.Fatal("fabricated verified battle result")
}
verify, _, _ := wire.Varint(reply, 7)
if verify != 0 {
t.Fatal("verification enabled without authoritative combat state")
}
_, replay, _, err := s.Handle("/BattlePhaseChange", request(5))
if err != nil || !bytes.Equal(reply, replay) || s.stateLocked().phase != 1 {
t.Fatal("replay advanced phase")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err == nil {
t.Fatal("skipped unstarted phase")
}
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(7), 2, 13)); err == nil {
t.Fatal("skipped deck accepted")
}
changedBlue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 600)
response := phaseStart(t, s, 8, 12, changedBlue)
got, _, _ := wire.Bytes(response, 2)
if !bytes.Equal(got, changedBlue) || !bytes.Equal(s.stateLocked().initialBlue[0], blue) {
t.Fatal("phase start reset player state or retry baseline")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(9)); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("victory before final start")
}
phaseStart(t, s, 10, 13, changedBlue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(11)); err == nil {
t.Fatal("advanced past final phase")
}
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(12)); err == nil {
t.Fatal("inactive phase request accepted")
}
}
func TestPhaseRetryRestoresFirstDeckAndInitialBlue(t *testing.T) {
s := phaseService(t)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 401), 4, 200)
phaseStart(t, s, 2, 11, blue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 4, 12, wire.AppendVarint(nil, 4, 1))
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(5), 2, 11)); err == nil {
t.Fatal("retry accepted wrong current deck")
}
code, response, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(6), 2, 12))
index, _, _ := wire.Varint(response, 3)
restored, _, _ := wire.Bytes(response, 2)
if err != nil || code != 58 || index != 11 || !bytes.Equal(restored, blue) {
t.Fatalf("retry: code=%d index=%d blue=%x err=%v", code, index, restored, err)
}
state := s.stateLocked()
if state.phase != 0 || state.deck != 11 || state.phaseStarted || state.phaseReply != nil {
t.Fatal("retry did not reset phase state")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(7)); err == nil {
t.Fatal("retry advanced before start")
}
}
func TestMalformedPhaseStartDoesNotConsumeRound(t *testing.T) {
s := phaseService(t)
start := append(wire.AppendVarint(request(2), 2, 11), 0x2a, 0x80)
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
t.Fatal("malformed start accepted")
}
if state := s.stateLocked(); state.round != 0 || state.index != 0 || state.phaseStarted {
t.Fatal("malformed request mutated battle")
}
}
func TestPhaseEntryRejectsLaterDeckWithoutReplacingActiveBattle(t *testing.T) {
s := phaseService(t)
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
enter := wire.AppendVarint(wire.AppendVarint(request(3), 4, 12), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil {
t.Fatal("entered a later phase directly")
}
state := s.stateLocked()
if state.deck != 11 || state.index != 11 || !state.phaseStarted {
t.Fatal("rejected enter replaced active battle")
}
}
func TestOrdinaryBattleRejectsPhaseChange(t *testing.T) {
s := &Service{}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err == nil {
t.Fatal("ordinary battle accepted phase change")
}
}
func TestPhaseVictoryRewardsOnlyFinalDeck(t *testing.T) {
s := phaseService(t)
var err error
s.inventory, err = player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s.gameDataRoot = "test-root"
s.stateLocked().pack, s.stateLocked().monster = 77, 42
loaded := uint64(0)
s.loadRewards = func(_, _ string, pack int, deck uint64) ([]gamedata.BattleReward, error) {
if pack != 77 {
t.Fatalf("reward pack = %d", pack)
}
loaded = deck
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 1}}, nil
}
blue := wire.AppendVarint(nil, 2, 400)
phaseStart(t, s, 2, 11, blue)
win := wire.AppendVarint(request(3), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("early win accepted")
}
if loaded != 0 {
t.Fatal("early win loaded rewards")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(4)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 5, 12, blue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 7, 13, blue)
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
t.Fatal(err)
}
if loaded != 13 || len(s.inventory.GrantedItems("pack77:monster42:deck13")) != 1 {
t.Fatalf("final reward deck=%d", loaded)
}
if len(s.inventory.GrantedItems("pack77:monster42:deck11")) != 0 {
t.Fatal("granted first phase reward")
}
}
@@ -1,82 +0,0 @@
package bootstrap
import (
"testing"
"time"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
func TestMaintenance(t *testing.T) {
req := wire.AppendVarint(nil, 1, 2)
req = wire.AppendVarint(req, 2, 8)
response, err := Maintenance(versionconfig.Game(), versionconfig.Bundle(), req)
if err != nil {
t.Fatal(err)
}
market, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("market info missing: %v", err)
}
typ, _, _ := wire.Varint(market, 1)
if typ != 4 {
t.Fatalf("market type does not match response contract: %d", typ)
}
connect, _, _ := wire.Varint(response, 3)
user, _, _ := wire.Varint(response, 4)
if connect != 1 || user == 0 {
t.Fatalf("client would enter update branch: connect=%d user=%d", connect, user)
}
}
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.Game(), BundleVer: versionconfig.Bundle()}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
response := ServerInfo(c)
info, _, _ := wire.Bytes(response, 1)
address, _, _ := wire.Bytes(info, 2)
if string(address) != c.BaseURL {
t.Fatalf("game URL: %q", address)
}
if _, exists, _ := wire.Bytes(info, 8); exists {
t.Fatal("unexpected GameData URL")
}
if v, _, _ := wire.Varint(ServerNowTime(time.UnixMilli(1234567)), 1); v != 1234567 {
t.Fatalf("time: %d", v)
}
}
func TestConfigAcceptsHTTPSPublicOrigin(t *testing.T) {
cfg := Config{
BaseURL: "https://bd2.example.com/game/", CDNURL: "https://bd2.example.com/assets/ServerData",
Version: "client", BundleVer: "bundle",
}
if err := cfg.Validate(); err != nil {
t.Fatalf("HTTPS self-hosted server config rejected: %v", err)
}
}
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.Game(), BundleVer: versionconfig.Bundle(),
GameDataURL: "http://127.0.0.1:8080/assets/GameData",
GameDataVer: "20260921140855",
}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
info, found, err := wire.Bytes(ServerInfo(c), 1)
if err != nil || !found {
t.Fatalf("server info missing: %v", err)
}
url, _, _ := wire.Bytes(info, 8)
version, _, _ := wire.Bytes(info, 9)
if string(url) != c.GameDataURL || string(version) != c.GameDataVer {
t.Fatalf("GameData mismatch: url=%q version=%q", url, version)
}
}
-194
View File
@@ -1,194 +0,0 @@
package calendar
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"fmt"
"unicode/utf8"
)
const MaxFileSize = 8 * 1024 * 1024
const maxRows = 100000
const maxString = 64 * 1024
const headerSize = 46
var magic = []byte{'B', 'D', '2', 'S', 'C', 'H', 0, 0}
type encoder struct {
data []byte
err error
rows uint64
}
func (e *encoder) u64(v uint64) {
if e.room(8) {
e.data = binary.LittleEndian.AppendUint64(e.data, v)
}
}
func (e *encoder) u32(v uint32) {
if e.room(4) {
e.data = binary.LittleEndian.AppendUint32(e.data, v)
}
}
func (e *encoder) b(v bool) {
if !e.room(1) {
return
}
if v {
e.data = append(e.data, 1)
} else {
e.data = append(e.data, 0)
}
}
func (e *encoder) str(v string) {
if e.err != nil {
return
}
if len(v) > maxString || !utf8.ValidString(v) {
e.err = fmt.Errorf("calendar: invalid/oversized UTF8 string")
return
}
if !e.room(4 + len(v)) {
return
}
e.u32(uint32(len(v)))
e.data = append(e.data, v...)
}
func (e *encoder) count(n int) {
if e.err != nil {
return
}
e.rows += uint64(n)
if n > maxRows || e.rows > maxRows {
e.err = fmt.Errorf("calendar: row limit exceeded")
return
}
e.u32(uint32(n))
}
func (e *encoder) room(n int) bool {
if e.err != nil {
return false
}
if n > MaxFileSize-headerSize-len(e.data) {
e.err = fmt.Errorf("calendar: file size limit exceeded")
return false
}
return true
}
type decoder struct {
data []byte
pos int
err error
rows uint64
}
func (d *decoder) take(n int) []byte {
if d.err != nil {
return nil
}
if n < 0 || n > len(d.data)-d.pos {
d.err = fmt.Errorf("calendar: truncated payload")
return nil
}
v := d.data[d.pos : d.pos+n]
d.pos += n
return v
}
func (d *decoder) u64() uint64 {
v := d.take(8)
if len(v) != 8 {
return 0
}
return binary.LittleEndian.Uint64(v)
}
func (d *decoder) u32() uint32 {
v := d.take(4)
if len(v) != 4 {
return 0
}
return binary.LittleEndian.Uint32(v)
}
func (d *decoder) b() bool {
v := d.take(1)
if len(v) != 1 {
return false
}
if v[0] > 1 {
d.err = fmt.Errorf("calendar: invalid boolean/presence")
}
return v[0] == 1
}
func (d *decoder) str() string {
n := d.u32()
if n > maxString {
d.err = fmt.Errorf("calendar: string limit exceeded")
return ""
}
v := d.take(int(n))
if !utf8.Valid(v) {
d.err = fmt.Errorf("calendar: invalid UTF8")
}
return string(v)
}
func (d *decoder) count() int {
n := d.u32()
d.rows += uint64(n)
if n > maxRows || d.rows > maxRows || uint64(n) > uint64(len(d.data)-d.pos) {
d.err = fmt.Errorf("calendar: row count limit/truncation")
return 0
}
return int(n)
}
// MarshalBinary emits format 1's fixed-order record payload and checksum.
func MarshalBinary(m Manifest) ([]byte, error) {
if m.SchemaVersion != 1 {
return nil, fmt.Errorf("calendar: unsupported schema")
}
e := &encoder{}
e.manifest(m)
if e.err != nil {
return nil, e.err
}
if len(e.data) > MaxFileSize-headerSize {
return nil, fmt.Errorf("calendar: file size limit exceeded")
}
out := append([]byte(nil), magic...)
out = binary.LittleEndian.AppendUint16(out, 1)
out = binary.LittleEndian.AppendUint32(out, uint32(len(e.data)))
sum := sha256.Sum256(e.data)
out = append(out, sum[:]...)
return append(out, e.data...), nil
}
// UnmarshalBinary rejects damaged, unsupported, oversized and trailing data.
func UnmarshalBinary(raw []byte) (Manifest, error) {
var m Manifest
if len(raw) < headerSize || len(raw) > MaxFileSize {
return m, fmt.Errorf("calendar: invalid file size")
}
if !bytes.Equal(raw[:8], magic) || binary.LittleEndian.Uint16(raw[8:10]) != 1 {
return m, fmt.Errorf("calendar: unsupported magic/format")
}
n := binary.LittleEndian.Uint32(raw[10:14])
if uint64(n) != uint64(len(raw)-headerSize) {
return m, fmt.Errorf("calendar: payload length mismatch")
}
payload := raw[headerSize:]
sum := sha256.Sum256(payload)
if !bytes.Equal(raw[14:46], sum[:]) {
return m, fmt.Errorf("calendar: checksum mismatch")
}
d := &decoder{data: payload}
m = d.manifest()
if d.err != nil {
return Manifest{}, d.err
}
if d.pos != len(payload) {
return Manifest{}, fmt.Errorf("calendar: trailing payload")
}
m.SchemaVersion = 1
return m, nil
}
-115
View File
@@ -1,115 +0,0 @@
package calendar
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestInstalledBinaryFilesReencodeExactly(t *testing.T) {
paths, e := filepath.Glob("../../../../schedules/*.bd2schedule")
if e != nil || len(paths) != 6 {
t.Fatalf("installed files=%d err=%v", len(paths), e)
}
for _, path := range paths {
raw, e := os.ReadFile(path)
if e != nil {
t.Fatal(e)
}
m, e := UnmarshalBinary(raw)
if e != nil {
t.Fatal(e)
}
actual, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
if !bytes.Equal(raw, actual) {
t.Fatalf("canonical bytes changed %s", path)
}
}
}
func TestBinaryRejectsInvalidPresence(t *testing.T) {
raw, e := MarshalBinary(baseManifest())
if e != nil {
t.Fatal(e)
}
d := &decoder{data: raw[headerSize:]}
d.str()
d.str()
d.str()
d.count()
d.count()
d.count()
raw[headerSize+d.pos] = 2
sum := sha256.Sum256(raw[headerSize:])
copy(raw[14:46], sum[:])
if _, e = UnmarshalBinary(raw); e == nil {
t.Fatal("invalid optional presence accepted despite valid checksum")
}
}
func TestBinaryRoundTripAllRecords(t *testing.T) {
m := baseManifest()
m.Events = []Event{{UID: 5, Type: 11, ID: 7, Start: "2026-01-01T00:00:00Z", End: "2026-02-01T00:00:00Z"}}
m.Gacha = []Gacha{{GroupID: 2, FreeCountBonus: true}}
m.StepUps = []Gacha{{GroupID: 3}}
m.Regular = &Regular{Contents: []Content{{ID: 4, Current: Season{ID: 8, Error: true}, Next: Season{ID: 9, Return: true}}}}
m.MonsterHunt = &MonsterHunt{Seasons: []Hunt{{HuntID: 6, CostumeBanIDs: []uint64{5}, BurstBanIDs: []uint64{7}, IndependentFlag: true}}, History: []HuntHistory{{Season: 5, HuntID: 6, Hidden: true}}}
m.CashProducts = []CashProduct{{GroupID: 1, ProductID: 2, EventIndex: 3}}
m.EventHubs = []EventHub{{HubID: 4, Settings: []HubSetting{{Slot: 5, EventUIDs: []uint64{6, 7}}}}}
m.MiniGameHubs = []MiniGameHub{{Slot: 8, EventUID: 5}}
raw, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
actual, e := UnmarshalBinary(raw)
if e != nil {
t.Fatal(e)
}
if !reflect.DeepEqual(actual, m) {
t.Fatalf("roundtrip changed records: %+v", actual)
}
}
func TestBinaryRejectsCorruptionLimitsAndTrailing(t *testing.T) {
raw, e := MarshalBinary(baseManifest())
if e != nil {
t.Fatal(e)
}
for n := range len(raw) {
if _, e = UnmarshalBinary(raw[:n]); e == nil {
t.Fatalf("accepted truncation %d", n)
}
}
for _, offset := range []int{0, 8, 10, 14, 46} {
bad := append([]byte(nil), raw...)
bad[offset] ^= 0xff
if _, e = UnmarshalBinary(bad); e == nil {
t.Fatalf("accepted corruption %d", offset)
}
}
if _, e = UnmarshalBinary(append(raw, 0)); e == nil {
t.Fatal("accepted trailing data")
}
m := baseManifest()
m.Revision = string(make([]byte, maxString+1))
if _, e = MarshalBinary(m); e == nil {
t.Fatal("oversized string accepted")
}
m = baseManifest()
m.Events = make([]Event, maxRows+1)
if _, e = MarshalBinary(m); e == nil {
t.Fatal("oversized count accepted")
}
bad := append([]byte(nil), raw...)
binary.LittleEndian.PutUint32(bad[46:50], ^uint32(0))
sum := sha256.Sum256(bad[46:])
copy(bad[14:46], sum[:])
if _, e = UnmarshalBinary(bad); e == nil {
t.Fatal("overflow string length accepted")
}
}
-133
View File
@@ -1,133 +0,0 @@
package calendar
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"fmt"
)
// ValidateDesign checks playable identities against their domain's installed
// GameData. Announcement-only types 3/6/14/15/16/18 have no local gameplay
// design resolver and receive only the manifest's protocol/format validation.
func (s *Set) ValidateDesign(root, version string) error {
cash, e := gamedata.LoadCashCatalog(root, version)
if e != nil {
return e
}
keys := map[gamedata.CashProductKey]bool{}
for _, v := range cash.Products {
keys[v.Key] = true
}
for _, v := range s.CashProducts {
if !keys[gamedata.CashProductKey{GroupID: v.GroupID, ProductID: v.ProductID, SaleGroup: v.SaleGroup}] {
return fmt.Errorf("calendar: cash product %d/%d/%d missing GameData", v.GroupID, v.ProductID, v.SaleGroup)
}
}
if s.MonsterHunt != nil {
ids, e := gamedata.LoadMonsterHuntIDs(root, version)
if e != nil {
return e
}
for _, v := range s.MonsterHunt.Seasons {
if !ids[v.HuntID] {
return fmt.Errorf("calendar: active/playable hunt %d missing GameData", v.HuntID)
}
}
}
play, e := gamedata.LoadEventPlayCatalog(root, version)
if e != nil {
return e
}
tasks, e := gamedata.LoadEventTasksDesign(root, version)
if e != nil {
return e
}
exchange, e := gamedata.LoadEventExchangeCatalog(root, version)
if e != nil {
return e
}
actions, e := gamedata.LoadEventActionsDesign(root, version)
if e != nil {
return e
}
packs, e := gamedata.LoadCalendarPackIDs(root, version)
if e != nil {
return e
}
for _, h := range s.EventHubs {
if _, e = play.Row("PackEventHubTable", 14, h.HubID); e != nil {
return fmt.Errorf("calendar: hub %d: %w", h.HubID, e)
}
}
if err := s.validateMiniHubBindings(play); err != nil {
return err
}
uid := map[uint64]events.Schedule{}
for _, v := range s.Events {
if v.UID != 0 {
uid[v.UID] = v
}
}
for _, h := range s.MiniGameHubs {
v, ok := uid[h.EventUID]
if !ok || v.Type != 11 {
return fmt.Errorf("calendar: mini game slot %d references non-minigame event %d", h.Slot, h.EventUID)
}
if _, e := play.Row("PackEventMiniGameTable", 8, v.ID); e != nil {
return e
}
}
for _, v := range s.Events {
valid := true
var err error
switch v.Type {
case 0:
_, valid = tasks.Attendance[v.ID]
case 1:
valid = false
for k := range tasks.LimitRewards {
if k[0] == v.ID {
valid = true
break
}
}
case 4:
_, valid = tasks.MissionGroups[v.ID]
case 5:
_, valid = tasks.Passes[v.ID]
case 7:
_, valid = exchange.Groups[v.ID]
case 8:
valid = packs[v.ID]
case 9:
_, err = play.Row("PackEventBattleGroupTable", 3, v.ID)
case 10:
_, err = play.Row("PackEventStoryGroupTable", 1, v.ID)
case 11:
_, err = play.Row("PackEventMiniGameTable", 8, v.ID)
case 12, 13, 17, 19:
_, err = gamedata.LoadEventGame(root, version, v.Type, v.ID)
case 20:
_, valid = actions.Row("TacticsBingoGroupTable", 3, v.ID)
case 21:
_, valid = actions.Row("FieldSpawnEventTable", 5, v.ID)
case 22:
_, valid = actions.Row("FireworksTable", 4, v.ID)
case 23:
_, valid = actions.Row("VotingEventTable", 6, v.ID)
case 24:
_, valid = actions.Row("FriendshipSpecialEpisodeTable", 5, v.ID)
case 25:
id := v.SubID
if id == 0 {
id = v.ID
}
_, valid = actions.Row("CafeteriaEventTable", 5, id)
}
if !valid || err != nil {
return fmt.Errorf("calendar: event uid %d type %d id %d missing GameData: %v", v.UID, v.Type, v.ID, err)
}
}
return nil
}
-138
View File
@@ -1,138 +0,0 @@
package calendar
import (
"bd2server/internal/server/readonly"
"bd2server/internal/server/wire"
"os"
"path/filepath"
"testing"
)
func writeManifest(t *testing.T, dir, name string, m Manifest) {
t.Helper()
raw, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
if e = os.WriteFile(filepath.Join(dir, name), raw, 0600); e != nil {
t.Fatal(e)
}
}
func baseManifest() Manifest {
return Manifest{SchemaVersion: 1, Revision: "2026-10-05", GameVersion: "2.35.10", GameDataVersion: "2.35.10"}
}
func TestInstalledCalendarsContainAllRuntimeDomains(t *testing.T) {
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if e != nil {
t.Fatal(e)
}
if len(set.Events) != 54 || len(set.GachaSeed.Schedules) != 11 || len(set.GachaSeed.StepUps) != 2 || set.RegularService == nil || len(set.RegularService.Contents) != 9 || set.MonsterHunt == nil || len(set.MonsterHunt.History) != 80 || len(set.CashProducts) != 61 || len(set.EventHubs) != 4 || len(set.MiniGameHubs) != 6 {
t.Fatalf("installed calendar omitted a domain: events=%d gacha=%d steps=%d cash=%d hubs=%d mini=%d", len(set.Events), len(set.GachaSeed.Schedules), len(set.GachaSeed.StepUps), len(set.CashProducts), len(set.EventHubs), len(set.MiniGameHubs))
}
var mooncake, chained *EventHub
for i := range set.EventHubs {
h := &set.EventHubs[i]
if h.UID == 3 && h.HubID == 1003 {
mooncake = h
}
if h.UID == 76 && h.HubID == 59 {
chained = h
}
}
if mooncake == nil || mooncake.PlayEnd != "2026-09-30T23:59:59.000Z" || len(mooncake.Settings) != 3 || mooncake.Settings[2].EventUIDs[0] != 10000034 {
t.Fatal("published Mooncake Wishes calendar omitted its deferred board slot")
}
if chained == nil || len(chained.Settings) != 6 || chained.Settings[2].EventUIDs[0] != 80 {
t.Fatal("published Chained Soldier hub omitted its event shop")
}
}
func TestMultiFileCalendarAtomicDeterministicAndIndependentRevision(t *testing.T) {
dir := t.TempDir()
a := baseManifest()
a.Revision = "a"
a.Gacha = []Gacha{{GroupID: 9, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
writeManifest(t, dir, "z.bd2schedule", a)
b := baseManifest()
b.Revision = "b"
b.Gacha = []Gacha{{GroupID: 1, Start: "2027-01-01T09:00:00+09:00", End: "2027-01-02T09:00:00+09:00"}, {GroupID: 9, Start: "2026-10-02T00:00:00Z", End: "2026-10-03T00:00:00Z"}}
writeManifest(t, dir, "a.bd2schedule", b)
set, e := LoadDirectory(dir, "2.35.10", "2.35.10")
if e != nil {
t.Fatal(e)
}
if len(set.GachaSeed.Schedules) != 3 || set.GachaSeed.Schedules[0].GroupID != 1 || set.Revisions[0] != "b" {
t.Fatalf("unexpected merged set %+v", set)
}
b.Revision = "changed without game update"
b.Gacha[0].End = "2027-01-03T09:00:00+09:00"
writeManifest(t, dir, "a.bd2schedule", b)
changed, e := LoadDirectory(dir, "2.35.10", "2.35.10")
if e != nil || changed.GachaSeed.Schedules[0].EndTime == set.GachaSeed.Schedules[0].EndTime {
t.Fatalf("calendar update ignored: %v", e)
}
if e = os.WriteFile(filepath.Join(dir, "broken.bd2schedule"), []byte("broken binary"), 0600); e != nil {
t.Fatal(e)
}
if partial, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil || partial != nil {
t.Fatal("bad file returned partial calendar")
}
}
func TestStrictCalendarRejectsConflictsAndInvalidData(t *testing.T) {
for _, mutate := range []func(*Manifest){func(m *Manifest) { m.GameDataVersion = "wrong" }, func(m *Manifest) { m.Gacha[0].Start = "2026-10-01" }, func(m *Manifest) { m.Gacha[0].End = m.Gacha[0].Start }, func(m *Manifest) { m.Gacha = append(m.Gacha, m.Gacha[0]) }, func(m *Manifest) { m.Gacha[0].GroupID = 1 << 32 }} {
dir := t.TempDir()
m := baseManifest()
m.Gacha = []Gacha{{GroupID: 1, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
mutate(&m)
writeManifest(t, dir, "one.bd2schedule", m)
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
t.Fatal("invalid calendar accepted")
}
}
dir := t.TempDir()
m := baseManifest()
m.Events = []Event{{UID: 77, Type: 12, ID: 3, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
writeManifest(t, dir, "a.bd2schedule", m)
writeManifest(t, dir, "b.bd2schedule", m)
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
t.Fatal("duplicate UID across files accepted")
}
}
func TestReadonlyCalendarReplacementPreservesStaticResponses(t *testing.T) {
set := &Set{CashProducts: []CashProduct{{GroupID: 11, ProductID: 22, EventIndex: 99}}, MonsterHunt: &MonsterHunt{StartRegularSeason: 5, History: []HuntHistory{{Season: 78, HuntID: 73, Hidden: true}}}}
old := &readonly.Seed{Version: "2.35.10", Responses: map[string]readonly.Response{"/Static": {PacketCode: 17, Fields: []readonly.Field{scalar(1, 10)}}, "/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{scalar(2, 1)}}}}
result, e := set.ApplyReadonly(old)
if e != nil {
t.Fatal(e)
}
if len(old.Responses["/CashShopInfo"].Fields) != 1 || result.Responses["/Static"].PacketCode != 17 {
t.Fatal("source seed changed/static endpoint lost")
}
_, raw, handled, e := result.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
if e != nil || !handled {
t.Fatal(e)
}
p, found, e := wire.Bytes(raw, 1)
if e != nil || !found {
t.Fatal("missing product")
}
id, _, _ := wire.Varint(p, 8)
if id != 99 {
t.Fatalf("event index=%d", id)
}
if _, found, _ = wire.Varint(raw, 2); found {
t.Fatal("stale reset timestamp retained")
}
}
func TestInstalledCalendarRealGameData(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if e != nil {
t.Fatal(e)
}
if e = set.ValidateDesign(root, "20260923193640"); e != nil {
t.Fatal(e)
}
}
-113
View File
@@ -1,113 +0,0 @@
package calendar
import (
"fmt"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
// validateMiniHubBindings rejects invalid published routes at startup instead
// of letting a client open an incompatible prefab or use another activity UID.
func (s *Set) validateMiniHubBindings(design *gamedata.EventPlayCatalog) error {
uid := make(map[uint64]events.Schedule)
for _, v := range s.Events {
if v.UID != 0 {
uid[v.UID] = v
}
}
value := func(row []byte, field int) uint64 { v, _, _ := wire.Varint(row, field); return v }
for _, hub := range s.EventHubs {
table, err := design.Row("PackEventHubTable", 14, hub.HubID)
if err != nil {
return err
}
if value(table, 13) != 1 {
continue
}
if hub.UID == 0 {
return fmt.Errorf("calendar: mini hub %d requires a nonzero UID", hub.HubID)
}
start, err := timestamp(hub.Start)
if err != nil {
return err
}
playEnd, err := timestamp(hub.PlayEnd)
if err != nil {
return err
}
end, err := timestamp(hub.End)
if err != nil {
return err
}
for _, binding := range hub.Settings {
var slot []byte
for _, row := range design.Rows("PackEventListTable", 6, hub.HubID) {
if value(row, 11) != binding.Slot {
continue
}
if slot != nil {
return fmt.Errorf("calendar: mini hub %d slot %d is ambiguous in GameData", hub.HubID, binding.Slot)
}
slot = row
}
if slot == nil {
return fmt.Errorf("calendar: mini hub %d slot %d missing GameData", hub.HubID, binding.Slot)
}
contentType, contentID := value(slot, 9), value(slot, 7)
if binding.ProgressType != contentType {
return fmt.Errorf("calendar: mini hub %d slot %d content type %d, want %d", hub.HubID, binding.Slot, binding.ProgressType, contentType)
}
eventType, supported := gamedata.MiniHubEventType(contentType)
// Mini stories and NPC quizzes have their own slot UID namespace,
// distinct from Define_EventType (13 there means bingo). Their group
// and availability derive from this static slot and the hub window.
if contentType == 13 || contentType == 14 {
if len(binding.EventUIDs) != 1 || binding.EventUIDs[0] == 0 {
return fmt.Errorf("calendar: mini hub %d slot %d requires one local content UID", hub.HubID, binding.Slot)
}
if _, collision := uid[binding.EventUIDs[0]]; collision {
return fmt.Errorf("calendar: mini hub %d slot %d content UID collides with global event", hub.HubID, binding.Slot)
}
if value(slot, 4) != 0 {
return fmt.Errorf("calendar: mini hub %d story/quiz slot has invalid end type", hub.HubID)
}
continue
}
if !supported {
return fmt.Errorf("calendar: mini hub %d slot %d content type %d has no supported scheduled route", hub.HubID, binding.Slot, contentType)
}
endType := value(slot, 4)
if endType > 1 {
return fmt.Errorf("calendar: mini hub %d slot %d unsupported end type %d", hub.HubID, binding.Slot, endType)
}
matches := 0
for _, id := range binding.EventUIDs {
child, ok := uid[id]
if !ok || child.Type != eventType || child.ID != contentID || child.SubID != 0 {
return fmt.Errorf("calendar: mini hub %d slot %d references incompatible event UID %d", hub.HubID, binding.Slot, id)
}
childStart, childEnd := child.Start, child.End
if childStart < int64(start) {
childStart = int64(start)
}
if childEnd > int64(end) {
childEnd = int64(end)
}
// Project policy: EndDateType=0 closes the slot at PlayEnd;
// EndDateType=1 allows it through the final hub End window.
if endType == 0 && childEnd > int64(playEnd) {
childEnd = int64(playEnd)
}
if childStart < childEnd {
matches++
}
}
if matches != 1 {
return fmt.Errorf("calendar: mini hub %d slot %d has %d schedules in its window, want one", hub.HubID, binding.Slot, matches)
}
}
}
return nil
}
@@ -1,81 +0,0 @@
package calendar
import (
"testing"
"time"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
func TestMiniHubPublishedRoutesValidateIndependentEnumsAndWindows(t *testing.T) {
row := func(fields ...uint64) []byte {
var b []byte
for i := 0; i < len(fields); i += 2 {
b = wire.AppendVarint(b, int(fields[i]), fields[i+1])
}
return b
}
start := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
format := func(d time.Duration) string { return start.Add(d).Format(time.RFC3339) }
design := &gamedata.EventPlayCatalog{Tables: map[string][][]byte{
"PackEventHubTable": {row(14, 7, 13, 1)},
"PackEventListTable": {row(6, 7, 10, 42, 11, 5, 9, 12, 7, 99, 4, 1)},
}}
fresh := func() *Set {
return &Set{
Events: []events.Schedule{{UID: 21, Type: 19, ID: 99, Start: start.UnixMilli(), End: start.Add(48 * time.Hour).UnixMilli()}},
EventHubs: []EventHub{{UID: 1, HubID: 7, Start: format(0), PlayEnd: format(24 * time.Hour), End: format(48 * time.Hour), Settings: []HubSetting{{Slot: 5, ProgressType: 12, EventUIDs: []uint64{21}}}}},
}
}
if err := fresh().validateMiniHubBindings(design); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
change func(*Set)
}{
{"wrong content enum", func(s *Set) { s.EventHubs[0].Settings[0].ProgressType = 19 }},
{"table id is not slot index", func(s *Set) { s.EventHubs[0].Settings[0].Slot = 42 }},
{"missing UID", func(s *Set) { s.EventHubs[0].Settings[0].EventUIDs = []uint64{22} }},
{"wrong event enum", func(s *Set) { s.Events[0].Type = 12 }},
{"wrong design id", func(s *Set) { s.Events[0].ID = 7 }},
{"wrong sub identity", func(s *Set) { s.Events[0].SubID = 7 }},
{"no window overlap", func(s *Set) {
s.Events[0].Start = start.Add(72 * time.Hour).UnixMilli()
s.Events[0].End = start.Add(96 * time.Hour).UnixMilli()
}},
{"ambiguous schedule", func(s *Set) {
other := s.Events[0]
other.UID = 22
s.Events = append(s.Events, other)
s.EventHubs[0].Settings[0].EventUIDs = append(s.EventHubs[0].Settings[0].EventUIDs, 22)
}},
} {
t.Run(test.name, func(t *testing.T) {
s := fresh()
test.change(s)
if err := s.validateMiniHubBindings(design); err == nil {
t.Fatal("invalid published route accepted")
}
})
}
// A future hub must validate without comparing its dates to the current clock.
design.Tables["PackEventListTable"][0] = row(6, 7, 10, 42, 11, 5, 9, 13, 7, 99, 4, 1)
s := fresh()
s.EventHubs[0].Settings[0].ProgressType = 13
if err := s.validateMiniHubBindings(design); err == nil {
t.Fatal("quiz borrowed global bingo event type")
}
design.Tables["PackEventListTable"][0] = row(6, 7, 10, 42, 11, 11, 9, 13, 7, 99)
s = fresh()
s.EventHubs[0].Settings = []HubSetting{{Slot: 11, ProgressType: 13, EventUIDs: []uint64{10000032}}}
if err := s.validateMiniHubBindings(design); err != nil {
t.Fatalf("independent quiz content UID rejected: %v", err)
}
s.EventHubs[0].Settings[0].EventUIDs = []uint64{21}
if err := s.validateMiniHubBindings(design); err == nil {
t.Fatal("quiz content UID collided with global event")
}
}
@@ -1,41 +0,0 @@
package calendar
import "testing"
// Current-version publication facts were verified against the client schemas,
// GameData identities and 2026-10-05 schedule comparison. No capture is read.
func TestPublishedRegularAndGachaCalendarCompleteness(t *testing.T) {
set, err := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if err != nil {
t.Fatal(err)
}
want := map[uint64][2]uint64{1: {163, 164}, 2: {58, 58}, 3: {131, 999999}, 4: {877, 999999}, 5: {30, 31}, 6: {27, 28}, 7: {15, 999999}, 8: {44, 45}, 9: {2, 999999}}
if len(set.RegularService.Contents) != len(want) || set.RegularService.CalculateMilliseconds != 32400000 {
t.Fatal("regular content calendar incomplete")
}
for _, c := range set.RegularService.Contents {
if want[c.ID] != [2]uint64{c.Current.ID, c.Next.ID} {
t.Fatalf("content %d stale seasons %d/%d", c.ID, c.Current.ID, c.Next.ID)
}
}
current := map[uint64]bool{166: true, 30010: true, 30011: true, 206: true, 205: true, 208: true, 153: true, 72: true, 71: true, 207: true}
for _, g := range set.GachaSeed.Schedules {
if current[g.GroupID] {
delete(current, g.GroupID)
if g.EndTime != 1791417599000 {
t.Fatalf("gacha %d truncated window", g.GroupID)
}
}
}
if len(current) != 0 {
t.Fatalf("missing published gacha groups %v", current)
}
if len(set.GachaSeed.StepUps) != 2 {
t.Fatal("step-up schedule omitted")
}
for _, g := range set.GachaSeed.StepUps {
if g.GroupID != 29 && g.GroupID != 30 || g.EndTime != 1791417599000 {
t.Fatalf("step-up schedule incorrect %+v", g)
}
}
}
-312
View File
@@ -1,312 +0,0 @@
package calendar
import "bd2server/internal/server/schedule"
func (e *encoder) manifest(v Manifest) {
e.str(v.Revision)
e.str(v.GameVersion)
e.str(v.GameDataVersion)
e.count(len(v.Events))
for _, item := range v.Events {
e.event(item)
}
e.count(len(v.Gacha))
for _, item := range v.Gacha {
e.gacha(item)
}
e.count(len(v.StepUps))
for _, item := range v.StepUps {
e.gacha(item)
}
e.b(v.Regular != nil)
if v.Regular != nil {
e.regular(*v.Regular)
}
e.b(v.MonsterHunt != nil)
if v.MonsterHunt != nil {
e.monsterhunt(*v.MonsterHunt)
}
e.count(len(v.CashProducts))
for _, item := range v.CashProducts {
e.cashproduct(item)
}
e.count(len(v.EventHubs))
for _, item := range v.EventHubs {
e.eventhub(item)
}
e.count(len(v.MiniGameHubs))
for _, item := range v.MiniGameHubs {
e.minigamehub(item)
}
}
func (d *decoder) manifest() Manifest {
var v Manifest
v.Revision = d.str()
v.GameVersion = d.str()
v.GameDataVersion = d.str()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Events = append(v.Events, d.event())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Gacha = append(v.Gacha, d.gacha())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.StepUps = append(v.StepUps, d.gacha())
}
if d.b() {
item := d.regular()
v.Regular = &item
}
if d.b() {
item := d.monsterhunt()
v.MonsterHunt = &item
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.CashProducts = append(v.CashProducts, d.cashproduct())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.EventHubs = append(v.EventHubs, d.eventhub())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.MiniGameHubs = append(v.MiniGameHubs, d.minigamehub())
}
return v
}
func (e *encoder) event(v Event) {
e.u64(v.UID)
e.u64(v.Type)
e.u64(v.ID)
e.u64(v.SubID)
e.str(v.Start)
e.str(v.End)
}
func (d *decoder) event() Event {
var v Event
v.UID = d.u64()
v.Type = d.u64()
v.ID = d.u64()
v.SubID = d.u64()
v.Start = d.str()
v.End = d.str()
return v
}
func (e *encoder) gacha(v Gacha) {
e.u64(v.GroupID)
e.str(v.Start)
e.str(v.End)
e.b(v.FreeCountBonus)
e.b(v.CashCountBonus)
}
func (d *decoder) gacha() Gacha {
var v Gacha
v.GroupID = d.u64()
v.Start = d.str()
v.End = d.str()
v.FreeCountBonus = d.b()
v.CashCountBonus = d.b()
return v
}
func (e *encoder) season(v Season) {
e.u64(v.ID)
e.str(v.Start)
e.str(v.End)
e.u64(v.RankRewardGroupID)
e.b(v.Error)
e.b(v.Return)
}
func (d *decoder) season() Season {
var v Season
v.ID = d.u64()
v.Start = d.str()
v.End = d.str()
v.RankRewardGroupID = d.u64()
v.Error = d.b()
v.Return = d.b()
return v
}
func (e *encoder) content(v Content) {
e.u64(v.ID)
e.season(v.Current)
e.season(v.Next)
}
func (d *decoder) content() Content {
var v Content
v.ID = d.u64()
v.Current = d.season()
v.Next = d.season()
return v
}
func (e *encoder) regular(v Regular) {
e.u64(v.CalculateMilliseconds)
e.count(len(v.Contents))
for _, item := range v.Contents {
e.content(item)
}
e.count(len(v.Regular))
for _, item := range v.Regular {
e.regularseason(item)
}
}
func (d *decoder) regular() Regular {
var v Regular
v.CalculateMilliseconds = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Contents = append(v.Contents, d.content())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Regular = append(v.Regular, d.regularseason())
}
return v
}
func (e *encoder) hunt(v Hunt) {
e.season(v.Season)
e.u64(v.HuntID)
e.u64(v.InfoOpenDay)
e.str(v.CalculateEndAt)
e.b(v.ErrorFlag)
e.b(v.IndependentFlag)
e.u64(v.RankRewardGroupID)
e.count(len(v.CostumeBanIDs))
for _, item := range v.CostumeBanIDs {
e.u64(item)
}
e.count(len(v.BurstBanIDs))
for _, item := range v.BurstBanIDs {
e.u64(item)
}
}
func (d *decoder) hunt() Hunt {
var v Hunt
v.Season = d.season()
v.HuntID = d.u64()
v.InfoOpenDay = d.u64()
v.CalculateEndAt = d.str()
v.ErrorFlag = d.b()
v.IndependentFlag = d.b()
v.RankRewardGroupID = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.CostumeBanIDs = append(v.CostumeBanIDs, d.u64())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.BurstBanIDs = append(v.BurstBanIDs, d.u64())
}
return v
}
func (e *encoder) hunthistory(v HuntHistory) {
e.u64(v.Season)
e.u64(v.HuntID)
e.b(v.ErrorFlag)
e.b(v.Hidden)
}
func (d *decoder) hunthistory() HuntHistory {
var v HuntHistory
v.Season = d.u64()
v.HuntID = d.u64()
v.ErrorFlag = d.b()
v.Hidden = d.b()
return v
}
func (e *encoder) monsterhunt(v MonsterHunt) {
e.count(len(v.Seasons))
for _, item := range v.Seasons {
e.hunt(item)
}
e.u64(v.StartRegularSeason)
e.count(len(v.History))
for _, item := range v.History {
e.hunthistory(item)
}
}
func (d *decoder) monsterhunt() MonsterHunt {
var v MonsterHunt
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Seasons = append(v.Seasons, d.hunt())
}
v.StartRegularSeason = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.History = append(v.History, d.hunthistory())
}
return v
}
func (e *encoder) cashproduct(v CashProduct) {
e.u64(v.GroupID)
e.u64(v.ProductID)
e.u64(v.SaleGroup)
e.str(v.Start)
e.str(v.End)
e.u64(v.EndDelayMinutes)
e.u64(v.EventIndex)
}
func (d *decoder) cashproduct() CashProduct {
var v CashProduct
v.GroupID = d.u64()
v.ProductID = d.u64()
v.SaleGroup = d.u64()
v.Start = d.str()
v.End = d.str()
v.EndDelayMinutes = d.u64()
v.EventIndex = d.u64()
return v
}
func (e *encoder) hubsetting(v HubSetting) {
e.u64(v.Slot)
e.u64(v.ProgressType)
e.count(len(v.EventUIDs))
for _, item := range v.EventUIDs {
e.u64(item)
}
}
func (d *decoder) hubsetting() HubSetting {
var v HubSetting
v.Slot = d.u64()
v.ProgressType = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.EventUIDs = append(v.EventUIDs, d.u64())
}
return v
}
func (e *encoder) eventhub(v EventHub) {
e.u64(v.UID)
e.u64(v.HubID)
e.str(v.Start)
e.str(v.PlayEnd)
e.str(v.End)
e.count(len(v.Settings))
for _, item := range v.Settings {
e.hubsetting(item)
}
}
func (d *decoder) eventhub() EventHub {
var v EventHub
v.UID = d.u64()
v.HubID = d.u64()
v.Start = d.str()
v.PlayEnd = d.str()
v.End = d.str()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Settings = append(v.Settings, d.hubsetting())
}
return v
}
func (e *encoder) minigamehub(v MiniGameHub) {
e.u64(v.Slot)
e.u64(v.EventUID)
e.u64(v.ProgressType)
}
func (d *decoder) minigamehub() MiniGameHub {
var v MiniGameHub
v.Slot = d.u64()
v.EventUID = d.u64()
v.ProgressType = d.u64()
return v
}
func (e *encoder) regularseason(v schedule.RegularSeason) {
e.u64(v.ContentID)
e.u64(v.Season)
}
func (d *decoder) regularseason() schedule.RegularSeason {
var v schedule.RegularSeason
v.ContentID = d.u64()
v.Season = d.u64()
return v
}
-142
View File
@@ -1,142 +0,0 @@
package commerce
import (
"crypto/sha256"
"encoding/json"
"fmt"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type attendanceHandler interface {
Handle(string, []byte) (int, []byte, bool, error)
}
// AttendanceHandler preserves the original event progress response and adds
// subscription grants in extension fields understood by the commerce plugin.
// All operations execute inside the transport's account transaction.
type AttendanceHandler struct {
Events attendanceHandler
Economy *EntitlementEconomy
LoginPasses *LoginPasses
Store stateio.Store
}
type attendanceReceipt struct {
Digest string `json:"digest"`
Bundle []byte `json:"bundle"`
}
func (h AttendanceHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
return h.HandleSession(path, request, "")
}
func (h AttendanceHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
if path != "/Attendance" {
return 0, nil, false, nil
}
if session == "" || h.Events == nil || h.Economy == nil || h.Store == nil {
return 0, nil, true, fmt.Errorf("commerce: attendance dependencies/session unavailable")
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, nil, true, fmt.Errorf("commerce: missing attendance sequence")
}
code, response, handled, err := h.Events.Handle(path, request)
if err != nil || !handled {
return code, response, handled, err
}
// The event handler may already have granted ordinary attendance rewards.
// The client accepts exactly one reward envelope, so combine every grant in
// execution order under this wrapper's replay identity.
response, eventBundle, err := takeAttendanceRewardEnvelope(response)
if err != nil {
return code, nil, true, err
}
key := fmt.Sprintf("commerce_attendance:%x:%d", sha256.Sum256([]byte(session)), seq)
digest := fmt.Sprintf("%x", sha256.Sum256(request))
var receipt attendanceReceipt
previous, err := h.Store.Load(key)
if err != nil {
return code, nil, true, err
}
var loginBundle []byte
if h.LoginPasses != nil {
var infos [][]byte
loginBundle, infos, err = h.LoginPasses.ClaimAndInfo(key)
if err != nil {
return code, nil, true, err
}
for _, info := range infos {
response = wire.AppendBytes(response, 6, info)
}
}
if previous != nil {
if err = json.Unmarshal(previous, &receipt); err != nil || receipt.Digest != digest {
return code, nil, true, fmt.Errorf("commerce: conflicting attendance replay")
}
} else {
receipt.Digest = digest
subscriptionBundle, err := h.Economy.ClaimSubscriptions(key)
if err != nil {
return code, nil, true, err
}
// Preserve events -> login pass -> subscription execution order and all
// repeated reward entries in the single client envelope.
receipt.Bundle = append(receipt.Bundle, eventBundle...)
receipt.Bundle = append(receipt.Bundle, loginBundle...)
receipt.Bundle = append(receipt.Bundle, subscriptionBundle...)
raw, err := json.Marshal(receipt)
if err != nil {
return code, nil, true, err
}
if err = h.Store.Save(key, raw); err != nil {
return code, nil, true, err
}
}
response, err = h.Economy.MergeAttendance(response)
if err != nil {
return code, nil, true, err
}
if len(receipt.Bundle) != 0 {
response = wire.AppendBytes(response, 1001, receipt.Bundle)
response = wire.AppendString(response, 1002, key)
}
return code, response, true, nil
}
// Strip the child envelope before adding the combined one. Keep native and
// unrelated unknown fields byte-for-byte; reject ambiguous child envelopes
// rather than returning a response the client would silently ignore.
func takeAttendanceRewardEnvelope(response []byte) ([]byte, []byte, error) {
var native, bundle []byte
var receipt string
var hasBundle, hasReceipt bool
err := wire.Walk(response, func(f wire.Field) error {
switch f.Number {
case 1001:
if f.Type != 2 || hasBundle {
return fmt.Errorf("commerce: ambiguous attendance reward bundle")
}
hasBundle = true
bundle = append([]byte(nil), f.Value...)
case 1002:
if f.Type != 2 || hasReceipt {
return fmt.Errorf("commerce: ambiguous attendance reward receipt")
}
hasReceipt = true
receipt = string(f.Value)
default:
native = append(native, response[f.Start:f.End]...)
}
return nil
})
if err != nil {
return nil, nil, err
}
if hasBundle != hasReceipt || hasReceipt && (receipt == "" || len(receipt) > 1024) {
return nil, nil, fmt.Errorf("commerce: incomplete attendance reward envelope")
}
return native, bundle, nil
}
@@ -1,256 +0,0 @@
package commerce
import (
"bytes"
"testing"
"time"
"bd2server/internal/server/events"
"bd2server/internal/server/eventtasks"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
type originalAttendance struct{}
func (originalAttendance) Handle(path string, _ []byte) (int, []byte, bool, error) {
return 0, wire.AppendVarint(nil, 55, 123), path == "/Attendance", nil
}
type attendanceBalances struct {
balance uint64
calls int
}
func (e *attendanceBalances) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) {
e.calls++
var bundle []byte
for _, r := range rewards {
e.balance += r.Count
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count}))
}
return bundle, nil
}
func TestAttendanceMailsOrdinaryRewardsAndCombinesLoginPassAndSubscription(t *testing.T) {
e, items, _, _, now := entitlementFixture(t)
// eventtasks uses the production clock; align the commerce fixture with it
// while buying the subscription on the previous reset day.
*now = time.Now().UTC().Add(-24 * time.Hour)
balances := &attendanceBalances{}
e.base = balances
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
registry := events.NewRegistry()
if err := registry.Replace([]events.Schedule{{UID: 17, Type: 0, ID: 1, Start: 1, End: now.Add(time.Hour).UnixMilli()}}); err != nil {
t.Fatal(err)
}
design := &gamedata.EventTasksDesign{
Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 10}},
AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{10: {{Group: 10, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 3, Count: 2}}}},
}
tasks, err := eventtasks.Open(e.store, design, registry, e)
if err != nil {
t.Fatal(err)
}
tasks.SetSession("combined-session")
mailWallet, err := player.OpenWallet(e.store, player.Currency{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(e.store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, mailWallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachAttendanceRewardEconomy(e); err != nil {
t.Fatal(err)
}
tasks.AttachAttendanceMail(mailbox)
passes, err := NewLoginPasses(e.store, &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
20: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}}},
}}, e, items, func(uint64) bool { return true })
if err != nil {
t.Fatal(err)
}
passes.SetClock(func() time.Time { return *now }, 0)
h := AttendanceHandler{Events: tasks, Economy: e, LoginPasses: passes, Store: e.store}
request := wire.AppendVarint(nil, 1, 1)
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil {
t.Fatal(err)
}
if balances.calls != 3 || balances.balance != 17 {
t.Fatalf("wrong combined grant: calls=%d balance=%d", balances.calls, balances.balance)
}
counts := map[int]int{}
if err := wire.Walk(response, func(f wire.Field) error { counts[f.Number]++; return nil }); err != nil {
t.Fatal(err)
}
if counts[1001] != 1 || counts[1002] != 1 || counts[5] != 1 || counts[6] != 1 {
t.Fatalf("client requires a single combined envelope and native notices: %v", counts)
}
bundle, _, _ := wire.Bytes(response, 1001)
var snapshots []uint64
if err := wire.Walk(bundle, func(f wire.Field) error {
if f.Number == 1 {
count, _, err := wire.Varint(f.Value, 4)
if err != nil {
return err
}
snapshots = append(snapshots, count)
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(snapshots) != 2 || snapshots[0] != 11 || snapshots[1] != 5 {
t.Fatalf("combined reward entries lost or reordered: %v", snapshots)
}
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || !bytes.Equal(response, replay) || balances.calls != 3 {
t.Fatal("combined retry changed rewards", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
if err != nil || balances.calls != 3 {
t.Fatal("fresh request granted again", err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok {
t.Fatal("fresh request replays old reward envelope")
}
if _, ok, _ := wire.Bytes(next, 5); ok {
t.Fatal("fresh request replays old attendance stamps")
}
_, mailInfo, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3))
if err != nil {
t.Fatal(err)
}
mailCount := 0
var mailID uint64
if err := wire.Walk(mailInfo, func(f wire.Field) error {
if f.Number == 1 {
mailCount++
mailID, _, _ = wire.Varint(f.Value, 1)
}
return nil
}); err != nil || mailCount != 1 {
t.Fatalf("ordinary attendance did not issue exactly one mail: count=%d err=%v", mailCount, err)
}
open := wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, mailID)
if _, _, _, err := mailbox.Handle("/MailOpen", open); err != nil {
t.Fatal(err)
}
if balances.calls != 4 || balances.balance != 19 {
t.Fatalf("ordinary reward must arrive only after opening mail: calls=%d balance=%d", balances.calls, balances.balance)
}
}
func TestAttendanceRejectsAmbiguousChildRewardEnvelope(t *testing.T) {
native := wire.AppendVarint(nil, 55, 123)
bundle := wire.AppendBytes(native, 1001, wire.AppendVarint(nil, 7, 1))
valid := wire.AppendString(bundle, 1002, "event-receipt")
stripped, rewards, err := takeAttendanceRewardEnvelope(valid)
if err != nil || !bytes.Equal(stripped, native) || len(rewards) == 0 {
t.Fatal("valid envelope was not preserved", err)
}
for _, malformed := range [][]byte{
bundle,
wire.AppendString(native, 1002, "event-receipt"),
wire.AppendBytes(valid, 1001, nil),
wire.AppendString(valid, 1002, "second"),
wire.AppendString(bundle, 1002, ""),
wire.AppendVarint(native, 1001, 1),
} {
if _, _, err := takeAttendanceRewardEnvelope(malformed); err == nil {
t.Fatalf("ambiguous envelope accepted: %x", malformed)
}
}
}
func TestAttendanceExtensionReplaysRewardAndPreservesNativeResponse(t *testing.T) {
e, _, base, _, now := entitlementFixture(t)
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, Store: e.store}
request := wire.AppendVarint(nil, 1, 1)
_, response, handled, err := h.HandleSession("/Attendance", request, "session")
if err != nil || !handled {
t.Fatal(err)
}
if v, ok, _ := wire.Varint(response, 55); !ok || v != 123 {
t.Fatal("native response lost")
}
bundle, ok, err := wire.Bytes(response, 1001)
if err != nil || !ok || len(bundle) == 0 {
t.Fatal("reward extension missing")
}
if receipt, ok, _ := wire.Bytes(response, 1002); !ok || len(receipt) == 0 {
t.Fatal("receipt extension missing")
}
calls := base.Calls
_, replay, _, err := h.HandleSession("/Attendance", request, "session")
if err != nil || !bytes.Equal(response, replay) || base.Calls != calls {
t.Fatal("retry lost reward or granted twice", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "session")
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok || base.Calls != calls {
t.Fatal("same day duplicate grant")
}
_, _, _, err = h.HandleSession("/Attendance", append(request, wire.AppendVarint(nil, 2, 1)...), "session")
if err == nil {
t.Fatal("conflicting sequence accepted")
}
}
func TestAttendanceCombinesLoginPassAndSubscriptionWithoutDuplicateClaims(t *testing.T) {
e, items, base, _, now := entitlementFixture(t)
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
design := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}, Premium: gamedata.Reward{Type: 3, Count: 22}}},
}}
passes, err := NewLoginPasses(e.store, design, e, items, func(group uint64) bool { return group == 10 })
if err != nil {
t.Fatal(err)
}
passes.SetClock(func() time.Time { return *now }, 0)
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, LoginPasses: passes, Store: e.store}
calls := base.Calls
request := wire.AppendVarint(nil, 1, 1)
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || base.Calls != calls+2 {
t.Fatalf("daily claims not combined: calls=%d err=%v", base.Calls-calls, err)
}
info, ok, err := wire.Bytes(response, 6)
if err != nil || !ok {
t.Fatal("login-pass metadata lost", err)
}
if group, _, _ := wire.Varint(info, 1); group != 10 {
t.Fatal("wrong login-pass group")
}
bundle, ok, err := wire.Bytes(response, 1001)
if err != nil || !ok || len(bundle) == 0 {
t.Fatal("combined reward envelope missing", err)
}
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || !bytes.Equal(replay, response) || base.Calls != calls+2 {
t.Fatal("retry changed the envelope or repeated claims", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
if err != nil || base.Calls != calls+2 {
t.Fatal("fresh request claimed the same day again", err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok {
t.Fatal("fresh request replayed old rewards")
}
}

Some files were not shown because too many files have changed in this diff Show More