refactor(all): reorganize packages, establish player ownership and isolate accounts
This commit is contained in:
@@ -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.
|
||||
"""
|
||||
@@ -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` 编译。
|
||||
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@
|
||||
| 14 | 32 | payload 的 SHA-256 原始摘要 |
|
||||
| 46 | payload 长度 | 按固定顺序编码的记录 |
|
||||
|
||||
定义在 `go/internal/server/calendar/records.go` ,自行查看。
|
||||
定义在 `go/internal/server/domain/events/calendar/protocol_adapter.go` ,自行查看。
|
||||
|
||||
## 更新和验证
|
||||
|
||||
排期更新由项目维护,相关文件的 revision 随数据修订更新。
|
||||
排期更新由项目维护,相关文件的 revision 随数据修订更新。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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()...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()...)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
-1
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user