diff --git a/.codex/agents/test-reviewer.toml b/.codex/agents/test-reviewer.toml new file mode 100644 index 0000000..0ebdfbd --- /dev/null +++ b/.codex/agents/test-reviewer.toml @@ -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. +""" \ No newline at end of file diff --git a/README.md b/README.md index 2086c94..0af84bb 100644 --- a/README.md +++ b/README.md @@ -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` 编译。 diff --git a/SCHEDULES.md b/SCHEDULES.md index 10e871e..e807cd6 100644 --- a/SCHEDULES.md +++ b/SCHEDULES.md @@ -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 随数据修订更新。 \ No newline at end of file +排期更新由项目维护,相关文件的 revision 随数据修订更新。 diff --git a/go/build/sdk_pack.go b/go/build/sdk_pack.go index 0d939da..43dfcd0 100644 --- a/go/build/sdk_pack.go +++ b/go/build/sdk_pack.go @@ -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 { diff --git a/go/build/sdk_verify.go b/go/build/sdk_verify.go index 2057e20..143053a 100644 --- a/go/build/sdk_verify.go +++ b/go/build/sdk_verify.go @@ -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 } diff --git a/go/build/version_source_test.go b/go/build/version_source_test.go deleted file mode 100644 index 1f0e8d7..0000000 --- a/go/build/version_source_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/go/cmd/bd2client/dev_command_release_test.go b/go/cmd/bd2client/dev_command_release_test.go deleted file mode 100644 index d917de9..0000000 --- a/go/cmd/bd2client/dev_command_release_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/cmd/bd2client/dev_command_test.go b/go/cmd/bd2client/dev_command_test.go deleted file mode 100644 index c12dfb5..0000000 --- a/go/cmd/bd2client/dev_command_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/cmd/bd2client/dev_sdk_integration_test.go b/go/cmd/bd2client/dev_sdk_integration_test.go deleted file mode 100644 index 916d0e2..0000000 --- a/go/cmd/bd2client/dev_sdk_integration_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/cmd/bd2server/dev_command.go b/go/cmd/bd2server/dev_command.go index 16ee9e6..393c62a 100644 --- a/go/cmd/bd2server/dev_command.go +++ b/go/cmd/bd2server/dev_command.go @@ -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) { diff --git a/go/cmd/bd2server/dev_command_release_test.go b/go/cmd/bd2server/dev_command_release_test.go deleted file mode 100644 index 4ff7930..0000000 --- a/go/cmd/bd2server/dev_command_release_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/cmd/bd2server/dev_command_test.go b/go/cmd/bd2server/dev_command_test.go deleted file mode 100644 index 68c559a..0000000 --- a/go/cmd/bd2server/dev_command_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/cmd/bd2server/development_settings_test.go b/go/cmd/bd2server/development_settings_test.go deleted file mode 100644 index 0702431..0000000 --- a/go/cmd/bd2server/development_settings_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/cmd/bd2server/logging_test.go b/go/cmd/bd2server/logging_test.go deleted file mode 100644 index cda6612..0000000 --- a/go/cmd/bd2server/logging_test.go +++ /dev/null @@ -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()) - } -} diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index bda7893..fe49eea 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -1,63 +1,14 @@ package main import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "flag" + "bd2server/internal/server/app" "fmt" "log/slog" - "net/http" "os" - "os/signal" - "path/filepath" - "strings" - "time" - - "bd2server/internal/server/account" - "bd2server/internal/server/accountstate" - "bd2server/internal/server/auth" - "bd2server/internal/server/authconfig" - "bd2server/internal/server/battle" - "bd2server/internal/server/bootstrap" - "bd2server/internal/server/calendar" - "bd2server/internal/server/commerce" - "bd2server/internal/server/deck" - "bd2server/internal/server/eventactions" - "bd2server/internal/server/eventexchange" - "bd2server/internal/server/eventgames" - "bd2server/internal/server/eventplay" - "bd2server/internal/server/events" - "bd2server/internal/server/eventtasks" - "bd2server/internal/server/feature" - "bd2server/internal/server/gacha" - "bd2server/internal/server/gameconfig" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/hunting" - "bd2server/internal/server/lifecycle" - "bd2server/internal/server/mail" - "bd2server/internal/server/missions" - "bd2server/internal/server/monsterhunt" - "bd2server/internal/server/npcinn" - "bd2server/internal/server/npcshop" - "bd2server/internal/server/pictorial" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/readonly" - "bd2server/internal/server/resourcefetch" - "bd2server/internal/server/resourcepolicy" - "bd2server/internal/server/session" - "bd2server/internal/server/stateio" - "bd2server/internal/server/todayquest" - "bd2server/internal/server/transport" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "bd2server/internal/server/world" ) func main() { - if err := configureLogging(os.Stderr, "", ""); err != nil { + if err := app.ConfigureLogging(os.Stderr, "", ""); err != nil { fmt.Fprintln(os.Stderr, "logging configuration failed:", err) os.Exit(2) } @@ -75,13 +26,13 @@ func main() { var err error switch os.Args[1] { case "serve": - err = serve(os.Args[2:]) + err = app.Serve(os.Args[2:]) case "state": - err = stateCommand(os.Args[2:]) + err = app.State(os.Args[2:]) case "resources": - err = resourcesCommand(os.Args[2:]) + err = app.Resources(os.Args[2:]) case "preflight": - err = preflight(os.Args[2:]) + err = app.Preflight(os.Args[2:]) case "help", "-h", "--help": usage() return @@ -94,1389 +45,6 @@ func main() { } } -func serve(args []string) (serveErr 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") - stateFile := fs.String("state", "", "account SQLite database override") - 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 err - } - if err := configureLogging(os.Stderr, *logLevel, *logColor); err != nil { - return err - } - defer func() { - if err := gamedata.CloseDatabaseCache(); err != nil { - serveErr = errors.Join(serveErr, fmt.Errorf("close GameData query cache: %w", err)) - } - }() - var versions versionconfig.Config - var err error - if *versionConfigPath == "" { - versions, err = versionconfig.Find() - } else { - versions, err = versionconfig.Load(*versionConfigPath) - } - if err != nil { - return 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 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 fmt.Errorf("load project calendars: %w", err) - } - if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 { - return 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 err - } - } - gameRules, err := gameconfig.Load(*gameConfigPath) - if err != nil { - return err - } - if *authConfigPath == "" { - *authConfigPath, err = authconfig.BesideExecutable() - if err != nil { - return err - } - } - authentication, err := authconfig.Load(*authConfigPath) - if err != nil { - return err - } - authRuntime, err := authentication.ResolveEnvironment() - if err != nil { - return err - } - defer clear(authRuntime.MasterKey) - if *resourceConfigPath == "" { - *resourceConfigPath, err = resourcepolicy.BesideExecutable() - if err != nil { - return err - } - } - resources, err := resourcepolicy.Load(*resourceConfigPath) - if err != nil { - return err - } - if *dataDir == "" { - executable, executableErr := os.Executable() - if executableErr != nil { - return 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 fmt.Errorf("resolve server data directory: %w", err) - } - *devToolsConfig = resolveDevelopmentSettingsPath(*dataDir, *devToolsConfig) - gameData := filepath.Join(*dataDir, "resources", "GameData") - if *stateFile == "" { - *stateFile = filepath.Join(*dataDir, "state", "state.db") - } - if err := os.MkdirAll(filepath.Dir(filepath.Clean(*stateFile)), 0o755); err != nil { - return fmt.Errorf("create server state directory: %w", err) - } - 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 err - } - verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, gameData, *gameDataVersion, *gameDataOrigin) - if err != nil { - return 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 fmt.Errorf("validate project calendar GameData references: %w", err) - } - login, err := account.Load(filepath.Clean(*accountSeed)) - if err != nil { - return fmt.Errorf("load local account seed: %w", err) - } - starter, err := player.Load(filepath.Clean(*playerSeed)) - if err != nil { - return fmt.Errorf("load starter player: %w", err) - } - if login.Version != versions.GameVersion || starter.Version != versions.GameVersion { - return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", versions.GameVersion, login.Version, starter.Version) - } - gachaSchedule := 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) - } - regularGacha, equipmentGacha, err := gamedata.LoadActiveGachaForSchedules(gameData, *gameDataVersion, scheduleGroupIDs, stepUpGroupIDs) - if err != nil { - return fmt.Errorf("load active gacha GameData: %w", err) - } - if gameRules.Gacha.IncludeCollaborationURWeapons { - if err := equipmentGacha.IncludeCollaborationURWeapons(gameData, *gameDataVersion); err != nil { - return fmt.Errorf("apply collaboration UR weapon game rule: %w", err) - } - } - slog.Info("server gameplay rules loaded", "config", *gameConfigPath, - "include_collaboration_ur_weapons", gameRules.Gacha.IncludeCollaborationURWeapons, - "starting_pack_id", gameRules.Story.StartPackID) - limitedCostumes, err := gamedata.LoadLimitedCostumes(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load limited costume GameData: %w", err) - } - firstGacha, err := gamedata.LoadFirstGacha(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load first gacha GameData: %w", err) - } - stateRepository, err := accountstate.Open(filepath.Clean(*stateFile)) - if err != nil { - return fmt.Errorf("open account state database: %w", err) - } - defer func() { _ = stateRepository.Close() }() - var authService *auth.Service - if authentication.Mode == "oauth" { - authStore, err := auth.Open(filepath.Join(filepath.Dir(filepath.Clean(*stateFile)), "auth.db"), authRuntime.MasterKey) - if err != nil { - return fmt.Errorf("open authentication database: %w", err) - } - defer func() { _ = authStore.Close() }() - authService, err = auth.New(authRuntime, authStore) - if err != nil { - return err - } - } - accountDomains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"} - initializationState, err := stateRepository.InitializationState(accountDomains...) - if err != nil { - return fmt.Errorf("reject incomplete account database: %w", err) - } - if initializationState == accountstate.InitializationCorrupt { - return errors.New("reject incomplete account database: corrupt initialization state") - } - initializeAccount := initializationState == accountstate.InitializationPending - startupTransaction, err := stateRepository.BeginOperation() - if err != nil { - return fmt.Errorf("begin startup state transaction: %w", err) - } - startupCommitted := false - defer func() { - if startupCommitted { - return - } - if rollbackErr := startupTransaction.Rollback(); rollbackErr != nil { - serveErr = errors.Join(serveErr, rollbackErr) - } - }() - startingPackID, err := stateRepository.LockStartingPack(gameRules.Story.StartPackID, initializeAccount) - if err != nil { - return fmt.Errorf("server starting chapter policy: %w", err) - } - serverConfig, err := readonly.Load(filepath.Clean(*readonlySeed)) - if err != nil { - return fmt.Errorf("load readonly server configuration: %w", err) - } - serverConfig, err = calendars.ApplyReadonly(serverConfig) - if err != nil { - return fmt.Errorf("encode project calendars: %w", err) - } - seasonSchedule := calendars.RegularService - mailbox, err := mail.Load(filepath.Clean(*mailSeed)) - if err != nil { - return fmt.Errorf("load starter mailbox: %w", err) - } - progressState, err := progress.OpenStore(stateRepository) - if err != nil { - return err - } - deckConfig, err := deck.LoadSeed(filepath.Clean(*deckSeed)) - if err != nil { - return fmt.Errorf("load starter deck: %w", err) - } - presetDesign, err := gamedata.LoadPresetDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load party preset GameData: %w", err) - } - deckStateStore, err := deck.OpenStore(stateRepository, deckConfig, *presetDesign) - if err != nil { - return fmt.Errorf("load deck state: %w", err) - } - if err := login.AttachPresetSlots(deckStateStore); err != nil { - return fmt.Errorf("attach preset slots to login: %w", err) - } - if err := login.AttachPortrait(deckStateStore); err != nil { - return fmt.Errorf("attach persisted portrait to login: %w", err) - } - ownedItems, err := player.OpenInventory(stateRepository, starter) - if err != nil { - return fmt.Errorf("load owned inventory: %w", err) - } - recipeDesign, err := gamedata.LoadCookingRecipeDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load cooking recipes: %w", err) - } - recipeService, err := feature.NewRecipeService(recipeDesign, starter.CookingRecipes, ownedItems) - if err != nil { - return fmt.Errorf("load learned recipes: %w", err) - } - randomBoxes, err := gamedata.LoadRandomBoxDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load deterministic random-box GameData: %w", err) - } - if err := ownedItems.AttachRandomBoxes(randomBoxes); err != nil { - return fmt.Errorf("attach random-box GameData: %w", err) - } - gold, freeJewelry, jewelry, mileage, err := login.SeedCurrencies() - if err != nil { - return fmt.Errorf("read account seed currency: %w", err) - } - hopePowder, err := login.SeedHopePowder() - if err != nil { - return fmt.Errorf("read account seed hope powder: %w", err) - } - catalyst, err := login.SeedCatalyst() - if err != nil { - return fmt.Errorf("read account seed catalyst: %w", err) - } - equipMileage, equipMileageExchangeGage, err := login.SeedEquipmentMileage() - if err != nil { - return fmt.Errorf("read account seed equipment mileage: %w", err) - } - wallet, err := player.OpenWallet(stateRepository, player.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 := login.AttachCurrencies(wallet); err != nil { - return fmt.Errorf("attach wallet to login: %w", err) - } - slotDesign, err := gamedata.LoadInventorySlotDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load inventory slot GameData: %w", err) - } - itemSlots, storageSlots, equipmentInventorySlots, equipmentStorageSlots, err := login.SeedInventorySlots() - if err != nil { - return fmt.Errorf("read account seed inventory slots: %w", err) - } - inventorySlots, err := player.OpenInventorySlots(stateRepository, slotDesign, player.InventorySlotCounts{ - Items: itemSlots, Storage: storageSlots, Equipment: equipmentInventorySlots, EquipmentStorage: equipmentStorageSlots, - }, wallet) - if err != nil { - return fmt.Errorf("load inventory slot state: %w", err) - } - inventorySlots.AttachDevelopmentSettings(*devToolsConfig) - if err := login.AttachInventorySlots(inventorySlots); err != nil { - return fmt.Errorf("attach inventory slots to login: %w", err) - } - mailService, err := mail.OpenService(stateRepository, mailbox, ownedItems, wallet) - if err != nil { - return fmt.Errorf("load mail state: %w", err) - } - contentTickets, err := gamedata.LoadGachaContentTicketDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load gacha content tickets: %w", err) - } - if err := mailService.AttachContentTickets(contentTickets); err != nil { - return fmt.Errorf("attach mailbox content tickets: %w", err) - } - if err := mailService.AttachSeedPath(filepath.Clean(*mailSeed)); err != nil { - return fmt.Errorf("watch mail seed: %w", err) - } - if *mailGrantSpool != "" { - if err := mailService.AttachGrantSpoolPath(*mailGrantSpool); err != nil { - return fmt.Errorf("attach mail grant spool: %w", err) - } - } - missionDesign, err := gamedata.LoadMissionDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load mission GameData: %w", err) - } - missionService, err := missions.Open(stateRepository, missionDesign, ownedItems) - if err != nil { - return fmt.Errorf("load mission state: %w", err) - } - if err := missionService.AttachWallet(wallet); err != nil { - return fmt.Errorf("attach mission wallet: %w", err) - } - levelDesign, err := gamedata.LoadAchievementLevelDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load user level rewards: %w", err) - } - if err := missionService.AttachUserLevelRewards(levelDesign); err != nil { - return fmt.Errorf("attach user level rewards: %w", err) - } - if err := login.AttachLevelReward(missionService); err != nil { - return fmt.Errorf("attach persisted user level reward: %w", err) - } - if err := missionService.AttachMail(mailService); err != nil { - return fmt.Errorf("attach mission compensation mailbox: %w", err) - } - ownedEquipment, err := player.OpenEquipmentInventory(stateRepository) - if err != nil { - return fmt.Errorf("load owned equipment: %w", err) - } - equipmentSlots, err := gamedata.LoadEquipmentSlots(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load equipment slot GameData: %w", err) - } - if err := ownedEquipment.AttachSlots(equipmentSlots); err != nil { - return fmt.Errorf("attach equipment slot GameData: %w", err) - } - equipmentUpgrade, err := gamedata.LoadEquipmentUpgradeDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load equipment upgrade GameData: %w", err) - } - if err := ownedEquipment.AttachUpgrade(equipmentUpgrade, wallet, ownedItems); err != nil { - return fmt.Errorf("attach equipment upgrade GameData: %w", err) - } - equipmentCraft, err := gamedata.LoadEquipmentCraftDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load equipment crafting GameData: %w", err) - } - talentGrowth, err := gamedata.LoadTalentGrowthDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load talent growth GameData: %w", err) - } - if err := ownedEquipment.AttachCraft(equipmentCraft); err != nil { - return fmt.Errorf("attach equipment crafting GameData: %w", err) - } - equipmentSmelting, err := gamedata.LoadEquipmentSmeltingDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load equipment smelting GameData: %w", err) - } - if err := ownedEquipment.AttachSmelting(equipmentSmelting, wallet, ownedItems); err != nil { - return fmt.Errorf("attach equipment smelting GameData: %w", err) - } - equipmentOptionReroll, err := gamedata.LoadEquipmentOptionRerollDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load equipment option reroll GameData: %w", err) - } - if err := ownedEquipment.AttachOptionReroll(equipmentOptionReroll, wallet, ownedItems); err != nil { - return fmt.Errorf("attach equipment option reroll GameData: %w", err) - } - worldService, err := world.Load(filepath.Clean(*worldSeed), gameData, *gameDataVersion, - stateRepository, progressState, starter, ownedEquipment, ownedItems, wallet) - if err != nil { - return fmt.Errorf("load world state: %w", err) - } - if err := worldService.ConfigureStartPack(startingPackID, initializeAccount); err != nil { - return fmt.Errorf("configure account starting chapter: %w", err) - } - missionUnlocked := worldService.MissionsUnlocked - if err := missionService.RecordLogin(missionUnlocked); err != nil { - return fmt.Errorf("record login missions: %w", err) - } - if err := login.AttachLastPlayedPack(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 collection entry; attaching it after opening - // collection would reject its otherwise valid burst ledger on restart. - baseCostumes := append([]player.Costume(nil), starter.Costumes...) - if reward, earned := worldService.EarnedQuestCostume(); earned { - baseCostumes = append(baseCostumes, reward) - } - collection, err := player.OpenCollectionStore(stateRepository, baseCostumes) - if err != nil { - return fmt.Errorf("load owned collection: %w", err) - } - infiniteGacha, err := gamedata.LoadInfiniteGachaForSchedules(gameData, *gameDataVersion, scheduleGroupIDs) - if err != nil { - return fmt.Errorf("load infinite gacha GameData: %w", err) - } - gachaService, err := gacha.NewService(infiniteGacha, regularGacha, collection, wallet) - if err != nil { - return err - } - if err := login.AttachPurchaseCounts(gachaService); err != nil { - return fmt.Errorf("attach cash purchase counts to login: %w", err) - } - if err := gachaService.AttachSchedule(gachaSchedule); err != nil { - return fmt.Errorf("attach gacha schedule: %w", err) - } - previewEventIndex, err := serverConfig.CashProductEventIndex(infiniteGacha.ProductGroupID, infiniteGacha.ProductID) - if err != nil { - return fmt.Errorf("load infinite preview event: %w", err) - } - if err := gachaService.AttachPreviewEventIndex(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 := login.AttachFirstGacha(gachaService); err != nil { - return fmt.Errorf("attach first gacha status to login: %w", err) - } - if err := gachaService.AttachFirstGacha(firstGacha); err != nil { - return fmt.Errorf("attach first gacha GameData: %w", err) - } - gachaService.AttachInventory(ownedItems) - gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment) - gachaService.AttachDrawMission(func(count uint64) error { - return missionService.RecordEvent(missions.ConditionGachaBuy, 0, count, missionUnlocked) - }) - var permanentBaseCharacters []player.Character - for _, c := range worldService.CharacterService().RawAll() { - if !player.IsCharmCharacter(c) { - permanentBaseCharacters = append(permanentBaseCharacters, c) - } - } - if err := collection.BindBaseCharacters(permanentBaseCharacters); err != nil { - return fmt.Errorf("bind base collection characters: %w", err) - } - if err := mailService.AttachCostumeRewards(collection, limitedCostumes); err != nil { - return fmt.Errorf("attach limited costume mail rewards: %w", err) - } - if err := worldService.AttachCollection(collection); err != nil { - return fmt.Errorf("attach gacha collection state: %w", err) - } - if err := worldService.AttachDecks(deckStateStore); err != nil { - return fmt.Errorf("attach world deck state: %w", err) - } - if err := worldService.AttachWaypointRuntime(gameData, *gameDataVersion); err != nil { - return fmt.Errorf("attach waypoint runtime: %w", err) - } - if err := worldService.AttachFieldObjectRuntime(gameData, *gameDataVersion); err != nil { - return fmt.Errorf("attach field object runtime: %w", err) - } - if err := ownedEquipment.AttachCharacters(worldService.CharacterService()); err != nil { - return fmt.Errorf("attach equipment character state: %w", err) - } - if err := deckStateStore.AttachPresetRuntime(wallet, worldService.CharacterService(), ownedEquipment, collection); err != nil { - return fmt.Errorf("attach ordinary preset runtime: %w", err) - } - fieldSettingsDesign, err := gamedata.LoadFieldSettingsDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load field character settings: %w", err) - } - if err := deckStateStore.AttachFieldSettingsPack(worldService.CurrentPackID); err != nil { - return err - } - if err := deckStateStore.AttachFieldSettings(fieldSettingsDesign); err != nil { - return fmt.Errorf("attach field character settings: %w", err) - } - if err := login.AttachAutoReviveSettings(deckStateStore); err != nil { - return fmt.Errorf("attach automatic revival settings: %w", err) - } - pictorialDesign, err := gamedata.LoadPictorialDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load pictorial GameData: %w", err) - } - pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService} - equipmentStatDesign, err := gamedata.LoadEquipmentStatDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load equipment stat GameData: %w", err) - } - if err := ownedEquipment.AttachStatDesign(equipmentStatDesign); err != nil { - return err - } - pictorialService.EquipmentContributions = ownedEquipment.StatContributions - charAwakeDesign, err := gamedata.LoadCharAwakeDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load character awakening GameData: %w", err) - } - charAwakeService, err := player.NewCharAwakeService(charAwakeDesign, collection, worldService.CharacterService(), ownedItems, wallet) - if err != nil { - return err - } - pictorialService.AwakeContributions = charAwakeService.Contributions - if err := worldService.CharacterService().AttachMaxHealth(pictorialService.MaxHealth); err != nil { - return fmt.Errorf("attach pictorial character stats: %w", err) - } - if err := worldService.CharacterService().AttachWallet(wallet); err != nil { - return fmt.Errorf("attach character promotion wallet: %w", err) - } - if err := worldService.CharacterService().AttachTalentGrowth(talentGrowth); err != nil { - return fmt.Errorf("attach character talent growth: %w", err) - } - immortalDesign, err := gamedata.LoadImmortalDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load immortal talent GameData: %w", err) - } - if err := worldService.CharacterService().AttachImmortalDesign(immortalDesign); err != nil { - return fmt.Errorf("attach immortal talent GameData: %w", err) - } - costumePotentialDesign, err := gamedata.LoadCostumePotentialDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load costume potential GameData: %w", err) - } - costumePotentialService, err := player.NewCostumePotentialService(costumePotentialDesign, collection, worldService.CharacterService(), ownedItems, wallet) - if err != nil { - return err - } - pictorialService.PotentialContributions = costumePotentialService.Contributions - costumeBurstDesign, err := gamedata.LoadCostumeBurstDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load costume burst GameData: %w", err) - } - costumeBurstService, err := player.NewCostumeBurstService(costumeBurstDesign, collection, ownedItems, wallet) - if err != nil { - return err - } - friendshipDesign, err := gamedata.LoadFriendshipDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load friendship GameData: %w", err) - } - friendshipService, err := player.NewFriendshipService(&friendshipDesign, charAwakeDesign, costumePotentialDesign, collection, ownedItems, wallet) - if err != nil { - return fmt.Errorf("load friendship state: %w", err) - } - if err := login.AttachFriendshipAP(friendshipService); err != nil { - return err - } - accountName, found, err := wire.Bytes(login.UserInfo, 2) - if err != nil || !found || len(accountName) == 0 { - return errors.New("account seed requires its existing display name for master title") - } - masterTitleService, err := player.OpenMasterTitleService(stateRepository, string(accountName)) - if err != nil { - return fmt.Errorf("load master title: %w", err) - } - battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID) - freeHuntingAP, bonusHuntingAP, err := login.SeedHuntingAP() - if err != nil { - return fmt.Errorf("read initial hunting AP: %w", err) - } - gameplayStore := stateio.EntrySnapshotStore{Entries: stateRepository, Domain: "missions", Bucket: "gameplay"} - if err := costumePotentialService.AttachConnectStore(gameplayStore); err != nil { - return fmt.Errorf("attach costume potential connection state: %w", err) - } - if err := worldService.AttachFieldMonsterState(gameplayStore); err != nil { - return fmt.Errorf("attach field monster state: %w", err) - } - battleService.AttachFieldMonsters(worldService) - battleService.AttachFieldBuffConsume(worldService.ConsumeFieldBattleBuff) - if err := worldService.AttachFieldBuffRuntime(gameData, *gameDataVersion); err != nil { - return fmt.Errorf("attach field monster damage: %w", err) - } - contentOpeningDesign, err := gamedata.LoadContentOpeningDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load content opening GameData: %w", err) - } - contentOpenService, err := player.NewContentOpenService(contentOpeningDesign, ownedItems, gameplayStore, func() (uint64, error) { - experience, err := missionService.AchievementExperience() - if err != nil { - return 0, err - } - return levelDesign.Level(experience), nil - }) - if err != nil { - return fmt.Errorf("load content opening state: %w", err) - } - huntingService, err := hunting.Open(gameplayStore, gameData, *gameDataVersion, ownedItems, wallet, - worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP) - if err != nil { - return fmt.Errorf("load hunting state: %w", err) - } - if err := login.AttachHuntingAP(huntingService); err != nil { - return fmt.Errorf("attach persisted hunting AP: %w", err) - } - huntingAPDesign, err := gamedata.LoadHuntingAPDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load hunting AP reset: %w", err) - } - if err = huntingService.AttachAPRefresh(huntingAPDesign); err != nil { - return err - } - battleService.AttachHunting(huntingService) - huntingService.AttachEligibility(worldService.HuntingEligibility) - if err := worldService.AttachHuntingGround(huntingService); err != nil { - return err - } - eventRegistry := events.NewRegistry() - if err := eventRegistry.Replace(calendars.Events); err != nil { - return err - } - rewardGraph, err := gamedata.LoadRewardGraph(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load event reward graph: %w", err) - } - rewardEquipment, err := gamedata.LoadRewardEquipmentCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load reward equipment: %w", err) - } - rewardCostumes, err := gamedata.LoadRewardCostumeCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load reward costumes: %w", err) - } - initialEventCurrency := map[uint64]uint64{} - for itemType, field := range events.AdditionalCurrencyFields { - value, _, readErr := wire.Varint(login.UserInfo, field) - if readErr != nil { - return readErr - } - initialEventCurrency[itemType] = value - } - eventEconomy, err := events.NewEconomy(gameplayStore, ownedItems, wallet, collection, ownedEquipment, rewardCostumes, rewardEquipment, rewardGraph, initialEventCurrency) - if err != nil { - return fmt.Errorf("load event economy: %w", err) - } - eventEconomy.AttachHuntingAP(huntingService) - talentUseDesign, err := gamedata.LoadTalentUseDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load field talent skills: %w", err) - } - talentUseService, err := player.NewTalentUseService(talentUseDesign, gameplayStore, worldService.CharacterService(), ownedItems, wallet, eventEconomy) - if err != nil { - return fmt.Errorf("load field talent state: %w", err) - } - talentUseService.AttachContext(worldService.TalentFieldContext) - if err := worldService.AttachAutoRecoveryPolicy(gameData, *gameDataVersion); err != nil { - return fmt.Errorf("attach automatic recovery policy: %w", err) - } - deckStateStore.AttachAutoRecoveryAllowed(worldService.AutoRecoveryAllowed) - deckStateStore.AttachAutoRecovery(talentUseService.AutoRecover) - worldService.AttachTalentPackInfo(talentUseService.PackInfo) - worldService.AttachOverwhelmAuthorization(talentUseService.ConsumeOverwhelm) - worldService.AttachOverwhelmHunting(huntingService) - if err := worldService.AttachOverwhelmDesign(gameData, *gameDataVersion); err != nil { - return fmt.Errorf("attach overwhelm design: %w", err) - } - talentUseService.AttachEffect(4, worldService.ApplyTalentFieldAbsorb) - talentUseService.AttachEffect(20, worldService.ApplyTalentMonsterSummon) - dispatchDesign, err := gamedata.LoadTalentDispatchDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load talent dispatch design: %w", err) - } - dispatchService, err := player.OpenTalentDispatch(gameplayStore, dispatchDesign, eventEconomy) - if err != nil { - return fmt.Errorf("load talent dispatch state: %w", err) - } - talentUseService.AttachEffect(18, dispatchService.Start) - itemCraftDesign, err := gamedata.LoadItemCraftDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load item crafting design: %w", err) - } - itemCraftService, err := player.NewItemCraftService(itemCraftDesign, talentUseDesign, gameplayStore, ownedItems, worldService.CharacterService(), wallet, recipeService.Knows) - if err != nil { - return fmt.Errorf("load item crafting state: %w", err) - } - itemCraftService.AttachContext(func() (int, bool, error) { - pack, err := worldService.CurrentPackID() - return pack, battleService.Active(), err - }) - if err := worldService.ConfigureNPCRuntime(gameData, *gameDataVersion, gameplayStore); err != nil { - return fmt.Errorf("configure NPC world runtime: %w", err) - } - innService, err := npcinn.New(gameplayStore, worldService.CharacterService(), wallet, worldService.InnContext, - func() (uint64, error) { - experience, err := missionService.AchievementExperience() - if err != nil { - return 0, err - } - return levelDesign.Level(experience), nil - }, battleService.Active) - if err != nil { - return fmt.Errorf("load inn recovery: %w", err) - } - npcShopDesign, err := gamedata.LoadNPCShopDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load NPC shop design: %w", err) - } - npcShopService, err := npcshop.New(npcShopDesign, gameplayStore, eventEconomy, ownedItems, worldService.PackAvailable) - if err != nil { - return fmt.Errorf("load NPC shop state: %w", err) - } - npcShopService.SetReputationSource(worldService.NPCShopReputation) - npcShopService.SetTalentDiscountSource(talentUseService.ShopDiscount) - commissionDesign, err := gamedata.LoadTodayQuests(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load NPC commission design: %w", err) - } - commissionService, err := todayquest.Open(gameplayStore, commissionDesign, eventEconomy, ownedItems, worldService.CommissionPackUnlocked) - if err != nil { - return fmt.Errorf("load NPC commission state: %w", err) - } - commissionService.CompleteReputation = worldService.CompleteNPCReputation - if err := worldService.AttachTodayQuests(commissionService); err != nil { - return fmt.Errorf("attach NPC commissions: %w", err) - } - if err = worldService.AttachResearchRuntime(gameData, *gameDataVersion, eventEconomy); err != nil { - return fmt.Errorf("attach field research: %w", err) - } - prestigeCatalog, err := gamedata.LoadPrestigeSkinCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load reward prestige skins: %w", err) - } - eventEconomy.AttachPrestigeSkins(prestigeCatalog.Skins) - eventEconomy.AttachPrestigePortrait(deckStateStore.PortraitCostume) - if err := worldService.AttachPrestigeSelections(eventEconomy.PrestigeSkinSelections); err != nil { - return fmt.Errorf("attach prestige skin selections: %w", err) - } - ownedEventItems, err := gamedata.LoadOwnedEventItemDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load event inventory design: %w", err) - } - eventEconomy.AttachOwnedItemDesign(ownedEventItems) - avatarRewards, err := gamedata.LoadAvatarRewardDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load avatar rewards: %w", err) - } - eventEconomy.AttachAvatarRewards(avatarRewards) - buffDesign, err := gamedata.LoadBuffRewardDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load permanent buff rewards: %w", err) - } - buffRewards, err := events.OpenBuffRewards(gameplayStore, buffDesign) - if err != nil { - return fmt.Errorf("load permanent buff ownership: %w", err) - } - eventEconomy.AttachBuffRewards(buffRewards) - pictorialService.AttachPermanentBuffs(buffRewards.SnapshotBuffs) - eventAPCaps, eventAPReset, err := gamedata.LoadEventAPDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load event AP reset: %w", err) - } - if err = eventEconomy.AttachAPRefresh(eventAPCaps, eventAPReset); err != nil { - return err - } - if err = login.AttachAdditionalCurrencies(eventEconomy); err != nil { - return err - } - huntingService.AttachRewards(func(identity string, rewards []gamedata.Reward) ([]byte, error) { - return eventEconomy.Apply(identity, nil, rewards) - }) - cashDesign, err := gamedata.LoadCashCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load cash products: %w", err) - } - cashCatalog, err := commerce.NewCatalog(versions.GameVersion, cashDesign, gameRules.Purchases) - if err != nil { - return fmt.Errorf("configure cash products: %w", err) - } - cashEntitlementDesign, err := gamedata.LoadCashEntitlementDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load cash entitlement design: %w", err) - } - cashRewards, err := gamedata.LoadCashRewardResolver(gameData, *gameDataVersion, rewardGraph) - if err != nil { - return fmt.Errorf("load cash product rewards: %w", err) - } - cashEconomy, err := commerce.NewEntitlementEconomy(gameplayStore, eventEconomy, cashRewards, ownedItems, cashEntitlementDesign) - if err != nil { - return fmt.Errorf("load cash entitlements: %w", err) - } - cashEconomy.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) - cashMailTemplates, err := gamedata.LoadCashMailTemplates(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load cash mail templates: %w", err) - } - if err := mailService.AttachCashRewards(cashEconomy, cashMailTemplates); err != nil { - return err - } - if err := cashEconomy.AttachCashMail(mailService); err != nil { - return err - } - cashService, err := commerce.NewService(cashCatalog, gameplayStore, cashEconomy) - if err != nil { - return fmt.Errorf("load cash purchase state: %w", err) - } - clearPackageDesign, err := gamedata.LoadClearPackageCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load clear-package rewards: %w", err) - } - clearPackages, err := commerce.NewClearPackages(gameplayStore, clearPackageDesign, cashEconomy, ownedItems) - if err != nil { - return fmt.Errorf("load clear-package claims: %w", err) - } - clearPackages.AttachProgress(worldService.CashPackagePackCleared, nil) - cashService.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) - if err := cashService.AttachPackageRules(cashDesign.Packages); err != nil { - return fmt.Errorf("attach cash package progression: %w", err) - } - if err := cashService.AttachShopSeed(serverConfig); err != nil { - return fmt.Errorf("attach cash product availability: %w", err) - } - if err := cashService.AttachEventShopSchedules(cashDesign, calendars.Events); err != nil { - return fmt.Errorf("attach event shop availability: %w", err) - } - cashBonusDesign, err := gamedata.LoadCashBonusCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load cash bonus design: %w", err) - } - cashBonuses, err := commerce.NewCashBonuses(gameplayStore, cashEconomy, cashService, cashBonusDesign, cashDesign.Packages) - if err != nil { - return fmt.Errorf("load cash bonus claims: %w", err) - } - cashService.AttachLegacyCounts(gachaService) - cashSpecialProducts := []gamedata.CashProductKey{{GroupID: infiniteGacha.ProductGroupID, ProductID: infiniteGacha.ProductID, SaleGroup: infiniteGacha.SaleGroup}} - for _, group := range 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 := cashService.AttachSpecialProducts(cashSpecialProducts); err != nil { - return fmt.Errorf("attach special cash products: %w", err) - } - cashService.AttachDelegate(func(key gamedata.CashProductKey, request []byte) ([]byte, bool, error) { - known := key.GroupID == infiniteGacha.ProductGroupID && key.ProductID == infiniteGacha.ProductID && key.SaleGroup == infiniteGacha.SaleGroup - for _, group := range 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 := gachaService.Handle("/CashShopBuy", request) - if err != nil || !handled { - return nil, handled, err - } - bundle, _, err := wire.Bytes(response, 1) - return bundle, true, err - }) - if err := login.AttachPurchaseCounts(cashService); err != nil { - return fmt.Errorf("attach cash purchase counts: %w", err) - } - eventTasksDesign, err := gamedata.LoadEventTasksDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load event tasks design: %w", err) - } - eventTasksService, err := eventtasks.Open(gameplayStore, eventTasksDesign, eventRegistry, eventEconomy) - if err != nil { - return fmt.Errorf("load event tasks state: %w", err) - } - if err := mailService.AttachAttendanceRewardEconomy(eventEconomy); err != nil { - return fmt.Errorf("attach attendance mail rewards: %w", err) - } - eventTasksService.AttachAttendanceMail(mailService) - newbieStep, _, err := wire.Varint(login.UserInfo, 39) - if err != nil { - return err - } - if err = eventTasksService.SetNewbieStep(newbieStep); err != nil { - return err - } - if err = login.AttachNewbieStep(eventTasksService); err != nil { - return err - } - eventTasksService.AttachCashAuthorization(func(passID, buyType uint64) bool { - for _, buy := range eventTasksDesign.PassBuys[passID] { - if buy.Type == buyType && buy.CashID != 0 { - return cashService.ConsumeEntitlement(gamedata.CashProductKey{GroupID: buy.CashGroup, ProductID: buy.CashID, SaleGroup: buy.CashSales}) - } - } - return false - }) - eventTasksService.AttachAttendancePremium(func(ticket uint64) bool { - for _, item := range ownedItems.All() { - if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(time.Now().UnixMilli())) { - return true - } - } - return false - }) - loginPassDesign, err := gamedata.LoadLoginPassCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load login-pass rewards: %w", err) - } - loginPasses, err := commerce.NewLoginPasses(gameplayStore, loginPassDesign, cashEconomy, ownedItems, func(group uint64) bool { - for _, pack := range cashDesign.Packages { - if pack.PackageType == 7 && pack.ID == group && cashService.IsAvailable(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) - } - loginPasses.SetClock(time.Now, eventAPReset.ResetSeconds-9*3600) - eventTasksService.AttachUnlockResolver(missionUnlocked) - if err = missionService.AttachEventHandler(eventTasksService); err != nil { - return fmt.Errorf("attach mission event handler: %w", err) - } - if err = missionService.RecordLogin(missionUnlocked); err != nil { - return err - } - eventGamesService, err := eventgames.Open(gameplayStore, gameData, *gameDataVersion, eventRegistry, eventEconomy) - if err != nil { - return fmt.Errorf("load event games state: %w", err) - } - eventExchangeDesign, err := gamedata.LoadEventExchangeCatalog(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load event exchange design: %w", err) - } - eventExchangeService, err := eventexchange.Open(gameplayStore, eventExchangeDesign, eventRegistry, eventEconomy) - if err != nil { - return fmt.Errorf("load event exchange state: %w", err) - } - boxService, err := events.OpenBoxes(gameplayStore, ownedItems, eventEconomy) - if err != nil { - return fmt.Errorf("load random box state: %w", err) - } - eventPlayService, err := eventplay.Open(gameplayStore, gameData, *gameDataVersion, eventRegistry, eventEconomy) - if err != nil { - return fmt.Errorf("load event play state: %w", err) - } - eventBattleChallenges, err := gamedata.LoadEventBattleChallenges(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load event battle challenges: %w", err) - } - eventPlayService.AttachBattleChallenges(eventBattleChallenges) - eventPlayService.AttachHubCalendars(serverConfig) - if err := eventPlayService.AttachFieldBindingsFile(filepath.Join(filepath.Dir(*worldSeed), "event_field_bindings.json")); err != nil { - return fmt.Errorf("attach hidden field bindings: %w", err) - } - if err := worldService.AttachEventFieldPacks(eventPlayService); err != nil { - return fmt.Errorf("attach event field packs: %w", err) - } - battleService.AttachEventBattle(eventPlayService) - eventActionsDesign, err := gamedata.LoadEventActionsDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load event action design: %w", err) - } - eventActionsService, err := eventactions.Open(gameplayStore, eventActionsDesign, eventRegistry, eventEconomy) - if err != nil { - return fmt.Errorf("load event action state: %w", err) - } - miniContent, err := gamedata.LoadMiniContentDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load mini event content: %w", err) - } - if err = eventActionsService.AttachMiniContent(eventPlayService, miniContent); err != nil { - return fmt.Errorf("attach mini event content: %w", err) - } - eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 { - for _, entry := range collection.FriendshipEntries() { - if entry.State != nil && entry.State.CostumeID == id { - return entry.State.Level - } - } - return 0 - }) - eventActionsService.AttachChargeInfo(func() ([]byte, error) { - rows, err := eventEconomy.ChargeInfo() - if err != nil { - return nil, err - } - huntingRows, err := huntingService.APChargeInfo() - if err != nil { - return nil, err - } - return append(rows, huntingRows...), nil - }) - eventActionsService.AttachProgress(func(condition, sub, count uint64) error { - if err := missionService.RecordEvent(condition, sub, count, missionUnlocked); err != nil { - return err - } - return eventTasksService.RecordEvent(condition, sub, count, missionUnlocked) - }) - eventPlayService.AttachProgress(func(condition, sub, count uint64) error { - return missionService.RecordEvent(condition, sub, count, missionUnlocked) - }) - eventTasksService.AttachAssociatedMissionGroup(func(schedule events.Schedule) uint64 { - if group := eventActionsService.AssociatedMissionGroup(schedule); group != 0 { - return group - } - group, err := eventPlayService.AssociatedMissionGroup(schedule) - if err != nil { - slog.Error("event mission design unavailable", "event_uid", schedule.UID, "event_id", schedule.ID, "error", err) - } - return group - }) - battleService.AttachEventBattle(eventActionsService) - battleService.AttachCurrentDifficulty(worldService.CurrentQuestDifficulty) - if err := worldService.AttachBattleActive(battleService.Active); err != nil { - return fmt.Errorf("attach world battle guard: %w", err) - } - characters := worldService.CharacterService() - monsterHuntService, err := monsterhunt.Open(gameplayStore, gameData, *gameDataVersion, serverConfig, ownedItems, wallet) - if err != nil { - return fmt.Errorf("load monster hunt state: %w", err) - } - if err := monsterHuntService.AttachPresetRuntime(characters, ownedEquipment, collection); err != nil { - return fmt.Errorf("attach monster hunt preset ownership: %w", err) - } - if err := login.AttachMonsterHuntSlots(monsterHuntService); err != nil { - return fmt.Errorf("attach monster hunt preset slots: %w", err) - } - battleService.AttachMonsterHunt(monsterHuntService) - monsterHuntService.AttachRewards(func(identity string, rewards []gamedata.Reward) ([]byte, error) { - return eventEconomy.Apply(identity, nil, rewards) - }) - recruitDesign, err := gamedata.LoadRecruitDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load recruitment GameData: %w", err) - } - recruitService, err := player.NewRecruitService(&recruitDesign, &recruitDesign, collection, ownedItems, wallet, - func(npcID uint64) (uint64, error) { - return worldService.ResolveRecruitNPC(npcID, gameData, *gameDataVersion, &recruitDesign) - }) - if err != nil { - return fmt.Errorf("load recruitment service: %w", err) - } - battleService.AttachCommittedHealth(func(health map[uint64]uint64) error { - for index, hp := range health { - maximum, err := characters.MaxHealth(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 := characters.SetCurrentHealth(index, hp); err != nil { - return err - } - } - return nil - }) - foodDesign, err := gamedata.LoadFoodDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load food GameData: %w", err) - } - foodService, err := player.OpenFoodService(stateRepository, foodDesign, ownedItems, characters) - if err != nil { - return fmt.Errorf("load food state: %w", err) - } - if err := foodService.AttachContext(worldService.CurrentPackID, battleService.Active); err != nil { - return err - } - battleService.AttachMonsterWinMission(func() error { - return missionService.CompleteSingleTargetEvent(missions.ConditionMonsterKill, missionUnlocked) - }) - battleService.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) { - _, buffs, err := pictorialService.Snapshot() - return buffs, err - }) - achievementCounterDesign, err := gamedata.LoadAchievementCounterDesign(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load achievement counters: %w", err) - } - achievementCounters, err := world.NewAchievementService(achievementCounterDesign, stateRepository, missionService) - if err != nil { - return fmt.Errorf("load achievement counter state: %w", err) - } - if err := missionService.AttachAchievementProgress(achievementCounters); err != nil { - return fmt.Errorf("attach achievement completion validation: %w", err) - } - commissionService.CompleteAchievement = func(identity string) error { - _, err := achievementCounters.RecordEvent(identity, 17, 0, 1) - return err - } - if err := login.AttachAchievementExperience(missionService); err != nil { - return fmt.Errorf("attach persisted achievement experience: %w", err) - } - achievementGrades, err := gamedata.LoadGameplayAchievementGrades(gameData, *gameDataVersion) - if err != nil { - return fmt.Errorf("load achievement gameplay grades: %w", err) - } - achievementObserver, err := world.NewGameplayAchievementObserver(achievementCounters, - worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades)) - if err != nil { - return fmt.Errorf("initialize achievement gameplay observer: %w", err) - } - if err := achievementObserver.SyncRecordedHistory(); err != nil { - return fmt.Errorf("restore recorded achievement history: %w", err) - } - eventTasksService.AttachGameplayProvider(worldService.GameplayAchievementProvider(achievementCounterDesign, achievementGrades)) - game, err := session.NewServerWithProgress(login, progressState, - cashService, - cashBonuses, - clearPackages, - commerce.PackInfoHandler{World: worldService, Claims: clearPackages}, - commerce.AttendanceHandler{Events: eventTasksService, Economy: cashEconomy, LoginPasses: loginPasses, Store: gameplayStore}, - eventRegistry, - eventGamesService, - eventExchangeService, - boxService, - eventPlayService, - eventActionsService, - npcShopService, - innService, - events.SkinHandler{Economy: eventEconomy}, - battleService, - huntingService, - monsterHuntService, - worldService, - worldService.CharacterService(), - progressState, - deckStateStore, - ownedItems, - ownedEquipment, - inventorySlots, - charAwakeService, - costumePotentialService, - costumeBurstService, - friendshipService, - contentOpenService, - masterTitleService, - recruitService, - foodService, - talentUseService, - dispatchService, - itemCraftService, - recipeService, - starter, - mailService, - gachaService, - achievementCounters, - missionService, - eventTasksService, - pictorialService, - seasonSchedule, - readonly.Service{Seed: serverConfig}, - feature.Service{}, - ) - if err != nil { - return err - } - if err := game.AttachResponseObserver(achievementObserver); err != nil { - return fmt.Errorf("attach achievement progress notifications: %w", err) - } - if err := game.AttachResponseObserver(eventTasksService); err != nil { - return err - } - if err := game.AttachResponseObserver(mailService); err != nil { - return fmt.Errorf("attach new mail notifications: %w", err) - } - if authService != nil { - if err := game.AttachLoginAuthenticator(authService); err != nil { - return err - } - } - featured := gacha.ActivePickupCostumes(regularGacha, gachaSchedule, uint64(time.Now().UTC().UnixMilli())) - limitedIDs := limitedCostumes.Excluding(featured) - if len(limitedIDs) != 0 { - if err := mailService.EnsureStarterLimitedCostumes(limitedIDs, time.Now().UTC()); err != nil { - return fmt.Errorf("ensure account limited-costume entitlement: %w", err) - } - } - prestigeIDs := prestigeCatalog.Giftable(cashService.IsAvailable) - if len(prestigeIDs) != 0 { - if err := mailService.EnsureStarterPrestigeSkins(prestigeIDs, time.Now().UTC()); err != nil { - return fmt.Errorf("ensure account prestige-skin entitlement: %w", err) - } - } - if initializeAccount { - if err := ensureAccountStateInitialized( - progressState, deckStateStore, ownedItems, ownedEquipment, - worldService.CharacterService(), collection, wallet, inventorySlots, mailService, missionService, - ); err != nil { - return fmt.Errorf("initialize complete account state generation: %w", err) - } - if err := worldService.EnsureInitialPackPurchase(); err != nil { - return fmt.Errorf("grant initial pack purchase rewards: %w", err) - } - if err := stateRepository.MarkInitializationComplete(); err != nil { - return fmt.Errorf("mark account initialization complete: %w", err) - } - } - if err := masterTitleService.EnsurePersisted(); err != nil { - return fmt.Errorf("persist master title: %w", err) - } - problems, err := stateRepository.Validate() - if err != nil { - return fmt.Errorf("validate account state database: %w", err) - } - if len(problems) != 0 { - return stateProblemsError("account state database rejected", problems) - } - if err := startupTransaction.Commit(); err != nil { - return fmt.Errorf("commit startup state transaction: %w", err) - } - startupCommitted = true - if err := stateRepository.Check(); err != nil { - return fmt.Errorf("startup state transaction requires recovery restart: %w", err) - } - if err := game.AttachStateStore(stateRepository); err != nil { - return err - } - dispatcher := transport.Bootstrap{Config: cfg} - 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: authentication, - AuthenticationHandler: authHandler, ResourcePolicy: publicResources, - CommerceManifest: func() any { return cashCatalog.Manifest() }, Availability: availability, InstanceID: instanceID, - }.Handler() - server := &http.Server{ - Addr: *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", *listen, "instance_id", instanceID, "server_version", versions.ServerVersion, "game_version", cfg.Version, "bundle", cfg.BundleVer, "resourceMode", publicResources.Mode, "gameData", verifiedGameData.ArchivePath, "gameDataEntries", verifiedGameData.EntryCount, "accountSeed", *accountSeed) - serveResult := make(chan error, 1) - go func() { serveResult <- server.ListenAndServe() }() - stateFailure := make(chan error, 1) - stopStateMonitor := make(chan struct{}) - defer close(stopStateMonitor) - go func() { - ticker := time.NewTicker(250 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-ticker.C: - if err := stateRepository.Check(); err != nil { - select { - case stateFailure <- err: - default: - } - return - } - case <-stopStateMonitor: - return - } - } - }() - signals := make(chan os.Signal, 1) - signal.Notify(signals, lifecycle.ShutdownSignals()...) - defer signal.Stop(signals) - var shutdownErr error - select { - case err := <-serveResult: - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err - case received := <-signals: - slog.Info("BD2 server draining", "signal", received.String()) - case err := <-stateFailure: - shutdownErr = err - slog.Error("BD2 server state failed closed; draining for process recovery", "error", err) - } - availability.Drain() - drainContext, cancelDrain := context.WithTimeout(context.Background(), 60*time.Second) - defer cancelDrain() - if err := availability.Wait(drainContext); err != nil { - slog.Warn("BD2 request drain timed out", "error", err) - } - if err := server.Shutdown(drainContext); err != nil { - _ = server.Close() - return fmt.Errorf("shutdown drained server: %w", err) - } - if err := <-serveResult; err != nil && !errors.Is(err, http.ErrServerClosed) { - return err - } - slog.Info("BD2 server stopped after drain") - return shutdownErr -} - -type accountStateInitializer interface { - EnsurePersisted() error -} - -func ensureAccountStateInitialized(stores ...accountStateInitializer) error { - for _, store := range stores { - if store == nil { - return errors.New("nil account state initializer") - } - if err := store.EnsurePersisted(); err != nil { - return err - } - } - return nil -} - -func resourcesCommand(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 -} - func usage() { fmt.Fprintln(os.Stderr, `bd2server - BrownDust II server diff --git a/go/cmd/bd2server/preflight.go b/go/cmd/bd2server/preflight.go deleted file mode 100644 index 1a4f8c1..0000000 --- a/go/cmd/bd2server/preflight.go +++ /dev/null @@ -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) -} diff --git a/go/internal/client/app/app_test.go b/go/internal/client/app/app_test.go deleted file mode 100644 index fd82e92..0000000 --- a/go/internal/client/app/app_test.go +++ /dev/null @@ -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 -} diff --git a/go/internal/client/app/desktop.go b/go/internal/client/app/desktop.go index e42a437..18284bf 100644 --- a/go/internal/client/app/desktop.go +++ b/go/internal/client/app/desktop.go @@ -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, }, diff --git a/go/internal/client/app/game_proxy.go b/go/internal/client/app/game_proxy.go index 1c6efd2..5ecc1ca 100644 --- a/go/internal/client/app/game_proxy.go +++ b/go/internal/client/app/game_proxy.go @@ -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()...) -} diff --git a/go/internal/client/app/game_proxy_test.go b/go/internal/client/app/game_proxy_test.go deleted file mode 100644 index 6bd5056..0000000 --- a/go/internal/client/app/game_proxy_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/client/app/logging_test.go b/go/internal/client/app/logging_test.go deleted file mode 100644 index 8c91a98..0000000 --- a/go/internal/client/app/logging_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/client/app/platform_darwin.go b/go/internal/client/app/platform_darwin.go index 1a0b459..3520462 100644 --- a/go/internal/client/app/platform_darwin.go +++ b/go/internal/client/app/platform_darwin.go @@ -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()...) +} diff --git a/go/internal/client/app/platform_windows.go b/go/internal/client/app/platform_windows.go index 811c2ac..2bef08b 100644 --- a/go/internal/client/app/platform_windows.go +++ b/go/internal/client/app/platform_windows.go @@ -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} diff --git a/go/internal/client/app/platform_windows_test.go b/go/internal/client/app/platform_windows_test.go deleted file mode 100644 index 70aed13..0000000 --- a/go/internal/client/app/platform_windows_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/client/app/web/index.html b/go/internal/client/app/web/index.html index adc8c0a..aa0e996 100644 --- a/go/internal/client/app/web/index.html +++ b/go/internal/client/app/web/index.html @@ -13,20 +13,28 @@ .topbar{height:94px;max-width:1440px;margin:auto;padding:0 58px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);gap:24px}.brand{display:flex;gap:13px;align-items:center;flex-shrink:0}.emblem{width:36px;height:36px;border:1px solid #7d624a;transform:rotate(45deg);display:grid;place-items:center}.emblem span{font:20px var(--serif);transform:rotate(-45deg)}.brand-name{font-size:13px;letter-spacing:.19em;line-height:1.4}.brand-name small{display:block;font-size:9px;letter-spacing:.28em;color:var(--muted);margin-top:4px}.top-note{font-size:11px;color:var(--muted);letter-spacing:.07em}.text-btn{background:none;border:0;color:var(--muted);padding:8px 0;border-bottom:1px solid transparent}.text-btn:hover{color:var(--rust);border-bottom-color:var(--rust)} main{max-width:1324px;margin:auto;padding:0 58px}.scene{position:relative}.intro-scene{min-height:calc(100svh - 178px);display:grid;grid-template-columns:1.1fr .9fr;align-items:center;gap:8%;padding:66px 0 82px}.eyebrow{font-size:10px;letter-spacing:.22em;text-transform:uppercase;display:flex;align-items:center;gap:12px;color:#81634c;margin-bottom:24px}.eyebrow:before{content:'';height:1px;width:32px;background:currentColor}h1,h2,h3,p{margin:0}h1{font:400 clamp(42px,4.8vw,68px)/1.28 var(--serif);letter-spacing:.035em}.lead{max-width:380px;font-size:14px;color:var(--muted);line-height:1.95;margin-top:26px}.intro-label{display:flex;gap:14px;align-items:center;margin-top:44px;font-size:11px;color:#8a7059}.intro-label i{height:1px;width:30px;background:#987759}.form-card{position:relative;background:linear-gradient(135deg,rgba(250,246,237,.89),rgba(238,225,203,.7));border:1px solid rgba(255,255,255,.6);box-shadow:0 24px 70px -35px #65472c77,inset 0 0 0 1px rgba(116,89,55,.035);backdrop-filter:blur(24px);border-radius:4px;padding:34px 32px}.card-number{font:italic 46px/1 var(--serif);color:#b2997e;opacity:.6;margin-bottom:30px}.form-card h2{font:400 23px var(--serif);margin-bottom:9px}.description{font-size:12px;line-height:1.8;color:var(--muted);margin-bottom:26px}code{font:11px/1.8 Consolas,monospace;overflow-wrap:anywhere}label.field-label{display:block;font-size:11px;margin-bottom:9px;color:#6f5c49}.input-row{display:flex;gap:8px}input[type=text]{width:100%;min-width:0;border:1px solid #aa927755;border-radius:3px;padding:13px 12px;background:#fffcf57d;color:var(--ink);outline:none;transition:background .2s,border-color .2s;font-size:12px}input[type=text]:focus{border-color:var(--rust);background:#fffaf0}.btn{border:1px solid #81664a55;border-radius:3px;padding:11px 18px;display:inline-flex;align-items:center;justify-content:center;gap:12px;min-height:43px;background:#fffcf533;color:var(--ink);transition:background .25s,box-shadow .25s,transform .25s;white-space:nowrap;font-size:12px}.btn:hover:not(:disabled){background:#fff9eb99;transform:translateY(-2px)}.btn.primary{background:#4b3a2c;color:#fff5e4;border-color:#4b3a2c}.btn.primary:hover:not(:disabled){background:#654932;box-shadow:0 6px 17px #4b3a2c24}.btn.rust{background:#985e40;color:#fff6e8;border-color:#985e40}.btn.rust:hover:not(:disabled){background:#ad6c49}.arrow{font-size:20px;font-weight:300;line-height:1}.wide{width:100%;justify-content:space-between;margin-top:28px;padding:14px 18px}.small-note{font-size:11px;color:#877460;margin-top:12px;line-height:1.8}.inline-result{font-size:11px;min-height:22px;margin-top:10px;color:var(--muted)}.inline-result.error{color:var(--bad)}.form-tools{display:flex;align-items:center;justify-content:space-between;margin-top:4px;gap:8px;font-size:11px}.server-back{margin-top:14px;text-align:center}.server-back .text-btn{font-size:11px}.bottom-bar{max-width:1324px;margin:auto;padding:0 58px 28px;display:flex;justify-content:space-between;align-items:center;gap:24px}.steps{display:flex;list-style:none;padding:0;margin:0;gap:26px;font-size:11px;color:#8f7d69}.steps li{display:flex;gap:8px;align-items:center}.steps .num{font:italic 14px var(--serif)}.steps li.active{color:#503b2b}.steps li.active:after{content:'';width:19px;height:1px;background:#86573b}.bottom-note{font-size:10px;color:#897963;letter-spacing:.04em} .scene.is-entering .reveal{animation:reveal 1.3s var(--ease) both;animation-delay:calc(var(--i,0)*110ms)}.scene.is-leaving{animation:leave .55s cubic-bezier(.4,0,.6,1) both;pointer-events:none}.transition-veil{position:fixed;inset:0;background:linear-gradient(115deg,#eee4d4ed,#dfc7a8e8);z-index:30;pointer-events:none;opacity:0;backdrop-filter:blur(8px)}.transition-veil.play{animation:veil 1.25s ease-in-out both}.opening-curtain{position:fixed;inset:0;background:#e8decc;pointer-events:none;z-index:40;animation:opening 1.65s ease forwards} -.desk-scene{padding:46px 0 34px}.desk-header{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:30px}.desk-header .eyebrow{margin-bottom:13px}.desk-header h1{font-size:42px}.desk-header p{margin-top:12px;font-size:12px;color:var(--muted)}.connection-chip{display:flex;align-items:center;gap:9px;font-size:11px;color:#74634f;border:1px solid var(--line);padding:8px 13px;border-radius:30px}.dot{width:6px;height:6px;border-radius:50%;background:#a99b85;display:inline-block;flex-shrink:0}.dot.good{background:var(--olive);box-shadow:0 0 0 4px #68744912}.dot.bad{background:var(--bad)}.desk-grid{display:grid;grid-template-columns:minmax(0,1fr) 298px;gap:24px}.main-panel{background:#f9f5ec94;border:1px solid #fff9;border-radius:4px;box-shadow:0 12px 50px -40px #5c412c55}.panel-section{padding:25px 28px;border-bottom:1px solid var(--line)}.panel-section:last-child{border-bottom:0}.section-title{display:flex;align-items:center;gap:11px;margin-bottom:22px}.section-title .index{font:italic 14px var(--serif);color:#a08569}.section-title h2{font-size:15px;font-weight:500}.section-title .end{margin-left:auto;font-size:10px;color:var(--muted)}.setting-row{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:18px}.setting-name{font-size:10px;color:var(--muted);margin-bottom:4px}.setting-value{font-size:12px;overflow-wrap:anywhere}.setting-row>div{min-width:0}.setting-row .text-btn{font-size:11px;flex-shrink:0;color:var(--rust)}.route-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.route{position:relative;cursor:pointer}.route input{position:absolute;opacity:0;inset:0;width:100%;height:100%;margin:0}.route-content{display:block;position:relative;pointer-events:none;min-height:139px;padding:15px 14px;border:1px solid var(--line);border-radius:3px;transition:.25s;background:#f8f3e83d}.route-content .route-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.route-code{font:10px var(--serif);letter-spacing:.08em;color:#98826b}.radio-dot{width:11px;height:11px;border-radius:50%;border:1px solid #aa9680;display:block}.route-content strong{font-size:12px;font-weight:500;display:block;margin-bottom:5px}.route-content small{font-size:10px;line-height:1.75;display:block;color:var(--muted)}.route input:checked+.route-content{border-color:#9e7551;background:#b9986920;box-shadow:inset 0 0 0 1px #9e755118}.route input:checked+.route-content .radio-dot{background:#916c4a;border:3px solid #e8d9c3;box-shadow:0 0 0 1px #916c4a}.route input:focus-visible+.route-content{outline:2px solid var(--rust);outline-offset:4px}.route input:disabled+.route-content{opacity:.55}.route-actions{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:18px}.route-actions .text-btn{font-size:11px}.route-hint{font-size:10px;color:#927d64;margin-top:13px}.policy{font:11px/1.9 Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:#dfd2bc50;border-left:2px solid #a3835e;padding:13px;margin-top:16px}.prepare-note{font-size:11px;color:var(--muted);margin:-8px 0 19px;line-height:1.8}.prepare-actions{display:flex;gap:10px;flex-wrap:wrap}.prepare-actions .btn{flex:1}.status-panel{align-self:start;padding:25px 23px;background:#dfd1ba66;border:1px solid #9875501c;border-radius:4px}.status-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.status-title h2{font:400 19px var(--serif)}.status-title span{font-size:9px;color:#927d65;letter-spacing:.1em}.check{display:flex;align-items:center;justify-content:space-between;padding:14px 0;border-bottom:1px solid var(--line);font-size:11px;gap:8px}.check span:last-child{color:#948471;font-size:10px}.check span.ok{color:var(--olive)}.check span.warn{color:var(--bad)}.status-foot{font-size:10px;color:#8d7a63;margin-top:14px;line-height:1.8}.log-wrap{border-top:1px solid var(--line);margin-top:24px;padding-top:19px}.log-heading{font-size:10px;color:#8d745b;display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.log{font-size:11px;color:#7e6c56;line-height:1.8;overflow-wrap:anywhere;min-height:65px}.log strong{display:block;color:var(--ink);font-size:12px;font-weight:500;margin-bottom:5px}.history{margin-top:14px;border-top:1px solid var(--line);padding-top:12px;font-size:10px;color:#806e59}.history summary{cursor:pointer}.history ol{list-style:none;padding:0;margin:10px 0 0;max-height:180px;overflow:auto}.history li{padding:7px 0;border-top:1px solid #a487651a;overflow-wrap:anywhere}.history time{display:block;color:#9a876f;font-size:9px}.history li.error{color:var(--bad)}.unsaved{font-size:10px;color:var(--rust);margin-top:12px}.desk-footer{display:flex;justify-content:space-between;gap:20px;margin-top:24px;color:#8a765e;font-size:10px}.exit-btn{font-size:11px} +.desk-scene{padding:46px 0 34px}.desk-header{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:30px}.desk-header .eyebrow{margin-bottom:13px}.desk-header h1{font-size:42px}.desk-header p{margin-top:12px;font-size:12px;color:var(--muted)}.connection-chip{display:flex;align-items:center;gap:9px;font-size:11px;color:#74634f;border:1px solid var(--line);padding:8px 13px;border-radius:30px}.dot{width:6px;height:6px;border-radius:50%;background:#a99b85;display:inline-block;flex-shrink:0}.dot.good{background:var(--olive);box-shadow:0 0 0 4px #68744912}.dot.bad{background:var(--bad)}.desk-grid{display:grid;grid-template-columns:minmax(0,1fr) 298px;gap:24px}.main-panel{background:#f9f5ec94;border:1px solid #fff9;border-radius:4px;box-shadow:0 12px 50px -40px #5c412c55}.panel-section{padding:25px 28px;border-bottom:1px solid var(--line)}.panel-section:last-child{border-bottom:0}.section-title{display:flex;align-items:center;gap:11px;margin-bottom:22px}.section-title .index{font:italic 14px var(--serif);color:#a08569}.section-title h2{font-size:15px;font-weight:500}.section-title .end{margin-left:auto;font-size:10px;color:var(--muted)}.setting-row{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:18px}.setting-name{font-size:10px;color:var(--muted);margin-bottom:4px}.setting-value{font-size:12px;overflow-wrap:anywhere}.setting-row>div{min-width:0}.setting-row .text-btn{font-size:11px;flex-shrink:0;color:var(--rust)}.route-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.route{position:relative;cursor:pointer}.route input{position:absolute;opacity:0;inset:0;width:100%;height:100%;margin:0}.route-content{display:block;position:relative;pointer-events:none;min-height:139px;padding:15px 14px;border:1px solid var(--line);border-radius:3px;transition:.25s;background:#f8f3e83d}.route-content .route-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.route-code{font:10px var(--serif);letter-spacing:.08em;color:#98826b}.radio-dot{width:11px;height:11px;border-radius:50%;border:1px solid #aa9680;display:block}.route-content strong{font-size:12px;font-weight:500;display:block;margin-bottom:5px}.route-content small{font-size:10px;line-height:1.75;display:block;color:var(--muted)}.route input:checked+.route-content{border-color:#9e7551;background:#b9986920;box-shadow:inset 0 0 0 1px #9e755118}.route input:checked+.route-content .radio-dot{background:#916c4a;border:3px solid #e8d9c3;box-shadow:0 0 0 1px #916c4a}.route input:focus-visible+.route-content{outline:2px solid var(--rust);outline-offset:4px}.route input:disabled+.route-content{opacity:.55}.route-actions{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:18px}.route-actions .text-btn{font-size:11px}.route-hint{font-size:10px;color:#927d64;margin-top:13px}.policy{font:11px/1.9 Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;background:#dfd2bc50;border-left:2px solid #a3835e;padding:13px;margin-top:16px}.prepare-note{font-size:11px;color:var(--muted);margin:-8px 0 19px;line-height:1.8}.prepare-actions{display:flex;gap:10px;flex-wrap:wrap}.prepare-actions .btn{flex:1}.status-panel{align-self:start;padding:25px 23px;background:#dfd1ba66;border:1px solid #9875501c;border-radius:4px}.status-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.status-title h2{font:400 19px var(--serif)}.status-title span{font-size:9px;color:#927d65;letter-spacing:.1em}.check{display:flex;align-items:center;justify-content:space-between;padding:14px 0;border-bottom:1px solid var(--line);font-size:11px;gap:8px}.check span:last-child{color:#948471;font-size:10px}.check span.ok{color:var(--olive)}.check span.warn{color:var(--bad)}.status-foot{font-size:10px;color:#8d7a63;margin-top:14px;line-height:1.8}.log-wrap{border-top:1px solid var(--line);margin-top:24px;padding-top:19px}.log-heading{font-size:10px;color:#8d745b;display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.log{font-size:11px;color:#7e6c56;line-height:1.8;overflow-wrap:anywhere;min-height:65px}.log strong{display:block;color:var(--ink);font-size:12px;font-weight:500;margin-bottom:5px}.history{margin-top:14px;border-top:1px solid var(--line);padding-top:12px;font-size:10px;color:#806e59}.history summary{cursor:pointer}.history ol{list-style:none;padding:0;margin:10px 0 0;max-height:180px;overflow:auto}.history li{padding:7px 0;border-top:1px solid #a487651a;overflow-wrap:anywhere}.history time{display:block;color:#9a876f;font-size:9px}.history li.error{color:var(--bad)}.unsaved{font-size:10px;color:var(--rust);margin-top:12px}.desk-footer{display:flex;justify-content:space-between;gap:20px;margin-top:24px;color:#8a765e;font-size:10px} .desk-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}.desk-actions .btn{min-height:38px;padding:8px 16px}.local-resource{margin-top:14px;padding:14px;border:1px solid var(--line);background:#f8f3e83d;border-radius:3px}.local-resource[hidden]{display:none}.local-resource .small-note{margin:8px 0 0} .toast{position:fixed;bottom:28px;left:50%;transform:translate(-50%,18px);max-width:min(590px,calc(100% - 32px));width:max-content;padding:13px 21px;background:#483a2e;color:#fff4e0;box-shadow:0 10px 40px #483a2e26;border:1px solid #c9ac82;border-radius:3px;font-size:12px;opacity:0;pointer-events:none;transition:.35s var(--ease);z-index:70}.toast.show{opacity:1;transform:translate(-50%,0)}.toast.error{background:#793e30;color:#fff0e3}dialog{width:min(490px,calc(100% - 32px));padding:30px;border:1px solid #fff9;border-radius:5px;background:#f0e7d7;color:var(--ink);box-shadow:0 30px 140px #37271955}dialog::backdrop{background:#40322266;backdrop-filter:blur(9px)}dialog[open]{animation:reveal .55s var(--ease)}.dialog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:25px}.dialog-head h2{font:24px var(--serif)}.dialog-close{background:none;border:0;font-size:23px;color:var(--muted);padding:0 7px}.dialog-actions{display:flex;justify-content:flex-end;margin-top:20px}.quit-scene{min-height:65svh;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center}.quit-scene h1{font-size:44px}.quit-scene p{color:var(--muted);margin-top:20px;font-size:13px} @keyframes reveal{from{opacity:0;filter:blur(10px);transform:translateY(24px) scale(.986)}to{opacity:1;filter:blur(0);transform:none}}@keyframes leave{to{opacity:0;filter:blur(8px);transform:translateY(-15px) scale(1.014)}}@keyframes veil{0%,100%{opacity:0}45%,58%{opacity:1}}@keyframes opening{0%,15%{opacity:1}100%{opacity:0}}@keyframes drift{to{transform:translate(-4%,3%) scale(1.05)}} @media(min-width:1500px){.intro-scene{gap:13%}.form-card{padding:40px}.intro-scene h1{font-size:72px}}@media(max-width:1050px){.topbar{padding:0 30px}main,.bottom-bar{padding-left:30px;padding-right:30px}.intro-scene{gap:5%}.form-card{padding:27px 23px}.desk-grid{grid-template-columns:minmax(0,1fr) 250px;gap:16px}.panel-section{padding:23px 20px}.status-panel{padding:23px 18px}.route-content{padding:12px 10px}.top-note{display:none}}@media(max-width:760px){.topbar{height:77px;padding:0 22px}main,.bottom-bar{padding-left:22px;padding-right:22px}.intro-scene{grid-template-columns:1fr;gap:32px;padding:42px 0;min-height:auto}.intro-scene h1{font-size:43px}.eyebrow{margin-bottom:17px}.lead{margin-top:18px;max-width:100%}.intro-label{display:none}.form-card{padding:25px}.card-number{position:absolute;right:25px;top:25px;font-size:31px}.form-card h2{padding-right:45px}.bottom-bar{align-items:start;flex-direction:column;gap:15px}.steps{gap:21px}.desk-scene{padding-top:31px}.desk-header{align-items:start}.desk-header h1{font-size:34px}.connection-chip{font-size:10px;padding:6px 10px;margin-top:4px}.desk-grid{grid-template-columns:1fr}.status-panel{display:grid;grid-template-columns:1fr 1fr;gap:0 30px}.status-title{grid-column:1/-1}.log-wrap{margin-top:10px;border-top:0}.status-foot{margin-bottom:14px}.desk-footer{flex-direction:column;gap:7px}.bottom-note{font-size:9px}.landscape svg{width:180%;left:-60%}.desk-header p{max-width:240px}.route-content{min-height:145px}}@media(max-width:400px){main,.bottom-bar{padding-left:16px;padding-right:16px}.form-card{padding:23px 18px}.route-options{grid-template-columns:1fr}.route-content{min-height:0;padding:12px 14px}.route-content .route-top{float:right;margin:0 0 10px 20px;gap:12px}.route-content small{max-width:90%}.status-panel{display:block}.desk-header{flex-direction:column;gap:14px}.panel-section{padding:22px 16px}.btn{padding-left:13px;padding-right:13px}.steps{gap:15px}.route-actions{align-items:stretch}.prepare-actions{flex-direction:column}} @media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}.opening-curtain{display:none}} -.proxy-select{width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:3px;background:#f8f3e8;color:var(--ink);font:inherit}#proxyURL{width:100%} +.proxy-select{width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:3px;background:#f8f3e8;color:var(--ink);font:inherit}#proxyURL{width:100%}/* Window chrome belongs to the page; keep scrolling without a visible gutter. */ +html,body{scrollbar-width:none}html::-webkit-scrollbar,body::-webkit-scrollbar{display:none} +.topbar{--wails-draggable:drag;position:sticky;top:0;z-index:50;max-width:none;margin:0;padding-right:164px;user-select:none;background:linear-gradient(90deg,#f1e9d9d9,#e7d5b6b3);backdrop-filter:blur(18px)} +.window-controls{--wails-draggable:no-drag;position:absolute;right:8px;top:8px;display:flex;gap:2px} +.window-control{--wails-draggable:no-drag;width:44px;height:36px;padding:0;border:0;border-radius:4px;display:grid;place-items:center;background:transparent;color:var(--ink);cursor:default;transition:background .15s,color .15s} +.window-control:hover{background:#8064481c}.window-control.window-close:hover{background:#b84b3a;color:white}.window-control:focus-visible{outline:2px solid var(--rust);outline-offset:-3px} +.window-control svg{width:12px;height:12px;pointer-events:none;fill:none;stroke:currentColor;stroke-width:1.3} +@media(max-width:1050px){.topbar{padding-right:164px}}@media(max-width:760px){.topbar{padding-right:152px}.brand-name{font-size:11px;letter-spacing:.12em}.brand-name small{font-size:8px}.window-control{width:40px}} + -
BD2 CLIENT STUDIOCLIENT CONFIGURATION
客户端设置工具 · 配置保存在本机
+
BD2 CLIENT STUDIOCLIENT CONFIGURATION
客户端设置工具 · 配置保存在本机
01 / GAME DIRECTORY

先从游戏目录
开始。

选择已安装的游戏文件夹。
我们会检查游戏程序和资源文件,再继续设置连接。

BrownDust II · 客户端配置
@@ -70,7 +78,7 @@ if(!zhCN){ document.documentElement.lang='en'; document.title='BD2 Client Studio'; const translations=new Map(Object.entries({ - '客户端设置工具 · 配置保存在本机':'Client setup utility · Settings stay on this computer','退出工具 ↗':'Exit tool ↗','先从游戏目录':'Start with the game directory','开始。':'first.','选择已安装的游戏文件夹。':'Select the installed game folder.','我们会检查游戏程序和资源文件,再继续设置连接。':'We will inspect the game executable and assets before configuring the connection.','客户端配置':'Client configuration','选择游戏目录':'Select game directory','请选择包含':'Select the folder containing','的文件夹。':'.','游戏安装目录':'Game installation directory','浏览':'Browse','可直接填写路径,也可以浏览目录。':'Enter a path or browse for a directory.','检查目录':'Inspect directory','继续设置服务器':'Continue to server setup','此步骤只检查文件,不会修改游戏资源。':'This step only inspects files and does not modify game assets.','接下来,':'Next,','设置服务器。':'configure the server.','填写要连接的服务端地址。':'Enter the server you want to connect to.','进入工作台后,你可以选择资源来源、应用补丁和安装插件。':'In the workspace you can select a resource source, apply the patch, and install plugins.','目录已选择 · 之后仍可修改':'Directory selected · You can change it later','服务器地址':'Server address','填写完整地址,可以包含端口。':'Enter the complete origin, including a port when needed.','地址与端口':'Address and port','例如':'Example','不要添加':'Do not add','或其他路径。真实连接地址由 LI 插件读取。':'or another path. The LI plugin reads the actual connection address.','进入工作台':'Open workspace','返回目录设置':'Back to directory setup','客户端工作台':'Client workspace','管理连接设置,完成游戏启动前的准备。':'Manage connection settings and prepare the game client.','启动游戏':'Launch game','等待检查':'Waiting','连接设置':'Connection settings','随时可以修改':'Editable at any time','游戏目录':'Game directory','尚未选择':'Not selected','修改目录 ↗':'Change directory ↗','尚未填写':'Not entered','修改地址 ↗':'Change address ↗','资源来源':'Resource source','官方 CDN':'Official CDN','从官方节点下载资源,不占用服主的大文件流量。':'Download from official nodes without using the server owner’s bandwidth.','本机已下载资源':'Downloaded local resources','直接读取电脑上的 ServerData 和 GameData,不再下载相同文件。':'Read ServerData and GameData already stored on this computer.','服主资源':'Server-provided resources','由服务器提供资源地址;服主可自建 CDN,也可以反代官方 CDN。':'The server supplies resource URLs backed by either a private CDN or an official-CDN proxy.','本机 CDN 根目录':'Local CDN root directory','所选目录内必须同时包含':'The selected directory must contain both',' 和 ':' and ','。':'.','官方模式由客户端直连版本锁定的官方 CDN。':'Official mode connects directly to the release-locked official CDN.','验证资源来源 ↗':'Verify resource source ↗','保存连接设置':'Save connection settings','当前设置尚未保存。':'Current settings have not been saved.','补丁与插件':'Patch and plugins','操作前请先关闭游戏。补丁只修改内置 Intro 地址,并保留':'Close the game first. The patch only changes the built-in Intro address and retains a','备份。安装插件前,请先手动安装 BepInEx。':'backup. Install BepInEx manually before installing the plugins.','应用客户端补丁':'Apply client patch','安装 / 更新插件':'Install / update plugins','当前状态':'Current status','游戏程序':'Game executable','资源文件':'Asset file','需手动安装':'Manual install required','连接配置':'Connection configuration','尚未写入':'Not written','状态来自本机文件检查,不代表服务器在线。':'Status is based on local files and does not indicate server availability.','客户端版本:':'Client version:','游戏版本:':'Game version:','诊断日志:':'Diagnostic log:','重新检查 ↻':'Inspect again ↻','最近操作':'Recent operation','等待操作':'Waiting for an operation','选择游戏目录后即可开始检查。':'Select the game directory to begin.','查看操作记录':'View operation history','配置仅保存在你的电脑上':'Settings are stored only on your computer','不会保存账号凭据或服主密钥':'Account credentials and server-owner secrets are never stored','工具已退出':'Tool exited','现在可以关闭这个页面。':'You can close this page now.','选择目录':'Select directory','设置服务器':'Configure server','修改设置':'Edit settings','修改后请在工作台保存连接设置。':'Save changes from the workspace when finished.','完成修改':'Finish editing','请启用 JavaScript 以使用客户端设置工具。':'Enable JavaScript to use the client setup tool.','CDN 类型':'CDN source','客户端状态':'Client status','设置进度':'Setup progress','关闭设置':'Close settings','代理':'Proxy','设置代理 ↗':'Set proxy ↗','直接连接':'Direct connection','连接方式':'Connection mode','手动 HTTP 代理':'Manual HTTP proxy','HTTP 代理地址':'HTTP proxy address','填写 http://主机:端口,不支持账号、路径或查询参数。':'Enter http://host:port without credentials, a path, or query parameters.','保存后在下次启动游戏时生效。本机地址 localhost、127.0.0.1 和 ::1 始终直接连接。':'Saved changes apply on the next game launch. localhost, 127.0.0.1, and ::1 always connect directly.','HTTP 代理通过 CONNECT 支持游戏的 HTTPS 连接,仍会验证证书。浏览器使用自身的代理设置。':'The HTTP proxy supports HTTPS game connections through CONNECT with certificate verification. Browsers use their own proxy settings.' + '客户端设置工具 · 配置保存在本机':'Client setup utility · Settings stay on this computer','先从游戏目录':'Start with the game directory','开始。':'first.','选择已安装的游戏文件夹。':'Select the installed game folder.','我们会检查游戏程序和资源文件,再继续设置连接。':'We will inspect the game executable and assets before configuring the connection.','客户端配置':'Client configuration','选择游戏目录':'Select game directory','请选择包含':'Select the folder containing','的文件夹。':'.','游戏安装目录':'Game installation directory','浏览':'Browse','可直接填写路径,也可以浏览目录。':'Enter a path or browse for a directory.','检查目录':'Inspect directory','继续设置服务器':'Continue to server setup','此步骤只检查文件,不会修改游戏资源。':'This step only inspects files and does not modify game assets.','接下来,':'Next,','设置服务器。':'configure the server.','填写要连接的服务端地址。':'Enter the server you want to connect to.','进入工作台后,你可以选择资源来源、应用补丁和安装插件。':'In the workspace you can select a resource source, apply the patch, and install plugins.','目录已选择 · 之后仍可修改':'Directory selected · You can change it later','服务器地址':'Server address','填写完整地址,可以包含端口。':'Enter the complete origin, including a port when needed.','地址与端口':'Address and port','例如':'Example','不要添加':'Do not add','或其他路径。真实连接地址由 LI 插件读取。':'or another path. The LI plugin reads the actual connection address.','进入工作台':'Open workspace','返回目录设置':'Back to directory setup','客户端工作台':'Client workspace','管理连接设置,完成游戏启动前的准备。':'Manage connection settings and prepare the game client.','启动游戏':'Launch game','等待检查':'Waiting','连接设置':'Connection settings','随时可以修改':'Editable at any time','游戏目录':'Game directory','尚未选择':'Not selected','修改目录 ↗':'Change directory ↗','尚未填写':'Not entered','修改地址 ↗':'Change address ↗','资源来源':'Resource source','官方 CDN':'Official CDN','从官方节点下载资源,不占用服主的大文件流量。':'Download from official nodes without using the server owner’s bandwidth.','本机已下载资源':'Downloaded local resources','直接读取电脑上的 ServerData 和 GameData,不再下载相同文件。':'Read ServerData and GameData already stored on this computer.','服主资源':'Server-provided resources','由服务器提供资源地址;服主可自建 CDN,也可以反代官方 CDN。':'The server supplies resource URLs backed by either a private CDN or an official-CDN proxy.','本机 CDN 根目录':'Local CDN root directory','所选目录内必须同时包含':'The selected directory must contain both',' 和 ':' and ','。':'.','官方模式由客户端直连版本锁定的官方 CDN。':'Official mode connects directly to the release-locked official CDN.','验证资源来源 ↗':'Verify resource source ↗','保存连接设置':'Save connection settings','当前设置尚未保存。':'Current settings have not been saved.','补丁与插件':'Patch and plugins','操作前请先关闭游戏。补丁只修改内置 Intro 地址,并保留':'Close the game first. The patch only changes the built-in Intro address and retains a','备份。安装插件前,请先手动安装 BepInEx。':'backup. Install BepInEx manually before installing the plugins.','应用客户端补丁':'Apply client patch','安装 / 更新插件':'Install / update plugins','当前状态':'Current status','游戏程序':'Game executable','资源文件':'Asset file','需手动安装':'Manual install required','连接配置':'Connection configuration','尚未写入':'Not written','状态来自本机文件检查,不代表服务器在线。':'Status is based on local files and does not indicate server availability.','客户端版本:':'Client version:','游戏版本:':'Game version:','诊断日志:':'Diagnostic log:','重新检查 ↻':'Inspect again ↻','最近操作':'Recent operation','等待操作':'Waiting for an operation','选择游戏目录后即可开始检查。':'Select the game directory to begin.','查看操作记录':'View operation history','配置仅保存在你的电脑上':'Settings are stored only on your computer','不会保存账号凭据或服主密钥':'Account credentials and server-owner secrets are never stored','工具已退出':'Tool exited','现在可以关闭这个页面。':'You can close this page now.','选择目录':'Select directory','设置服务器':'Configure server','修改设置':'Edit settings','修改后请在工作台保存连接设置。':'Save changes from the workspace when finished.','完成修改':'Finish editing','请启用 JavaScript 以使用客户端设置工具。':'Enable JavaScript to use the client setup tool.','CDN 类型':'CDN source','客户端状态':'Client status','设置进度':'Setup progress','关闭设置':'Close settings','代理':'Proxy','设置代理 ↗':'Set proxy ↗','直接连接':'Direct connection','连接方式':'Connection mode','手动 HTTP 代理':'Manual HTTP proxy','HTTP 代理地址':'HTTP proxy address','填写 http://主机:端口,不支持账号、路径或查询参数。':'Enter http://host:port without credentials, a path, or query parameters.','保存后在下次启动游戏时生效。本机地址 localhost、127.0.0.1 和 ::1 始终直接连接。':'Saved changes apply on the next game launch. localhost, 127.0.0.1, and ::1 always connect directly.','HTTP 代理通过 CONNECT 支持游戏的 HTTPS 连接,仍会验证证书。浏览器使用自身的代理设置。':'The HTTP proxy supports HTTPS game connections through CONNECT with certificate verification. Browsers use their own proxy settings.' })); const walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);let node; while(node=walker.nextNode()){if(node.parentElement&&['SCRIPT','STYLE'].includes(node.parentElement.tagName))continue;let value=node.nodeValue;for(const [source,target] of [...translations].sort((a,b)=>b[0].length-a[0].length))value=value.replaceAll(source,target);node.nodeValue=value} @@ -96,7 +104,7 @@ function requireDirectory(){if(!payload().game_directory){$('gameDir').focus();t function requireOrigin(){const value=$('origin').value.trim();let u;try{u=new URL(value)}catch{throw new Error(localized('请输入完整服务器地址,例如 http://127.0.0.1:8080','Enter a complete server origin, for example http://127.0.0.1:8080'))}if(!['http:','https:'].includes(u.protocol)||u.username||u.password||u.search||u.hash||(u.pathname!=='/'&&u.pathname!==''))throw new Error(localized('服务器地址仅填写 http(s) 地址与端口,不要包含路径、查询参数或账号信息','Enter only an HTTP(S) origin and port, without a path, query, or credentials'));const loopback=['localhost','127.0.0.1','[::1]'].includes(u.hostname.toLowerCase());if(u.protocol!=='https:'&&!loopback)throw new Error(localized('公网或局域网服务器必须使用 HTTPS;只有本机 loopback 可以使用 HTTP','Public and LAN servers must use HTTPS; only loopback may use HTTP'));$('origin').value=value.replace(/\/$/,'');$('serverResult').textContent='';sync()} function requireResources(){const p=payload();if(p.cdn_mode==='local'&&!p.local_resource_directory){$('localResourceDir').focus();throw new Error(localized('请选择包含 ServerData 和 GameData 的本机 CDN 目录','Select a local CDN directory containing ServerData and GameData'))}} function requireProxy(){if($('proxyMode').value!=='manual')return;const value=$('proxyURL').value.trim();let u;try{u=new URL(value)}catch{}if(!u||u.protocol!=='http:'||u.username||u.password||u.search||u.hash||u.pathname!=='/'||!/^http:\/\/(?:\[[^\]]+\]|[^\s/:?#@]+):[0-9]+$/.test(value)||Number(value.slice(value.lastIndexOf(':')+1))<1||Number(value.slice(value.lastIndexOf(':')+1))>65535){const message=localized('请输入 HTTP 代理地址,例如 http://127.0.0.1:7890;仅支持主机和端口','Enter an HTTP proxy address such as http://127.0.0.1:7890, with only a host and port');$('proxyResult').textContent=message;$('proxyResult').className='inline-result error';$('proxyURL').focus();throw new Error(message)}$('proxyURL').value=value;$('proxyResult').textContent='';sync()} -function setBusy(value){busy=value;document.querySelectorAll('button,input,select').forEach(e=>{e.disabled=value});document.body.setAttribute('aria-busy',String(value))} +function setBusy(value){busy=value;document.querySelectorAll('button:not(.window-control),input,select').forEach(e=>{e.disabled=value});document.body.setAttribute('aria-busy',String(value))} async function action(id,fn){if(busy||transitioning)return;const b=$(id),old=b.innerHTML;setBusy(true);b.textContent=localized('请稍候…','Please wait…');try{await fn()}catch(e){const message=e.message||String(e);show(message,true);if(scene==='directory'||editorKind==='directory'){$('directoryResult').textContent=message;$('directoryResult').className='inline-result error'}if(scene==='server'||editorKind==='server'){$('serverResult').textContent=message;$('serverResult').className='inline-result error'}}finally{b.innerHTML=old;setBusy(false)}} const delay=ms=>new Promise(resolve=>setTimeout(resolve,ms)); async function navigateScene(next){if(transitioning||scene===next)return;transitioning=true;const old=$(scene+'Scene'),target=$(next+'Scene');if(!reduced.matches){old.classList.add('is-leaving');$('veil').classList.add('play');await delay(570)}old.hidden=true;old.classList.remove('is-leaving','is-entering');scene=next;document.body.dataset.scene=next;target.hidden=false;target.classList.add('is-entering');$('journey').hidden=next==='desk'||next==='quit';document.querySelectorAll('[data-step]').forEach(e=>{const active=e.dataset.step===next;e.classList.toggle('active',active);if(active)e.setAttribute('aria-current','step');else e.removeAttribute('aria-current')});window.scrollTo({top:0,behavior:'instant'});$(next==='directory'?'directoryTitle':next==='server'?'serverTitle':next==='desk'?'deskTitle':'quitTitle').focus({preventScroll:true});if(!reduced.matches)await delay(680);$('veil').classList.remove('play');transitioning=false} @@ -122,7 +130,12 @@ $('save').onclick=()=>action('save',async()=>{requireDirectory();requireOrigin() $('patch').onclick=()=>action('patch',async()=>{requireDirectory();requireOrigin();requireResources();requireProxy();const r=await api('patch',payload());saved();show(r.message)}); $('install').onclick=()=>action('install',async()=>{requireDirectory();requireOrigin();requireResources();requireProxy();const r=await api('install',payload());status.bepinex=true;saved();show(r.message)}); $('launch').onclick=()=>action('launch',async()=>{requireDirectory();requireOrigin();requireResources();requireProxy();const r=await api('launch',payload());saved();show(r.message)}); -$('quit').onclick=()=>{if(!busy){clearTimeout(toastTimer);bridge().Quit().catch(error=>show(error.message||String(error),true))}}; +$('windowMinimise').onclick=()=>window.runtime?.WindowMinimise(); +$('windowMaximise').onclick=()=>window.runtime?.WindowToggleMaximise(); +document.querySelector('.topbar').ondblclick=e=>{if(!e.target.closest('.window-controls'))window.runtime?.WindowToggleMaximise()}; +$('windowClose').onclick=()=>{clearTimeout(toastTimer);bridge().Quit().catch(error=>show(error.message||String(error),true))}; +if(!zhCN){for(const [id,label] of [['windowMinimise','Minimise'],['windowMaximise','Maximise or restore'],['windowClose','Close']]){const button=$(id);button.title=label;button.setAttribute('aria-label',label)}document.querySelector('.window-controls').setAttribute('aria-label','Window controls')} + $('gameDir').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('inspect'):$('directoryNext')).click()}});$('origin').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();(editorKind?$('doneEditor'):$('serverNext')).click()}}); async function initialize(){setBusy(true);try{const initial=await bridge().Initialize();HOST_PLATFORM=initial.platform;INITIAL_CDN=initial.cdn_mode;AUTO_OPEN=initial.auto_open;$('gameDir').value=initial.game_directory||'';$('origin').value=initial.server_origin||'http://127.0.0.1:8080';$('localResourceDir').value=initial.local_resource_directory||'';$('proxyURL').value=initial.proxy_url||'';$('proxyMode').value=initial.proxy_url?'manual':'direct';$('clientReleaseVersion').textContent=initial.client_version||'';$('gameReleaseVersion').textContent=initial.game_version||'';$('persistentLogPath').textContent=initial.log_path||'';const initialRadio=document.querySelector(`input[name="cdn"][value="${CSS.escape(INITIAL_CDN)}"]`);if(initialRadio)initialRadio.checked=true;if(HOST_PLATFORM==='darwin'){$('gameDir').placeholder=localized('例如 /Applications/BrownDust II.app','Example: /Applications/BrownDust II.app');$('directoryScene').querySelector('.description').textContent=localized('请选择 BrownDust II.app 或它所在的文件夹。','Select BrownDust II.app or its parent folder.')}sync();if($('gameDir').value.trim()){await inspect();if(AUTO_OPEN){saved();await navigateScene('desk')}}}catch(error){show(error.message||String(error),true)}finally{setBusy(false)}} initialize(); diff --git a/go/internal/client/config/config_test.go b/go/internal/client/config/config_test.go deleted file mode 100644 index 44928b5..0000000 --- a/go/internal/client/config/config_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/client/config/preferences_test.go b/go/internal/client/config/preferences_test.go deleted file mode 100644 index 32477f6..0000000 --- a/go/internal/client/config/preferences_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/client/config/release_test.go b/go/internal/client/config/release_test.go deleted file mode 100644 index adb4112..0000000 --- a/go/internal/client/config/release_test.go +++ /dev/null @@ -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") - } - }) - } -} diff --git a/go/internal/client/introdb/introdb_test.go b/go/internal/client/introdb/introdb_test.go deleted file mode 100644 index 055862f..0000000 --- a/go/internal/client/introdb/introdb_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/client/layout/layout_test.go b/go/internal/client/layout/layout_test.go deleted file mode 100644 index 92a4d4a..0000000 --- a/go/internal/client/layout/layout_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/internal/client/plugin/install_test.go b/go/internal/client/plugin/install_test.go deleted file mode 100644 index d942363..0000000 --- a/go/internal/client/plugin/install_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/internal/client/setup/local_catalog.go b/go/internal/client/setup/local_catalog.go index 3a5739c..4672b2d 100644 --- a/go/internal/client/setup/local_catalog.go +++ b/go/internal/client/setup/local_catalog.go @@ -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)) diff --git a/go/internal/client/setup/local_catalog_test.go b/go/internal/client/setup/local_catalog_test.go deleted file mode 100644 index 24d446f..0000000 --- a/go/internal/client/setup/local_catalog_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/internal/client/setup/setup_test.go b/go/internal/client/setup/setup_test.go deleted file mode 100644 index 83b2e62..0000000 --- a/go/internal/client/setup/setup_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/account/achievement_experience_test.go b/go/internal/server/account/achievement_experience_test.go deleted file mode 100644 index 0ecbb1f..0000000 --- a/go/internal/server/account/achievement_experience_test.go +++ /dev/null @@ -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") - } - } -} diff --git a/go/internal/server/account/auto_revive_settings.go b/go/internal/server/account/auto_revive_settings.go deleted file mode 100644 index fc2a106..0000000 --- a/go/internal/server/account/auto_revive_settings.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/account/auto_revive_settings_test.go b/go/internal/server/account/auto_revive_settings_test.go deleted file mode 100644 index 633110f..0000000 --- a/go/internal/server/account/auto_revive_settings_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/internal/server/account/friendship_test.go b/go/internal/server/account/friendship_test.go deleted file mode 100644 index d61bb99..0000000 --- a/go/internal/server/account/friendship_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/account/last_pack_test.go b/go/internal/server/account/last_pack_test.go deleted file mode 100644 index fe6ab6b..0000000 --- a/go/internal/server/account/last_pack_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/account/login_first_gacha_test.go b/go/internal/server/account/login_first_gacha_test.go deleted file mode 100644 index 567cbc2..0000000 --- a/go/internal/server/account/login_first_gacha_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/account/login_test.go b/go/internal/server/account/login_test.go deleted file mode 100644 index 05fecaa..0000000 --- a/go/internal/server/account/login_test.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/account/portrait_test.go b/go/internal/server/account/portrait_test.go deleted file mode 100644 index 704eb8e..0000000 --- a/go/internal/server/account/portrait_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/account/reward_state_test.go b/go/internal/server/account/reward_state_test.go deleted file mode 100644 index 93dcb45..0000000 --- a/go/internal/server/account/reward_state_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/accountstate/atomic_entry.go b/go/internal/server/accountstate/atomic_entry.go deleted file mode 100644 index 4e3e81a..0000000 --- a/go/internal/server/accountstate/atomic_entry.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/accountstate/atomic_entry_test.go b/go/internal/server/accountstate/atomic_entry_test.go deleted file mode 100644 index cc5d610..0000000 --- a/go/internal/server/accountstate/atomic_entry_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/accountstate/bootstrap_test.go b/go/internal/server/accountstate/bootstrap_test.go deleted file mode 100644 index 8374525..0000000 --- a/go/internal/server/accountstate/bootstrap_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/accountstate/entry_test.go b/go/internal/server/accountstate/entry_test.go deleted file mode 100644 index 08e3101..0000000 --- a/go/internal/server/accountstate/entry_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/accountstate/repository_test.go b/go/internal/server/accountstate/repository_test.go deleted file mode 100644 index 743b917..0000000 --- a/go/internal/server/accountstate/repository_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/internal/server/accountstate/schema_test.go b/go/internal/server/accountstate/schema_test.go deleted file mode 100644 index 04a9e78..0000000 --- a/go/internal/server/accountstate/schema_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/accountstate/server_policy_test.go b/go/internal/server/accountstate/server_policy_test.go deleted file mode 100644 index 3af4ded..0000000 --- a/go/internal/server/accountstate/server_policy_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/accountstate/validate_test.go b/go/internal/server/accountstate/validate_test.go deleted file mode 100644 index 65d8b1f..0000000 --- a/go/internal/server/accountstate/validate_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/go/internal/server/app/config.go b/go/internal/server/app/config.go new file mode 100644 index 0000000..f4c360f --- /dev/null +++ b/go/internal/server/app/config.go @@ -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 +} diff --git a/go/internal/server/app/design.go b/go/internal/server/app/design.go new file mode 100644 index 0000000..3f04ddd --- /dev/null +++ b/go/internal/server/app/design.go @@ -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 +} diff --git a/go/cmd/bd2server/development_settings.go b/go/internal/server/app/development_settings.go similarity index 94% rename from go/cmd/bd2server/development_settings.go rename to go/internal/server/app/development_settings.go index 94850bd..4c52891 100644 --- a/go/cmd/bd2server/development_settings.go +++ b/go/internal/server/app/development_settings.go @@ -1,4 +1,4 @@ -package main +package app import "path/filepath" diff --git a/go/internal/server/app/field_traps_integration_test.go b/go/internal/server/app/field_traps_integration_test.go new file mode 100644 index 0000000..5dbfd0e --- /dev/null +++ b/go/internal/server/app/field_traps_integration_test.go @@ -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) +} diff --git a/go/cmd/bd2server/logging.go b/go/internal/server/app/logging.go similarity index 77% rename from go/cmd/bd2server/logging.go rename to go/internal/server/app/logging.go index 86830e7..2a14759 100644 --- a/go/cmd/bd2server/logging.go +++ b/go/internal/server/app/logging.go @@ -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 diff --git a/go/internal/server/app/monster_hunt_rules.go b/go/internal/server/app/monster_hunt_rules.go new file mode 100644 index 0000000..5747387 --- /dev/null +++ b/go/internal/server/app/monster_hunt_rules.go @@ -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 +} diff --git a/go/internal/server/app/player_assets.go b/go/internal/server/app/player_assets.go new file mode 100644 index 0000000..8d3f26f --- /dev/null +++ b/go/internal/server/app/player_assets.go @@ -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 +} diff --git a/go/internal/server/app/player_commerce.go b/go/internal/server/app/player_commerce.go new file mode 100644 index 0000000..f6297b1 --- /dev/null +++ b/go/internal/server/app/player_commerce.go @@ -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 +} diff --git a/go/internal/server/app/player_events.go b/go/internal/server/app/player_events.go new file mode 100644 index 0000000..dfcf05b --- /dev/null +++ b/go/internal/server/app/player_events.go @@ -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 +} diff --git a/go/internal/server/app/player_executor.go b/go/internal/server/app/player_executor.go new file mode 100644 index 0000000..4c49819 --- /dev/null +++ b/go/internal/server/app/player_executor.go @@ -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()} +} diff --git a/go/internal/server/app/player_factory.go b/go/internal/server/app/player_factory.go new file mode 100644 index 0000000..591f7fa --- /dev/null +++ b/go/internal/server/app/player_factory.go @@ -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 +} diff --git a/go/internal/server/app/player_gameplay.go b/go/internal/server/app/player_gameplay.go new file mode 100644 index 0000000..cf979ff --- /dev/null +++ b/go/internal/server/app/player_gameplay.go @@ -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 +} diff --git a/go/internal/server/app/player_isolation_test.go b/go/internal/server/app/player_isolation_test.go new file mode 100644 index 0000000..5de5fef --- /dev/null +++ b/go/internal/server/app/player_isolation_test.go @@ -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) + } + } + +} diff --git a/go/internal/server/app/player_registry.go b/go/internal/server/app/player_registry.go new file mode 100644 index 0000000..7211a84 --- /dev/null +++ b/go/internal/server/app/player_registry.go @@ -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()) + } +} diff --git a/go/internal/server/app/player_registry_lifecycle_test.go b/go/internal/server/app/player_registry_lifecycle_test.go new file mode 100644 index 0000000..a31f564 --- /dev/null +++ b/go/internal/server/app/player_registry_lifecycle_test.go @@ -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) + } + } +} diff --git a/go/internal/server/app/player_session.go b/go/internal/server/app/player_session.go new file mode 100644 index 0000000..bbcdb87 --- /dev/null +++ b/go/internal/server/app/player_session.go @@ -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 +} diff --git a/go/internal/server/app/player_transient_integration_test.go b/go/internal/server/app/player_transient_integration_test.go new file mode 100644 index 0000000..1806ecf --- /dev/null +++ b/go/internal/server/app/player_transient_integration_test.go @@ -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") + } +} diff --git a/go/internal/server/app/player_validation.go b/go/internal/server/app/player_validation.go new file mode 100644 index 0000000..56b93e6 --- /dev/null +++ b/go/internal/server/app/player_validation.go @@ -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 +} diff --git a/go/internal/server/app/player_world_roster.go b/go/internal/server/app/player_world_roster.go new file mode 100644 index 0000000..36badc2 --- /dev/null +++ b/go/internal/server/app/player_world_roster.go @@ -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 +} diff --git a/go/internal/server/app/preflight.go b/go/internal/server/app/preflight.go new file mode 100644 index 0000000..40f6819 --- /dev/null +++ b/go/internal/server/app/preflight.go @@ -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) +} diff --git a/go/internal/server/app/quest_battle_integration_test.go b/go/internal/server/app/quest_battle_integration_test.go new file mode 100644 index 0000000..97c2469 --- /dev/null +++ b/go/internal/server/app/quest_battle_integration_test.go @@ -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") + } + }) + } +} diff --git a/go/internal/server/app/resources.go b/go/internal/server/app/resources.go new file mode 100644 index 0000000..fce9b85 --- /dev/null +++ b/go/internal/server/app/resources.go @@ -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 +} diff --git a/go/internal/server/app/seeds.go b/go/internal/server/app/seeds.go new file mode 100644 index 0000000..eb4f134 --- /dev/null +++ b/go/internal/server/app/seeds.go @@ -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 +} diff --git a/go/internal/server/app/serve.go b/go/internal/server/app/serve.go new file mode 100644 index 0000000..f49472d --- /dev/null +++ b/go/internal/server/app/serve.go @@ -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 +} diff --git a/go/internal/server/app/server_state.go b/go/internal/server/app/server_state.go new file mode 100644 index 0000000..9ee423a --- /dev/null +++ b/go/internal/server/app/server_state.go @@ -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 +} diff --git a/go/cmd/bd2server/state.go b/go/internal/server/app/state.go similarity index 79% rename from go/cmd/bd2server/state.go rename to go/internal/server/app/state.go index 47694a7..c0b93aa 100644 --- a/go/cmd/bd2server/state.go +++ b/go/internal/server/app/state.go @@ -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 diff --git a/go/cmd/bd2server/state_validation.go b/go/internal/server/app/state_validation.go similarity index 84% rename from go/cmd/bd2server/state_validation.go rename to go/internal/server/app/state_validation.go index bae01a2..3678bef 100644 --- a/go/cmd/bd2server/state_validation.go +++ b/go/internal/server/app/state_validation.go @@ -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 { diff --git a/go/internal/server/auth/service.go b/go/internal/server/auth/service.go deleted file mode 100644 index 1935e98..0000000 --- a/go/internal/server/auth/service.go +++ /dev/null @@ -1,1026 +0,0 @@ -package auth - -import ( - "bytes" - "context" - "crypto/sha256" - "crypto/subtle" - "database/sql" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "sync" - "time" - - "bd2server/internal/server/authconfig" - "bd2server/internal/server/wire" -) - -type Service struct { - config authconfig.Runtime - store *Store - client *http.Client - limits requestLimiter -} - -type limitWindow struct { - started time.Time - count int -} - -type requestLimiter struct { - mu sync.Mutex - windows map[string]limitWindow - lastSweep time.Time -} - -type deviceResult struct { - Provider string `json:"provider"` - AccessToken string `json:"access_token"` - AccessExpiresIn int64 `json:"access_expires_in"` - RefreshToken string `json:"refresh_token"` - RefreshExpiresIn int64 `json:"refresh_expires_in"` -} - -type refreshAttemptResult struct { - Provider string `json:"provider"` - AccessToken string `json:"access_token"` - AccessExpiresAt int64 `json:"access_expires_at"` - RefreshToken string `json:"refresh_token"` - RefreshExpiresAt int64 `json:"refresh_expires_at"` -} - -func New(config authconfig.Runtime, store *Store) (*Service, error) { - if config.Mode != "oauth" || store == nil { - return nil, errors.New("auth: OAuth service requires oauth configuration and store") - } - // Store.Open has already derived its purpose-specific keys. Do not retain - // the environment master key in the long-lived HTTP service configuration. - clear(config.MasterKey) - config.MasterKey = nil - return &Service{config: config, store: store, client: &http.Client{Timeout: 15 * time.Second}, limits: requestLimiter{windows: make(map[string]limitWindow)}}, nil -} - -func (s *Service) Handler() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("POST /auth/device", s.createDevice) - mux.HandleFunc("GET /auth/{provider}/start", s.start) - mux.HandleFunc("GET /auth/{provider}/callback", s.callback) - mux.HandleFunc("POST /auth/device/{id}/poll", s.poll) - mux.HandleFunc("POST /auth/session/refresh", s.refresh) - mux.HandleFunc("POST /auth/session/revoke", s.revoke) - return securityHeaders(mux) -} - -func securityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Cache-Control", "no-store") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "no-referrer") - w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") - w.Header().Set("X-Frame-Options", "DENY") - next.ServeHTTP(w, r) - }) -} - -func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { - defer func() { _ = r.Body.Close() }() - data, err := io.ReadAll(io.LimitReader(r.Body, 16<<10+1)) - if err != nil || len(data) > 16<<10 { - http.Error(w, "request too large", http.StatusRequestEntityTooLarge) - return false - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - http.Error(w, "invalid JSON", http.StatusBadRequest) - return false - } - var trailing any - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - http.Error(w, "invalid JSON", http.StatusBadRequest) - return false - } - return true -} - -func writeJSON(w http.ResponseWriter, status int, value any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(value) -} - -func (s *Service) createDevice(w http.ResponseWriter, r *http.Request) { - clientIP := remoteIP(r.RemoteAddr) - if !s.limits.allow("create:"+clientIP, s.store.now(), time.Minute, 10) { - w.Header().Set("Retry-After", "60") - http.Error(w, "too many login attempts", http.StatusTooManyRequests) - return - } - var request struct { - Provider string `json:"provider"` - } - if !decodeJSON(w, r, &request) { - return - } - if _, ok := s.config.Providers[request.Provider]; !ok { - http.Error(w, "provider is not enabled", http.StatusBadRequest) - return - } - id, err := randomToken(18) - if err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - secret, err := randomToken(32) - if err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - startTicket, err := randomToken(32) - if err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - now := s.store.now() - clientHash := s.store.digest("client-ip", clientIP) - tx, err := s.store.db.Begin() - if err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - defer func() { _ = tx.Rollback() }() - if err := cleanupExpired(tx, now.Unix()); err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - var pending int - if err := tx.QueryRow(`SELECT COUNT(*) FROM devices WHERE client_hash=? AND status IN ('created','authorizing') AND expires_at>?`, clientHash, now.Unix()).Scan(&pending); err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - if pending >= 5 { - w.Header().Set("Retry-After", strconv.FormatInt(int64(s.config.DeviceTTL.Seconds()), 10)) - http.Error(w, "too many pending login transactions", http.StatusTooManyRequests) - return - } - _, err = tx.Exec(`INSERT INTO devices(id,client_hash,secret_hash,start_hash,provider,status,created_at,expires_at) VALUES(?,?,?,?,?,'created',?,?)`, id, clientHash, s.store.digest("device-secret", secret), s.store.digest("start-ticket", startTicket), request.Provider, now.Unix(), now.Add(s.config.DeviceTTL).Unix()) - if err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - if err := tx.Commit(); err != nil { - http.Error(w, "could not create transaction", http.StatusInternalServerError) - return - } - start := *s.config.PublicURLParsed - start.Path = "/auth/" + request.Provider + "/start" - query := start.Query() - query.Set("transaction_id", id) - query.Set("ticket", startTicket) - start.RawQuery = query.Encode() - writeJSON(w, http.StatusCreated, map[string]any{"transaction_id": id, "device_secret": secret, "start_url": start.String(), "expires_in": int64(s.config.DeviceTTL.Seconds()), "poll_interval": 2}) -} - -func (s *Service) start(w http.ResponseWriter, r *http.Request) { - provider := r.PathValue("provider") - if _, ok := s.config.Providers[provider]; !ok { - http.Error(w, "provider is not enabled", http.StatusNotFound) - return - } - id, ticket := r.URL.Query().Get("transaction_id"), r.URL.Query().Get("ticket") - var storedHash []byte - var storedProvider, status string - var expires int64 - err := s.store.db.QueryRow(`SELECT start_hash,provider,status,expires_at FROM devices WHERE id=?`, id).Scan(&storedHash, &storedProvider, &status, &expires) - if err != nil || subtle.ConstantTimeCompare(storedHash, s.store.digest("start-ticket", ticket)) != 1 || storedProvider != provider || status != "created" { - http.Error(w, "invalid login transaction", http.StatusForbidden) - return - } - if s.store.now().Unix() >= expires { - http.Error(w, "login transaction expired", http.StatusGone) - return - } - state, err := randomToken(32) - if err != nil { - http.Error(w, "could not start authorization", http.StatusInternalServerError) - return - } - verifier, err := randomToken(32) - if err != nil { - http.Error(w, "could not start authorization", http.StatusInternalServerError) - return - } - nonce, err := randomToken(24) - if err != nil { - http.Error(w, "could not start authorization", http.StatusInternalServerError) - return - } - verifierCipher, err := s.store.seal(id, "pkce", []byte(verifier)) - if err != nil { - http.Error(w, "could not start authorization", http.StatusInternalServerError) - return - } - nonceCipher, err := s.store.seal(id, "nonce", []byte(nonce)) - if err != nil { - http.Error(w, "could not start authorization", http.StatusInternalServerError) - return - } - result, err := s.store.db.Exec(`UPDATE devices SET state_hash=?,verifier_cipher=?,nonce_cipher=?,start_hash=X'',status='authorizing' WHERE id=? AND status='created'`, s.store.digest("oauth-state", state), verifierCipher, nonceCipher, id) - count, affectedErr := rowsAffected(result) - if err != nil || affectedErr != nil || count != 1 { - http.Error(w, "could not start authorization", http.StatusConflict) - return - } - redirect := s.redirectURL(provider) - challenge := sha256.Sum256([]byte(verifier)) - values := url.Values{"client_id": {s.config.Providers[provider].ClientID}, "redirect_uri": {redirect}, "response_type": {"code"}, "scope": {providerScope(provider)}, "state": {state}, "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, "code_challenge_method": {"S256"}} - if provider == "google" { - values.Set("nonce", nonce) - } - http.Redirect(w, r, providerAuthorizeURL(provider)+"?"+values.Encode(), http.StatusFound) -} - -func (s *Service) callback(w http.ResponseWriter, r *http.Request) { - provider, state, code := r.PathValue("provider"), r.URL.Query().Get("state"), r.URL.Query().Get("code") - if _, ok := s.config.Providers[provider]; !ok { - http.Error(w, "provider is not enabled", http.StatusNotFound) - return - } - if state == "" { - http.Error(w, "authorization was not completed", http.StatusBadRequest) - return - } - if r.URL.Query().Get("error") != "" { - result, err := s.store.db.Exec(`UPDATE devices SET status='failed',error_code='provider_cancelled',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL - WHERE state_hash=? AND provider=? AND status='authorizing' AND expires_at>?`, s.store.digest("oauth-state", state), provider, s.store.now().Unix()) - if err != nil { - http.Error(w, "authorization state unavailable", http.StatusInternalServerError) - return - } - if count, err := rowsAffected(result); err != nil || count != 1 { - http.Error(w, "invalid or expired authorization state", http.StatusForbidden) - return - } - http.Error(w, "authorization was cancelled", http.StatusBadRequest) - return - } - if code == "" { - http.Error(w, "authorization was not completed", http.StatusBadRequest) - return - } - var id, storedProvider, status string - var verifierCipher, nonceCipher []byte - var expires int64 - err := s.store.db.QueryRow(`SELECT id,provider,status,verifier_cipher,nonce_cipher,expires_at FROM devices WHERE state_hash=?`, s.store.digest("oauth-state", state)).Scan(&id, &storedProvider, &status, &verifierCipher, &nonceCipher, &expires) - if err != nil || provider != storedProvider || status != "authorizing" || s.store.now().Unix() >= expires { - http.Error(w, "invalid or expired authorization state", http.StatusForbidden) - return - } - verifier, err := s.store.open(id, "pkce", verifierCipher) - if err != nil { - http.Error(w, "authorization state unavailable", http.StatusInternalServerError) - return - } - nonce, err := s.store.open(id, "nonce", nonceCipher) - if err != nil { - http.Error(w, "authorization state unavailable", http.StatusInternalServerError) - return - } - identity, err := s.exchangeIdentity(r.Context(), provider, code, string(verifier), string(nonce)) - clear(verifier) - clear(nonce) - if err != nil { - _, _ = s.store.db.Exec(`UPDATE devices SET status='failed',error_code='provider_rejected',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND status='authorizing'`, id) - var failure *providerFailure - if errors.As(err, &failure) { - slog.Warn("OAuth provider authorization failed", - "provider", failure.Provider, - "stage", failure.Stage, - "reason", failure.Reason, - "http_status", failure.HTTPStatus, - "oauth_error", failure.OAuthError) - if failure.OAuthError == "invalid_client" { - http.Error(w, "server OAuth configuration is invalid; contact the server administrator", http.StatusBadGateway) - return - } - } else { - slog.Warn("OAuth provider authorization failed", "provider", provider, "reason", "internal_error") - } - http.Error(w, "provider authorization failed", http.StatusBadGateway) - return - } - if err := s.completeDevice(id, provider, identity); err != nil { - code := http.StatusInternalServerError - if errors.Is(err, ErrNotAllowed) { - code = http.StatusForbidden - } else if errors.Is(err, ErrConsumed) { - code = http.StatusConflict - } - http.Error(w, "authorization could not be completed", code) - return - } - w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = io.WriteString(w, `BD2 login

Login complete. You can return to the game.

`) -} - -type providerIdentity struct{ issuer, subject string } - -type providerFailure struct { - Provider string - Stage string - Reason string - HTTPStatus int - OAuthError string -} - -func (e *providerFailure) Error() string { - return fmt.Sprintf("provider=%s stage=%s reason=%s status=%d oauth_error=%s", e.Provider, e.Stage, e.Reason, e.HTTPStatus, e.OAuthError) -} - -func networkProviderFailure(ctx context.Context, provider, stage string, err error) error { - reason := "network_error" - if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { - reason = "timeout" - } else if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) { - reason = "cancelled" - } else { - var networkError net.Error - if errors.As(err, &networkError) && networkError.Timeout() { - reason = "timeout" - } - } - return &providerFailure{Provider: provider, Stage: stage, Reason: reason} -} - -func rejectedProviderFailure(provider, stage string, response *http.Response) error { - failure := &providerFailure{Provider: provider, Stage: stage, Reason: "http_rejected", HTTPStatus: response.StatusCode} - var body struct { - Error string `json:"error"` - } - decoder := json.NewDecoder(io.LimitReader(response.Body, 8<<10)) - if decoder.Decode(&body) == nil { - failure.OAuthError = safeOAuthError(body.Error) - } - return failure -} - -func invalidProviderResponse(provider, stage string) error { - return &providerFailure{Provider: provider, Stage: stage, Reason: "invalid_response", HTTPStatus: http.StatusOK} -} - -func safeOAuthError(value string) string { - switch value { - case "invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", - "unsupported_grant_type", "invalid_scope", "access_denied", "server_error", "temporarily_unavailable": - return value - default: - return "unknown" - } -} - -func (s *Service) exchangeIdentity(ctx context.Context, provider, code, verifier, nonce string) (providerIdentity, error) { - values := url.Values{"client_id": {s.config.Providers[provider].ClientID}, "client_secret": {s.config.ProviderSecrets[provider]}, "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {s.redirectURL(provider)}, "code_verifier": {verifier}} - request, _ := http.NewRequestWithContext(ctx, http.MethodPost, providerTokenURL(provider), strings.NewReader(values.Encode())) - request.Header.Set("Content-Type", "application/x-www-form-urlencoded") - response, err := s.client.Do(request) - if err != nil { - return providerIdentity{}, networkProviderFailure(ctx, provider, "token_exchange", err) - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return providerIdentity{}, rejectedProviderFailure(provider, "token_exchange", response) - } - var token struct { - AccessToken string `json:"access_token"` - IDToken string `json:"id_token"` - } - if err := decodeProviderJSON(response.Body, &token); err != nil || token.AccessToken == "" { - return providerIdentity{}, invalidProviderResponse(provider, "token_exchange") - } - if provider == "google" { - if token.IDToken == "" { - return providerIdentity{}, invalidProviderResponse(provider, "token_exchange") - } - identity, err := s.verifyGoogleIDToken(ctx, token.IDToken, nonce) - token.AccessToken, token.IDToken = "", "" - return identity, err - } - userinfo, _ := http.NewRequestWithContext(ctx, http.MethodGet, providerUserURL(provider), nil) - userinfo.Header.Set("Authorization", "Bearer "+token.AccessToken) - response, err = s.client.Do(userinfo) - token.AccessToken = "" - if err != nil { - return providerIdentity{}, networkProviderFailure(ctx, provider, "userinfo", err) - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return providerIdentity{}, rejectedProviderFailure(provider, "userinfo", response) - } - var user struct { - ID string `json:"id"` - Sub string `json:"sub"` - } - if err := decodeProviderJSON(response.Body, &user); err != nil { - return providerIdentity{}, invalidProviderResponse(provider, "userinfo") - } - if provider == "discord" && user.ID != "" { - return providerIdentity{issuer: "https://discord.com", subject: user.ID}, nil - } - return providerIdentity{}, invalidProviderResponse(provider, "userinfo") -} - -// verifyGoogleIDToken delegates signature and standard-claim verification to -// Google's HTTPS tokeninfo endpoint, then independently verifies this server's -// audience, nonce and expiry. The raw ID token is never persisted or logged. -func (s *Service) verifyGoogleIDToken(ctx context.Context, idToken, nonce string) (providerIdentity, error) { - endpoint := "https://oauth2.googleapis.com/tokeninfo?id_token=" + url.QueryEscape(idToken) - request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) - response, err := s.client.Do(request) - if err != nil { - return providerIdentity{}, networkProviderFailure(ctx, "google", "id_token_verify", err) - } - defer func() { _ = response.Body.Close() }() - if response.StatusCode != http.StatusOK { - return providerIdentity{}, rejectedProviderFailure("google", "id_token_verify", response) - } - var claims struct { - Issuer string `json:"iss"` - Audience string `json:"aud"` - Subject string `json:"sub"` - Nonce string `json:"nonce"` - Expires string `json:"exp"` - } - if err := decodeProviderJSON(response.Body, &claims); err != nil { - return providerIdentity{}, invalidProviderResponse("google", "id_token_verify") - } - expires, err := strconv.ParseInt(claims.Expires, 10, 64) - validIssuer := claims.Issuer == "https://accounts.google.com" || claims.Issuer == "accounts.google.com" - if err != nil || !validIssuer || claims.Audience != s.config.Providers["google"].ClientID || claims.Subject == "" || claims.Nonce != nonce || s.store.now().Unix() >= expires { - return providerIdentity{}, &providerFailure{Provider: "google", Stage: "id_token_verify", Reason: "invalid_claims"} - } - return providerIdentity{issuer: "https://accounts.google.com", subject: claims.Subject}, nil -} - -func decodeProviderJSON(reader io.Reader, target any) error { - data, err := io.ReadAll(io.LimitReader(reader, 1<<20+1)) - if err != nil { - return err - } - if len(data) > 1<<20 { - return errors.New("auth: provider response is too large") - } - decoder := json.NewDecoder(bytes.NewReader(data)) - if err := decoder.Decode(target); err != nil { - return err - } - var trailing any - if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { - return errors.New("auth: provider response has trailing JSON") - } - return nil -} - -func (s *Service) completeDevice(deviceID, provider string, identity providerIdentity) error { - if err := validateProviderIdentity(provider, identity); err != nil { - return err - } - now := s.store.now() - tx, err := s.store.db.Begin() - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - var accountID, status string - subjectHash := s.store.identityDigest(identity.issuer, identity.subject) - err = tx.QueryRow(`SELECT i.account_id,a.status FROM identities i JOIN accounts a ON a.id=i.account_id WHERE i.issuer=? AND i.subject_hash=?`, identity.issuer, subjectHash).Scan(&accountID, &status) - if errors.Is(err, sql.ErrNoRows) { - var count int - if err := tx.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&count); err != nil { - return err - } - if count != 0 { - result, err := tx.Exec(`UPDATE devices SET status='failed',error_code='not_allowed',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, deviceID, provider) - if err != nil { - return err - } - if count, err := rowsAffected(result); err != nil { - return err - } else if count != 1 { - return ErrConsumed - } - if err := tx.Commit(); err != nil { - return err - } - return ErrNotAllowed - } - accountID, err = randomToken(18) - if err != nil { - return err - } - if _, err = tx.Exec(`INSERT INTO accounts(id,status,created_at,last_login_at) VALUES(?,'active',?,?)`, accountID, now.Unix(), now.Unix()); err != nil { - return err - } - if _, err = tx.Exec(`INSERT INTO identities(provider,issuer,subject_hash,account_id,created_at,last_login_at) VALUES(?,?,?,?,?,?)`, provider, identity.issuer, subjectHash, accountID, now.Unix(), now.Unix()); err != nil { - return err - } - status = "active" - } else if err != nil { - return err - } else { - if _, err = tx.Exec(`UPDATE identities SET last_login_at=? WHERE issuer=? AND subject_hash=?`, now.Unix(), identity.issuer, subjectHash); err != nil { - return err - } - } - if status != "active" { - result, err := tx.Exec(`UPDATE devices SET status='failed',error_code='not_allowed',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, deviceID, provider) - if err != nil { - return err - } - if count, err := rowsAffected(result); err != nil { - return err - } else if count != 1 { - return ErrConsumed - } - if err := tx.Commit(); err != nil { - return err - } - return ErrNotAllowed - } - result, familyID, err := s.issueTokens(tx, accountID, provider, now) - if err != nil { - return err - } - _ = familyID - payload, err := json.Marshal(result) - if err != nil { - return err - } - sealed, err := s.store.seal(deviceID, "result", payload) - clear(payload) - if err != nil { - return err - } - update, err := tx.Exec(`UPDATE devices SET result_cipher=?,status='complete',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, sealed, deviceID, provider) - if err != nil { - return err - } - if count, err := rowsAffected(update); err != nil { - return err - } else if count != 1 { - return ErrConsumed - } - return tx.Commit() -} - -func validateProviderIdentity(provider string, identity providerIdentity) error { - switch provider { - case "discord": - if identity.issuer != "https://discord.com" || len(identity.subject) == 0 || len(identity.subject) > 32 { - return errors.New("auth: invalid Discord identity") - } - for _, digit := range identity.subject { - if digit < '0' || digit > '9' { - return errors.New("auth: invalid Discord identity") - } - } - case "google": - if identity.issuer != "https://accounts.google.com" || len(identity.subject) == 0 || len(identity.subject) > 255 { - return errors.New("auth: invalid Google identity") - } - default: - return errors.New("auth: unsupported identity provider") - } - return nil -} - -func (s *Service) issueTokens(tx *sql.Tx, accountID, provider string, now time.Time) (deviceResult, string, error) { - familyID, err := randomToken(18) - if err != nil { - return deviceResult{}, "", err - } - access, err := randomToken(32) - if err != nil { - return deviceResult{}, "", err - } - refresh, err := randomToken(32) - if err != nil { - return deviceResult{}, "", err - } - if _, err := tx.Exec(`INSERT INTO families(id,account_id,provider,created_at,expires_at) VALUES(?,?,?,?,?)`, familyID, accountID, provider, now.Unix(), now.Add(s.config.RefreshTTL).Unix()); err != nil { - return deviceResult{}, "", err - } - if _, err := tx.Exec(`INSERT INTO access_tokens(token_hash,family_id,account_id,created_at,expires_at) VALUES(?,?,?,?,?)`, s.store.digest("access-token", access), familyID, accountID, now.Unix(), now.Add(s.config.AccessTTL).Unix()); err != nil { - return deviceResult{}, "", err - } - if _, err := tx.Exec(`INSERT INTO refresh_tokens(token_hash,family_id,created_at,expires_at) VALUES(?,?,?,?)`, s.store.digest("refresh-token", refresh), familyID, now.Unix(), now.Add(s.config.RefreshTTL).Unix()); err != nil { - return deviceResult{}, "", err - } - return deviceResult{Provider: provider, AccessToken: access, AccessExpiresIn: int64(s.config.AccessTTL.Seconds()), RefreshToken: refresh, RefreshExpiresIn: int64(s.config.RefreshTTL.Seconds())}, familyID, nil -} - -func (s *Service) poll(w http.ResponseWriter, r *http.Request) { - id := r.PathValue("id") - if !s.limits.allow("poll:"+remoteIP(r.RemoteAddr)+":"+id, s.store.now(), time.Minute, 60) { - w.Header().Set("Retry-After", "2") - http.Error(w, "poll rate exceeded", http.StatusTooManyRequests) - return - } - authorization := r.Header.Get("Authorization") - if !strings.HasPrefix(authorization, "Device ") { - http.Error(w, "invalid device transaction", http.StatusForbidden) - return - } - secret := strings.TrimPrefix(authorization, "Device ") - tx, err := s.store.db.Begin() - if err != nil { - http.Error(w, "login result unavailable", http.StatusInternalServerError) - return - } - defer func() { _ = tx.Rollback() }() - var storedHash, sealed []byte - var status, errorCode string - var expires int64 - err = tx.QueryRow(`SELECT secret_hash,status,COALESCE(result_cipher,X''),COALESCE(error_code,''),expires_at FROM devices WHERE id=?`, id).Scan(&storedHash, &status, &sealed, &errorCode, &expires) - if err != nil || subtle.ConstantTimeCompare(storedHash, s.store.digest("device-secret", secret)) != 1 { - http.Error(w, "invalid device transaction", http.StatusForbidden) - return - } - if s.store.now().Unix() >= expires { - http.Error(w, "device transaction expired", http.StatusGone) - return - } - switch status { - case "created", "authorizing": - _ = tx.Rollback() - writeJSON(w, http.StatusAccepted, map[string]any{"status": "pending", "retry_after": 2}) - case "failed": - _ = tx.Rollback() - writeJSON(w, http.StatusForbidden, map[string]string{"status": "failed", "error": errorCode}) - case "complete": - plain, err := s.store.open(id, "result", sealed) - if err != nil { - http.Error(w, "login result unavailable", http.StatusInternalServerError) - return - } - result, err := tx.Exec(`UPDATE devices SET result_cipher=NULL,status='consumed' WHERE id=? AND status='complete'`, id) - count, affectedErr := rowsAffected(result) - if err != nil || affectedErr != nil || count != 1 { - _ = tx.Rollback() - clear(plain) - http.Error(w, "login result already consumed", http.StatusGone) - return - } - if err := tx.Commit(); err != nil { - clear(plain) - http.Error(w, "login result unavailable", http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - _, _ = w.Write(plain) - clear(plain) - default: - _ = tx.Rollback() - http.Error(w, "device transaction consumed", http.StatusGone) - } -} - -func (l *requestLimiter) allow(key string, now time.Time, duration time.Duration, maximum int) bool { - l.mu.Lock() - defer l.mu.Unlock() - if l.windows == nil { - l.windows = make(map[string]limitWindow) - } - if l.lastSweep.IsZero() || now.Sub(l.lastSweep) >= time.Minute { - for candidate, window := range l.windows { - if now.Sub(window.started) >= duration { - delete(l.windows, candidate) - } - } - l.lastSweep = now - } - window, exists := l.windows[key] - if !exists || now.Sub(window.started) >= duration { - if !exists && len(l.windows) >= 4096 { - return false - } - l.windows[key] = limitWindow{started: now, count: 1} - return true - } - if window.count >= maximum { - return false - } - window.count++ - l.windows[key] = window - return true -} - -func remoteIP(remoteAddr string) string { - host, _, err := net.SplitHostPort(remoteAddr) - if err == nil && host != "" { - return host - } - return remoteAddr -} - -func cleanupExpired(tx *sql.Tx, now int64) error { - statements := []struct { - query string - args []any - }{ - {`DELETE FROM devices WHERE expires_at<=?`, []any{now}}, - {`DELETE FROM access_tokens WHERE expires_at<=? OR family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now, now}}, - {`DELETE FROM refresh_attempts WHERE expires_at<=? OR family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now, now}}, - // Used refresh rows remain until their family expires so their reuse can - // still revoke every credential in that family. - {`DELETE FROM refresh_tokens WHERE family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now}}, - {`DELETE FROM families WHERE expires_at<=?`, []any{now}}, - } - for _, statement := range statements { - if _, err := tx.Exec(statement.query, statement.args...); err != nil { - return err - } - } - return nil -} - -func (s *Service) refresh(w http.ResponseWriter, r *http.Request) { - var request struct { - RefreshToken string `json:"refresh_token"` - AttemptID string `json:"attempt_id"` - } - if !decodeJSON(w, r, &request) { - return - } - if request.RefreshToken == "" || !validRefreshAttemptID(request.AttemptID) { - http.Error(w, "refresh_token and valid attempt_id required", http.StatusBadRequest) - return - } - now := s.store.now() - requestTokenHash := s.store.digest("refresh-token", request.RefreshToken) - attemptHash := s.store.digest("refresh-attempt", request.AttemptID) - tx, err := s.store.db.Begin() - if err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - defer func() { _ = tx.Rollback() }() - if err := cleanupExpired(tx, now.Unix()); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - var familyID, accountID, provider, accountStatus string - var tokenExpires, familyExpires int64 - var usedAt, revokedAt sql.NullInt64 - err = tx.QueryRow(`SELECT r.family_id,f.account_id,f.provider,a.status,r.expires_at,f.expires_at,r.used_at,COALESCE(r.revoked_at,f.revoked_at) FROM refresh_tokens r JOIN families f ON f.id=r.family_id JOIN accounts a ON a.id=f.account_id WHERE r.token_hash=?`, requestTokenHash).Scan(&familyID, &accountID, &provider, &accountStatus, &tokenExpires, &familyExpires, &usedAt, &revokedAt) - if err != nil || revokedAt.Valid || accountStatus != "active" || now.Unix() >= familyExpires { - w.Header().Set("X-BD2-Refresh-Invalid", "1") - http.Error(w, "refresh token invalid", http.StatusUnauthorized) - return - } - var savedRequestHash, resultCipher []byte - err = tx.QueryRow(`SELECT request_token_hash,result_cipher FROM refresh_attempts WHERE family_id=? AND attempt_hash=?`, familyID, attemptHash).Scan(&savedRequestHash, &resultCipher) - if err == nil { - if subtle.ConstantTimeCompare(savedRequestHash, requestTokenHash) != 1 { - w.Header().Set("X-BD2-Refresh-Invalid", "1") - http.Error(w, "refresh attempt_id already belongs to another request", http.StatusConflict) - return - } - plain, openErr := s.store.open(refreshAttemptSealID(familyID, attemptHash), "result", resultCipher) - if openErr != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - var saved refreshAttemptResult - decodeErr := json.Unmarshal(plain, &saved) - clear(plain) - if decodeErr != nil || saved.Provider == "" || saved.AccessToken == "" || saved.RefreshToken == "" { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, saved.deviceResult(now.Unix())) - return - } - if !errors.Is(err, sql.ErrNoRows) { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - if now.Unix() >= tokenExpires { - w.Header().Set("X-BD2-Refresh-Invalid", "1") - http.Error(w, "refresh token expired", http.StatusUnauthorized) - return - } - if usedAt.Valid { - if _, err := tx.Exec(`UPDATE families SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, now.Unix(), familyID); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - if err := tx.Commit(); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - w.Header().Set("X-BD2-Refresh-Invalid", "1") - http.Error(w, "refresh token replayed", http.StatusUnauthorized) - return - } - newAccess, err := randomToken(32) - if err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - newRefresh, err := randomToken(32) - if err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - updated, err := tx.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL`, now.Unix(), requestTokenHash) - if err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - if count, err := rowsAffected(updated); err != nil || count != 1 { - w.Header().Set("X-BD2-Refresh-Invalid", "1") - http.Error(w, "refresh token invalid", http.StatusUnauthorized) - return - } - if _, err = tx.Exec(`DELETE FROM access_tokens WHERE family_id=?`, familyID); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - if _, err = tx.Exec(`INSERT INTO access_tokens(token_hash,family_id,account_id,created_at,expires_at) VALUES(?,?,?,?,?)`, s.store.digest("access-token", newAccess), familyID, accountID, now.Unix(), now.Add(s.config.AccessTTL).Unix()); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - refreshExpiry := min(familyExpires, now.Add(s.config.RefreshTTL).Unix()) - if _, err = tx.Exec(`INSERT INTO refresh_tokens(token_hash,family_id,created_at,expires_at) VALUES(?,?,?,?)`, s.store.digest("refresh-token", newRefresh), familyID, now.Unix(), refreshExpiry); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - result := refreshAttemptResult{ - Provider: provider, AccessToken: newAccess, AccessExpiresAt: now.Add(s.config.AccessTTL).Unix(), - RefreshToken: newRefresh, RefreshExpiresAt: refreshExpiry, - } - plain, err := json.Marshal(result) - if err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - resultCipher, err = s.store.seal(refreshAttemptSealID(familyID, attemptHash), "result", plain) - clear(plain) - if err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - if _, err = tx.Exec(`INSERT INTO refresh_attempts(family_id,attempt_hash,request_token_hash,result_cipher,created_at,expires_at) VALUES(?,?,?,?,?,?)`, familyID, attemptHash, requestTokenHash, resultCipher, now.Unix(), refreshExpiry); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - if err = tx.Commit(); err != nil { - http.Error(w, "refresh unavailable", http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, result.deviceResult(now.Unix())) -} - -func validRefreshAttemptID(value string) bool { - if len(value) < 16 || len(value) > 128 { - return false - } - for _, item := range value { - if item < 'a' || item > 'z' { - if item < 'A' || item > 'Z' { - if item < '0' || item > '9' { - if item != '-' && item != '_' { - return false - } - } - } - } - } - return true -} - -func refreshAttemptSealID(familyID string, attemptHash []byte) string { - return familyID + ":" + base64.RawURLEncoding.EncodeToString(attemptHash) -} - -func (r refreshAttemptResult) deviceResult(now int64) deviceResult { - accessTTL := max(r.AccessExpiresAt-now, 0) - refreshTTL := max(r.RefreshExpiresAt-now, 0) - return deviceResult{ - Provider: r.Provider, AccessToken: r.AccessToken, AccessExpiresIn: accessTTL, - RefreshToken: r.RefreshToken, RefreshExpiresIn: refreshTTL, - } -} - -func (s *Service) revoke(w http.ResponseWriter, r *http.Request) { - authorization := r.Header.Get("Authorization") - if !strings.HasPrefix(authorization, "Bearer ") { - http.Error(w, "access token required", http.StatusUnauthorized) - return - } - token := strings.TrimPrefix(authorization, "Bearer ") - if token == "" { - http.Error(w, "access token required", http.StatusUnauthorized) - return - } - now := s.store.now().Unix() - tx, err := s.store.db.Begin() - if err != nil { - http.Error(w, "revocation unavailable", http.StatusInternalServerError) - return - } - defer func() { _ = tx.Rollback() }() - var familyID, accountStatus string - var expires int64 - var revoked sql.NullInt64 - err = tx.QueryRow(`SELECT t.family_id,a.status,t.expires_at,COALESCE(t.revoked_at,f.revoked_at) - FROM access_tokens t JOIN families f ON f.id=t.family_id JOIN accounts a ON a.id=t.account_id - WHERE t.token_hash=?`, s.store.digest("access-token", token)).Scan(&familyID, &accountStatus, &expires, &revoked) - if err != nil || accountStatus != "active" || revoked.Valid || now >= expires { - http.Error(w, "access token invalid", http.StatusUnauthorized) - return - } - result, err := tx.Exec(`UPDATE families SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, now, familyID) - if err != nil { - http.Error(w, "revocation unavailable", http.StatusInternalServerError) - return - } - if count, err := rowsAffected(result); err != nil || count != 1 { - http.Error(w, "access token invalid", http.StatusUnauthorized) - return - } - if err := tx.Commit(); err != nil { - http.Error(w, "revocation unavailable", http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Service) ValidateAccess(token string) (string, error) { - if token == "" { - return "", ErrUnauthorized - } - var accountID, status string - var expires int64 - var revoked sql.NullInt64 - err := s.store.db.QueryRow(`SELECT t.account_id,a.status,t.expires_at,COALESCE(t.revoked_at,f.revoked_at) FROM access_tokens t JOIN families f ON f.id=t.family_id JOIN accounts a ON a.id=t.account_id WHERE t.token_hash=?`, s.store.digest("access-token", token)).Scan(&accountID, &status, &expires, &revoked) - if err != nil || status != "active" || revoked.Valid || s.store.now().Unix() >= expires { - return "", ErrUnauthorized - } - return accountID, nil -} - -// AuthenticateLogin validates LoginUserRequest.access_token (field 2) before -// the game session is established. -func (s *Service) AuthenticateLogin(request []byte) (string, error) { - token, found, err := wire.Bytes(request, 2) - if err != nil || !found { - return "", ErrUnauthorized - } - return s.ValidateAccess(string(token)) -} - -func (s *Service) redirectURL(provider string) string { - return s.config.PublicURL + "/auth/" + provider + "/callback" -} -func providerScope(provider string) string { - if provider == "discord" { - return "identify" - } - return "openid" -} -func providerAuthorizeURL(provider string) string { - if provider == "discord" { - return "https://discord.com/oauth2/authorize" - } - return "https://accounts.google.com/o/oauth2/v2/auth" -} -func providerTokenURL(provider string) string { - if provider == "discord" { - return "https://discord.com/api/v10/oauth2/token" - } - return "https://oauth2.googleapis.com/token" -} -func providerUserURL(provider string) string { - return "https://discord.com/api/v10/users/@me" -} -func rowsAffected(result sql.Result) (int64, error) { - if result == nil { - return 0, errors.New("auth: missing SQL result") - } - value, err := result.RowsAffected() - if err != nil { - return 0, fmt.Errorf("auth: count affected rows: %w", err) - } - return value, nil -} diff --git a/go/internal/server/auth/service_test.go b/go/internal/server/auth/service_test.go deleted file mode 100644 index 91bea3a..0000000 --- a/go/internal/server/auth/service_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/auth/store_test.go b/go/internal/server/auth/store_test.go deleted file mode 100644 index 5bb2410..0000000 --- a/go/internal/server/auth/store_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/authconfig/config_test.go b/go/internal/server/authconfig/config_test.go deleted file mode 100644 index 3dc09fc..0000000 --- a/go/internal/server/authconfig/config_test.go +++ /dev/null @@ -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") - } - }) - } -} diff --git a/go/internal/server/battle/battle_test.go b/go/internal/server/battle/battle_test.go deleted file mode 100644 index 3d30ef0..0000000 --- a/go/internal/server/battle/battle_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/battle/event_test.go b/go/internal/server/battle/event_test.go deleted file mode 100644 index c3f4c63..0000000 --- a/go/internal/server/battle/event_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/battle/field_buff_test.go b/go/internal/server/battle/field_buff_test.go deleted file mode 100644 index 5699810..0000000 --- a/go/internal/server/battle/field_buff_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/battle/health_test.go b/go/internal/server/battle/health_test.go deleted file mode 100644 index 382da98..0000000 --- a/go/internal/server/battle/health_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/battle/hunting_test.go b/go/internal/server/battle/hunting_test.go deleted file mode 100644 index 453c2ef..0000000 --- a/go/internal/server/battle/hunting_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/battle/monster_hunt_test.go b/go/internal/server/battle/monster_hunt_test.go deleted file mode 100644 index 7203a74..0000000 --- a/go/internal/server/battle/monster_hunt_test.go +++ /dev/null @@ -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") - } - } -} diff --git a/go/internal/server/battle/phase_test.go b/go/internal/server/battle/phase_test.go deleted file mode 100644 index 912253e..0000000 --- a/go/internal/server/battle/phase_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/bootstrap/bootstrap_test.go b/go/internal/server/bootstrap/bootstrap_test.go deleted file mode 100644 index 3e07e1a..0000000 --- a/go/internal/server/bootstrap/bootstrap_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/calendar/binary.go b/go/internal/server/calendar/binary.go deleted file mode 100644 index dd0e2a5..0000000 --- a/go/internal/server/calendar/binary.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/calendar/binary_test.go b/go/internal/server/calendar/binary_test.go deleted file mode 100644 index 4c6c80b..0000000 --- a/go/internal/server/calendar/binary_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/calendar/design.go b/go/internal/server/calendar/design.go deleted file mode 100644 index f5769aa..0000000 --- a/go/internal/server/calendar/design.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/calendar/load_test.go b/go/internal/server/calendar/load_test.go deleted file mode 100644 index 008e393..0000000 --- a/go/internal/server/calendar/load_test.go +++ /dev/null @@ -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) - } -} diff --git a/go/internal/server/calendar/mini_hubs.go b/go/internal/server/calendar/mini_hubs.go deleted file mode 100644 index ba49c4e..0000000 --- a/go/internal/server/calendar/mini_hubs.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/calendar/mini_hubs_test.go b/go/internal/server/calendar/mini_hubs_test.go deleted file mode 100644 index 4902f87..0000000 --- a/go/internal/server/calendar/mini_hubs_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/calendar/published_complete_test.go b/go/internal/server/calendar/published_complete_test.go deleted file mode 100644 index 6dc915c..0000000 --- a/go/internal/server/calendar/published_complete_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/go/internal/server/calendar/records.go b/go/internal/server/calendar/records.go deleted file mode 100644 index 02f1c03..0000000 --- a/go/internal/server/calendar/records.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/commerce/attendance.go b/go/internal/server/commerce/attendance.go deleted file mode 100644 index 33dc84b..0000000 --- a/go/internal/server/commerce/attendance.go +++ /dev/null @@ -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 -} diff --git a/go/internal/server/commerce/attendance_test.go b/go/internal/server/commerce/attendance_test.go deleted file mode 100644 index 07669e6..0000000 --- a/go/internal/server/commerce/attendance_test.go +++ /dev/null @@ -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") - } -} diff --git a/go/internal/server/commerce/bonuses.go b/go/internal/server/commerce/bonuses.go deleted file mode 100644 index 1e6b64c..0000000 --- a/go/internal/server/commerce/bonuses.go +++ /dev/null @@ -1,216 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "sort" - "sync" -) - -type bonusGroup struct{ ProductGroup, ContentsGroup uint64 } -type bonusPurchases interface { - LifetimePurchaseTotal([]gamedata.CashProductKey) (uint64, error) -} -type bonusState struct { - Claims map[string]bool `json:"claims"` - Receipts map[string]purchaseReceipt `json:"receipts"` -} -type CashBonuses struct { - mu sync.Mutex - store stateio.Store - economy Economy - counts bonusPurchases - groups map[bonusGroup][]gamedata.CashProductKey - rewards map[bonusGroup][]gamedata.CashBonusReward - ordered []bonusGroup - claimIDs map[string]bool -} - -func bonusClaimID(group bonusGroup, id uint64) string { - return fmt.Sprintf("%d:%d:%d", group.ProductGroup, group.ContentsGroup, id) -} - -func NewCashBonuses(store stateio.Store, economy Economy, counts bonusPurchases, design *gamedata.CashBonusCatalog, packages []gamedata.CashPackageDesign) (*CashBonuses, error) { - if store == nil || economy == nil || counts == nil || design == nil { - return nil, fmt.Errorf("commerce: missing cash bonus dependency") - } - s := &CashBonuses{store: store, economy: economy, counts: counts, groups: map[bonusGroup][]gamedata.CashProductKey{}, rewards: map[bonusGroup][]gamedata.CashBonusReward{}, claimIDs: map[string]bool{}} - seen := map[gamedata.CashProductKey]bool{} - for _, p := range packages { - // EPackages.BonusBundleGroup=8; unrelated packages can reuse a contents ID. - if p.PackageType != 8 || len(design.Groups[p.ContentsGroupID]) == 0 { - continue - } - k := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup} - if seen[k] { - return nil, fmt.Errorf("commerce: duplicate cash bonus product %+v", k) - } - seen[k] = true - group := bonusGroup{p.GroupID, p.ContentsGroupID} - s.groups[group] = append(s.groups[group], k) - s.rewards[group] = design.Groups[p.ContentsGroupID] - } - for group, rows := range s.rewards { - s.ordered = append(s.ordered, group) - for _, row := range rows { - s.claimIDs[bonusClaimID(group, row.ID)] = true - } - } - sort.Slice(s.ordered, func(i, j int) bool { - if s.ordered[i].ProductGroup != s.ordered[j].ProductGroup { - return s.ordered[i].ProductGroup < s.ordered[j].ProductGroup - } - return s.ordered[i].ContentsGroup < s.ordered[j].ContentsGroup - }) - _, err := s.load() - return s, err -} - -func (s *CashBonuses) load() (bonusState, error) { - v := bonusState{Claims: map[string]bool{}, Receipts: map[string]purchaseReceipt{}} - raw, err := s.store.Load("commerce_cash_bonuses") - if err != nil || raw == nil { - return v, err - } - if err = stateio.RequireExactJSONObject(raw, "claims", "receipts"); err != nil { - return v, err - } - if err = json.Unmarshal(raw, &v); err != nil { - return v, err - } - if v.Claims == nil || v.Receipts == nil { - return v, fmt.Errorf("commerce: malformed cash bonus state") - } - for id, claimed := range v.Claims { - if !claimed || !s.claimIDs[id] { - return v, fmt.Errorf("commerce: invalid cash bonus claim %s", id) - } - } - for id, r := range v.Receipts { - digest, err := hex.DecodeString(r.Digest) - if id == "" || err != nil || len(digest) != sha256.Size || len(r.Response) == 0 { - return v, fmt.Errorf("commerce: invalid cash bonus receipt") - } - } - return v, nil -} - -func (s *CashBonuses) rewarded(v bonusState, group bonusGroup, field int) []byte { - var out []byte - for _, row := range s.rewards[group] { - if v.Claims[bonusClaimID(group, row.ID)] { - out = wire.AppendVarint(out, field, row.ID) - } - } - return out -} - -func (s *CashBonuses) Handle(path string, request []byte) (int, []byte, bool, error) { - return s.HandleSession(path, request, "") -} - -func (s *CashBonuses) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { - code := 588 - if path == "/CashBonusReward" { - code = 589 - } else if path != "/CashBonusInfo" { - return 0, nil, false, nil - } - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 || seq > math.MaxInt32 { - return code, nil, true, fmt.Errorf("commerce: invalid cash bonus sequence") - } - if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil { - return code, nil, true, err - } - s.mu.Lock() - defer s.mu.Unlock() - v, err := s.load() - if err != nil { - return code, nil, true, err - } - if path == "/CashBonusInfo" { - var out []byte - for _, group := range s.ordered { - n, err := s.counts.LifetimePurchaseTotal(s.groups[group]) - if err != nil { - return code, nil, true, err - } - // Preserve the native empty response for groups with no purchases. - if n == 0 { - continue - } - b := wire.AppendVarint(nil, 1, group.ProductGroup) - b = wire.AppendVarint(b, 2, group.ContentsGroup) - b = wire.AppendVarint(b, 3, n) - b = append(b, s.rewarded(v, group, 4)...) - out = wire.AppendBytes(out, 1, b) - } - return code, out, true, nil - } - if session == "" { - return code, nil, true, fmt.Errorf("commerce: cash bonus session unavailable") - } - identity := fmt.Sprintf("cash-bonus:%x:%d", sha256.Sum256([]byte(session)), seq) - digest := fmt.Sprintf("%x", sha256.Sum256(request)) - if receipt, ok := v.Receipts[identity]; ok { - if receipt.Digest != digest { - return code, nil, true, fmt.Errorf("commerce: conflicting cash bonus replay") - } - return code, append([]byte(nil), receipt.Response...), true, nil - } - groupID, _, err := wire.Varint(request, 2) - if err != nil { - return code, nil, true, err - } - contents, _, err := wire.Varint(request, 3) - if err != nil { - return code, nil, true, err - } - id, _, err := wire.Varint(request, 4) - if err != nil { - return code, nil, true, err - } - group := bonusGroup{groupID, contents} - var chosen *gamedata.CashBonusReward - for _, row := range s.rewards[group] { - if row.ID == id { - copy := row - chosen = © - break - } - } - if chosen == nil { - return code, nil, true, fmt.Errorf("commerce: unknown cash bonus group=%d contents=%d bonus=%d", groupID, contents, id) - } - n, err := s.counts.LifetimePurchaseTotal(s.groups[group]) - if err != nil { - return code, nil, true, err - } - if n < chosen.RequireCount { - return code, nil, true, fmt.Errorf("commerce: cash bonus purchase threshold not reached group=%d contents=%d bonus=%d count=%d require=%d", groupID, contents, id, n, chosen.RequireCount) - } - claimID := bonusClaimID(group, id) - var bundle []byte - if !v.Claims[claimID] { - bundle, err = s.economy.Apply("cash-bonus-grant:"+claimID, nil, []gamedata.Reward{chosen.Reward}) - if err != nil { - return code, nil, true, err - } - v.Claims[claimID] = true - } - response := wire.AppendBytes(nil, 1, bundle) - response = append(response, s.rewarded(v, group, 2)...) - v.Receipts[identity] = purchaseReceipt{Digest: digest, Response: response} - raw, err := json.Marshal(v) - if err == nil { - err = s.store.Save("commerce_cash_bonuses", raw) - } - return code, response, true, err -} diff --git a/go/internal/server/commerce/bonuses_test.go b/go/internal/server/commerce/bonuses_test.go deleted file mode 100644 index 13638b6..0000000 --- a/go/internal/server/commerce/bonuses_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "testing" -) - -func bonusRequest(seq, group, contents, bonus uint64) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 2, group) - b = wire.AppendVarint(b, 3, contents) - return wire.AppendVarint(b, 4, bonus) -} - -func TestCashBonusUsesCommittedSeriesPurchasesAndNativeClaimProtocol(t *testing.T) { - shop, eco, store := serviceFixture(t, 1) - packages := []gamedata.CashPackageDesign{ - {GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7}, - {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7}, - // Another package kind can reuse contents=7 but must not count. - {GroupID: 1, ID: 1, PackageType: 3, ContentsGroupID: 7}, - } - design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{ - 7: {{ID: 1, RequireCount: 2, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}, {ID: 2, RequireCount: 3, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 15}}}, - }} - s, err := NewCashBonuses(store, eco, shop, design, packages) - if err != nil { - t.Fatal(err) - } - infoRequest := wire.AppendVarint(nil, 1, 1) - if code, info, handled, err := s.Handle("/CashBonusInfo", infoRequest); err != nil || code != 588 || !handled || len(info) != 0 { - t.Fatal("new account bonus info must be empty", code, info, err) - } - claim := bonusRequest(1, 2, 7, 1) - if _, _, _, err := s.HandleSession("/CashBonusReward", claim, "s"); err == nil || eco.calls != 0 { - t.Fatal("unearned bonus granted") - } - for seq, sale := range []uint64{0, 1} { - if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(uint64(seq+1), 2, 1, sale, ""), "s"); err != nil { - t.Fatal(err) - } - } - // Normal recharge is not a member of the bonus series. - if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(3, 1, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - _, info, _, err := s.Handle("/CashBonusInfo", infoRequest) - if err != nil { - t.Fatal(err) - } - row, present, _ := wire.Bytes(info, 1) - count, _, _ := wire.Varint(row, 3) - if !present || count != 2 { - t.Fatal("incorrect committed series purchase count", count) - } - calls := eco.calls - code, reply, handled, err := s.HandleSession("/CashBonusReward", claim, "s") - if err != nil || code != 589 || !handled || eco.calls != calls+1 || len(eco.rewards) != 1 || eco.rewards[0].Count != 2 || len(eco.costs) != 0 { - t.Fatal("earned bonus not granted through native protocol", code, err, eco.rewards) - } - if id, _, _ := wire.Varint(reply, 2); id != 1 { - t.Fatal("native rewarded ID list missing") - } - reopened, err := NewCashBonuses(store, eco, shop, design, packages) - if err != nil { - t.Fatal(err) - } - _, replay, _, err := reopened.HandleSession("/CashBonusReward", claim, "s") - if err != nil || !bytes.Equal(reply, replay) || eco.calls != calls+1 { - t.Fatal("restart replay duplicated bonus", err) - } - _, noOp, _, err := reopened.HandleSession("/CashBonusReward", bonusRequest(2, 2, 7, 1), "s") - bundle, present, _ := wire.Bytes(noOp, 1) - if err != nil || !present || len(bundle) != 0 || eco.calls != calls+1 { - t.Fatal("fresh duplicate must return empty grant with claimed IDs", err) - } - for _, request := range [][]byte{bonusRequest(1, 2, 7, 2), bonusRequest(4, 1, 7, 1), bonusRequest(5, 2, 7, 99), bonusRequest(6, 2, 7, 2)} { - if _, _, _, err := reopened.HandleSession("/CashBonusReward", request, "s"); err == nil || eco.calls != calls+1 { - t.Fatal("conflicting replay, wrong series or unmet bonus accepted") - } - } - _, info, _, err = reopened.Handle("/CashBonusInfo", infoRequest) - row, _, _ = wire.Bytes(info, 1) - if id, _, _ := wire.Varint(row, 4); err != nil || id != 1 { - t.Fatal("claimed IDs missing after restart", err) - } -} - -func TestCashBonusFailedGrantDoesNotMarkClaimed(t *testing.T) { - shop, eco, store := serviceFixture(t, 0) - if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{7: {{ID: 1, RequireCount: 1, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}}}} - s, err := NewCashBonuses(store, eco, shop, design, []gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7}}) - if err != nil { - t.Fatal(err) - } - eco.fail = true - request := bonusRequest(1, 2, 7, 1) - if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err == nil { - t.Fatal("failed grant accepted") - } - v, err := s.load() - if err != nil || len(v.Claims) != 0 || len(v.Receipts) != 0 { - t.Fatal("failed grant marked bonus claimed", err) - } - eco.fail = false - if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err != nil { - t.Fatal("failed grant could not be retried", err) - } -} diff --git a/go/internal/server/commerce/catalog_test.go b/go/internal/server/commerce/catalog_test.go deleted file mode 100644 index e47d5e8..0000000 --- a/go/internal/server/commerce/catalog_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gameconfig" - "bd2server/internal/server/gamedata" - "math" - "testing" -) - -func testDesign() *gamedata.CashCatalog { - return &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{ - {Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100}, - {Key: gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100}, - {Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "skin", AppleSKU: "skin-ios", PriceType: 1, PriceCount: 5501}, - {Key: gamedata.CashProductKey{GroupID: 3, ProductID: 1}, GoogleSKU: "native", PriceType: 2, PriceCount: 100}, - }} -} -func TestCashPricesAliasesAndIsolation(t *testing.T) { - for _, currency := range []string{"free", "gold", "diamonds", "ban", ""} { - cfg := gameconfig.Default().Purchases - cfg.DiamondRecharge.Currency = currency - c, err := NewCatalog("test", testDesign(), cfg) - if err != nil { - t.Fatal(err) - } - m := c.Manifest() - if m.SchemaVersion != 1 || len(m.Products) != 6 { - t.Fatalf("manifest %+v", m) - } - skin, err := c.Quote(gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, 2) - if err != nil || skin.Cost != 502 || skin.ItemType != 2 || skin.Currency != "paid_diamonds" { - t.Fatalf("ordinary goods affected by %q: %+v %v", currency, skin, err) - } - recharge, err := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, 1) - if currency == "ban" || currency == "" { - if err == nil { - t.Fatal("disabled recharge accepted") - } - continue - } - if err != nil { - t.Fatal(err) - } - want := uint64(0) - if currency == "gold" { - want = 100000 - } - if currency == "diamonds" { - want = 100 - } - if recharge.Cost != want { - t.Fatalf("currency %s cost=%d", currency, recharge.Cost) - } - second, _ := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, 1) - if second.Cost != recharge.Cost { - t.Fatal("first purchase incorrectly doubled cost") - } - m.Products[0].SKU = "changed" - if c.Manifest().Products[0].SKU == "changed" { - t.Fatal("manifest mutable through caller") - } - if _, err = c.Quote(gamedata.CashProductKey{GroupID: 3, ProductID: 1}, 1); err == nil { - t.Fatal("native wallet item intercepted") - } - } -} -func TestMonetaryBoundsAndBaseline(t *testing.T) { - for _, v := range [][3]uint64{{math.MaxUint64, 2, 1}, {1, 0, 1}, {1, 1, 0}, {math.MaxInt32 + 1, 1, 1}} { - if _, err := ceilRatio(v[0], v[1], v[2]); err == nil { - t.Fatalf("accepted %v", v) - } - } - d := testDesign() - d.Products[0].PriceCount = 2000 - d.Products[1].PriceCount = 2000 - c, err := NewCatalog("test", d, gameconfig.Default().Purchases) - if err != nil { - t.Fatal(err) - } - p, _ := c.Quote(d.Products[2].Key, 1) - if p.Cost != 276 { - t.Fatalf("baseline not derived dynamically: %+v", p) - } - if _, err = c.Quote(d.Products[2].Key, math.MaxInt32); err == nil { - t.Fatal("overflow accepted") - } - if _, err = c.Quote(d.Products[2].Key, 0); err == nil { - t.Fatal("zero purchase accepted") - } - d.Products[0].Recharge = false - d.Products[1].Recharge = false - if _, err = NewCatalog("test", d, gameconfig.Default().Purchases); err == nil { - t.Fatal("missing baseline accepted") - } -} diff --git a/go/internal/server/commerce/clear_packages.go b/go/internal/server/commerce/clear_packages.go deleted file mode 100644 index 0f17aa4..0000000 --- a/go/internal/server/commerce/clear_packages.go +++ /dev/null @@ -1,258 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "encoding/binary" - "encoding/json" - "fmt" - "math" - "sort" - "sync" - "time" -) - -type ClearPackageInventory interface{ All() []player.Item } -type clearClaim struct{ Kind, GroupID, TicketID, TargetID, Level uint64 } -type clearClaimReceipt struct { - Claim clearClaim `json:"claim"` - Response []byte `json:"response"` -} -type ClearPackages struct { - mu sync.Mutex - store stateio.Store - economy Economy - items ClearPackageInventory - design map[clearClaim]gamedata.ClearPackageRewardDesign - packCleared func(uint64, uint64) bool - towerCleared func(uint64, uint64) bool - now func() time.Time -} - -func NewClearPackages(store stateio.Store, design *gamedata.ClearPackageCatalog, economy Economy, items ClearPackageInventory) (*ClearPackages, error) { - if store == nil || design == nil || economy == nil || items == nil { - return nil, fmt.Errorf("commerce: invalid clear package dependencies") - } - s := &ClearPackages{store: store, economy: economy, items: items, design: map[clearClaim]gamedata.ClearPackageRewardDesign{}, now: time.Now} - for _, r := range design.Rewards { - k := clearClaim{r.Kind, r.GroupID, r.TicketID, r.TargetID, r.Level} - if _, ok := s.design[k]; ok { - return nil, fmt.Errorf("commerce: duplicate clear reward") - } - s.design[k] = r - } - _, err := s.load() - return s, err -} -func (s *ClearPackages) AttachProgress(pack, tower func(uint64, uint64) bool) { - s.packCleared = pack - s.towerCleared = tower -} -func (s *ClearPackages) load() (map[string]clearClaimReceipt, error) { - v := map[string]clearClaimReceipt{} - raw, err := s.store.Load("commerce_clear_claims") - if err != nil || raw == nil { - return v, err - } - err = json.Unmarshal(raw, &v) - if err == nil && v == nil { - err = fmt.Errorf("commerce: invalid clear claim state") - } - if err == nil { - for identity, receipt := range v { - if identity != clearClaimID(receipt.Claim) || len(receipt.Response) == 0 { - err = fmt.Errorf("commerce: invalid saved clear claim") - break - } - if _, ok := s.design[receipt.Claim]; !ok { - err = fmt.Errorf("commerce: unknown saved clear claim") - break - } - } - } - return v, err -} -func clearClaimID(c clearClaim) string { - return fmt.Sprintf("clear-package:%d:%d:%d:%d:%d", c.Kind, c.GroupID, c.TicketID, c.TargetID, c.Level) -} -func (s *ClearPackages) entitled(ticket uint64) bool { - for _, item := range s.items.All() { - if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(s.now().UnixMilli())) { - return true - } - } - return false -} -func (s *ClearPackages) Handle(path string, request []byte) (int, []byte, bool, error) { - return s.HandleSession(path, request, "") -} - -// Both clear-info fields are ordinary proto3 messages, not a oneof. The native -// client creates an empty message for the inactive kind (CommonPacket), and -// omits scalar zero values such as ClearPackagePack and the normal pack level. -func parseClearClaim(request []byte) (clearClaim, error) { - var scalar [2]uint64 - var rows [2][]byte - var seen [4]bool - err := wire.Walk(request, func(f wire.Field) error { - if f.Number < 1 || f.Number > 4 { - return nil - } - if seen[f.Number-1] { - return fmt.Errorf("commerce: duplicate clear claim field %d", f.Number) - } - seen[f.Number-1] = true - if f.Number <= 2 { - if f.Type != 0 { - return fmt.Errorf("commerce: invalid clear claim scalar %d", f.Number) - } - scalar[f.Number-1], _ = binary.Uvarint(f.Value) - if scalar[f.Number-1] > math.MaxInt32 { - return fmt.Errorf("commerce: clear claim scalar %d exceeds int32", f.Number) - } - } else { - if f.Type != 2 { - return fmt.Errorf("commerce: invalid clear claim row %d", f.Number) - } - rows[f.Number-3] = f.Value - } - return nil - }) - if err != nil { - return clearClaim{}, err - } - if scalar[0] == 0 { - return clearClaim{}, fmt.Errorf("commerce: invalid clear claim sequence") - } - kind := scalar[1] - if kind > 1 { - return clearClaim{}, fmt.Errorf("commerce: invalid clear claim type") - } - if !seen[kind+2] { - return clearClaim{}, fmt.Errorf("commerce: missing clear claim row") - } - var active [4]uint64 - for i, row := range rows { - var values [4]uint64 - var fields [4]bool - if err := wire.Walk(row, func(f wire.Field) error { - if f.Number < 1 || f.Number > 4 { - // Unknown active fields retain normal protobuf compatibility. An - // inactive row must contain only the schema's default scalars. - if uint64(i) != kind { - return fmt.Errorf("commerce: nonempty inactive clear claim row") - } - return nil - } - if fields[f.Number-1] || f.Type != 0 { - return fmt.Errorf("commerce: invalid clear claim row scalar %d", f.Number) - } - fields[f.Number-1] = true - v, _ := binary.Uvarint(f.Value) - if v > math.MaxInt32 { - return fmt.Errorf("commerce: clear claim row scalar %d exceeds int32", f.Number) - } - if uint64(i) != kind && v != 0 { - return fmt.Errorf("commerce: conflicting inactive clear claim row") - } - values[f.Number-1] = v - return nil - }); err != nil { - return clearClaim{}, err - } - if uint64(i) == kind { - active = values - } - } - if active[0] == 0 || active[1] == 0 { - return clearClaim{}, fmt.Errorf("commerce: invalid clear claim identity") - } - return clearClaim{kind, active[0], active[1], active[2], active[3]}, nil -} - -func (s *ClearPackages) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { - if path != "/ClearPackageReward" { - return 0, nil, false, nil - } - claim, err := parseClearClaim(request) - if err != nil { - return 286, nil, true, err - } - d, ok := s.design[claim] - if !ok { - return 286, nil, true, fmt.Errorf("commerce: unknown clear reward") - } - s.mu.Lock() - defer s.mu.Unlock() - v, err := s.load() - if err != nil { - return 286, nil, true, err - } - identity := clearClaimID(claim) - if receipt, ok := v[identity]; ok { - return 286, append([]byte(nil), receipt.Response...), true, nil - } - if d.Type == 1 && !s.entitled(d.TicketID) { - return 286, nil, true, fmt.Errorf("commerce: clear reward premium ticket required") - } - proof := s.packCleared - if claim.Kind == 1 { - proof = s.towerCleared - } - if proof == nil || !proof(d.TargetID, d.Level) { - return 286, nil, true, fmt.Errorf("commerce: clear reward progression incomplete") - } - rewards := []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}} - var bundle []byte - if delivery, ok := s.economy.(interface { - ApplyPurchase(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) - }); ok { - // Clear reward groups carry MailId just like cash products. The - // delivery resolver selects the versioned mail template and keeps - // mailed contents out of the direct inventory response. - bundle, err = delivery.ApplyPurchase(identity, nil, rewards) - } else { - bundle, err = s.economy.Apply(identity, nil, rewards) - } - if err != nil { - return 286, nil, true, err - } - response := wire.AppendBytes(nil, 1, bundle) - v[identity] = clearClaimReceipt{Claim: claim, Response: response} - raw, err := json.Marshal(v) - if err == nil { - err = s.store.Save("commerce_clear_claims", raw) - } - return 286, response, true, err -} - -// RewardDBInfos is attached to PackInfoResponse fields 3 and 4 so reconnects -// restore claimed reward buttons from server state. -func (s *ClearPackages) RewardDBInfos() (pack, evil [][]byte, err error) { - s.mu.Lock() - defer s.mu.Unlock() - v, err := s.load() - if err != nil { - return nil, nil, err - } - keys := make([]string, 0, len(v)) - for k := range v { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - c := v[k].Claim - b := wire.AppendVarint(nil, 1, c.GroupID) - b = wire.AppendVarint(b, 2, c.TicketID) - b = wire.AppendVarint(b, 3, c.TargetID) - b = wire.AppendVarint(b, 4, c.Level) - if c.Kind == 0 { - pack = append(pack, b) - } else { - evil = append(evil, b) - } - } - return pack, evil, nil -} diff --git a/go/internal/server/commerce/clear_packages_test.go b/go/internal/server/commerce/clear_packages_test.go deleted file mode 100644 index a79eb7c..0000000 --- a/go/internal/server/commerce/clear_packages_test.go +++ /dev/null @@ -1,478 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/binary" - "errors" - "fmt" - "math" - "os" - "path/filepath" - "reflect" - "testing" - "time" -) - -type clearInventory struct{ items []player.Item } - -func (i *clearInventory) All() []player.Item { return i.items } -func clearRequest(kind, ticket uint64) []byte { - b := wire.AppendVarint(nil, 1, 1) - if kind != 0 { - b = wire.AppendVarint(b, 2, kind) - } - row := wire.AppendVarint(nil, 1, 10) - row = wire.AppendVarint(row, 2, ticket) - row = wire.AppendVarint(row, 3, 2) - b = wire.AppendBytes(b, 3+int(kind), row) - return wire.AppendBytes(b, 4-int(kind), nil) -} - -func TestClearPackageNativeProtoDefaultsAndInactiveRows(t *testing.T) { - for _, kind := range []uint64{0, 1} { - for _, placeholder := range []bool{false, true} { - b := wire.AppendVarint(nil, 1, 1) - if kind != 0 { - b = wire.AppendVarint(b, 2, kind) - } - row := wire.AppendVarint(nil, 1, 10) - row = wire.AppendVarint(row, 2, 12) - row = wire.AppendVarint(row, 3, 2) - if placeholder { - // The native tower request serializes its empty pack placeholder - // before the active tower row; pack serializes it afterwards. - if kind == 1 { - b = wire.AppendBytes(b, 3, nil) - } - } - b = wire.AppendBytes(b, int(kind)+3, row) - if placeholder && kind == 0 { - b = wire.AppendBytes(b, 4, nil) - } - got, err := parseClearClaim(b) - want := clearClaim{kind, 10, 12, 2, 0} - if err != nil || got != want { - t.Fatalf("kind %d placeholder %t: %+v %v", kind, placeholder, got, err) - } - } - } - // Explicit defaults are also valid protobuf; they must not look like a - // second active claim, nor be confused with absent active message presence. - row := wire.AppendVarint(nil, 1, 10) - row = wire.AppendVarint(row, 2, 12) - row = wire.AppendVarint(row, 3, 2) - b := wire.AppendVarint(nil, 1, 1) - b = wire.AppendVarint(b, 2, 0) - b = wire.AppendBytes(b, 3, wire.AppendVarint(row, 4, 0)) - var defaults []byte - for f := 1; f <= 4; f++ { - defaults = wire.AppendVarint(defaults, f, 0) - } - if _, err := parseClearClaim(wire.AppendBytes(b, 4, defaults)); err != nil { - t.Fatal(err) - } -} - -func TestClearPackageRejectsConflictingMalformedAndSpoofedRows(t *testing.T) { - row := wire.AppendVarint(nil, 1, 10) - row = wire.AppendVarint(row, 2, 12) - row = wire.AppendVarint(row, 3, 2) - prefix := wire.AppendVarint(nil, 1, 1) - row = row[:len(row):len(row)] //nolint:modernize // slicesclip - prefix = prefix[:len(prefix):len(prefix)] //nolint:modernize // slicesclip - active := wire.AppendBytes(append([]byte(nil), prefix...), 3, row) - active = active[:len(active):len(active)] //nolint:modernize // slicesclip - requests := map[string][]byte{ - "missing row": prefix, - "empty active": wire.AppendBytes(prefix, 3, nil), - "only inactive": wire.AppendBytes(prefix, 4, nil), - "conflicting inactive": wire.AppendBytes(active, 4, row), - "duplicate active": wire.AppendBytes(active, 3, row), - "duplicate inactive": wire.AppendBytes(wire.AppendBytes(active, 4, nil), 4, nil), - "duplicate kind": wire.AppendVarint(wire.AppendVarint(active, 2, 0), 2, 1), - "duplicate seq": wire.AppendVarint(active, 1, 1), - "kind wire type": wire.AppendBytes(active, 2, nil), - "active wire type": wire.AppendVarint(prefix, 3, 1), - "inactive wire type": wire.AppendVarint(active, 4, 0), - "row scalar wire type": wire.AppendBytes(prefix, 3, wire.AppendBytes(row, 4, nil)), - "duplicate row scalar": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 3, 2)), - "overflow seq": wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(math.MaxInt32)+1), 3, row), - "negative kind": wire.AppendVarint(active, 2, math.MaxUint64), - "negative level": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 4, math.MaxUint64)), - "truncated inactive": append(append([]byte(nil), active...), 34, 2, 8), - "unknown inactive data": wire.AppendBytes(active, 4, wire.AppendVarint(nil, 5, 1)), - "unknown static row": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 4, 1)), - } - for name, request := range requests { - t.Run(name, func(t *testing.T) { - eco := &purchaseEconomy{} - design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100}}} - s, err := NewClearPackages(stateio.NewMemory(), design, eco, &clearInventory{}) - if err != nil { - t.Fatal(err) - } - s.AttachProgress(func(uint64, uint64) bool { return true }, nil) - if _, _, _, err := s.Handle("/ClearPackageReward", request); err == nil || eco.calls != 0 { - t.Fatal("invalid request granted a reward", err, eco.calls) - } - }) - } -} - -func TestClearPackagePremiumTicketExpiryAndTowerProof(t *testing.T) { - items := &clearInventory{items: []player.Item{{Type: 19, ID: 77, Count: 1, ExpiryTime: 1000}}} - eco := &purchaseEconomy{} - design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 100, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 101}}} - s, err := NewClearPackages(stateio.NewMemory(), design, eco, items) - if err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.UnixMilli(1000) } - s.AttachProgress(func(uint64, uint64) bool { return true }, nil) - if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil || eco.calls != 0 { - t.Fatal("expired premium accepted", err) - } - if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil || eco.calls != 0 { - t.Fatal("pack proof authorized tower", err) - } - s.AttachProgress(nil, func(tower, floor uint64) bool { return tower == 2 && floor == 0 }) - if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(1, 12)); err != nil { - t.Fatal(err) - } - p, e, err := s.RewardDBInfos() - if err != nil || len(p) != 0 || len(e) != 1 { - t.Fatal("tower receipt not separated", p, e, err) - } - if got, _, _ := wire.Varint(e[0], 3); got != 2 { - t.Fatal("tower_type field wrong", got) - } -} - -type clearReceiptFailure struct { - stateio.Store - fail bool -} - -func (s *clearReceiptFailure) Save(name string, payload []byte) error { - if s.fail && name == "commerce_clear_claims" { - return fmt.Errorf("injected clear receipt failure") - } - return s.Store.Save(name, payload) -} - -func TestClearPackageMailSQLiteAtomicRetryAndReconnect(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100}}} - graph := &deliveryGraph{} - open := func(fail bool) (*accountstate.Repository, *ClearPackages, *mail.Service, *player.Wallet, *player.Inventory) { - t.Helper() - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repo, player.Currency{}) - if err != nil { - t.Fatal(err) - } - for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted} { - if err := persist(); err != nil { - t.Fatal(err) - } - } - economy, err := NewEntitlementEconomy(repo, deliveryBase{wallet, items}, graph, items, &gamedata.CashEntitlementDesign{}) - if err != nil { - t.Fatal(err) - } - mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet) - if err != nil { - t.Fatal(err) - } - if err := mailbox.AttachCashRewards(economy, map[uint64]bool{40: true}); err != nil { - t.Fatal(err) - } - if err := economy.AttachCashMail(mailbox); err != nil { - t.Fatal(err) - } - s, err := NewClearPackages(&clearReceiptFailure{Store: repo, fail: fail}, design, economy, items) - if err != nil { - t.Fatal(err) - } - s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil) - return repo, s, mailbox, wallet, items - } - repo, s, _, _, _ := open(true) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil { - t.Fatal("receipt failure lost") - } - if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, s, mailbox, wallet, items := open(false) - if ids, _ := cashList(t, mailbox, 0, 100); len(ids) != 0 { - t.Fatal("mail survived failed transaction", ids) - } - for _, name := range []string{"commerce_clear_claims", "commerce_entitlements"} { - if raw, err := repo.Load(name); err != nil || raw != nil { - t.Fatal("receipt survived rollback", name, err) - } - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, response, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 12)) - if err != nil { - _ = op.Rollback() - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { - t.Fatal("mail contents directly granted") - } - bundle, ok, err := wire.Bytes(response, 1) - if err != nil || !ok { - t.Fatal("response bundle missing", err) - } - if _, direct, _ := wire.Bytes(bundle, 1); direct { - t.Fatal("mailed items returned as inventory") - } - // CommonPacket updates the claimed row from the request and shows the - // localized "sent to mail" notice. An empty bundle is valid for mail-only - // delivery; it must not claim that those attachments entered inventory. - if len(bundle) != 0 { - t.Fatal("mail-only clear returned direct rewards", bundle) - } - ids, _ := cashList(t, mailbox, 0, 100) - if len(ids) != 1 { - t.Fatal("one clear reward should issue one mail", ids) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - _, s, mailbox, _, _ = open(false) - // A replay must restore the exact response without requiring progress again - // or issuing a second mail, including after sequence/session changes. - s.AttachProgress(nil, nil) - request, _, err := wire.ReplaceVarint(clearRequest(0, 12), 1, 9) - if err != nil { - t.Fatal(err) - } - _, replay, _, err := s.HandleSession("/ClearPackageReward", request, "reconnected") - if err != nil || !bytes.Equal(replay, response) { - t.Fatal("reconnect response differs", err) - } - if got, _ := cashList(t, mailbox, 0, 100); !reflect.DeepEqual(got, ids) { - t.Fatal("retry duplicated mail", got, ids) - } - p, e, err := s.RewardDBInfos() - if err != nil || len(p) != 1 || len(e) != 0 { - t.Fatal("claimed row not restored", p, e, err) - } - for f, want := range []uint64{10, 12, 2, 0} { - if got, _, err := wire.Varint(p[0], f+1); err != nil || got != want { - t.Fatal("claim protocol field", f+1, got, want, err) - } - } -} - -func TestInstalledClearPackageDeliveryAndNativeClaims23510(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - const version = "20260923193640" - design, err := gamedata.LoadClearPackageCatalog(root, version) - if err != nil { - t.Fatal(err) - } - resolver, err := gamedata.LoadCashRewardResolver(root, version) - if err != nil { - t.Fatal(err) - } - templates, err := gamedata.LoadCashMailTemplates(root, version) - if err != nil { - t.Fatal(err) - } - store := stateio.NewMemory() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - eco, err := NewEntitlementEconomy(store, deliveryBase{wallet, items}, resolver, items, &gamedata.CashEntitlementDesign{}) - if err != nil { - t.Fatal(err) - } - mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet) - if err != nil { - t.Fatal(err) - } - if err := mailbox.AttachCashRewards(eco, templates); err != nil { - t.Fatal(err) - } - if err := eco.AttachCashMail(mailbox); err != nil { - t.Fatal(err) - } - s, err := NewClearPackages(store, design, eco, items) - if err != nil { - t.Fatal(err) - } - // Current design has normal and premium rows for both pack and tower. - // Every row must derive its mail from GameData and retain its claim identity. - counts := [2][2]int{} - for index, row := range design.Rewards { - counts[row.Kind][row.Type]++ - plan, err := resolver.ResolveDelivery([]gamedata.BattleReward{{Type: 9, ID: row.RandomBoxID, Count: 1}}) - if err != nil || len(plan.Direct) != 0 || len(plan.Mail) != 1 || plan.Mail[0].TemplateID == 0 || len(plan.Mail[0].Rewards) == 0 { - t.Fatalf("clear row %+v delivery %+v: %v", row, plan, err) - } - if row.Type == 1 { - if _, err := items.GrantOnce(fmt.Sprintf("installed-clear-ticket:%d", index), []gamedata.BattleReward{{Type: 19, ID: row.TicketID, Count: 1}}); err != nil { - t.Fatal(err) - } - } - proof := func(target, level uint64) bool { return target == row.TargetID && level == row.Level } - if row.Kind == 0 { - s.AttachProgress(proof, nil) - } else { - s.AttachProgress(nil, proof) - } - request := wire.AppendVarint(nil, 1, 1) - if row.Kind != 0 { - request = wire.AppendVarint(request, 2, row.Kind) - } - var nested []byte - for f, value := range []uint64{row.GroupID, row.TicketID, row.TargetID, row.Level} { - if value != 0 { - nested = wire.AppendVarint(nested, f+1, value) - } - } - request = wire.AppendBytes(request, 3+int(row.Kind), nested) - request = wire.AppendBytes(request, 4-int(row.Kind), nil) - _, response, _, err := s.Handle("/ClearPackageReward", request) - if err != nil { - t.Fatalf("native clear row %+v: %v", row, err) - } - bundle, present, err := wire.Bytes(response, 1) - if err != nil || !present || len(bundle) != 0 { - t.Fatal("mail-only native response", row, response, err) - } - _, replay, _, err := s.Handle("/ClearPackageReward", request) - if err != nil || !bytes.Equal(response, replay) { - t.Fatal("native clear retry", row, err) - } - listRequest := wire.AppendVarint(nil, 1, 1) - listRequest = wire.AppendVarint(listRequest, 3, 1) - _, list, _, err := mailbox.Handle("/CashMailInfo", listRequest) - if err != nil { - t.Fatal(err) - } - if total, _, err := wire.Varint(list, 2); err != nil || total != uint64(index+1) { - t.Fatal("mail duplicated or missing", row, total, err) - } - entry, present, err := wire.Bytes(list, 1) - if err != nil || !present { - t.Fatal("newest clear mail missing", row, err) - } - if template, _, err := wire.Varint(entry, 3); err != nil || template != plan.Mail[0].TemplateID { - t.Fatal("GameData clear mail template mismatch", row, template, plan.Mail[0].TemplateID, err) - } - // MailDBInfo represents attachment vectors as packed repeated int32. - for field, expected := range map[int]func(gamedata.BattleReward) uint64{ - 8: func(r gamedata.BattleReward) uint64 { return r.Type }, - 9: func(r gamedata.BattleReward) uint64 { return r.ID }, - 10: func(r gamedata.BattleReward) uint64 { return r.Count }, - } { - packed, present, err := wire.Bytes(entry, field) - if err != nil || !present { - t.Fatal("clear mail attachments missing", row, field, err) - } - for _, reward := range plan.Mail[0].Rewards { - value, n := binary.Uvarint(packed) - if n <= 0 || value != expected(reward) { - t.Fatal("clear mail attachment mismatch", row, field, value, reward) - } - packed = packed[n:] - } - if len(packed) != 0 { - t.Fatal("extra clear mail attachment", row, field) - } - } - } - if wallet.Snapshot() != (player.Currency{}) { - t.Fatal("mail contents directly credited", wallet.Snapshot()) - } - p, e, err := s.RewardDBInfos() - if err != nil || len(p) != counts[0][0]+counts[0][1] || len(e) != counts[1][0]+counts[1][1] { - t.Fatal("native claims not restored by kind", len(p), len(e), counts, err) - } - for kind, byType := range counts { - for typ, n := range byType { - if n == 0 { - t.Fatalf("missing kind %d type %d design rows", kind, typ) - } - } - } -} -func TestClearPackageRequiresServerProgressAndPremiumEntitlement(t *testing.T) { - store := stateio.NewMemory() - eco := &purchaseEconomy{} - items := &clearInventory{} - design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100, Type: 0}, {Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 101, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 102, Type: 0}}} - s, err := NewClearPackages(store, design, eco, items) - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil || eco.calls != 0 { - t.Fatal("client spoofed clear progress") - } - s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil) - if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil { - t.Fatal("unpaid premium claim accepted") - } - code, _, ok, err := s.Handle("/ClearPackageReward", clearRequest(0, 12)) - if err != nil || !ok || code != 286 || eco.rewards[0].ID != 100 { - t.Fatal(code, err) - } - items.items = []player.Item{{Type: 19, ID: 77, Count: 1}} - if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil { - t.Fatal(err) - } - reloaded, err := NewClearPackages(store, design, eco, items) - if err != nil { - t.Fatal(err) - } - if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil || eco.calls != 2 { - t.Fatal("restart duplicated grant", err, eco.calls) - } - p, e, err := reloaded.RewardDBInfos() - if err != nil || len(p) != 2 || len(e) != 0 { - t.Fatal(p, e, err) - } - if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil { - t.Fatal("unimplemented evil progress granted reward") - } -} diff --git a/go/internal/server/commerce/delivery.go b/go/internal/server/commerce/delivery.go deleted file mode 100644 index 4168194..0000000 --- a/go/internal/server/commerce/delivery.go +++ /dev/null @@ -1,95 +0,0 @@ -package commerce - -import ( - "bytes" - "crypto/sha256" - "encoding/json" - "fmt" - "time" - - "bd2server/internal/server/gamedata" -) - -type CashMailIssuer interface { - IssueCashOnce(string, uint64, []gamedata.Reward, time.Time) error -} - -func (e *EntitlementEconomy) AttachCashMail(issuer CashMailIssuer) error { - if issuer == nil { - return fmt.Errorf("commerce: cash mail issuer unavailable") - } - if _, ok := e.graph.(interface { - ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error) - }); !ok { - return fmt.Errorf("commerce: cash delivery resolver unavailable") - } - e.mail = issuer - return nil -} - -// ApplyPurchase selects rewards once, activates direct entitlements, and issues -// durable cash mail. All writes run in the enclosing account transaction. -func (e *EntitlementEconomy) ApplyPurchase(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - e.mu.Lock() - defer e.mu.Unlock() - if e.mail == nil { - return nil, fmt.Errorf("commerce: cash mail issuer unavailable") - } - definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) - digest := sha256.Sum256(definition) - key := identity + ":delivery" - state, err := e.load() - if err != nil { - return nil, err - } - if receipt, ok := state.Receipts[key]; ok { - if !bytes.Equal(receipt.Definition, digest[:]) { - return nil, fmt.Errorf("commerce: delivery identity reused") - } - return append([]byte(nil), receipt.Bundle...), nil - } - resolver, ok := e.graph.(interface { - ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error) - }) - if !ok { - return nil, fmt.Errorf("commerce: missing cash delivery resolver") - } - input := make([]gamedata.BattleReward, len(rewards)) - for i, r := range rewards { - input[i] = gamedata.BattleReward(r) - } - plan, err := resolver.ResolveDelivery(input) - if err != nil { - return nil, err - } - direct := make([]gamedata.Reward, len(plan.Direct)) - for i, r := range plan.Direct { - direct[i] = gamedata.Reward(r) - } - var mailed []gamedata.BattleReward - for _, mail := range plan.Mail { - mailed = append(mailed, mail.Rewards...) - } - bundle, err := e.applyPrepared(identity+":direct", costs, direct, true, mailed) - if err != nil { - return nil, err - } - for i, mail := range plan.Mail { - attachments := make([]gamedata.Reward, len(mail.Rewards)) - for j, r := range mail.Rewards { - attachments[j] = gamedata.Reward(r) - } - if err := e.mail.IssueCashOnce(fmt.Sprintf("%s:%d", identity, i), mail.TemplateID, attachments, e.now()); err != nil { - return nil, err - } - } - state, err = e.load() - if err != nil { - return nil, err - } - state.Receipts[key] = entitlementReceipt{Definition: digest[:], Bundle: append([]byte(nil), bundle...)} - if err = e.save(state); err != nil { - return nil, err - } - return bundle, nil -} diff --git a/go/internal/server/commerce/delivery_test.go b/go/internal/server/commerce/delivery_test.go deleted file mode 100644 index cef016d..0000000 --- a/go/internal/server/commerce/delivery_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package commerce - -import ( - "bytes" - "errors" - "fmt" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type deliveryGraph struct{ selections int } - -func (g *deliveryGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - return r, nil -} -func (g *deliveryGraph) ResolveDelivery(r []gamedata.BattleReward) (gamedata.CashDelivery, error) { - g.selections++ - return gamedata.CashDelivery{Mail: []gamedata.CashMailReward{{TemplateID: 40, Rewards: []gamedata.BattleReward{{Type: 4, Count: 40}, {Type: 9, ID: 200, Count: 1}}}}}, nil -} - -type deliveryBase struct { - wallet *player.Wallet - items *player.Inventory -} - -func (e deliveryBase) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - var currencies []gamedata.Reward - for _, r := range rewards { - if r.Type != 9 { - currencies = append(currencies, r) - } - } - if err := e.wallet.ExchangeOnce(identity, costs, currencies); err != nil { - return nil, err - } - var items []gamedata.BattleReward - var bundle []byte - for _, r := range rewards { - if r.Type == 9 { - items = append(items, gamedata.BattleReward(r)) - } else { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count})) - } - } - granted, err := e.items.GrantOnce(identity, items) - if err != nil { - return nil, err - } - if len(granted) == 0 { - granted = e.items.GrantedItems(identity) - } - for _, item := range granted { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - } - return bundle, nil -} -func cashList(t *testing.T, m *mail.Service, start, count uint64) ([]uint64, []byte) { - t.Helper() - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, start) - request = wire.AppendVarint(request, 3, count) - code, response, handled, err := m.Handle("/CashMailInfo", request) - if err != nil || !handled || code != 140 { - t.Fatalf("cash list: %d %t %v", code, handled, err) - } - var ids []uint64 - _ = wire.Walk(response, func(f wire.Field) error { - if f.Number == 1 && f.Type == 2 { - id, _, _ := wire.Varint(f.Value, 1) - template, _, _ := wire.Varint(f.Value, 3) - typ, _, _ := wire.Varint(f.Value, 2) - cash, _, _ := wire.Varint(f.Value, 15) - if id == 0 || template != 40 || typ != 0 || cash != 1 { - t.Fatalf("invalid cash mail %x", f.Value) - } - ids = append(ids, id) - } - return nil - }) - return ids, response -} -func TestCashPurchaseMailSQLiteRollbackRestartBatchAndReplay(t *testing.T) { - fixture, _, _ := serviceFixture(t, 0) - nativeKey := gamedata.CashProductKey{GroupID: 44, ProductID: 1} - fixture.catalog.designs[nativeKey] = gamedata.CashProductDesign{Key: nativeKey, PriceType: 2, PriceCount: 1000, RandomBoxID: 20} - path := filepath.Join(t.TempDir(), "state.db") - graph := &deliveryGraph{} - open := func() (*accountstate.Repository, *Service, *mail.Service, *player.Wallet, *player.Inventory) { - t.Helper() - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 4000}) - if err != nil { - t.Fatal(err) - } - items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err = items.EnsurePersisted(); err != nil { - t.Fatal(err) - } - economy, err := NewEntitlementEconomy(repo, deliveryBase{wallet, items}, graph, items, &gamedata.CashEntitlementDesign{}) - if err != nil { - t.Fatal(err) - } - mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet) - if err != nil { - t.Fatal(err) - } - if err = mailbox.AttachCashRewards(economy, map[uint64]bool{40: true}); err != nil { - t.Fatal(err) - } - if err = economy.AttachCashMail(mailbox); err != nil { - t.Fatal(err) - } - shop, err := NewService(fixture.catalog, repo, economy) - if err != nil { - t.Fatal(err) - } - return repo, shop, mailbox, wallet, items - } - rollback := func(op stateio.RequestOperation) { - t.Helper() - if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - } - var mailbox *mail.Service - var wallet *player.Wallet - var items *player.Inventory - repo, shop, _, _, _ := open() - shop.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { - return fmt.Errorf("injected failure after issuing mail") - }) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil { - t.Fatal("failure ignored") - } - rollback(op) - _ = repo.Close() - repo, shop, mailbox, wallet, items = open() - if wallet.Snapshot().Jewelry != 4000 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { - t.Fatal("rollback retained debit/grant") - } - if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 0 { - t.Fatal("rollback retained cash mail") - } - for seq := uint64(1); seq <= 2; seq++ { - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if err = mailbox.BeforeDispatch("/CashShopBuy", nil); err != nil { - t.Fatal(err) - } - request := buyRequest(seq, 2, 1, 0, "") - if seq == 2 { - request = nativeBuyRequest(seq, 44, [3]uint64{1, 0, 1}) - } - _, response, _, err := shop.HandleSession("/CashShopBuy", request, "s") - if err != nil { - t.Fatal(err) - } - bundle, present, _ := wire.Bytes(response, 1) - if !present || len(bundle) != 0 { - t.Fatalf("mail-only purchase must not return direct rewards: %x", response) - } - notify, err := mailbox.AfterDispatch("/CashShopBuy", nil, nil) - flag, _, _ := wire.Varint(notify, 1) - if err != nil || flag != 1 { - t.Fatal("new mail notification missing") - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - } - if wallet.Snapshot().Jewelry != 2000 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { - t.Fatal("unclaimed attachments granted early") - } - ids, list := cashList(t, mailbox, 0, 1) - total, _, _ := wire.Varint(list, 2) - if len(ids) != 1 || ids[0] != 2 || total != 2 { - t.Fatal("cash first page", ids, total) - } - next, _ := cashList(t, mailbox, ids[0], 1) - if len(next) != 1 || next[0] != 1 { - t.Fatal("cash cursor skipped/duplicated", next) - } - _, ordinary, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - if _, present, _ := wire.Bytes(ordinary, 1); present { - t.Fatal("cash mail leaked into ordinary list") - } - beforeSelections := graph.selections - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if graph.selections != beforeSelections || wallet.Snapshot().Jewelry != 2000 { - t.Fatal("purchase replay selected or charged again") - } - _ = repo.Close() - repo, _, mailbox, _, _ = open() - claim := wire.AppendVarint(nil, 1, 3) - claim = wire.AppendVarint(claim, 2, 1) - claim = wire.AppendVarint(claim, 2, 2) - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = mailbox.Handle("/MailOpen", claim); err != nil { - t.Fatal(err) - } - rollback(op) - _ = repo.Close() - repo, _, mailbox, wallet, items = open() - if wallet.Snapshot().Gold != 0 || len(items.All()) != 0 { - t.Fatal("claim rollback retained rewards") - } - if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 2 { - t.Fatal("claim rollback hid mails") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, claimed, _, err := mailbox.Handle("/MailOpen", claim) - if err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - countBoxes := func(items *player.Inventory) uint64 { - var n uint64 - for _, r := range items.All() { - if r.Type == 9 && r.ID == 200 { - n += r.Count - } - } - return n - } - if wallet.Snapshot().Gold != 80 || countBoxes(items) != 2 { - t.Fatal("batch claim failed", wallet.Snapshot(), items.All()) - } - _ = repo.Close() - repo, _, mailbox, wallet, items = open() - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, replayed, _, err := mailbox.Handle("/MailOpen", claim) - if err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if !bytes.Equal(claimed, replayed) || wallet.Snapshot().Gold != 80 || countBoxes(items) != 2 { - t.Fatal("restart replay duplicated claim") - } - if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 0 { - t.Fatal("claimed mails still visible") - } - historyReq := wire.AppendVarint(nil, 1, 4) - historyReq = wire.AppendVarint(historyReq, 3, 20) - _, history, _, err := mailbox.Handle("/MailHistoryInfo", historyReq) - n, _, _ := wire.Varint(history, 2) - if err != nil || n != 2 { - t.Fatal("claim history missing", n, err) - } -} -func TestDirectSubscriptionDoesNotDuplicateFirstDayAlreadyInMail(t *testing.T) { - e, _, base, _, _ := entitlementFixture(t) - e.mu.Lock() - _, err := e.applyPrepared("direct", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}, true, []gamedata.BattleReward{{Type: 3, Count: 1}}) - e.mu.Unlock() - if err != nil || len(base.Rewards) != 0 { - t.Fatal("mailed first day was granted directly", base.Rewards, err) - } - if _, err = e.ApplyResolved("mail", nil, []gamedata.Reward{{Type: 3, Count: 1}}); err != nil { - t.Fatal(err) - } - if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 { - t.Fatal("claim did not grant exactly one first day", base.Rewards) - } - if _, err = e.ClaimSubscriptions("same day"); err != nil || len(base.Rewards) != 1 { - t.Fatal("same-day login duplicated first day", err) - } -} diff --git a/go/internal/server/commerce/entitlements.go b/go/internal/server/commerce/entitlements.go deleted file mode 100644 index ed2074c..0000000 --- a/go/internal/server/commerce/entitlements.go +++ /dev/null @@ -1,331 +0,0 @@ -package commerce - -import ( - "bytes" - "crypto/sha256" - "encoding/json" - "fmt" - "math" - "sort" - "strconv" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type grantedResolver interface { - ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error) -} -type entitlementReceipt struct { - Definition []byte `json:"definition"` - Bundle []byte `json:"bundle"` -} -type cashSubscription struct { - Start, Expiry int64 - Claimed uint64 - LastDay string -} -type entitlementState struct { - Receipts map[string]entitlementReceipt `json:"receipts"` - Subscriptions map[string]cashSubscription `json:"subscriptions"` -} -type EntitlementEconomy struct { - mu sync.Mutex - store stateio.Store - base Economy - graph grantedResolver - items *player.Inventory - design *gamedata.CashEntitlementDesign - now func() time.Time - resetSeconds int64 - mail CashMailIssuer -} - -func NewEntitlementEconomy(store stateio.Store, base Economy, graph grantedResolver, items *player.Inventory, design *gamedata.CashEntitlementDesign) (*EntitlementEconomy, error) { - if store == nil || base == nil || graph == nil || items == nil || design == nil { - return nil, fmt.Errorf("commerce: missing entitlement dependency") - } - e := &EntitlementEconomy{store: store, base: base, graph: graph, items: items, design: design, now: time.Now} - _, err := e.load() - return e, err -} -func (e *EntitlementEconomy) SetClock(now func() time.Time, resetSeconds int64) { - e.now = now - e.resetSeconds = resetSeconds -} -func (e *EntitlementEconomy) day() string { - return e.now().UTC().Add(-time.Duration(e.resetSeconds) * time.Second).Format("2006-01-02") -} -func (e *EntitlementEconomy) load() (entitlementState, error) { - s := entitlementState{Receipts: map[string]entitlementReceipt{}, Subscriptions: map[string]cashSubscription{}} - raw, err := e.store.Load("commerce_entitlements") - if err != nil || raw == nil { - return s, err - } - if err = stateio.RequireExactJSONObject(raw, "receipts", "subscriptions"); err != nil { - return s, err - } - if err = json.Unmarshal(raw, &s); err != nil { - return s, err - } - if s.Receipts == nil || s.Subscriptions == nil { - return s, fmt.Errorf("commerce: malformed entitlement state") - } - for id, r := range s.Receipts { - if id == "" || len(r.Definition) != sha256.Size { - return s, fmt.Errorf("commerce: malformed entitlement receipt") - } - } - for id, sub := range s.Subscriptions { - ticket, err := strconv.ParseUint(id, 10, 64) - if err != nil || len(e.design.Attendance[ticket]) == 0 || sub.Start <= 0 || sub.Expiry != 0 && sub.Expiry < sub.Start || sub.Claimed == 0 || sub.LastDay == "" { - return s, fmt.Errorf("commerce: malformed subscription") - } - } - return s, nil -} -func (e *EntitlementEconomy) save(s entitlementState) error { - b, err := json.Marshal(s) - if err != nil { - return err - } - return e.store.Save("commerce_entitlements", b) -} -func (e *EntitlementEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - e.mu.Lock() - defer e.mu.Unlock() - return e.apply(identity, costs, rewards) -} -func (e *EntitlementEconomy) ApplyResolved(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - e.mu.Lock() - defer e.mu.Unlock() - return e.applyPrepared(identity, costs, rewards, true, nil) -} -func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - return e.applyPrepared(identity, costs, rewards, false, nil) -} -func (e *EntitlementEconomy) applyPrepared(identity string, costs, rewards []gamedata.Reward, resolved bool, mailed []gamedata.BattleReward) ([]byte, error) { - if identity == "" { - return nil, fmt.Errorf("commerce: missing entitlement identity") - } - definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) - digest := sha256.Sum256(definition) - s, err := e.load() - if err != nil { - return nil, err - } - if r, ok := s.Receipts[identity]; ok { - if !bytes.Equal(r.Definition, digest[:]) { - return nil, fmt.Errorf("commerce: entitlement identity reused") - } - return append([]byte(nil), r.Bundle...), nil - } - input := make([]gamedata.BattleReward, len(rewards)) - for i, r := range rewards { - input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 - } - leaves := input - if !resolved { - leaves, err = e.graph.ResolveGranted(input) - } - if err != nil { - return nil, err - } - // Some product boxes include the first attendance reward (draw-ticket - // subscriptions); others include only the upfront paid currency. Add only - // the missing first-day components on activation, then mark row one claimed. - initial := append([]gamedata.BattleReward(nil), leaves...) - for _, r := range initial { - rows := e.design.Attendance[r.ID] - if r.Type != 19 || len(rows) == 0 { - continue - } - sub := s.Subscriptions[strconv.FormatUint(r.ID, 10)] - if sub.Start != 0 && (sub.Expiry == 0 || sub.Expiry > e.now().UnixMilli()) { - continue - } - first, resolveErr := e.graph.ResolveGranted([]gamedata.BattleReward{rows[0].Reward}) - if resolveErr != nil { - return nil, resolveErr - } - available := map[[2]uint64]uint64{} - for _, leaf := range append(append([]gamedata.BattleReward(nil), leaves...), mailed...) { - k := [2]uint64{leaf.Type, leaf.ID} - available[k] += leaf.Count - } - for _, leaf := range first { - k := [2]uint64{leaf.Type, leaf.ID} - if available[k] < leaf.Count { - leaf.Count -= available[k] - leaves = append(leaves, leaf) - } - } - } - var regular []gamedata.Reward - var special []player.Item - for _, r := range leaves { - if r.Count == 0 || r.Count > math.MaxInt32 { - return nil, fmt.Errorf("commerce: invalid entitlement quantity") - } - switch { - case r.Type == 62: - if !e.design.AvatarSets[r.ID] { - return nil, fmt.Errorf("commerce: unknown avatar set %d", r.ID) - } - // The shared gameplay economy expands AvatarSetTable members and - // emits real AvatarItem/AvatarMotion/AvatarChar ownership. - regular = append(regular, gamedata.Reward(r)) - case r.Type == 19 && e.design.TicketTypes[r.ID] == 2: - if len(e.design.Attendance[r.ID]) == 0 { - return nil, fmt.Errorf("commerce: subscription reward schedule missing") - } - now := e.now().UnixMilli() - expiry := max(int64(e.items.ContentTicketExpiry(r.ID)), now) - if r.Count > uint64((math.MaxInt64-expiry)/(30*86400000)) { - return nil, fmt.Errorf("commerce: subscription expiry overflow") - } - expiry += int64(r.Count) * 30 * 86400000 - special = append(special, player.Item{Type: 19, ID: r.ID, Count: r.Count, ExpiryTime: uint64(expiry), TimeValue: uint64(e.now().UnixMilli())}) - key := strconv.FormatUint(r.ID, 10) - sub := s.Subscriptions[key] - if sub.Start == 0 || sub.Expiry <= now { - sub = cashSubscription{Start: now, Claimed: 1, LastDay: e.day()} - } - sub.Expiry = expiry - s.Subscriptions[key] = sub - default: - regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - if r.Type == 19 && e.design.TicketTypes[r.ID] == 3 && len(e.design.Attendance[r.ID]) > 0 { - key := strconv.FormatUint(r.ID, 10) - if _, exists := s.Subscriptions[key]; !exists { - s.Subscriptions[key] = cashSubscription{Start: e.now().UnixMilli(), Claimed: 1, LastDay: e.day()} - } - } - } - } - bundle, err := e.base.Apply(identity+":base", costs, regular) - if err != nil { - return nil, err - } - items, err := e.items.GrantCommerceOnce(identity+":special", special) - if err != nil { - return nil, err - } - for _, i := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(i)) - bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{ID: i.ID, Type: i.Type, Count: i.Count})) - } - s.Receipts[identity] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)} - if err = e.save(s); err != nil { - return nil, err - } - return bundle, nil -} - -// ClaimSubscriptions grants the next row once per server reset day. Missing -// login days are not retroactively claimed. First-row purchase rewards are -// already present in the product box, matching the client's first-row marker. -func (e *EntitlementEconomy) ClaimSubscriptions(identity string) ([]byte, error) { - e.mu.Lock() - defer e.mu.Unlock() - if identity == "" { - return nil, fmt.Errorf("commerce: missing attendance receipt identity") - } - s, err := e.load() - if err != nil { - return nil, err - } - receiptKey := "attendance-reply:" + identity - if receipt, ok := s.Receipts[receiptKey]; ok { - return append([]byte(nil), receipt.Bundle...), nil - } - keys := make([]string, 0, len(s.Subscriptions)) - for k := range s.Subscriptions { - keys = append(keys, k) - } - sort.Strings(keys) - var bundle []byte - for _, key := range keys { - sub := s.Subscriptions[key] - ticket, _ := strconv.ParseUint(key, 10, 64) - rows := e.design.Attendance[ticket] - if sub.LastDay == e.day() || sub.Expiry != 0 && sub.Expiry <= e.now().UnixMilli() || sub.Expiry == 0 && sub.Claimed >= uint64(len(rows)) { - continue - } - index := sub.Claimed % uint64(len(rows)) - r := rows[index] - grant, err := e.apply(fmt.Sprintf("commerce:attendance:%s:%s", key, e.day()), nil, []gamedata.Reward{{Type: r.Reward.Type, ID: r.Reward.ID, Count: r.Reward.Count}}) - if err != nil { - return nil, err - } - bundle = append(bundle, grant...) - // apply persists its receipt; reload before saving progress so it survives. - latest, err := e.load() - if err != nil { - return nil, err - } - sub.Claimed++ - sub.LastDay = e.day() - latest.Subscriptions[key] = sub - if err = e.save(latest); err != nil { - return nil, err - } - s = latest - } - latest, err := e.load() - if err != nil { - return nil, err - } - digest := sha256.Sum256([]byte(receiptKey)) - latest.Receipts[receiptKey] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)} - if err = e.save(latest); err != nil { - return nil, err - } - return bundle, nil -} -func (e *EntitlementEconomy) MergeAttendance(response []byte) ([]byte, error) { - e.mu.Lock() - defer e.mu.Unlock() - s, err := e.load() - if err != nil { - return nil, err - } - out := append([]byte(nil), response...) - keys := make([]string, 0, len(s.Subscriptions)) - for k := range s.Subscriptions { - keys = append(keys, k) - } - sort.Strings(keys) - for _, key := range keys { - sub := s.Subscriptions[key] - ticket, _ := strconv.ParseUint(key, 10, 64) - if sub.Expiry != 0 { - b := wire.AppendVarint(nil, 1, ticket) - b = wire.AppendVarint(b, 2, uint64(sub.Start)) - b = wire.AppendVarint(b, 3, uint64(sub.Expiry)) - out = wire.AppendBytes(out, 3, b) - } - if typ := e.design.AttendanceTypes[ticket]; typ != 0 { - rewards := []byte{} - n := min(sub.Claimed, uint64(len(e.design.Attendance[ticket]))) - for i := uint64(1); i <= n; i++ { - rewards = wire.AppendVarint(rewards, 1, i) - } - entry := wire.AppendVarint(nil, 1, typ) - entry = wire.AppendBytes(entry, 2, rewards) - out = wire.AppendBytes(out, 7, entry) - } else { - for i := uint64(1); i <= sub.Claimed; i++ { - b := wire.AppendVarint(nil, 1, ticket) - b = wire.AppendVarint(b, 2, i) - b = wire.AppendVarint(b, 3, 1) - out = wire.AppendBytes(out, 4, b) - } - } - } - return out, nil -} diff --git a/go/internal/server/commerce/entitlements_test.go b/go/internal/server/commerce/entitlements_test.go deleted file mode 100644 index 7b5a23e..0000000 --- a/go/internal/server/commerce/entitlements_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "testing" - "time" -) - -type entitlementGraph struct{ Calls int } - -func (g *entitlementGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - g.Calls++ - return r, nil -} - -type entitlementBase struct { - Calls int - Rewards []gamedata.Reward -} - -func (b *entitlementBase) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { - b.Calls++ - b.Rewards = append(b.Rewards, r...) - return wire.AppendVarint(nil, 7, 1), nil -} -func entitlementFixture(t *testing.T) (*EntitlementEconomy, *player.Inventory, *entitlementBase, *entitlementGraph, *time.Time) { - t.Helper() - store := stateio.NewMemory() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - d := &gamedata.CashEntitlementDesign{AvatarSets: map[uint64]bool{10: true}, TicketTypes: map[uint64]uint64{38: 2, 99: 3}, Attendance: map[uint64][]gamedata.CashAttendanceReward{38: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 5}}}, 99: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 7}}}}, AttendanceTypes: map[uint64]uint64{38: 1}} - base := &entitlementBase{} - graph := &entitlementGraph{} - e, err := NewEntitlementEconomy(store, base, graph, items, d) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - e.SetClock(func() time.Time { return now }, 0) - return e, items, base, graph, &now -} -func TestEntitlementTypedSkinExpiryRetryAndRenewal(t *testing.T) { - e, items, base, graph, now := entitlementFixture(t) - rewards := []gamedata.Reward{{Type: 62, ID: 10, Count: 1}, {Type: 19, ID: 38, Count: 1}, {Type: 45, ID: 999, Count: 1}} - first, err := e.Apply("buy1", nil, rewards) - if err != nil { - t.Fatal(err) - } - if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+30*86400000) || len(base.Rewards) != 3 || base.Rewards[0].Type != 62 || base.Rewards[1].Type != 45 { - t.Fatal("expiry or prestige dispatch invalid") - } - for _, item := range items.All() { - if item.Type == 62 { - t.Fatal("commerce stored synthetic avatar set") - } - } - again, err := e.Apply("buy1", nil, rewards) - if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 { - t.Fatal("retry rerolled or double granted") - } - if _, err = e.Apply("buy1", nil, nil); err == nil { - t.Fatal("identity reuse accepted") - } - if _, err = e.Apply("invalid", nil, []gamedata.Reward{{Type: 62, ID: 888, Count: 1}}); err == nil || base.Calls != 1 { - t.Fatal("unknown skin partially applied") - } - if _, err = e.Apply("buy2", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil { - t.Fatal(err) - } - if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+60*86400000) { - t.Fatal("renewal lost prior period") - } - merged, err := e.MergeAttendance(nil) - if err != nil { - t.Fatal(err) - } - if _, ok, _ := wire.Bytes(merged, 3); !ok { - t.Fatal("subscription info missing") - } - if _, ok, _ := wire.Bytes(merged, 7); !ok { - t.Fatal("monthly reward map missing") - } -} -func TestSubscriptionDailyClaimResetExpiryAndPermanentCompletion(t *testing.T) { - e, _, base, _, now := entitlementFixture(t) - _, err := e.Apply("buy", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 19, ID: 99, Count: 1}}) - if err != nil { - t.Fatal(err) - } - calls := base.Calls - if _, err = e.ClaimSubscriptions("login"); err != nil || base.Calls != calls { - t.Fatal("purchase day double claim") - } - *now = now.Add(24 * time.Hour) - if _, err = e.ClaimSubscriptions("login2"); err != nil || base.Calls != calls+2 { - t.Fatalf("daily claims %v calls%d", err, base.Calls) - } - calls = base.Calls - if _, err = e.ClaimSubscriptions("retry"); err != nil || base.Calls != calls { - t.Fatal("daily retry grants again") - } - *now = now.Add(31 * 24 * time.Hour) - if _, err = e.ClaimSubscriptions("expired"); err != nil || base.Calls != calls { - t.Fatal("expired or completed claim") - } - merged, err := e.MergeAttendance(nil) - if err != nil { - t.Fatal(err) - } - if _, ok, _ := wire.Bytes(merged, 4); !ok { - t.Fatal("permanent attendance markers missing") - } -} -func TestSubscriptionFirstRewardAndLostReply(t *testing.T) { - e, _, base, _, now := entitlementFixture(t) - _, err := e.Apply("covered", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 3, Count: 1}}) - if err != nil { - t.Fatal(err) - } - if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 { - t.Fatalf("first-day reward duplicated: %+v", base.Rewards) - } - *now = now.Add(24 * time.Hour) - first, err := e.ClaimSubscriptions("same-session-request") - if err != nil || len(first) == 0 { - t.Fatalf("claim %x %v", first, err) - } - calls := base.Calls - replay, err := e.ClaimSubscriptions("same-session-request") - if err != nil || !bytes.Equal(first, replay) || base.Calls != calls { - t.Fatal("lost reply not replayed exactly") - } - fresh, err := e.ClaimSubscriptions("new-request-same-day") - if err != nil || len(fresh) != 0 || base.Calls != calls { - t.Fatal("new same-day request repeated award") - } -} diff --git a/go/internal/server/commerce/event_shops.go b/go/internal/server/commerce/event_shops.go deleted file mode 100644 index 64c7105..0000000 --- a/go/internal/server/commerce/event_shops.go +++ /dev/null @@ -1,74 +0,0 @@ -package commerce - -import ( - "fmt" - "sort" - - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -// AttachEventShopSchedules joins event type 15 to EventShopTable and then to -// CashProductTable. Hub IDs are presentation identities, never product groups. -// All scheduled goods are published; purchase handlers enforce their windows. -// Call once before serving sessions. -func (s *Service) AttachEventShopSchedules(design *gamedata.CashCatalog, schedules []events.Schedule) error { - if design == nil || s.shopWindows == nil { - return fmt.Errorf("commerce: missing event shop catalog or shop seed") - } - shops := map[uint64]uint64{} - groups := map[uint64]bool{} - for _, shop := range design.EventShops { - if shop.ID == 0 || shop.ProductGroupID == 0 || shops[shop.ID] != 0 || groups[shop.ProductGroupID] { - return fmt.Errorf("commerce: invalid or duplicate event shop identity") - } - shops[shop.ID], groups[shop.ProductGroupID] = shop.ProductGroupID, true - } - windows := map[gamedata.CashProductKey][][2]uint64{} - var products [][]byte - for _, raw := range s.shopProducts { - group, _, _ := wire.Varint(raw, 1) - if !groups[group] { - products = append(products, raw) - } - } - rows := append([]events.Schedule(nil), schedules...) - sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID }) - for _, row := range rows { - if row.Type != 15 { - continue - } - group := shops[row.ID] - if group == 0 || row.UID == 0 || row.Start <= 0 || row.Start >= row.End { - return fmt.Errorf("commerce: invalid event shop schedule %d", row.UID) - } - found := false - start, end := uint64(row.Start), uint64(row.End) - for _, product := range s.catalog.Designs() { - key := product.Key - if key.GroupID != group { - continue - } - found = true - for _, prior := range windows[key] { - if start < prior[1] && prior[0] < end { - return fmt.Errorf("commerce: overlapping event shop product windows") - } - } - windows[key] = append(windows[key], [2]uint64{start, end}) - raw := wire.AppendVarint(nil, 1, key.GroupID) - raw = wire.AppendVarint(raw, 2, key.ProductID) - raw = wire.AppendVarint(raw, 3, key.SaleGroup) - raw = wire.AppendVarint(raw, 4, start) - raw = wire.AppendVarint(raw, 5, end) - raw = wire.AppendVarint(raw, 8, row.UID) - products = append(products, raw) - } - if !found { - return fmt.Errorf("commerce: event shop %d has no products", row.ID) - } - } - s.shopProducts, s.eventShopWindows, s.eventShopGroups = products, windows, groups - return nil -} diff --git a/go/internal/server/commerce/event_shops_test.go b/go/internal/server/commerce/event_shops_test.go deleted file mode 100644 index 65d47be..0000000 --- a/go/internal/server/commerce/event_shops_test.go +++ /dev/null @@ -1,166 +0,0 @@ -package commerce - -import ( - "bytes" - "errors" - "fmt" - "path/filepath" - "testing" - "time" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestEventShopPublishesAllSchedulesAndNativePurchaseReplay(t *testing.T) { - s, eco, store := serviceFixture(t, 0) - key := gamedata.CashProductKey{GroupID: 700, ProductID: 3} - d := gamedata.CashProductDesign{Key: key, PriceType: 8, PriceID: 99, PriceCount: 5, RandomBoxID: 20, PurchaseLimitType: 4, PurchaseLimitCount: 2, BulkOrderAvailability: 1} - s.catalog.designs[key] = d - s.shopWindows = map[gamedata.CashProductKey][2]uint64{} - design := &gamedata.CashCatalog{EventShops: []gamedata.EventShopDesign{{ID: 7, ProductGroupID: 700}}} - rows := []events.Schedule{{UID: 9, Type: 15, ID: 7, Start: 100, End: 200}} - if err := s.AttachEventShopSchedules(design, rows); err != nil { - t.Fatal(err) - } - for _, now := range []int64{50, 150, 250} { - s.SetClock(func() time.Time { return time.UnixMilli(now) }, 0) - _, out, _, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s") - raw, found, _ := wire.Bytes(out, 1) - group, _, _ := wire.Varint(raw, 1) - uid, _, _ := wire.Varint(raw, 8) - if err != nil || !found || group != 700 || uid != 9 { - t.Fatalf("calendar filtered at %d: %v", now, err) - } - if s.IsAvailable(key) != (now == 150) { - t.Fatal("window authorization differs") - } - } - req := wire.AppendVarint(nil, 1, 1) - req = wire.AppendVarint(req, 3, 700) - line := wire.AppendVarint(nil, 1, 3) - line = wire.AppendVarint(line, 3, 2) - req = wire.AppendBytes(req, 4, line) - if _, _, handled, err := s.HandleSession("/CashShopBuy", req, "s"); !handled || err == nil || eco.calls != 0 { - t.Fatal("expired purchase authorized") - } - s.SetClock(func() time.Time { return time.UnixMilli(150) }, 0) - _, out, handled, err := s.HandleSession("/CashShopBuy", req, "s") - if err != nil || !handled || eco.calls != 1 { - t.Fatal(err, handled, eco.calls) - } - if len(eco.costs) != 1 || eco.costs[0] != (gamedata.Reward{Type: 8, ID: 99, Count: 10}) || eco.rewards[0].Count != 2 { - t.Fatal(eco) - } - // Reload service state, then replay the same request without another grant. - s2, err := NewService(s.catalog, store, eco) - if err != nil { - t.Fatal(err) - } - s2.shopWindows = map[gamedata.CashProductKey][2]uint64{} - if err = s2.AttachEventShopSchedules(design, rows); err != nil { - t.Fatal(err) - } - s2.SetClock(func() time.Time { return time.UnixMilli(250) }, 0) - _, replay, _, err := s2.HandleSession("/CashShopBuy", req, "s") - if err != nil || !bytes.Equal(out, replay) || eco.calls != 1 { - t.Fatal("replay failed", err) - } - req[1] = 2 - if _, _, _, err = s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 1 { - t.Fatal("purchase limit ignored") - } -} - -func TestNativeEventShopSQLiteRollsBackDebitGrantAndPurchaseCount(t *testing.T) { - fixture, _, _ := serviceFixture(t, 0) - key := gamedata.CashProductKey{GroupID: 700, ProductID: 3} - fixture.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 3, PriceCount: 5, RandomBoxID: 20, PurchaseLimitType: 4, PurchaseLimitCount: 1} - design := &gamedata.CashCatalog{EventShops: []gamedata.EventShopDesign{{ID: 7, ProductGroupID: 700}}} - rows := []events.Schedule{{UID: 9, Type: 15, ID: 7, Start: 100, End: 200}} - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repo, player.Currency{FreeJewelry: 10}) - if err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - open := func() *Service { - t.Helper() - s, err := NewService(fixture.catalog, repo, walletPurchaseEconomy{wallet, repo}) - if err != nil { - t.Fatal(err) - } - s.shopWindows = map[gamedata.CashProductKey][2]uint64{} - if err = s.AttachEventShopSchedules(design, rows); err != nil { - t.Fatal(err) - } - s.SetClock(func() time.Time { return time.UnixMilli(150) }, 0) - return s - } - s := open() - s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("grant hook failed") }) - req := wire.AppendVarint(nil, 1, 1) - req = wire.AppendVarint(req, 3, 700) - line := wire.AppendVarint(nil, 1, 3) - line = wire.AppendVarint(line, 3, 1) - req = wire.AppendBytes(req, 4, line) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, handled, err := s.HandleSession("/CashShopBuy", req, "s"); !handled || err == nil { - t.Fatal("hook failure swallowed") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - wallet, err = player.OpenWallet(repo, player.Currency{}) - if err != nil { - t.Fatal(err) - } - if wallet.Snapshot().FreeJewelry != 10 { - t.Fatal("debit survived rollback") - } - for _, name := range []string{"commerce", "commerce_test_rewards"} { - raw, err := repo.Load(name) - if err != nil || raw != nil { - t.Fatal("grant or receipt survived rollback", name, err) - } - } - s = open() - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.HandleSession("/CashShopBuy", req, "s"); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().FreeJewelry != 5 || !s.HasPurchased(key) { - t.Fatal("retry did not persist") - } -} diff --git a/go/internal/server/commerce/installed_audit_test.go b/go/internal/server/commerce/installed_audit_test.go deleted file mode 100644 index 6cb2b45..0000000 --- a/go/internal/server/commerce/installed_audit_test.go +++ /dev/null @@ -1,484 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/calendar" - "bd2server/internal/server/events" - "bd2server/internal/server/gameconfig" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/hunting" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/readonly" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" -) - -func TestInstalledCashProductRewardCoverage23510(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - catalog, err := gamedata.LoadCashCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - resolver, err := gamedata.LoadCashRewardResolver(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - // Infinite draw products grant the user's confirmed preview, rather than - // the placeholder cash box. Validate their dedicated design and route. - db, release, err := gamedata.OpenDatabase(root, "20260923193640", "common") - if err != nil { - t.Fatal(err) - } - rowsDB, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable") - if err != nil { - release() - t.Fatal(err) - } - type drawGroup struct{ id, gacha uint64 } - var groups []drawGroup - for rowsDB.Next() { - var id uint64 - var raw []byte - if err := rowsDB.Scan(&id, &raw); err != nil { - t.Fatal(err) - } - typ, _, _ := wire.Varint(raw, 17) - sub, _, _ := wire.Varint(raw, 16) - if typ == 1 && sub == 5 { - gacha, _, _ := wire.Varint(raw, 33) - groups = append(groups, drawGroup{id, gacha}) - } - } - if err := rowsDB.Err(); err != nil { - t.Fatal(err) - } - if err := rowsDB.Close(); err != nil { - t.Fatal(err) - } - release() - policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases) - if err != nil { - t.Fatal(err) - } - shop, err := NewService(policy, stateio.NewMemory(), &purchaseEconomy{}) - if err != nil { - t.Fatal(err) - } - seed, err := readonly.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json")) - if err != nil { - t.Fatal(err) - } - calendars, err := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", "20260923193640") - if err != nil { - t.Fatal(err) - } - seed, err = calendars.ApplyReadonly(seed) - if err != nil { - t.Fatal(err) - } - shop.SetClock(func() time.Time { return time.Date(2026, 10, 5, 5, 0, 0, 0, time.UTC) }, 0) - if err := shop.AttachShopSeed(seed); err != nil { - t.Fatal(err) - } - if err := shop.AttachEventShopSchedules(catalog, calendars.Events); err != nil { - t.Fatal(err) - } - graph, err := gamedata.LoadRewardGraph(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - costumes, err := gamedata.LoadRewardCostumeCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - equipment, err := gamedata.LoadRewardEquipmentCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - skins, err := gamedata.LoadPrestigeSkins(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - ownedDesign, err := gamedata.LoadOwnedEventItemDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - avatarRewards, err := gamedata.LoadAvatarRewardDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - templates, err := gamedata.LoadCashMailTemplates(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - newEconomy := func() (*EntitlementEconomy, *player.Wallet) { - store := stateio.NewMemory() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{Jewelry: 100000000, FreeJewelry: 100000000, Gold: 100000000}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(store, nil) - if err != nil { - t.Fatal(err) - } - ownedEquipment, err := player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, ownedEquipment.EnsurePersisted} { - if err := persist(); err != nil { - t.Fatal(err) - } - } - base, err := events.NewEconomy(store, items, wallet, collection, ownedEquipment, costumes, equipment, graph, nil) - if err != nil { - t.Fatal(err) - } - base.AttachPrestigeSkins(skins) - base.AttachOwnedItemDesign(ownedDesign) - base.AttachAvatarRewards(avatarRewards) - ap, err := hunting.Open(store, root, "20260923193640", items, wallet, func() (int, error) { return 21, nil }, 0, 0) - if err != nil { - t.Fatal(err) - } - base.AttachHuntingAP(ap) - e, err := NewEntitlementEconomy(store, base, resolver, items, entitlements) - if err != nil { - t.Fatal(err) - } - e.SetClock(func() time.Time { return time.Date(2026, 10, 5, 5, 0, 0, 0, time.UTC) }, 0) - mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet) - if err != nil { - t.Fatal(err) - } - if err = mailbox.AttachCashRewards(e, templates); err != nil { - t.Fatal(err) - } - if err = e.AttachCashMail(mailbox); err != nil { - t.Fatal(err) - } - return e, wallet - } - t.Run("event shop complete calendar and native purchase", func(t *testing.T) { - e, wallet := newEconomy() - shop, err := NewService(policy, e.store, e) - if err != nil { - t.Fatal(err) - } - if err = shop.AttachShopSeed(seed); err != nil { - t.Fatal(err) - } - rows := append([]events.Schedule(nil), calendars.Events...) - var current events.Schedule - var lastEnd int64 - for _, row := range rows { - if row.Type == 15 && row.ID == 71 { - current = row - if row.End > lastEnd { - lastEnd = row.End - } - } - } - if current.UID == 0 { - t.Fatal("real event shop 71 calendar missing") - } - future := events.Schedule{UID: 99999999, Type: 15, ID: 71, Start: lastEnd + 86400000, End: lastEnd + 2*86400000} - rows = append(rows, future) - if err = shop.AttachEventShopSchedules(catalog, rows); err != nil { - t.Fatal(err) - } - shop.SetClock(func() time.Time { return time.UnixMilli(current.Start + 1) }, 0) - _, info, handled, err := shop.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "audit") - if err != nil || !handled { - t.Fatal(err) - } - actual := map[uint64]map[gamedata.CashProductKey]bool{} - _ = wire.Walk(info, func(f wire.Field) error { - if f.Number != 1 || f.Type != 2 { - return nil - } - uid, _, _ := wire.Varint(f.Value, 8) - if uid == 0 { - return nil - } - group, _, _ := wire.Varint(f.Value, 1) - id, _, _ := wire.Varint(f.Value, 2) - sale, _, _ := wire.Varint(f.Value, 3) - if actual[uid] == nil { - actual[uid] = map[gamedata.CashProductKey]bool{} - } - actual[uid][gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}] = true - return nil - }) - seenOld := false - for _, row := range rows { - if row.Type != 15 { - continue - } - group := uint64(0) - for _, design := range catalog.EventShops { - if design.ID == row.ID { - group = design.ProductGroupID - } - } - if row.ID == 70 { - seenOld = true - } - if row.ID == 71 && group != 900071 { - t.Fatal("event shop 71 product group mismatch") - } - expected := 0 - for _, product := range catalog.Products { - if product.Key.GroupID == group { - expected++ - if !actual[row.UID][product.Key] { - t.Fatalf("shop %d UID %d missing product %+v", row.ID, row.UID, product.Key) - } - } - } - if len(actual[row.UID]) != expected { - t.Fatalf("shop %d UID %d count=%d expected=%d", row.ID, row.UID, len(actual[row.UID]), expected) - } - } - if !seenOld || len(actual[future.UID]) == 0 { - t.Fatal("old or future event shop filtered") - } - fund := []gamedata.Reward{{Type: 8, ID: 2074, Count: 100}} - if _, err = e.Apply("audit funds", nil, fund); err != nil { - t.Fatal(err) - } - beforeWallet := wallet.Snapshot() - countItem := func(id uint64) uint64 { - var n uint64 - for _, item := range e.items.All() { - if item.Type == 8 && item.ID == id { - n += item.Count - } - } - return n - } - beforeCoin, beforeReward := countItem(2074), countItem(710) - request := wire.AppendVarint(nil, 1, 2) - request = wire.AppendVarint(request, 3, 900071) - for _, id := range []uint64{1, 5} { - line := wire.AppendVarint(nil, 1, id) - line = wire.AppendVarint(line, 3, 1) - request = wire.AppendBytes(request, 4, line) - } - code, response, handled, err := shop.HandleSession("/CashShopBuy", request, "audit") - if err != nil || !handled || code != 61 { - t.Fatalf("native batch code=%d handled=%t err=%v", code, handled, err) - } - if wallet.Snapshot().FreeJewelry != beforeWallet.FreeJewelry-100 || countItem(2074) != beforeCoin-12 || countItem(710) != beforeReward+1 { - t.Fatal("native batch debit or real reward missing", wallet.Snapshot(), e.items.All()) - } - for _, id := range []uint64{1, 5} { - found := false - for _, raw := range shop.PurchaseCountDBInfos() { - g, _, _ := wire.Varint(raw, 1) - p, _, _ := wire.Varint(raw, 2) - n, _, _ := wire.Varint(raw, 4) - if g == 900071 && p == id && n == 1 { - found = true - } - } - if !found { - t.Fatal("native purchase count missing", id) - } - } - balance := wallet.Snapshot() - coins := countItem(2074) - rewardCount := countItem(710) - _, replay, _, err := shop.HandleSession("/CashShopBuy", request, "audit") - if err != nil || !bytes.Equal(response, replay) || wallet.Snapshot() != balance || countItem(2074) != coins || countItem(710) != rewardCount { - t.Fatal("native purchase replay duplicated", err) - } - }) - delegated := map[gamedata.CashProductKey]bool{} - retired := map[gamedata.CashProductKey]bool{} - for _, group := range groups { - d, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{group.id}) - if err != nil && strings.Contains(err.Error(), "infinite cash product missing") { - // Early infinite draws retain placeholder gold/count=0 boxes in this - // version. They are historical timed products, not grantable purchases. - found := false - for _, p := range catalog.Products { - if p.Key.ProductID != group.gacha { - continue - } - found = true - if p.TimeLimitType == 0 || shop.IsAvailable(p.Key) { - t.Fatalf("retired infinite product %+v is purchasable without a valid preview design", p.Key) - } - retired[p.Key] = true - } - if !found { - t.Fatalf("infinite group=%d has no matching retired product", group.id) - } - continue - } - if err != nil || d == nil || d.Count == 0 { - t.Fatalf("infinite draw group=%d cannot grant confirmed preview: %v", group.id, err) - } - delegated[gamedata.CashProductKey{GroupID: d.ProductGroupID, ProductID: d.ProductID, SaleGroup: d.SaleGroup}] = true - } - cash, recharge, delegateCount, retiredCount, mailedProducts := 0, 0, 0, 0, 0 - for _, p := range catalog.Products { - if p.PriceType != 1 { - continue - } - cash++ - if p.Recharge { - recharge++ - } - if delegated[p.Key] { - delegateCount++ - continue - } - if retired[p.Key] { - retiredCount++ - continue - } - if p.RandomBoxID == 0 { - t.Errorf("cash product %+v has no reward box", p.Key) - continue - } - for _, box := range []uint64{p.RandomBoxID, p.BonusRandomBoxID} { - if box == 0 { - continue - } - leaves, err := resolver.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: box, Count: 1}}) - if err != nil || len(leaves) == 0 { - t.Errorf("cash product %+v box=%d cannot grant rewards: %v", p.Key, box, err) - continue - } - for _, r := range leaves { - if r.Type == 19 && entitlements.TicketTypes[r.ID] == 0 || r.Type == 62 && !entitlements.AvatarSets[r.ID] { - t.Errorf("cash product %+v has unknown entitlement %+v", p.Key, r) - } - } - } - // Exercise the same grant and debit modules used by CashShopBuy against - // isolated in-memory accounts; no real save or authenticated API is touched. - q, err := policy.Quote(p.Key, 1) - if err != nil { - t.Fatal(err) - } - var costs []gamedata.Reward - if q.Cost > 0 { - costs = []gamedata.Reward{{Type: q.ItemType, Count: q.Cost}} - } - rewards := []gamedata.Reward{{Type: 9, ID: p.RandomBoxID, Count: 1}} - if p.BonusRandomBoxID != 0 { - rewards = append(rewards, gamedata.Reward{Type: 9, ID: p.BonusRandomBoxID, Count: 1}) - } - e, wallet := newEconomy() - bundle, err := e.ApplyPurchase("installed-audit", costs, rewards) - if err != nil { - t.Errorf("cash product %+v failed real debit/grant: %v", p.Key, err) - continue - } - _ = wire.Walk(bundle, func(f wire.Field) error { - if f.Number == 1 && f.Type == 2 { - typ, _, _ := wire.Varint(f.Value, 3) - if typ == 62 { - t.Errorf("cash product %+v returned synthetic avatar set", p.Key) - } - } - return nil - }) - balance := wallet.Snapshot() - replay, err := e.ApplyPurchase("installed-audit", costs, rewards) - if err != nil || !bytes.Equal(replay, bundle) || wallet.Snapshot() != balance { - t.Errorf("cash product %+v replay repeated debit/grant: %v", p.Key, err) - } - - mailbox := e.mail.(*mail.Service) - listReq := wire.AppendVarint(nil, 1, 1) - listReq = wire.AppendVarint(listReq, 3, 100) - code, list, ok, err := mailbox.Handle("/CashMailInfo", listReq) - if err != nil || !ok || code != 140 { - t.Fatalf("cash list %+v: %v", p.Key, err) - } - claim := wire.AppendVarint(nil, 1, 2) - mailCount := 0 - _ = wire.Walk(list, func(f wire.Field) error { - if f.Number == 1 && f.Type == 2 { - id, _, _ := wire.Varint(f.Value, 1) - template, _, _ := wire.Varint(f.Value, 3) - cash, _, _ := wire.Varint(f.Value, 15) - if !templates[template] || cash != 1 { - t.Errorf("invalid cash mail template/flag %+v", p.Key) - } - claim = wire.AppendVarint(claim, 2, id) - mailCount++ - } - return nil - }) - if mailCount > 0 { - mailedProducts++ - _, claimed, _, err := mailbox.Handle("/MailOpen", claim) - if err != nil { - t.Errorf("cash claim %+v: %v", p.Key, err) - continue - } - claimedBalance := wallet.Snapshot() - claimedItems := e.items.All() - _, replayed, _, err := mailbox.Handle("/MailOpen", claim) - if err != nil || !bytes.Equal(claimed, replayed) || wallet.Snapshot() != claimedBalance || !reflect.DeepEqual(e.items.All(), claimedItems) { - t.Errorf("cash claim replay %+v duplicated: %v", p.Key, err) - } - _, remaining, _, err := mailbox.Handle("/CashMailInfo", listReq) - n, _, _ := wire.Varint(remaining, 2) - if err != nil || n != 0 { - t.Errorf("claimed cash mail remains %+v", p.Key) - } - } - // Cash mailbox delivery must grant exactly what the previous immediate - // reward resolver granted, including subscriptions' first-day deduplication. - reference, referenceWallet := newEconomy() - if _, err := reference.Apply("reference", costs, rewards); err != nil { - t.Fatal(err) - } - itemTotals := func(items []player.Item) map[[3]uint64]uint64 { - totals := map[[3]uint64]uint64{} - for _, item := range items { - totals[[3]uint64{item.Type, item.ID, item.ExpiryTime}] += item.Count - } - return totals - } - if wallet.Snapshot() != referenceWallet.Snapshot() || !reflect.DeepEqual(itemTotals(e.items.All()), itemTotals(reference.items.All())) { - t.Errorf("cash product %+v mailed delivery differs from direct grant: wallet=%+v expected=%+v items=%+v expected=%+v", p.Key, wallet.Snapshot(), referenceWallet.Snapshot(), e.items.All(), reference.items.All()) - } - } - if cash != 614 || recharge != 14 || delegateCount != 3 || retiredCount != 5 { - t.Fatalf("cash coverage changed: cash=%d recharge=%d delegated=%d retired=%d", cash, recharge, delegateCount, retiredCount) - } - bonus, err := gamedata.LoadCashBonusCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - rows := bonus.Groups[1] - if len(rows) != 3 || rows[0].RequireCount != 2 || rows[0].Reward.Count != 2 || rows[1].RequireCount != 6 || rows[1].Reward.Count != 15 || rows[2].RequireCount != 8 || rows[2].Reward.Count != 30 { - t.Fatalf("cash bonus thresholds or rewards changed: %+v", rows) - } - t.Logf("validated %d cash rows: %d recharge variants, %d preview designs, %d unavailable historical draws; checked %d products through cash mail creation, batch claim, and replay with typed ticket/skin entitlements", cash, recharge, delegateCount, retiredCount, mailedProducts) -} diff --git a/go/internal/server/commerce/installed_native_test.go b/go/internal/server/commerce/installed_native_test.go deleted file mode 100644 index a894ae8..0000000 --- a/go/internal/server/commerce/installed_native_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gameconfig" - "bd2server/internal/server/readonly" - "bd2server/internal/server/wire" - "bytes" - "fmt" - "os" - "testing" - "time" - - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/hunting" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" -) - -// Covers static native goods independently of their current publication -// windows. The account is an isolated memory store; no player saves are read. -func TestInstalledAllNativeProductRewardAndPriceDomains(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - const version = "20260923193640" - catalog, err := gamedata.LoadCashCatalog(root, version) - if err != nil { - t.Fatal(err) - } - graph, err := gamedata.LoadRewardGraph(root, version) - if err != nil { - t.Fatal(err) - } - costumes, err := gamedata.LoadRewardCostumeCatalog(root, version) - if err != nil { - t.Fatal(err) - } - options, err := gamedata.LoadRewardEquipmentCatalog(root, version) - if err != nil { - t.Fatal(err) - } - owned, err := gamedata.LoadOwnedEventItemDesign(root, version) - if err != nil { - t.Fatal(err) - } - avatars, err := gamedata.LoadAvatarRewardDesign(root, version) - if err != nil { - t.Fatal(err) - } - skins, err := gamedata.LoadPrestigeSkins(root, version) - if err != nil { - t.Fatal(err) - } - newAccount := func() (*events.Economy, *player.Inventory, *player.Wallet, stateio.Store, *hunting.Service) { - store := stateio.NewMemory() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(store, nil) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - economy, err := events.NewEconomy(store, items, wallet, collection, equipment, costumes, options, graph, nil) - if err != nil { - t.Fatal(err) - } - economy.AttachOwnedItemDesign(owned) - economy.AttachAvatarRewards(avatars) - economy.AttachPrestigeSkins(skins) - ap, err := hunting.Open(store, root, version, items, wallet, func() (int, error) { return 21, nil }, 0, 0) - if err != nil { - t.Fatal(err) - } - economy.AttachHuntingAP(ap) - return economy, items, wallet, store, ap - } - economy, _, _, _, _ := newAccount() - families := map[uint64]int{} - free := 0 - native := 0 - // Each product reward is settled once through the actual shared economy. - // Prices are funded in that same typed domain and spent once separately, - // avoiding historical publication or predecessor constraints in this audit. - for _, product := range catalog.Products { - if product.PriceType == 1 { - continue - } - native++ - if native%50 == 0 { - economy, _, _, _, _ = newAccount() - } - families[product.PriceType]++ - identity := fmt.Sprintf("native-audit:%d:%d:%d", product.Key.GroupID, product.Key.ProductID, product.Key.SaleGroup) - var cost []gamedata.Reward - if product.PriceType == 0 { - free++ - if product.PriceID != 0 || product.PriceCount != 0 { - t.Errorf("free product has nonzero price %+v", product.Key) - } - } else { - cost = []gamedata.Reward{{Type: product.PriceType, ID: product.PriceID, Count: product.PriceCount}} - if _, err = economy.Apply(identity+":fund", nil, cost); err != nil { - t.Errorf("native price domain %+v failed: %v", product.Key, err) - continue - } - } - rewards := []gamedata.Reward{{Type: 9, ID: product.RandomBoxID, Count: 1}} - if product.BonusRandomBoxID != 0 { - rewards = append(rewards, gamedata.Reward{Type: 9, ID: product.BonusRandomBoxID, Count: 1}) - } - if _, err = economy.Apply(identity, cost, rewards); err != nil { - t.Errorf("native product %+v real reward/debit failed: %v", product.Key, err) - } - } - if native != 2274 || free != 100 || len(families) != 14 { - t.Fatalf("native design coverage changed: goods=%d free=%d families=%v", native, free, families) - } - t.Run("native dispatcher quantity free and total diamonds", func(t *testing.T) { - base, items, wallet, store, ap := newAccount() - entitlementDesign, err := gamedata.LoadCashEntitlementDesign(root, version) - if err != nil { - t.Fatal(err) - } - resolver, err := gamedata.LoadCashRewardResolver(root, version, graph) - if err != nil { - t.Fatal(err) - } - entitlement, err := NewEntitlementEconomy(store, base, resolver, items, entitlementDesign) - if err != nil { - t.Fatal(err) - } - mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet) - if err != nil { - t.Fatal(err) - } - templates, err := gamedata.LoadCashMailTemplates(root, version) - if err != nil { - t.Fatal(err) - } - if err = mailbox.AttachCashRewards(entitlement, templates); err != nil { - t.Fatal(err) - } - if err = entitlement.AttachCashMail(mailbox); err != nil { - t.Fatal(err) - } - policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases) - if err != nil { - t.Fatal(err) - } - service, err := NewService(policy, store, entitlement) - if err != nil { - t.Fatal(err) - } - field := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} } - seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{field(1, 1700001), field(2, 1), field(3, 0), field(4, 1), field(5, uint64(time.Now().Add(time.Hour).UnixMilli()))}}}}}} - if err = service.AttachShopSeed(seed); err != nil { - t.Fatal(err) - } - if _, err = base.Apply("fund diamonds", nil, []gamedata.Reward{{Type: 3, Count: 1000}, {Type: 2, Count: 50000}}); err != nil { - t.Fatal(err) - } - makeBuy := func(seq, group, id, count uint64) []byte { - r := wire.AppendVarint(nil, 1, seq) - r = wire.AppendVarint(r, 3, group) - line := wire.AppendVarint(nil, 1, id) - line = wire.AppendVarint(line, 3, count) - return wire.AppendBytes(r, 4, line) - } - request := makeBuy(1, 400001, 2, 300) - _, bonusBefore, err := ap.HuntingAP() - if err != nil { - t.Fatal(err) - } - code, response, handled, err := service.HandleSession("/CashShopBuy", request, "native-audit") - if err != nil || !handled || code != 61 { - t.Fatalf("native quantity300 code%d handled%t err%v", code, handled, err) - } - balance := wallet.Snapshot() - _, bonus, err := ap.HuntingAP() - if err != nil || balance.FreeJewelry != 0 || balance.Jewelry != 0 || bonus != bonusBefore+18000 { - t.Fatal("total diamond split or AP grant wrong", balance, bonus, err) - } - _, replay, _, err := service.HandleSession("/CashShopBuy", request, "native-audit") - _, againBonus, _ := ap.HuntingAP() - if err != nil || !bytes.Equal(response, replay) || againBonus != bonus || wallet.Snapshot() != balance { - t.Fatal("bulk native replay duplicated", err) - } - found := false - for _, raw := range service.PurchaseCountDBInfos() { - g, _, _ := wire.Varint(raw, 1) - id, _, _ := wire.Varint(raw, 2) - count, _, _ := wire.Varint(raw, 4) - if g == 400001 && id == 2 && count == 300 { - found = true - } - } - if !found { - t.Fatal("quantity300 count missing") - } - free := makeBuy(2, 1700001, 1, 1) - if code, _, handled, err = service.HandleSession("/CashShopBuy", free, "native-audit"); err != nil || !handled || code != 61 { - t.Fatal("free native goods failed", err) - } - if wallet.Snapshot().Jewelry != balance.Jewelry || wallet.Snapshot().FreeJewelry != balance.FreeJewelry { - t.Fatal("free goods charged currency") - } - if _, _, _, err = service.HandleSession("/CashShopBuy", makeBuy(3, 1700001, 1, 1), "native-audit"); err == nil { - t.Fatal("free limit bypassed") - } - if _, err = base.Apply("fund resource", nil, []gamedata.Reward{{Type: 8, ID: 1002, Count: 3000}}); err != nil { - t.Fatal(err) - } - if code, _, handled, err = service.HandleSession("/CashShopBuy", makeBuy(4, 700001, 2, 300), "native-audit"); err != nil || !handled || code != 61 { - t.Fatal("native resource quantity300 failed", err) - } - for _, item := range items.All() { - if item.Type == 8 && item.ID == 1002 && item.Count != 0 { - t.Fatal("resource price not consumed", item) - } - } - }) - t.Logf("validated %d native goods across %d typed price domains, including %d free goods", native, len(families), free) -} diff --git a/go/internal/server/commerce/installed_test.go b/go/internal/server/commerce/installed_test.go deleted file mode 100644 index de2a6ac..0000000 --- a/go/internal/server/commerce/installed_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package commerce - -import ( - "os" - "testing" - - "bd2server/internal/server/gameconfig" - "bd2server/internal/server/gamedata" -) - -// Opt in against the installed release; synthetic tests also run without it. -func TestInstalledCashCatalogAndRewardPrograms(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not set") - } - design, err := gamedata.LoadCashCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - catalog, err := NewCatalog("2.35.10", design, gameconfig.Default().Purchases) - if err != nil { - t.Fatal(err) - } - rewards, err := gamedata.LoadCashRewardResolver(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - counts, recharge := 0, 0 - for _, product := range design.Products { - if product.PriceType != 1 { - continue - } - counts++ - if product.Recharge { - recharge++ - } - quote, err := catalog.Quote(product.Key, 1) - if err != nil { - t.Fatalf("quote %+v: %v", product.Key, err) - } - if product.Recharge && (quote.ItemType != 0 || quote.Amount != 0) { - t.Fatalf("default recharge isn't free: %+v", quote) - } - if !product.Recharge && (quote.ItemType != 2 || quote.Amount == 0) { - t.Fatalf("cash goods aren't paid diamond: %+v", quote) - } - // Infinite preview products use the gacha delegate, whose selected - // costumes replace weighted/empty placeholder reward programs. - if product.Key.GroupID == 1100001 { - continue - } - leaves, err := rewards.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: product.RandomBoxID, Count: 1}}) - if err != nil || len(leaves) == 0 { - t.Fatalf("product %+v: no reward program: %v", product.Key, err) - } - for _, leaf := range leaves { - if leaf.Type == 19 && entitlements.TicketTypes[leaf.ID] == 2 && len(entitlements.Attendance[leaf.ID]) == 0 { - t.Fatalf("monthly reward schedule missing: %d", leaf.ID) - } - if leaf.Type == 62 && !entitlements.AvatarSets[leaf.ID] { - t.Fatalf("avatar set missing: %d", leaf.ID) - } - } - } - if counts != 614 || recharge != 14 { - t.Fatalf("unexpected cash coverage: goods=%d recharge=%d", counts, recharge) - } - for _, key := range []gamedata.CashProductKey{{GroupID: 1100001, ProductID: 9100033}, {GroupID: 310001, ProductID: 507}, {GroupID: 310001, ProductID: 518}} { - quote, err := catalog.Quote(key, 1) - if err != nil || quote.Amount != 1000 || quote.ItemType != 2 { - t.Fatalf("sample %+v: %+v %v", key, quote, err) - } - } - t.Logf("Verified all %d cash products, %d recharge variants and %d platform aliases", counts, recharge, len(catalog.Manifest().Products)) -} diff --git a/go/internal/server/commerce/login_pass.go b/go/internal/server/commerce/login_pass.go deleted file mode 100644 index 68b73f3..0000000 --- a/go/internal/server/commerce/login_pass.go +++ /dev/null @@ -1,157 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "encoding/json" - "fmt" - "slices" - "sync" - "time" -) - -type loginPassProgress struct { - Free, Premium uint64 - LastDay string -} -type loginPassReceipt struct { - Bundle []byte - Rewarded map[uint64]bool -} -type loginPassState struct { - Progress map[uint64]loginPassProgress - Receipts map[string]loginPassReceipt -} -type LoginPasses struct { - mu sync.Mutex - store stateio.Store - design *gamedata.LoginPassCatalog - economy Economy - items ClearPackageInventory - available func(uint64) bool - now func() time.Time - resetSeconds int64 -} - -func NewLoginPasses(store stateio.Store, design *gamedata.LoginPassCatalog, economy Economy, items ClearPackageInventory, available func(uint64) bool) (*LoginPasses, error) { - if store == nil || design == nil || economy == nil || items == nil || available == nil { - return nil, fmt.Errorf("commerce: invalid login-pass dependencies") - } - s := &LoginPasses{store: store, design: design, economy: economy, items: items, available: available, now: time.Now} - _, err := s.load() - return s, err -} -func (s *LoginPasses) SetClock(now func() time.Time, resetSeconds int64) { - s.now = now - s.resetSeconds = resetSeconds -} -func (s *LoginPasses) load() (loginPassState, error) { - v := loginPassState{Progress: map[uint64]loginPassProgress{}, Receipts: map[string]loginPassReceipt{}} - raw, err := s.store.Load("commerce_login_passes") - if err != nil || raw == nil { - return v, err - } - if err = json.Unmarshal(raw, &v); err != nil { - return v, err - } - if v.Progress == nil || v.Receipts == nil { - return v, fmt.Errorf("commerce: invalid login-pass state") - } - for group, p := range v.Progress { - rows, ok := s.design.Groups[group] - if !ok || p.Free > uint64(len(rows)) || p.Premium > p.Free || p.Free > 0 && p.LastDay == "" { - return v, fmt.Errorf("commerce: invalid login-pass progress") - } - } - return v, nil -} -func (s *LoginPasses) paid(ticket uint64) bool { - for _, i := range s.items.All() { - if i.Type == 19 && i.ID == ticket && i.Count > 0 && (i.ExpiryTime == 0 || i.ExpiryTime > uint64(s.now().UnixMilli())) { - return true - } - } - return false -} - -// ClaimAndInfo is called by AttendanceHandler inside the account transaction. -// Free progression advances once per observed reset day, and buying premium -// catches up the already earned rows without advancing the login day count. -func (s *LoginPasses) ClaimAndInfo(identity string) ([]byte, [][]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - if identity == "" { - return nil, nil, fmt.Errorf("commerce: missing login-pass identity") - } - v, err := s.load() - if err != nil { - return nil, nil, err - } - keys := make([]uint64, 0, len(s.design.Groups)) - for group := range s.design.Groups { - keys = append(keys, group) - } - slices.Sort(keys) - r, seen := v.Receipts[identity] - if !seen { - r = loginPassReceipt{Rewarded: map[uint64]bool{}} - var rewards []gamedata.Reward - day := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second).Format("2006-01-02") - for _, group := range keys { - if !s.available(group) { - continue - } - rows := s.design.Groups[group] - if len(rows) == 0 { - continue - } - p := v.Progress[group] - if p.LastDay != day && p.Free < uint64(len(rows)) { - rewards = append(rewards, rows[p.Free].Free) - p.Free++ - p.LastDay = day - r.Rewarded[group] = true - } - if s.paid(rows[0].TicketID) { - for p.Premium < p.Free { - rewards = append(rewards, rows[p.Premium].Premium) - p.Premium++ - r.Rewarded[group] = true - } - } - v.Progress[group] = p - } - if len(rewards) > 0 { - r.Bundle, err = s.economy.Apply("login-pass:"+identity, nil, rewards) - if err != nil { - return nil, nil, err - } - } - v.Receipts[identity] = r - raw, err := json.Marshal(v) - if err != nil { - return nil, nil, err - } - if err = s.store.Save("commerce_login_passes", raw); err != nil { - return nil, nil, err - } - } - var infos [][]byte - for _, group := range keys { - if !s.available(group) { - continue - } - p := v.Progress[group] - if p.Free == 0 { - continue - } - b := wire.AppendVarint(nil, 1, group) - b = wire.AppendVarint(b, 2, p.Free) - if r.Rewarded[group] { - b = wire.AppendVarint(b, 3, 1) - } - infos = append(infos, b) - } - return append([]byte(nil), r.Bundle...), infos, nil -} diff --git a/go/internal/server/commerce/login_pass_test.go b/go/internal/server/commerce/login_pass_test.go deleted file mode 100644 index 1785c5f..0000000 --- a/go/internal/server/commerce/login_pass_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "testing" - "time" -) - -type loginPassEconomy struct { - calls int - rewards [][]gamedata.Reward -} - -func (e *loginPassEconomy) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { - e.calls++ - e.rewards = append(e.rewards, append([]gamedata.Reward(nil), r...)) - return []byte{8, 1}, nil -} -func TestLoginPassFreeDaysPaidCatchupAndRestart(t *testing.T) { - d := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 100, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 200, Count: 1}}, {ID: 2, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 101, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 201, Count: 1}}}, 20: {{ID: 1, TicketID: 88, Free: gamedata.Reward{Type: 9, ID: 300, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 400, Count: 1}}}}} - store := stateio.NewMemory() - eco := &loginPassEconomy{} - items := &clearInventory{} - available := func(group uint64) bool { return group == 10 } - s, err := NewLoginPasses(store, d, eco, items, available) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) - s.SetClock(func() time.Time { return now }, 0) - bundle, infos, err := s.ClaimAndInfo("attendance1") - if err != nil || eco.calls != 1 || len(eco.rewards[0]) != 1 || eco.rewards[0][0].ID != 100 || len(infos) != 1 { - t.Fatal(err, eco.rewards, infos) - } - replay, _, err := s.ClaimAndInfo("attendance1") - if err != nil || !bytes.Equal(bundle, replay) || eco.calls != 1 { - t.Fatal("duplicate login grant", err) - } - _, infos, err = s.ClaimAndInfo("attendance2") - rewarded, _, _ := wire.Varint(infos[0], 3) - if err != nil || eco.calls != 1 || rewarded != 0 { - t.Fatal("same-day advanced") - } - items.items = []player.Item{{Type: 19, ID: 77, Count: 1}} - _, _, err = s.ClaimAndInfo("after-buy") - if err != nil || eco.calls != 2 || len(eco.rewards[1]) != 1 || eco.rewards[1][0].ID != 200 { - t.Fatal("premium catchup missing", err, eco.rewards) - } - now = now.Add(48 * time.Hour) - _, infos, err = s.ClaimAndInfo("next-login") - day, _, _ := wire.Varint(infos[0], 2) - if err != nil || day != 2 || eco.calls != 3 || len(eco.rewards[2]) != 2 { - t.Fatal("missed day incorrectly advanced", err, day) - } - s, err = NewLoginPasses(store, d, eco, items, available) - if err != nil { - t.Fatal(err) - } - s.SetClock(func() time.Time { return now }, 0) - _, _, err = s.ClaimAndInfo("restart") - if err != nil || eco.calls != 3 { - t.Fatal("restart advanced complete pass", err) - } -} diff --git a/go/internal/server/commerce/native_products_test.go b/go/internal/server/commerce/native_products_test.go deleted file mode 100644 index 00d4e9e..0000000 --- a/go/internal/server/commerce/native_products_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package commerce - -import ( - "bytes" - "errors" - "fmt" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func nativeBuyRequest(seq, group uint64, lines ...[3]uint64) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 3, group) - for _, line := range lines { - row := wire.AppendVarint(nil, 1, line[0]) - row = wire.AppendVarint(row, 2, line[1]) - row = wire.AppendVarint(row, 3, line[2]) - b = wire.AppendBytes(b, 4, row) - } - // Native clients repeat these display fields on subsequent purchases. - return wire.AppendBytes(b, 7, wire.AppendString(nil, 1, "2")) -} - -func TestNativeGoodsSingleQuantityFreeClaimsAndDisplayBilling(t *testing.T) { - s, eco, _ := serviceFixture(t, 0) - key := gamedata.CashProductKey{GroupID: 44, ProductID: 2} - d := gamedata.CashProductDesign{Key: key, PriceType: 22, PriceCount: 10, RandomBoxID: 500, PurchaseLimitType: 3, PurchaseLimitCount: 301} - s.catalog.designs[key] = d - req := nativeBuyRequest(1, 44, [3]uint64{2, 0, 300}) - code, out, handled, err := s.HandleSession("/CashShopBuy", req, "s") - if err != nil || !handled || code != 61 || eco.calls != 1 { - t.Fatal(code, handled, err) - } - if eco.costs[0] != (gamedata.Reward{Type: 22, Count: 3000}) || eco.rewards[0].Count != 300 { - t.Fatal(eco) - } - _, replay, _, err := s.HandleSession("/CashShopBuy", req, "s") - if err != nil || !bytes.Equal(out, replay) || eco.calls != 1 { - t.Fatal("duplicate native request", err) - } - if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(2, 44, [3]uint64{2, 0, 1}), "s"); err != nil { - t.Fatal("display billing treated as receipt", err) - } - if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(3, 44, [3]uint64{2, 0, 1}), "s"); err == nil || eco.calls != 2 { - t.Fatal("monthly limit ignored") - } - freeKey := gamedata.CashProductKey{GroupID: 45, ProductID: 1} - s.catalog.designs[freeKey] = gamedata.CashProductDesign{Key: freeKey, RandomBoxID: 501, PurchaseLimitType: 4, PurchaseLimitCount: 1} - if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(4, 45, [3]uint64{1, 0, 1}), "s"); err != nil || len(eco.costs) != 0 { - t.Fatal("free claim failed", err) - } - if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(5, 45, [3]uint64{1, 0, 1}), "s"); err == nil { - t.Fatal("free claimed twice") - } -} - -func TestNativeMultipleProductsAndSpecialPreviewsAreValidatedBeforeGrant(t *testing.T) { - s, eco, _ := serviceFixture(t, 0) - key := gamedata.CashProductKey{GroupID: 44, ProductID: 1} - other := gamedata.CashProductKey{GroupID: 44, ProductID: 2} - s.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 4, PriceCount: 1, RandomBoxID: 1} - s.catalog.designs[other] = gamedata.CashProductDesign{Key: other, PriceType: 4, PriceCount: 1, RandomBoxID: 2, BulkOrderAvailability: 1} - if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(1, 44, [3]uint64{1, 0, 1}, [3]uint64{2, 0, 1}), "s"); err == nil || eco.calls != 0 { - t.Fatal("ineligible batch executed") - } - if err := s.AttachSpecialProducts([]gamedata.CashProductKey{key}); err != nil { - t.Fatal(err) - } - delegates := 0 - s.AttachDelegate(func(k gamedata.CashProductKey, _ []byte) ([]byte, bool, error) { - delegates++ - if k != key { - return nil, false, fmt.Errorf("unexpected delegate key") - } - return []byte{8, 1}, true, nil - }) - if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(2, 44, [3]uint64{1, 0, 2}), "s"); err == nil || delegates != 0 || eco.calls != 0 { - t.Fatal("invalid preview mutated state") - } - if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(3, 44, [3]uint64{1, 0, 1}), "s"); err != nil || delegates != 1 || len(eco.rewards) != 0 { - t.Fatal("preview did not use delegate", err) - } - if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(4, 44, [3]uint64{2, 0, 2}), "s"); err != nil || delegates != 1 { - t.Fatal("ordinary native delegated", err) - } -} - -type nativeTestGraph struct{} - -func (nativeTestGraph) Resolve(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - var out []gamedata.BattleReward - for _, r := range rewards { - out = append(out, gamedata.BattleReward{Type: 8, ID: 710, Count: r.Count}) - } - return out, nil -} - -func TestNativeTotalDiamondPurchaseSQLiteRollbackAndRestartReplay(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repo, player.Currency{FreeJewelry: 5, Jewelry: 10}) - if err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - fixture, _, _ := serviceFixture(t, 0) - key := gamedata.CashProductKey{GroupID: 44, ProductID: 2} - fixture.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 3, PriceCount: 7, RandomBoxID: 1} - open := func() (*Service, *player.Inventory) { - t.Helper() - items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(repo, nil) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(repo) - if err != nil { - t.Fatal(err) - } - economy, err := events.NewEconomy(repo, items, wallet, collection, equipment, &gamedata.RegularGachaCatalog{}, &gamedata.EquipmentGachaCatalog{}, nativeTestGraph{}, nil) - if err != nil { - t.Fatal(err) - } - s, err := NewService(fixture.catalog, repo, economy) - if err != nil { - t.Fatal(err) - } - return s, items - } - s, _ := open() - s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("grant failed") }) - req := nativeBuyRequest(1, 44, [3]uint64{2, 0, 1}) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil { - t.Fatal("hook failure swallowed") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - wallet, err = player.OpenWallet(repo, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s, items := open() - if wallet.Snapshot().FreeJewelry != 5 || wallet.Snapshot().Jewelry != 10 || len(items.All()) != 0 || s.HasPurchased(key) { - t.Fatal("rollback retained partial purchase") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, out, _, err := s.HandleSession("/CashShopBuy", req, "s") - if err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().FreeJewelry != 0 || wallet.Snapshot().Jewelry != 8 || len(items.All()) != 1 { - t.Fatal("total diamonds not spent free-first") - } - s, items = open() - _, replay, _, err := s.HandleSession("/CashShopBuy", req, "s") - if err != nil || !bytes.Equal(out, replay) || wallet.Snapshot().Jewelry != 8 || len(items.All()) != 1 { - t.Fatal("restart replay repeated debit/grant", err) - } -} diff --git a/go/internal/server/commerce/pack_info.go b/go/internal/server/commerce/pack_info.go deleted file mode 100644 index c4130ba..0000000 --- a/go/internal/server/commerce/pack_info.go +++ /dev/null @@ -1,39 +0,0 @@ -package commerce - -import "bd2server/internal/server/wire" - -type PackInfoHandler struct { - World attendanceHandler - Claims *ClearPackages -} - -func (h PackInfoHandler) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/PackInfo" { - return 0, nil, false, nil - } - code, response, handled, err := h.World.Handle(path, request) - if err != nil || !handled { - return code, response, handled, err - } - pack, evil, err := h.Claims.RewardDBInfos() - if err != nil { - return code, nil, true, err - } - var result []byte - err = wire.Walk(response, func(field wire.Field) error { - if field.Number != 3 && field.Number != 4 { - result = append(result, response[field.Start:field.End]...) - } - return nil - }) - if err != nil { - return code, nil, true, err - } - for _, info := range pack { - result = wire.AppendBytes(result, 3, info) - } - for _, info := range evil { - result = wire.AppendBytes(result, 4, info) - } - return code, result, true, nil -} diff --git a/go/internal/server/commerce/service.go b/go/internal/server/commerce/service.go deleted file mode 100644 index e719c44..0000000 --- a/go/internal/server/commerce/service.go +++ /dev/null @@ -1,764 +0,0 @@ -package commerce - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "sort" - "strconv" - "strings" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/readonly" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -// Economy and the receipt store must participate in the caller's account -// transaction. A failed hook, delegate or save then rolls back all purchases. -type Economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} -type CountProvider interface{ PurchaseCountDBInfos() [][]byte } -type PurchaseDelegate func(gamedata.CashProductKey, []byte) ([]byte, bool, error) -type PurchaseHook func(string, gamedata.CashProductDesign, uint64) error -type purchaseReceipt struct { - Digest string `json:"digest"` - Response []byte `json:"response"` -} -type purchaseCount struct { - Count uint64 `json:"count"` - Period string `json:"period"` - Lifetime uint64 `json:"lifetime"` -} -type purchaseState struct { - Receipts map[string]purchaseReceipt `json:"receipts"` - Billing map[string]string `json:"billing"` - Counts map[string]purchaseCount `json:"counts"` -} -type Service struct { - mu sync.Mutex - catalog *Catalog - store stateio.Store - economy Economy - now func() time.Time - resetSeconds int64 - delegate PurchaseDelegate - specialProducts map[gamedata.CashProductKey]bool - hook PurchaseHook - legacy CountProvider - shopProducts [][]byte - shopWindows map[gamedata.CashProductKey][2]uint64 - eventShopWindows map[gamedata.CashProductKey][][2]uint64 - eventShopGroups map[uint64]bool - predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey -} - -func NewService(catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) { - if catalog == nil || store == nil || economy == nil { - return nil, fmt.Errorf("commerce: invalid service dependencies") - } - s := &Service{catalog: catalog, store: store, economy: economy, now: time.Now} - _, err := s.load() - return s, err -} -func (s *Service) AttachDelegate(d PurchaseDelegate) { s.delegate = d } -func (s *Service) AttachPurchaseHook(h PurchaseHook) { s.hook = h } -func (s *Service) AttachLegacyCounts(p CountProvider) { s.legacy = p } - -// AttachPackageRules enforces the client's type-2/type-8 step and relay -// ordering using versioned contentsGroupId/contentsSortId, never SKU numbers. -func (s *Service) AttachPackageRules(packages []gamedata.CashPackageDesign) error { - rules := map[gamedata.CashProductKey][]gamedata.CashProductKey{} - for _, p := range packages { - if (p.PackageType != 2 && p.PackageType != 8) || p.ContentsGroupID == 0 || p.ContentsSortID <= 1 { - continue - } - key := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup} - var prior []gamedata.CashProductKey - for _, q := range packages { - if q.PackageType == p.PackageType && q.ContentsGroupID == p.ContentsGroupID && q.ContentsSortID == p.ContentsSortID-1 { - prior = append(prior, gamedata.CashProductKey{GroupID: q.GroupID, ProductID: q.ID, SaleGroup: q.SaleGroup}) - } - } - // Some current-version packages have a sort label above one but an - // independent contents group. Only a real same-group predecessor is a - // progression rule; do not invent links between adjacent product IDs. - if len(prior) == 0 { - continue - } - rules[key] = prior - } - s.predecessors = rules - return nil -} -func (s *Service) predecessorBought(v purchaseState, key gamedata.CashProductKey) bool { - prior := s.predecessors[key] - if len(prior) == 0 { - return true - } - for _, p := range prior { - if v.Counts[skuKey(p)].Lifetime > 0 { - return true - } - } - if s.legacy != nil { - for _, raw := range s.legacy.PurchaseCountDBInfos() { - g, _, _ := wire.Varint(raw, 1) - id, _, _ := wire.Varint(raw, 2) - sale, _, _ := wire.Varint(raw, 3) - n, _, _ := wire.Varint(raw, 4) - if n > 0 { - for _, p := range prior { - if p.GroupID == g && p.ProductID == id && p.SaleGroup == sale { - return true - } - } - } - } - } - return false -} - -// AttachShopSeed retains versioned dynamic windows and event identities. It -// must run before serving sessions; absent windows never authorize timed goods. -func (s *Service) AttachShopSeed(seed *readonly.Seed) error { - if seed == nil { - return fmt.Errorf("commerce: missing shop seed") - } - _, raw, handled, err := seed.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || !handled { - return fmt.Errorf("commerce: invalid shop seed: %w", err) - } - windows := map[gamedata.CashProductKey][2]uint64{} - var products [][]byte - err = wire.Walk(raw, func(f wire.Field) error { - if f.Number != 1 { - return nil - } - if f.Type != 2 { - return fmt.Errorf("commerce: invalid shop product") - } - g, _, e := wire.Varint(f.Value, 1) - if e != nil { - return e - } - id, _, e := wire.Varint(f.Value, 2) - if e != nil { - return e - } - sale, _, e := wire.Varint(f.Value, 3) - if e != nil { - return e - } - start, _, e := wire.Varint(f.Value, 4) - if e != nil { - return e - } - end, _, e := wire.Varint(f.Value, 5) - if e != nil { - return e - } - k := gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale} - if _, ok := windows[k]; ok { - return fmt.Errorf("commerce: duplicate shop schedule") - } - if end != 0 && start >= end { - return fmt.Errorf("commerce: invalid shop window") - } - windows[k] = [2]uint64{start, end} - products = append(products, append([]byte(nil), f.Value...)) - return nil - }) - if err != nil { - return err - } - s.shopProducts = products - s.shopWindows = windows - return nil -} -func (s *Service) available(d gamedata.CashProductDesign) bool { - if s.eventShopGroups[d.Key.GroupID] { - now := uint64(s.now().UnixMilli()) - for _, w := range s.eventShopWindows[d.Key] { - if w[0] <= now && now < w[1] { - return true - } - } - return false - } - w, ok := s.shopWindows[d.Key] - if !ok { - return d.TimeLimitType == 0 - } - now := uint64(s.now().UnixMilli()) - return w[0] <= now && (w[1] == 0 || now < w[1]) -} -func (s *Service) IsAvailable(key gamedata.CashProductKey) bool { - d, ok := s.catalog.Design(key) - return ok && s.available(d) -} -func (s *Service) shopInfo() []byte { - var response []byte - for _, raw := range s.shopProducts { - g, _, _ := wire.Varint(raw, 1) - id, _, _ := wire.Varint(raw, 2) - sale, _, _ := wire.Varint(raw, 3) - _, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}) - if ok { - response = wire.AppendBytes(response, 1, raw) - } - } - t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second) - day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) - daily := day.AddDate(0, 0, 1) - days := (8 - int(day.Weekday())) % 7 - if days == 0 { - days = 7 - } - weekly := day.AddDate(0, 0, days) - monthly := time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC) - for i, reset := range []time.Time{daily, weekly, monthly} { - response = wire.AppendVarint(response, i+2, uint64(reset.Add(time.Duration(s.resetSeconds)*time.Second).UnixMilli())) - } - return response -} -func (s *Service) SetClock(now func() time.Time, resetSeconds int64) { - s.now = now - s.resetSeconds = resetSeconds -} -func skuKey(k gamedata.CashProductKey) string { - return fmt.Sprintf("%d:%d:%d", k.GroupID, k.ProductID, k.SaleGroup) -} -func (s *Service) load() (purchaseState, error) { - v := purchaseState{Receipts: map[string]purchaseReceipt{}, Billing: map[string]string{}, Counts: map[string]purchaseCount{}} - b, e := s.store.Load("commerce") - if e != nil || b == nil { - return v, e - } - if e = stateio.RequireExactJSONObject(b, "receipts", "billing", "counts"); e != nil { - return v, e - } - if e = json.Unmarshal(b, &v); e != nil { - return v, e - } - if v.Receipts == nil || v.Billing == nil || v.Counts == nil { - return v, fmt.Errorf("commerce: invalid state") - } - for identity, receipt := range v.Receipts { - digest, err := hex.DecodeString(receipt.Digest) - if identity == "" || err != nil || len(digest) != sha256.Size || len(receipt.Response) == 0 { - return v, fmt.Errorf("commerce: invalid saved receipt") - } - } - for _, identity := range v.Billing { - if _, ok := v.Receipts[identity]; !ok { - return v, fmt.Errorf("commerce: billing identity has no purchase receipt") - } - } - for key, count := range v.Counts { - if key == "" || count.Period == "" || count.Count == 0 || count.Count > math.MaxInt32 || count.Lifetime < count.Count { - return v, fmt.Errorf("commerce: invalid saved purchase count") - } - } - return v, nil -} -func (s *Service) period(typ uint64) string { - t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second) - switch typ { - case 1: - return t.Format("2006-01-02") - case 2: - y, w := t.ISOWeek() - return fmt.Sprintf("%d-W%02d", y, w) - case 3: - return t.Format("2006-01") - default: - return "account" - } -} -func (s *Service) count(v purchaseState, d gamedata.CashProductDesign) uint64 { - c := v.Counts[skuKey(d.Key)] - if c.Period != s.period(d.PurchaseLimitType) { - return 0 - } - return c.Count -} -func (s *Service) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { - switch path { - case "/CashShopInfo": - if s.shopWindows == nil { - return 0, nil, false, nil - } - if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 { - return 60, nil, true, fmt.Errorf("commerce: invalid shop sequence") - } - return 60, s.shopInfo(), true, nil - case "/CashShopPurchaseCountInfo": - if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 { - return 432, nil, true, fmt.Errorf("commerce: invalid count sequence") - } - infos, err := s.purchaseCountDBInfos() - if err != nil { - return 0, nil, true, err - } - var b []byte - for _, i := range infos { - b = wire.AppendBytes(b, 1, i) - } - return 432, b, true, nil - case "/CashShopBuy": - return s.buy(session, request) - default: - return 0, nil, false, nil - } -} -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - return s.HandleSession(path, request, "") -} - -type buyLine struct { - Key gamedata.CashProductKey - Count uint64 -} - -func parseBuy(request []byte) (uint64, []buyLine, []string, error) { - seq, found, e := wire.Varint(request, 1) - if e != nil || !found || seq == 0 || seq > math.MaxInt32 { - return 0, nil, nil, fmt.Errorf("commerce: invalid sequence") - } - group, found, e := wire.Varint(request, 3) - if e != nil || !found || group == 0 { - return 0, nil, nil, fmt.Errorf("commerce: invalid product group") - } - var lines []buyLine - var billing []string - seen := map[gamedata.CashProductKey]bool{} - e = wire.Walk(request, func(f wire.Field) error { - switch f.Number { - case 4: - if f.Type != 2 { - return fmt.Errorf("commerce: invalid buy info") - } - id, ok, err := wire.Varint(f.Value, 1) - if err != nil || !ok || id == 0 { - return fmt.Errorf("commerce: invalid product id") - } - sale, _, err := wire.Varint(f.Value, 2) - if err != nil { - return err - } - n, ok, err := wire.Varint(f.Value, 3) - if err != nil || !ok || n == 0 || n > math.MaxInt32 { - return fmt.Errorf("commerce: invalid buy count") - } - k := gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale} - if seen[k] { - return fmt.Errorf("commerce: duplicate buy info") - } - seen[k] = true - lines = append(lines, buyLine{k, n}) - case 7: - if f.Type != 2 { - return fmt.Errorf("commerce: invalid billing info") - } - return wire.Walk(f.Value, func(b wire.Field) error { - if b.Number == 1 || b.Number == 2 { - if b.Type != 2 { - return fmt.Errorf("commerce: invalid billing identity") - } - if len(b.Value) > 16384 { - return fmt.Errorf("commerce: excessive billing identity") - } - if len(b.Value) > 0 { - h := sha256.Sum256(b.Value) - billing = append(billing, fmt.Sprintf("%d:%x", b.Number, h)) - } - } - return nil - }) - } - return nil - }) - if e == nil && len(lines) == 0 { - e = fmt.Errorf("commerce: empty purchase") - } - return seq, lines, billing, e -} -func (s *Service) buy(session string, request []byte) (int, []byte, bool, error) { - if session == "" { - return 61, nil, true, fmt.Errorf("commerce: authenticated session required") - } - seq, lines, billing, e := parseBuy(request) - if e != nil { - return 61, nil, true, e - } - var native, cash bool - for _, line := range lines { - if design, ok := s.catalog.Design(line.Key); ok { - if design.PriceType == 1 { - cash = true - } else { - native = true - } - } - } - if native && cash { - return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase") - } - if !cash { - // Native BillingInfo contains display identifiers (often a product ID), - // not a unique payment receipt. Request identity provides replay safety. - billing = nil - } - h := sha256.Sum256(request) - digest := hex.EncodeToString(h[:]) - sessionHash := sha256.Sum256([]byte(session)) - identity := fmt.Sprintf("commerce:%x:%d", sessionHash, seq) - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.load() - if e != nil { - return 61, nil, true, e - } - if r, ok := v.Receipts[identity]; ok { - if r.Digest != digest { - return 61, nil, true, fmt.Errorf("commerce: sequence reused with different purchase") - } - return 61, append([]byte(nil), r.Response...), true, nil - } - for _, b := range billing { - if _, ok := v.Billing[b]; ok { - return 61, nil, true, fmt.Errorf("commerce: billing receipt already used") - } - } - // Validate every line before charging the first one. - designs := make([]gamedata.CashProductDesign, len(lines)) - quotes := make([]Product, len(lines)) - for i, l := range lines { - if !s.predecessorBought(v, l.Key) { - return 61, nil, true, fmt.Errorf("commerce: preceding package purchase required") - } - d, ok := s.catalog.Design(l.Key) - if !ok { - return 61, nil, true, fmt.Errorf("commerce: unknown product") - } - if !s.available(d) { - return 61, nil, true, fmt.Errorf("commerce: product is not currently available") - } - if s.specialProducts[l.Key] && (len(lines) != 1 || l.Count != 1) { - return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product") - } - var q Product - if d.PriceType == 1 { - var err error - q, err = s.catalog.Quote(l.Key, l.Count) - if err != nil { - return 61, nil, true, err - } - } else { - cost, err := nativePrice(d, l.Count) - if err != nil { - return 61, nil, true, err - } - q = Product{Enabled: true, ItemType: d.PriceType, Cost: cost} - } - if !q.Enabled { - return 61, nil, true, fmt.Errorf("commerce: product disabled") - } - if len(lines) > 1 && d.BulkOrderAvailability != 1 { - return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled") - } - n := s.count(v, d) - if s.legacy != nil { - for _, raw := range s.legacy.PurchaseCountDBInfos() { - g, _, _ := wire.Varint(raw, 1) - id, _, _ := wire.Varint(raw, 2) - sale, _, _ := wire.Varint(raw, 3) - legacyCount, _, _ := wire.Varint(raw, 4) - if g == l.Key.GroupID && id == l.Key.ProductID && sale == l.Key.SaleGroup && legacyCount > n { - n = legacyCount - } - } - } - if d.PurchaseLimitType > 4 { - return 61, nil, true, fmt.Errorf("commerce: unknown purchase limit") - } - if n > math.MaxInt32-l.Count { - return 61, nil, true, fmt.Errorf("commerce: purchase count overflow") - } - if d.PurchaseLimitType != 0 && (d.PurchaseLimitCount == 0 || n+l.Count > d.PurchaseLimitCount) { - return 61, nil, true, fmt.Errorf("commerce: purchase limit exceeded") - } - designs[i] = d - quotes[i] = q - } - if cash { - e = validateAcceptedQuote(request, quotes) - } - if e != nil { - return 61, nil, true, e - } - var bundle []byte - for i, l := range lines { - d, q := designs[i], quotes[i] - operation := fmt.Sprintf("%s:%d", identity, i) - var costs []gamedata.Reward - if q.Cost > 0 { - if d.PriceType != 1 { - costs = []gamedata.Reward{{Type: d.PriceType, ID: d.PriceID, Count: q.Cost}} - if resolver, ok := s.economy.(nativeCostResolver); ok { - var err error - costs, err = resolver.NativePurchaseCosts(costs[0]) - if err != nil { - return 61, nil, true, err - } - } - } else { - var typ uint64 - switch q.Currency { - case "paid_diamonds": - typ = 2 - case "diamonds": - typ = 3 - case "gold": - typ = 4 - default: - return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency) - } - costs = []gamedata.Reward{{Type: typ, Count: q.Cost}} - } - } - var special []byte - handled := false - // Delegate must only select known special products. Parent account transaction - // guarantees its grant and the subsequent debit commit together. - if s.delegate != nil && (d.PriceType == 1 || s.specialProducts[l.Key]) { - special, handled, e = s.delegate(l.Key, request) - if e != nil { - return 61, nil, true, e - } - if handled && (len(lines) != 1 || l.Count != 1) { - return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product") - } - } - var rewards []gamedata.Reward - if !handled { - if d.RandomBoxID == 0 { - return 61, nil, true, fmt.Errorf("commerce: product reward missing") - } - rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.RandomBoxID, Count: l.Count}) - if d.BonusRandomBoxID != 0 { - rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count}) - } - } - var b []byte - var err error - if delivery, ok := s.economy.(interface { - ApplyPurchase(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) - }); ok { - b, err = delivery.ApplyPurchase(operation, costs, rewards) - } else { - b, err = s.economy.Apply(operation, costs, rewards) - } - if err != nil { - return 61, nil, true, err - } - bundle = append(bundle, b...) - bundle = append(bundle, special...) - if s.hook != nil { - if err = s.hook(operation, d, l.Count); err != nil { - return 61, nil, true, err - } - } - old := v.Counts[skuKey(l.Key)] - if old.Lifetime > math.MaxUint64-l.Count { - return 61, nil, true, fmt.Errorf("commerce: lifetime purchase count overflow") - } - v.Counts[skuKey(l.Key)] = purchaseCount{Count: s.count(v, d) + l.Count, Period: s.period(d.PurchaseLimitType), Lifetime: old.Lifetime + l.Count} - } - response := wire.AppendBytes(nil, 1, bundle) - v.Receipts[identity] = purchaseReceipt{digest, response} - for _, b := range billing { - v.Billing[b] = identity - } - encoded, e := json.Marshal(v) - if e == nil { - e = s.store.Save("commerce", encoded) - } - return 61, response, true, e -} - -// The receipt repeats the client-visible quote for stale-catalog detection. -// Prices always come from Catalog, and a mismatch never changes the debit. -func validateAcceptedQuote(request []byte, quotes []Product) error { - var pay, receipt string - seen := false - err := wire.Walk(request, func(f wire.Field) error { - if f.Number != 7 { - return nil - } - if seen || f.Type != 2 { - return fmt.Errorf("commerce: invalid billing info") - } - seen = true - return wire.Walk(f.Value, func(b wire.Field) error { - if b.Number == 1 { - if b.Type != 2 || pay != "" { - return fmt.Errorf("commerce: invalid pay id") - } - pay = string(b.Value) - } - if b.Number == 2 { - if b.Type != 2 || receipt != "" { - return fmt.Errorf("commerce: invalid receipt") - } - receipt = string(b.Value) - } - return nil - }) - }) - if err != nil { - return err - } - parts := strings.Split(receipt, ":") - if len(parts) != 4 || parts[0] != "bd2-local-commerce-v1" || parts[1] != pay { - return fmt.Errorf("commerce: local billing quote required") - } - payID, err := strconv.ParseUint(pay, 10, 64) - if err != nil || payID == 0 || strconv.FormatUint(payID, 10) != pay { - return fmt.Errorf("commerce: invalid local pay id") - } - typ, err := strconv.ParseUint(parts[2], 10, 64) - if err != nil || strconv.FormatUint(typ, 10) != parts[2] { - return fmt.Errorf("commerce: invalid accepted quote currency") - } - amount, err := strconv.ParseUint(parts[3], 10, 64) - if err != nil || strconv.FormatUint(amount, 10) != parts[3] { - return fmt.Errorf("commerce: invalid accepted quote amount") - } - var expected uint64 - for _, q := range quotes { - if q.ItemType != typ || q.Cost > math.MaxInt32-expected { - return fmt.Errorf("commerce: accepted quote currency mismatch") - } - expected += q.Cost - } - if expected != amount { - return fmt.Errorf("commerce: purchase quote changed; refresh the shop") - } - return nil -} -func (s *Service) purchaseCountDBInfos() ([][]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.load() - if e != nil { - return nil, e - } - counts := map[gamedata.CashProductKey]uint64{} - if s.legacy != nil { - for _, raw := range s.legacy.PurchaseCountDBInfos() { - g, _, _ := wire.Varint(raw, 1) - id, _, _ := wire.Varint(raw, 2) - sale, _, _ := wire.Varint(raw, 3) - n, _, _ := wire.Varint(raw, 4) - counts[gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}] = n - } - } - for _, d := range s.catalog.Designs() { - if n := s.count(v, d); n > counts[d.Key] { - counts[d.Key] = n - } - } - keys := make([]gamedata.CashProductKey, 0, len(counts)) - for k := range counts { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - a, b := keys[i], keys[j] - if a.GroupID != b.GroupID { - return a.GroupID < b.GroupID - } - if a.ProductID != b.ProductID { - return a.ProductID < b.ProductID - } - return a.SaleGroup < b.SaleGroup - }) - var out [][]byte - for _, k := range keys { - b := wire.AppendVarint(nil, 1, k.GroupID) - b = wire.AppendVarint(b, 2, k.ProductID) - b = wire.AppendVarint(b, 3, k.SaleGroup) - b = wire.AppendVarint(b, 4, counts[k]) - out = append(out, b) - } - return out, nil -} -func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v } - -// LifetimePurchaseTotal derives cumulative bonuses from committed purchases, -// including previous reset periods, without keeping a second purchase counter. -func (s *Service) LifetimePurchaseTotal(keys []gamedata.CashProductKey) (uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - v, err := s.load() - if err != nil { - return 0, err - } - var total uint64 - for _, k := range keys { - n := v.Counts[skuKey(k)].Lifetime - if n > math.MaxInt32-total { - return 0, fmt.Errorf("commerce: bonus purchase count overflow") - } - total += n - } - return total, nil -} -func (s *Service) HasPurchased(k gamedata.CashProductKey) bool { - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.load() - if e != nil { - return false - } - return v.Counts[skuKey(k)].Count > 0 -} - -// ConsumeEntitlement is an authorization callback within the caller's account -// transaction. Each successful cash activation consumes one lifetime purchase. -// It must not be called recursively from the purchase hook or delegate. -func (s *Service) ConsumeEntitlement(k gamedata.CashProductKey) bool { - s.mu.Lock() - defer s.mu.Unlock() - v, err := s.load() - if err != nil { - return false - } - n := v.Counts[skuKey(k)].Lifetime - used := map[string]uint64{} - raw, err := s.store.Load("commerce_pass_receipts") - if err != nil { - return false - } - if raw != nil { - if err = json.Unmarshal(raw, &used); err != nil || used == nil { - return false - } - } - key := skuKey(k) - if used[key] >= n { - return false - } - used[key]++ - raw, err = json.Marshal(used) - if err != nil { - return false - } - return s.store.Save("commerce_pass_receipts", raw) == nil -} diff --git a/go/internal/server/commerce/service_test.go b/go/internal/server/commerce/service_test.go deleted file mode 100644 index 75f424c..0000000 --- a/go/internal/server/commerce/service_test.go +++ /dev/null @@ -1,320 +0,0 @@ -package commerce - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gameconfig" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/readonly" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/json" - "errors" - "fmt" - "path/filepath" - "testing" - "time" -) - -type purchaseEconomy struct { - calls int - costs, rewards []gamedata.Reward - fail bool -} - -func (e *purchaseEconomy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { - e.calls++ - e.costs = c - e.rewards = r - if e.fail { - return nil, fmt.Errorf("insufficient balance") - } - return []byte{8, 1}, nil -} -func serviceFixture(t *testing.T, limit uint64) (*Service, *purchaseEconomy, *stateio.Memory) { - t.Helper() - design := &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{ - {Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1}, GoogleSKU: "topup", PriceType: 1, PriceCount: 2200, RandomBoxID: 10, Recharge: true, NominalPaidDiamonds: 100}, - {Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1}, GoogleSKU: "goods", PriceType: 1, PriceCount: 22000, RandomBoxID: 20, PurchaseLimitType: limit, PurchaseLimitCount: 1}, - {Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "goods-sale", PriceType: 1, PriceCount: 11000, RandomBoxID: 21, PurchaseLimitType: 4, PurchaseLimitCount: 1}, - }} - c, err := NewCatalog("test", design, gameconfig.Default().Purchases) - if err != nil { - t.Fatal(err) - } - eco := &purchaseEconomy{} - store := stateio.NewMemory() - s, err := NewService(c, store, eco) - if err != nil { - t.Fatal(err) - } - return s, eco, store -} -func buyRequest(seq, group, id, sale uint64, billing string) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 3, group) - line := wire.AppendVarint(nil, 1, id) - line = wire.AppendVarint(line, 2, sale) - line = wire.AppendVarint(line, 3, 1) - b = wire.AppendBytes(b, 4, line) - if billing == "" { - billing = fmt.Sprint(seq) - } - if billing == "pay-1" { - billing = "1" - } - typ, amount := uint64(2), uint64(1000) - if sale == 1 { - amount = 500 - } - if group == 1 { - typ = 0 - amount = 0 - } - info := wire.AppendBytes(nil, 1, []byte(billing)) - info = wire.AppendBytes(info, 2, []byte(fmt.Sprintf("bd2-local-commerce-v1:%s:%d:%d", billing, typ, amount))) - b = wire.AppendBytes(b, 7, info) - return b -} -func TestPurchaseReplayPersistsAndRejectsCollisions(t *testing.T) { - s, eco, store := serviceFixture(t, 0) - req := buyRequest(1, 2, 1, 0, "pay-1") - code, out, ok, err := s.HandleSession("/CashShopBuy", req, "session") - if err != nil || !ok || code != 61 { - t.Fatal(code, ok, err) - } - if eco.calls != 1 || len(eco.costs) != 1 || eco.costs[0].Type != 2 || eco.costs[0].Count != 1000 || eco.rewards[0].ID != 20 { - t.Fatal(eco) - } - s2, err := NewService(s.catalog, store, eco) - if err != nil { - t.Fatal(err) - } - _, replayed, _, err := s2.HandleSession("/CashShopBuy", req, "session") - if err != nil || !bytes.Equal(out, replayed) || eco.calls != 1 { - t.Fatal(err, eco.calls) - } - changed := buyRequest(1, 2, 1, 1, "") - if _, _, _, err = s2.HandleSession("/CashShopBuy", changed, "session"); err == nil { - t.Fatal("changed sequence accepted") - } - if _, _, _, err = s2.HandleSession("/CashShopBuy", req, "other-session"); err == nil { - t.Fatal("billing identity reused") - } -} -func TestSaleGroupsLimitsAndReset(t *testing.T) { - s, eco, _ := serviceFixture(t, 1) - now := time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC) - s.SetClock(func() time.Time { return now }, 0) - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil { - t.Fatal("daily limit ignored") - } - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil { - t.Fatal("sale group should be independent", err) - } - now = now.Add(24 * time.Hour) - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(4, 2, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - if eco.calls != 3 { - t.Fatal(eco.calls) - } -} -func TestFailedEconomyDoesNotConsumeLimitAndFreeRecharge(t *testing.T) { - s, eco, _ := serviceFixture(t, 4) - eco.fail = true - req := buyRequest(1, 2, 1, 0, "") - if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil { - t.Fatal("failure accepted") - } - if s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) { - t.Fatal("failed buy persisted") - } - eco.fail = false - if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 1, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - if len(eco.costs) != 0 || eco.rewards[0].ID != 10 { - t.Fatal("free recharge wrong", eco) - } -} -func TestAuthenticatedSessionRequired(t *testing.T) { - s, eco, _ := serviceFixture(t, 0) - if _, _, _, err := s.Handle("/CashShopBuy", buyRequest(1, 2, 1, 0, "")); err == nil || eco.calls != 0 { - t.Fatal("unauthenticated buy accepted") - } -} -func TestPassEntitlementConsumedOncePerPurchase(t *testing.T) { - s, _, _ := serviceFixture(t, 0) - k := gamedata.CashProductKey{GroupID: 2, ProductID: 1} - if s.ConsumeEntitlement(k) { - t.Fatal("unpaid entitlement authorized") - } - for i := uint64(1); i <= 2; i++ { - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(i, 2, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - if !s.ConsumeEntitlement(k) || s.ConsumeEntitlement(k) { - t.Fatal("entitlement count not enforced") - } - } -} -func TestRelayRequiresPreviousProduct(t *testing.T) { - s, eco, _ := serviceFixture(t, 0) - if err := s.AttachPackageRules([]gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 1}, {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 2}}); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 1, ""), "s"); err == nil || eco.calls != 0 { - t.Fatal("relay step skipped") - } - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil { - t.Fatal(err) - } -} - -func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) { - s, eco, _ := serviceFixture(t, 0) - req := buyRequest(1, 2, 1, 0, "") - req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":3:1000")) - if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 { - t.Fatal("stale currency accepted") - } - req = buyRequest(2, 2, 1, 0, "") - req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":2:9999")) - if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 { - t.Fatal("stale amount accepted") - } -} -func TestShopPublishesAllWindowsAndAuthorizesActiveProducts(t *testing.T) { - s, eco, _ := serviceFixture(t, 0) - now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) - s.SetClock(func() time.Time { return now }, 0) - seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 2}, {Number: 2, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: uint64(now.Add(-time.Hour).UnixMilli())}, {Number: 5, Type: 0, Varint: uint64(now.Add(time.Hour).UnixMilli())}, {Number: 8, Type: 0, Varint: 123}}}}}}} - if err := s.AttachShopSeed(seed); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { - t.Fatal(err) - } - now = now.Add(2 * time.Hour) - if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil { - t.Fatal("expired product accepted") - } - code, out, ok, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s") - if err != nil || !ok || code != 60 { - t.Fatal(err) - } - products := 0 - _ = wire.Walk(out, func(f wire.Field) error { - if f.Number == 1 { - products++ - } - return nil - }) - if products != 1 || eco.calls != 1 { - t.Fatal(products, eco.calls) - } -} - -type walletPurchaseEconomy struct { - wallet *player.Wallet - store stateio.Store -} - -func (e walletPurchaseEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - if err := e.wallet.ExchangeOnce(identity, costs, nil); err != nil { - return nil, err - } - raw, err := json.Marshal(rewards) - if err != nil { - return nil, err - } - if err = e.store.Save("commerce_test_rewards", raw); err != nil { - return nil, err - } - return []byte{8, 1}, nil -} -func TestSQLiteAccountTransactionRollsBackCostRewardAndReceipt(t *testing.T) { - s, _, _ := serviceFixture(t, 4) - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 1000}) - if err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo}) - if err != nil { - t.Fatal(err) - } - s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("entitlement failure") }) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil { - t.Fatal("hook failure lost") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - wallet, err = player.OpenWallet(repo, player.Currency{}) - if err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Jewelry != 1000 { - t.Fatal("paid diamonds survived rollback") - } - for _, name := range []string{"commerce", "commerce_test_rewards"} { - raw, err := repo.Load(name) - if err != nil || raw != nil { - t.Fatal("purchase state survived rollback", name, err) - } - } - s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo}) - if err != nil { - t.Fatal(err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil { - _ = op.Rollback() - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Jewelry != 0 || !s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) { - t.Fatal("retry did not commit") - } -} diff --git a/go/internal/server/cryptox/cryptox_test.go b/go/internal/server/cryptox/cryptox_test.go deleted file mode 100644 index 2d55f76..0000000 --- a/go/internal/server/cryptox/cryptox_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package cryptox - -import ( - "bytes" - "testing" -) - -func TestAESRoundTripAndFixedKey(t *testing.T) { - plain := []byte("BD2 AES fixture: blocks and PKCS7") - encoded, err := EncryptBase64(plain, Key()) - if err != nil { - t.Fatal(err) - } - got, err := DecryptBase64(encoded, Key()) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, plain) { - t.Fatalf("round trip = %q, want %q", got, plain) - } -} - -func TestSessionKeyValidation(t *testing.T) { - key, err := SessionKey("7eeb5c2480c298f6fc4c00d22b1b0b15") - if err != nil || string(key) != "7eeb5c2480c298f6fc4c00d22b1b0b15" { - t.Fatalf("SessionKey = %q, %v", key, err) - } - if _, err := SessionKey("not-a-key"); err == nil { - t.Fatal("SessionKey accepted invalid key") - } -} - -func TestRejectBadPadding(t *testing.T) { - if _, err := Decrypt(make([]byte, 16), Key()); err == nil { - t.Fatal("Decrypt accepted bad padding") - } -} diff --git a/go/internal/server/dbcrypt/pages_test.go b/go/internal/server/dbcrypt/pages_test.go deleted file mode 100644 index c49bb19..0000000 --- a/go/internal/server/dbcrypt/pages_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package dbcrypt - -import ( - "bytes" - "testing" -) - -func TestRoundTrip(t *testing.T) { - plain := make([]byte, PageSize*2) - copy(plain, Header) - copy(plain[PageSize:], []byte("second independent page")) - encrypted, err := EncryptPages(plain) - if err != nil { - t.Fatal(err) - } - if bytes.Equal(encrypted, plain) { - t.Fatal("encryption did not change data") - } - decrypted, err := DecryptPages(encrypted) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(decrypted, plain) { - t.Fatal("round trip changed pages") - } -} diff --git a/go/internal/server/deck/auto_recovery.go b/go/internal/server/deck/auto_recovery.go deleted file mode 100644 index 8bfd1e4..0000000 --- a/go/internal/server/deck/auto_recovery.go +++ /dev/null @@ -1,242 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "crypto/sha256" - "encoding/json" - "fmt" - "sort" -) - -func (s *Store) AttachAutoRecovery(f func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error)) { - s.mu.Lock() - defer s.mu.Unlock() - s.autoRecovery = f -} -func (s *Store) AttachAutoRecoveryAllowed(f func() (bool, error)) { - s.mu.Lock() - defer s.mu.Unlock() - s.autoRecoveryAllowed = f -} - -type autoRecoveryReceipt struct { - Digest string - Body []byte -} - -func (s *Store) handleAutoRecovery(req []byte) (int, []byte, bool, error) { - fail := func(e error) (int, []byte, bool, error) { return 373, nil, true, e } - if e := checkSeq(req); e != nil { - return fail(e) - } - caster, _, e := wire.Varint(req, 2) - if e != nil { - return fail(e) - } - seq, _, _ := wire.Varint(req, 1) - s.mu.Lock() - defer s.mu.Unlock() - key := s.sessionID + ":" + fmt.Sprint(seq) - digest := fmt.Sprintf("%x", sha256.Sum256(req)) - if s.storage != nil { - raw, ok, err := s.storage.LoadEntry("deck", "auto_recovery", key) - if err != nil { - return fail(err) - } - if ok { - var r autoRecoveryReceipt - if err = json.Unmarshal(raw, &r); err != nil { - return fail(err) - } - if r.Digest != digest { - return fail(fmt.Errorf("deck: changed automatic recovery replay")) - } - return 373, r.Body, true, nil - } - } - n := clone(s.state) - if len(n.Deck) == 0 { - return fail(fmt.Errorf("deck: automatic recovery has no battle deck")) - } - result := player.AutoRecoveryResult{Caster: caster, Catalyst: n.AutoReviveCatalyst} - if s.wallet != nil { - result.Catalyst = s.wallet.CatalystBalance() - } - mode := uint64(2) - var settings fieldSettings - if s.characters != nil { - settings, e = s.loadFieldSettings() - if e != nil { - return fail(e) - } - if caster != settings.Caster { - return fail(fmt.Errorf("deck: automatic recovery caster differs from saved setting")) - } - if n.FieldCharControlDeckType == 2 { - allExhausted := true - for _, v := range n.Deck { - hp, err := s.characters.CurrentHealth(v.CharacterInvenIndex) - if err != nil { - return fail(err) - } - if hp > 0 { - allExhausted = false - } - } - if allExhausted { - mode = 3 - } - } - field := s.visibleFieldDeckLocked() - var targets []uint64 - seen := map[uint64]bool{} - add := func(id uint64) error { - if seen[id] { - return nil - } - seen[id] = true - c, ok := s.characters.Find(id) - if !ok || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) { - return nil - } - hp, err := s.characters.CurrentHealth(id) - if err != nil { - return err - } - if hp == 0 { - targets = append(targets, id) - } - return nil - } - if n.FieldCharControlDeckType == 0 { //nolint:staticcheck // QF1003 - for _, v := range n.Deck { - if e = add(v.CharacterInvenIndex); e != nil { - return fail(e) - } - } - } else if n.FieldCharControlDeckType == 1 { - for _, v := range field { - if e = add(v.CharacterInvenIndex); e != nil { - return fail(e) - } - } - } - allowed := true - if s.autoRecoveryAllowed != nil { - allowed, e = s.autoRecoveryAllowed() - if e != nil { - return fail(e) - } - } - if settings.AutoRevive && allowed && len(targets) > 0 { - if s.autoRecovery == nil { - return fail(fmt.Errorf("deck: automatic recovery executor unavailable")) - } - result, e = s.autoRecovery(seq, caster, targets) - if e != nil { - return fail(e) - } - settings.Caster = result.Caster - if len(result.Characters) > 0 { - mode = 1 - } - } - // On recovery failure, replace fatigued party members with living, - // permanent owned characters in inventory order. Keep the fatigued - // member when no replacement exists so the client can show exhaustion. - if mode != 1 && len(targets) > 0 { - all := s.characters.RawAll() - sort.Slice(all, func(i, j int) bool { return all[i].InvenIndex < all[j].InvenIndex }) - used := map[uint64]bool{} - for _, v := range n.Deck { - used[v.CharacterInvenIndex] = true - } - for _, v := range field { - used[v.CharacterInvenIndex] = true - } - replacements := map[uint64]player.Character{} - for _, id := range targets { - for _, c := range all { - if used[c.InvenIndex] || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) { - continue - } - hp, err := s.characters.CurrentHealth(c.InvenIndex) - if err != nil { - return fail(err) - } - if hp == 0 { - continue - } - replacements[id] = c - used[c.InvenIndex] = true - break - } - if _, ok := replacements[id]; !ok { - mode = 3 - } - } - for i, v := range n.Deck { - if c, ok := replacements[v.CharacterInvenIndex]; ok { - n.Deck[i].CharacterInvenIndex = c.InvenIndex - } - } - for i, v := range n.FieldDeck { - if c, ok := replacements[v.CharacterInvenIndex]; ok { - n.FieldDeck[i].CharacterInvenIndex = c.InvenIndex - n.FieldDeck[i].CostumeInvenIndex = c.UseCostume - } - } - } - } else if caster != 0 { - return fail(fmt.Errorf("deck: automatic recovery character provider unavailable")) - } - var out []byte - for _, v := range n.Deck { - b := wire.AppendVarint(nil, 1, v.CharacterInvenIndex) - b = wire.AppendVarint(b, 2, v.CostumeInvenIndex) - b = wire.AppendVarint(b, 3, v.Slot) - out = wire.AppendBytes(out, 1, b) - } - for _, v := range n.FieldDeck { - b := wire.AppendVarint(nil, 1, v.Slot) - b = wire.AppendVarint(b, 2, v.CharacterInvenIndex) - b = wire.AppendVarint(b, 3, v.CostumeInvenIndex) - out = wire.AppendBytes(out, 2, b) - } - for _, c := range result.Characters { - out = wire.AppendVarint(out, 3, c.InvenIndex) - out = wire.AppendBytes(out, 5, player.CharacterWire(c)) - } - out = wire.AppendVarint(out, 4, mode) - if result.Caster != 0 { - out = wire.AppendVarint(out, 9, result.Caster) - } - out = wire.AppendVarint(out, 6, result.Experience) - out = wire.AppendVarint(out, 7, result.Catalyst) - out = wire.AppendVarint(out, 8, result.Disabled) - if s.storage != nil { - core, err := json.Marshal(n) - if err != nil { - return fail(err) - } - raw, err := json.Marshal(autoRecoveryReceipt{digest, out}) - if err != nil { - return fail(err) - } - changes := []stateio.EntryMutation{{Bucket: "auto_recovery", Key: key, Payload: raw}} - if s.characters != nil { - raw, err = json.Marshal(settings) - if err != nil { - return fail(err) - } - changes = append(changes, stateio.EntryMutation{Bucket: "field_settings", Key: "state", Payload: raw}) - } - if err = s.storage.SaveWithEntries("deck", core, changes); err != nil { - return fail(err) - } - } - s.state = n - return 373, out, true, nil -} diff --git a/go/internal/server/deck/auto_recovery_test.go b/go/internal/server/deck/auto_recovery_test.go deleted file mode 100644 index 1f97abd..0000000 --- a/go/internal/server/deck/auto_recovery_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "bytes" - "testing" -) - -func recoveryFixture(t *testing.T) *presetFixture { - f := newPresetFixture(t) - f.deck.state.FieldCharControlDeckType = 0 - if e := f.deck.AttachFieldSettings(&gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 14}}); e != nil { - t.Fatal(e) - } - if err := f.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err := f.characters.SetCurrentHealth(100, 100); err != nil { - t.Fatal(err) - } - if err := f.characters.SetCurrentHealth(200, 0); err != nil { - t.Fatal(err) - } - if _, _, _, e := f.deck.Handle("/DeckSave", req(1, triple(200, 1, 1))); e != nil { - t.Fatal(e) - } - if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(2, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 100))); e != nil { - t.Fatal(e) - } - return f -} - -func TestAutoRecoveryPersistsResponseAndDisabledSettingNeverExecutes(t *testing.T) { - f := recoveryFixture(t) - calls := 0 - f.deck.AttachAutoRecovery(func(seq, caster uint64, targets []uint64) (player.AutoRecoveryResult, error) { - calls++ - if caster != 100 || len(targets) != 1 || targets[0] != 200 { - t.Fatal("wrong actual fatigue targets") - } - if err := f.characters.SetCurrentHealth(200, 25); err != nil { - return player.AutoRecoveryResult{}, err - } - c, _ := f.characters.Find(200) - c.HP = 25 - return player.AutoRecoveryResult{Caster: 100, Characters: []player.Character{c}, Experience: 2, Catalyst: 10}, nil - }) - b := req(3, wire.AppendVarint(nil, 2, 100)) - code, out, _, e := f.deck.Handle("/DeckCharAutoRevive", b) - if e != nil || code != 373 { - t.Fatal(e) - } - mode, _, _ := wire.Varint(out, 4) - caster, _, _ := wire.Varint(out, 9) - if mode != 1 || caster != 100 { - t.Fatalf("response%x", out) - } - reopened, e := OpenStore(f.storage, f.seed, testPresetDesign) - if e != nil { - t.Fatal(e) - } - reopened.BeginSession("preset-test") - _, again, _, e := reopened.Handle("/DeckCharAutoRevive", b) - if e != nil || !bytes.Equal(out, again) || calls != 1 { - t.Fatal("automatic recovery receipt replay") - } - if _, _, _, e = f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 3, 100))); e != nil { - t.Fatal(e) - } - if err := f.characters.SetCurrentHealth(200, 0); err != nil { - t.Fatal(err) - } - _, _, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100))) - if e != nil || calls != 1 { - t.Fatal("disabled automatic recovery executed", e) - } - if got := f.deck.CurrentDeck()[0].CharacterInvenIndex; got != 100 { - t.Fatalf("fatigued member not replaced: %d", got) - } - if _, _, _, e = f.deck.Handle("/CharAutoReviveSet", req(6, wire.AppendVarint(nil, 3, 200))); e == nil { - t.Fatal("disabled setting accepted Immortal class14 caster") - } -} - -func TestAutoRecoveryFailureReportsExhaustionAndStoryCannotRecover(t *testing.T) { - f := recoveryFixture(t) - if _, _, _, e := f.deck.Handle("/DeckSave", req(3, triple(100, 1, 1), triple(200, 2, 2))); e != nil { - t.Fatal(e) - } - f.deck.AttachAutoRecovery(func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) { - return player.AutoRecoveryResult{Caster: 100, Catalyst: 0, Disabled: 2}, nil - }) - _, out, _, e := f.deck.Handle("/DeckCharAutoRevive", req(4, wire.AppendVarint(nil, 2, 100))) - mode, _, _ := wire.Varint(out, 4) - disabled, _, _ := wire.Varint(out, 8) - if e != nil || mode != 3 || disabled != 2 { - t.Fatalf("failure mode%d disabled%d %v", mode, disabled, e) - } - if err := f.characters.SetCurrentHealth(100, 0); err != nil { - t.Fatal(err) - } - f.deck.state.FieldCharControlDeckType = 2 - f.deck.AttachAutoRecovery(func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) { - t.Fatal("story mode must not recover temporary party") - return player.AutoRecoveryResult{}, nil - }) - _, out, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100))) - mode, _, _ = wire.Varint(out, 4) - if e != nil || mode != 3 { - t.Fatalf("story fatigue mode%d %v", mode, e) - } -} diff --git a/go/internal/server/deck/costume_use.go b/go/internal/server/deck/costume_use.go deleted file mode 100644 index 6194ad9..0000000 --- a/go/internal/server/deck/costume_use.go +++ /dev/null @@ -1,65 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/wire" - "fmt" - "maps" - "math" -) - -func (s *Store) handleCostumeUse(req []byte) (int, []byte, bool, error) { - fail := func(e error) (int, []byte, bool, error) { return 41, nil, true, e } - if e := checkSeq(req); e != nil { - return fail(e) - } - assignments := map[uint64]uint64{} - e := wire.Walk(req, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 2 { - return fmt.Errorf("deck: invalid costume use entry") - } - cost, _, e := wire.Varint(f.Value, 1) - if e != nil || cost == 0 || cost > math.MaxInt64 { - return fmt.Errorf("deck: invalid costume index") - } - char, _, e := wire.Varint(f.Value, 2) - if e != nil || char == 0 || char > math.MaxInt64 { - return fmt.Errorf("deck: invalid costume character") - } - if _, ok := assignments[char]; ok { - return fmt.Errorf("deck: repeated costume character") - } - assignments[char] = cost - return nil - }) - if e != nil { - return fail(e) - } - if len(assignments) == 0 { - return fail(fmt.Errorf("deck: missing costume assignments")) - } - s.mu.Lock() - defer s.mu.Unlock() - if s.characters != nil { - if s.collection == nil { - return fail(fmt.Errorf("deck: costume collection unavailable")) - } - for char, cost := range assignments { - if _, ok := s.characters.Find(char); !ok { - return fail(fmt.Errorf("deck: unknown costume character")) - } - c, ok := s.collection.CostumeByIndex(cost) - if !ok || c.UseChar != char { - return fail(fmt.Errorf("deck: costume not owned by character")) - } - } - if _, e = s.characters.ApplyPresetCostumes(assignments); e != nil { - return fail(e) - } - } - n := clone(s.state) - maps.Copy(n.Costumes, assignments) - return 41, nil, true, s.commit(n) -} diff --git a/go/internal/server/deck/costume_use_test.go b/go/internal/server/deck/costume_use_test.go deleted file mode 100644 index bd32fd8..0000000 --- a/go/internal/server/deck/costume_use_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "path/filepath" - "testing" -) - -func TestBatchCostumeUseRestoresCharacterSelectionsFromSQLite(t *testing.T) { - p := filepath.Join(t.TempDir(), "state.db") - r, e := accountstate.Open(p) - if e != nil { - t.Fatal(e) - } - seed, e := LoadSeed("../../../seed/v2_35_10/decks.json") - if e != nil { - t.Fatal(e) - } - initial := []player.Character{{InvenIndex: 100, ID: 350, HP: 80, Level: 1, UseCostume: 1001, CostumeID: 3501, ConnectPotentialCostume: 3501}, {InvenIndex: 200, ID: 360, HP: 60, Level: 1, UseCostume: 2001, CostumeID: 3601, ConnectPotentialCostume: 3601}} - inv, e := player.OpenInventory(r, &player.Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - chars, e := player.OpenCharacterStore(r, initial, inv, "", "") - if e != nil { - t.Fatal(e) - } - coll, e := player.OpenCollectionStore(r, []player.Costume{{InvenIndex: 1001, ID: 3501, UseChar: 100}, {InvenIndex: 1002, ID: 3502, UseChar: 100}, {InvenIndex: 2001, ID: 3601, UseChar: 200}, {InvenIndex: 2002, ID: 3602, UseChar: 200}}) - if e != nil { - t.Fatal(e) - } - if e = chars.AttachCollection(coll); e != nil { - t.Fatal(e) - } - if e = coll.EnsurePersisted(); e != nil { - t.Fatal(e) - } - if e = chars.EnsurePersisted(); e != nil { - t.Fatal(e) - } - d, e := OpenStore(r, seed) - if e != nil { - t.Fatal(e) - } - d.characters = chars - d.collection = coll - invalid := req(1, wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1002), 2, 100)), wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1001), 2, 200))) - if _, _, _, err := d.Handle("/CostumeUse", invalid); err == nil { - t.Fatal("accepted another character's costume") - } - if c, _ := chars.Find(100); c.UseCostume != 1001 { - t.Fatal("invalid batch partially changed first character") - } - request := req(1, wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1002), 2, 100)), wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 2002), 2, 200))) - if _, body, _, e := d.Handle("/CostumeUse", request); e != nil || len(body) != 0 { - t.Fatal("empty response protocol", e) - } - if e = r.Close(); e != nil { - t.Fatal(e) - } - r, e = accountstate.Open(p) - if e != nil { - t.Fatal(e) - } - defer func() { - if err := r.Close(); err != nil { - t.Error(err) - } - }() - inv, e = player.OpenInventory(r, &player.Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - coll, e = player.OpenCollectionStore(r, nil) - if e != nil { - t.Fatal(e) - } - chars, e = player.OpenCharacterStore(r, initial, inv, "", "") - if e != nil { - t.Fatal(e) - } - if e = chars.AttachCollection(coll); e != nil { - t.Fatal(e) - } - for _, v := range []struct{ index, cost, id, hp, connect uint64 }{{100, 1002, 3502, 80, 3501}, {200, 2002, 3602, 60, 3601}} { - c, ok := chars.Find(v.index) - if !ok || c.UseCostume != v.cost || c.CostumeID != v.id || c.HP != v.hp || c.ConnectPotentialCostume != v.connect { - t.Fatalf("reconnected character%+v", c) - } - } -} diff --git a/go/internal/server/deck/deck.go b/go/internal/server/deck/deck.go deleted file mode 100644 index f08c79e..0000000 --- a/go/internal/server/deck/deck.go +++ /dev/null @@ -1,535 +0,0 @@ -// Package deck owns local deck, field-party, waypoint, and selected-costume -// state. It stores typed JSON, never captured protobuf/base64 envelopes. -package deck - -import ( - "encoding/json" - "errors" - "fmt" - "maps" - "os" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -type DeckEntry struct { - CharacterInvenIndex uint64 `json:"character_inven_index"` - // CostumeInvenIndex is retained as the persisted Go/JSON name for the - // development save format. On the wire DeckDBInfo field 2 is Position: a - // zero-based battle-grid cell (or -1 while unassigned), not a costume - // inventory index. - CostumeInvenIndex uint64 `json:"costume_inven_index"` - Slot uint64 `json:"slot"` -} -type FieldEntry struct { - Slot uint64 `json:"slot"` - CharacterInvenIndex uint64 `json:"character_inven_index"` - CostumeInvenIndex uint64 `json:"costume_inven_index"` -} -type Seed struct { - Version string `json:"version"` - FieldDeck []FieldEntry `json:"field_deck"` - FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` - AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"` -} -type state struct { - Version string `json:"version"` - Deck []DeckEntry `json:"deck"` - FieldDeck []FieldEntry `json:"field_deck"` - FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` - Waypoints map[uint64][]uint64 `json:"waypoints"` - Costumes map[uint64]uint64 `json:"costumes"` - Packs map[uint64]uint64 `json:"packs"` - HighestTotalBattlePower uint64 `json:"highest_total_battle_power"` - PortraitCostumeID uint64 `json:"portrait_costume_id"` - AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"` -} - -// PortraitCostume exposes the current portrait without changing the frozen deck schema. -func (s *Store) PortraitCostume() uint64 { - s.mu.RLock() - defer s.mu.RUnlock() - return s.state.PortraitCostumeID -} - -type Store struct { - autoRecovery func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) - autoRecoveryAllowed func() (bool, error) - fieldSettingsDesign *gamedata.FieldSettingsDesign - fieldSettingsPack func() (int, error) - mu sync.RWMutex - storage stateio.AtomicEntryStore - state state - presets map[uint64]Preset - presetSlots uint64 - presetDesign gamedata.PresetDesign - costumeSettings map[uint64]CostumeSetting - wallet *player.Wallet - characters *player.CharacterStore - equipment *player.EquipmentInventory - collection *player.CollectionStore - sessionID string - replies map[string]deckReply - waypointDesign func(uint64) (gamedata.WaypointPack, error) - waypointPack func(uint64, bool) error -} - -type deckReply struct { - code int - body []byte -} - -func (s *Store) CurrentDeck() []DeckEntry { - s.mu.RLock() - defer s.mu.RUnlock() - return append([]DeckEntry(nil), s.state.Deck...) -} - -func LoadSeed(path string) (Seed, error) { - b, e := os.ReadFile(path) - if e != nil { - return Seed{}, fmt.Errorf("deck: read seed: %w", e) - } - var s Seed - if e = json.Unmarshal(b, &s); e != nil { - return Seed{}, fmt.Errorf("deck: decode seed: %w", e) - } - if e = s.validate(); e != nil { - return Seed{}, e - } - return s, nil -} -func (s Seed) validate() error { - if s.Version != versionconfig.State() { - return errors.New("deck: wrong seed version") - } - return validField(s.FieldDeck) -} -func validField(entries []FieldEntry) error { - if len(entries) == 0 || len(entries) > 5 { - return errors.New("deck: invalid field deck size") - } - characters := map[uint64]bool{} - costumes := map[uint64]bool{} - sequences := map[uint64]bool{} - for _, e := range entries { - if e.Slot == 0 || e.Slot > 5 || e.CharacterInvenIndex == 0 || - characters[e.CharacterInvenIndex] || sequences[e.Slot] || - (e.CostumeInvenIndex != 0 && costumes[e.CostumeInvenIndex]) { - return errors.New("deck: invalid field deck") - } - characters[e.CharacterInvenIndex] = true - sequences[e.Slot] = true - if e.CostumeInvenIndex != 0 { - costumes[e.CostumeInvenIndex] = true - } - } - for slot := uint64(1); slot <= uint64(len(entries)); slot++ { - if !sequences[slot] { - return errors.New("deck: field deck has a missing sequence") - } - } - return nil -} - -func validDeck(entries []DeckEntry) error { - if len(entries) == 0 || len(entries) > 5 { - return errors.New("deck: invalid battle deck size") - } - characters := map[uint64]bool{} - positions := map[uint64]bool{} - sequences := map[uint64]bool{} - for _, entry := range entries { - position := entry.CostumeInvenIndex - unassigned := position == ^uint64(0) // int32 -1 sign-extends in protobuf varints. - if entry.CharacterInvenIndex == 0 || (!unassigned && position > 11) || entry.Slot == 0 || entry.Slot > 5 || - characters[entry.CharacterInvenIndex] || (!unassigned && positions[position]) || sequences[entry.Slot] { - return errors.New("deck: invalid battle deck") - } - characters[entry.CharacterInvenIndex] = true - if !unassigned { - positions[position] = true - } - sequences[entry.Slot] = true - } - return nil -} -func NewStore(seed Seed, designs ...gamedata.PresetDesign) (*Store, error) { - if e := seed.validate(); e != nil { - return nil, e - } - var design gamedata.PresetDesign - if len(designs) > 1 { - return nil, errors.New("deck: multiple preset designs") - } - if len(designs) == 1 { - design = designs[0] - if err := design.Validate(); err != nil { - return nil, err - } - } - return &Store{presetDesign: design, state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64][]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: design.BaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil -} -func OpenStore(storage stateio.Store, seed Seed, designs ...gamedata.PresetDesign) (*Store, error) { - s, e := NewStore(seed, designs...) - if e != nil { - return nil, e - } - entries, ok := storage.(stateio.AtomicEntryStore) - if storage == nil || !ok { - return nil, errors.New("deck: nil storage") - } - s.storage = entries - b, e := storage.Load("deck") - if e != nil { - return nil, fmt.Errorf("deck: load state: %w", e) - } - if b == nil { - if e = stateio.RequireNoEntries(entries, "deck", "presets", "preset_config", "costume_settings", "field_settings"); e != nil { - return nil, fmt.Errorf("deck: invalid entry storage: %w", e) - } - return s, nil - } - if e = stateio.RequireExactJSONObject(b, "version", "deck", "field_deck", "field_char_control_deck_type", "waypoints", "costumes", "packs", "highest_total_battle_power", "portrait_costume_id", "auto_revive_catalyst"); e != nil { - return nil, fmt.Errorf("deck: incompatible state layout: %w", e) - } - var loaded state - if e = json.Unmarshal(b, &loaded); e != nil { - return nil, fmt.Errorf("deck: malformed state: %w", e) - } - if loaded.Version != versionconfig.State() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil { - return nil, errors.New("deck: invalid saved state") - } - if e = validWaypointState(loaded.Waypoints); e != nil { - return nil, e - } - s.state = loaded - if e = s.loadPresetEntries(); e != nil { - return nil, e - } - return s, nil -} -func (s *Store) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - b, e := s.storage.Load("deck") - if e != nil { - return e - } - if b != nil { - return nil - } - return s.commit(clone(s.state)) -} -func (s *Store) commit(next state) error { - if s.storage != nil { - b, e := json.MarshalIndent(next, "", " ") - if e != nil { - return e - } - if e = s.storage.Save("deck", append(b, '\n')); e != nil { - return e - } - } - s.state = next - return nil -} -func clone(x state) state { - y := x - y.Deck = append([]DeckEntry(nil), x.Deck...) - y.FieldDeck = append([]FieldEntry(nil), x.FieldDeck...) - y.Waypoints = map[uint64][]uint64{} - for k, v := range x.Waypoints { - y.Waypoints[k] = append([]uint64(nil), v...) - } - y.Costumes = map[uint64]uint64{} - maps.Copy(y.Costumes, x.Costumes) - y.Packs = map[uint64]uint64{} - maps.Copy(y.Packs, x.Packs) - return y -} -func checkSeq(req []byte) error { - v, ok, e := wire.Varint(req, 1) - if e != nil || !ok || v == 0 || v > 2147483647 { - return errors.New("deck: invalid request sequence") - } - return nil -} -func triples(req []byte) ([]DeckEntry, error) { - var out []DeckEntry - e := wire.Walk(req, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 2 { - return errors.New("deck: deck field") - } - a, aok, e := wire.Varint(f.Value, 1) - if e != nil || !aok || a == 0 { - return errors.New("deck: deck character") - } - position, _, e := wire.Varint(f.Value, 2) - if e != nil || (position > 11 && position != ^uint64(0)) { - return errors.New("deck: invalid deck position") - } - sequence, sequenceOK, e := wire.Varint(f.Value, 3) - if e != nil || !sequenceOK || sequence == 0 || sequence > 5 { - return errors.New("deck: invalid deck sequence") - } - out = append(out, DeckEntry{CharacterInvenIndex: a, CostumeInvenIndex: position, Slot: sequence}) - return nil - }) - if e != nil { - return nil, e - } - if len(out) == 0 { - return nil, errors.New("deck: empty deck") - } - if e := validDeck(out); e != nil { - return nil, e - } - return out, nil -} -func fieldEntries(req []byte) ([]FieldEntry, error) { - var out []FieldEntry - e := wire.Walk(req, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 2 { - return errors.New("deck: field deck entry is not a message") - } - slot, slotOK, err := wire.Varint(f.Value, 1) - if err != nil || !slotOK || slot == 0 { - return errors.New("deck: invalid field deck slot") - } - character, characterOK, err := wire.Varint(f.Value, 2) - if err != nil || !characterOK || character == 0 { - return errors.New("deck: invalid field deck character") - } - costume, _, err := wire.Varint(f.Value, 3) - if err != nil { - return errors.New("deck: invalid field deck costume") - } - out = append(out, FieldEntry{Slot: slot, CharacterInvenIndex: character, CostumeInvenIndex: costume}) - return nil - }) - if e != nil { - return nil, e - } - if len(out) == 0 { - return nil, errors.New("deck: empty field deck") - } - return out, validField(out) -} - -func (s *Store) validateOwnedDeckLocked(entries []DeckEntry) error { - if s.characters == nil { - return nil - } - for _, entry := range entries { - if _, found := s.characters.Find(entry.CharacterInvenIndex); !found { - return fmt.Errorf("deck: battle deck references unknown character %d", entry.CharacterInvenIndex) - } - } - return nil -} - -func (s *Store) validateOwnedFieldDeckLocked(entries []FieldEntry) error { - if s.characters == nil || s.collection == nil { - return nil - } - for _, entry := range entries { - character, found := s.characters.Find(entry.CharacterInvenIndex) - if !found { - return fmt.Errorf("deck: field deck references unknown character %d", entry.CharacterInvenIndex) - } - if player.IsStoryCharacter(character) && !s.temporaryAllowed(character) { - return fmt.Errorf("deck: field character unavailable in this pack") - } - if entry.CostumeInvenIndex == 0 { - continue - } - if (player.IsStoryCharacter(character) || player.IsCharmCharacter(character)) && character.UseCostume == entry.CostumeInvenIndex { - continue - } - costume, found := s.collection.CostumeByIndex(entry.CostumeInvenIndex) - if !found { - return fmt.Errorf("deck: field deck references unknown costume %d", entry.CostumeInvenIndex) - } - if costume.UseChar != entry.CharacterInvenIndex { - return fmt.Errorf("deck: costume %d does not belong to character %d", entry.CostumeInvenIndex, entry.CharacterInvenIndex) - } - } - return nil -} - -// Handle implements session.Handler. Every mutation validates its complete -// typed request before committing a replacement JSON state. -func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) { - switch path { - case "/TalentSlotSave", "/CharAutoReviveSet": - return s.handleFieldSettings(path, req) - case "/PresetInfo": - return s.handlePresetInfo(req) - case "/PresetSave": - return s.handlePresetSave(req) - case "/PresetAddSlot": - return s.handlePresetAddSlot(req) - case "/PresetInfoChange": - return s.handlePresetInfoChange(req) - case "/PresetDelete": - return s.handlePresetDelete(req) - case "/PresetUse": - return s.handlePresetUse(req) - case "/DeckCostumeSettingInfo": - return s.handleCostumeSettingInfo(req) - case "/DeckCostumeSettingSave": - return s.handleCostumeSettingSave(req) - case "/DeckInfo": - if e := checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.RLock() - defer s.mu.RUnlock() - out := encodeDeck(s.state.Deck) - if s.fieldSettingsDesign != nil { - v, e := s.loadFieldSettings() - if e != nil { - return 0, nil, true, e - } - for _, id := range s.projectTalentSlots(v.TalentIDs) { - out = wire.AppendVarint(out, 2, id) - } - } - return 8, out, true, nil - case "/FieldDeckInfo": - if e := checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.RLock() - defer s.mu.RUnlock() - return 273, encodeField(s.visibleFieldDeckLocked()), true, nil - case "/DeckCharAutoRevive": - return s.handleAutoRecovery(req) - case "/WaypointInfo": - return s.handleWaypoint(path, req) - case "/DeckSave": - if e := checkSeq(req); e != nil { - return 0, nil, true, e - } - x, e := triples(req) - if e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - if e = s.validateOwnedDeckLocked(x); e != nil { - return 0, nil, true, e - } - n := clone(s.state) - n.Deck = x - e = s.commit(n) - return 10, nil, true, e - case "/FieldDeckSave": - if e := checkSeq(req); e != nil { - return 0, nil, true, e - } - x, e := fieldEntries(req) - if e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - if e = s.validateOwnedFieldDeckLocked(x); e != nil { - return 0, nil, true, e - } - n := clone(s.state) - n.FieldDeck = x - e = s.commit(n) - return 274, nil, true, e - case "/SaveFieldCharControlDeckType": - // Define_FieldCharControllDeckType is a proto3 enum whose valid values - // are BATTLE=0, FIELD=1 and STORY=2. BATTLE is the protobuf default, so - // the generated client deliberately omits field 2 when it switches out - // of story mode after the final quest. An absent field is therefore a - // real value 0, not a malformed request. - v, _, e := wire.Varint(req, 2) - if e != nil || v > 2 { - return 0, nil, true, errors.New("deck: invalid field control type") - } - if e = checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - n := clone(s.state) - n.FieldCharControlDeckType = v - e = s.commit(n) - return 288, nil, true, e - case "/WaypointSave", "/WaypointUse": - return s.handleWaypoint(path, req) - case "/CostumeUse": - return s.handleCostumeUse(req) - case "/SaveTotalBattlePower": - power, ok, e := wire.Varint(req, 2) - if e != nil || !ok || power == 0 { - return 0, nil, true, errors.New("deck: invalid total battle power") - } - if e = checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - n := clone(s.state) - if power > n.HighestTotalBattlePower { - n.HighestTotalBattlePower = power - } - if e = s.commit(n); e != nil { - return 0, nil, true, e - } - return 258, wire.AppendVarint(nil, 1, n.HighestTotalBattlePower), true, nil - case "/UserPortraitChange": - costumeID, ok, e := wire.Varint(req, 2) - if e != nil || !ok || costumeID == 0 { - return 0, nil, true, errors.New("deck: invalid portrait costume") - } - if e = checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - n := clone(s.state) - n.PortraitCostumeID = costumeID - if e = s.commit(n); e != nil { - return 0, nil, true, e - } - return 75, wire.AppendVarint(nil, 1, costumeID), true, nil - } - return 0, nil, false, nil -} -func encodeDeck(xs []DeckEntry) []byte { - var b []byte - for _, x := range xs { - v := wire.AppendVarint(nil, 1, x.CharacterInvenIndex) - v = wire.AppendVarint(v, 2, x.CostumeInvenIndex) - v = wire.AppendVarint(v, 3, x.Slot) - b = wire.AppendBytes(b, 1, v) - } - return b -} -func encodeField(xs []FieldEntry) []byte { - var b []byte - for _, x := range xs { - v := wire.AppendVarint(nil, 1, x.Slot) - v = wire.AppendVarint(v, 2, x.CharacterInvenIndex) - v = wire.AppendVarint(v, 3, x.CostumeInvenIndex) - b = wire.AppendBytes(b, 1, v) - } - return b -} diff --git a/go/internal/server/deck/deck_test.go b/go/internal/server/deck/deck_test.go deleted file mode 100644 index 352d910..0000000 --- a/go/internal/server/deck/deck_test.go +++ /dev/null @@ -1,377 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "path/filepath" - "testing" -) - -func seeded(t *testing.T) *Store { - t.Helper() - x, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json")) - if e != nil { - t.Fatal(e) - } - s, e := NewStore(x, testPresetDesign) - if e != nil { - t.Fatal(e) - } - return s -} -func req(seq uint64, fields ...[]byte) []byte { - b := wire.AppendVarint(nil, 1, seq) - for _, f := range fields { - b = append(b, f...) - } - return b -} -func triple(a, b, c uint64) []byte { - v := wire.AppendVarint(nil, 1, a) - v = wire.AppendVarint(v, 2, b) - v = wire.AppendVarint(v, 3, c) - return wire.AppendBytes(nil, 2, v) -} - -func attachFormationOwnership(t *testing.T, store *Store) { - t.Helper() - starter := &player.Starter{ - Version: versionconfig.State(), - Characters: []player.Character{ - {InvenIndex: 101, ID: 350, Level: 1}, - {InvenIndex: 102, ID: 351, Level: 1}, - {InvenIndex: 103, ID: 352, Level: 1}, - {InvenIndex: 104, ID: 353, Level: 1}, - {InvenIndex: 105, ID: 354, Level: 1}, - }, - Costumes: []player.Costume{ - {InvenIndex: 201, ID: 60101, UseChar: 101}, - {InvenIndex: 202, ID: 60201, UseChar: 102}, - {InvenIndex: 203, ID: 60301, UseChar: 103}, - {InvenIndex: 204, ID: 60401, UseChar: 104}, - {InvenIndex: 205, ID: 60501, UseChar: 105}, - }, - } - inventory, err := player.OpenInventory(stateio.NewMemory(), starter) - if err != nil { - t.Fatal(err) - } - characters, err := player.OpenCharacterStore(stateio.NewMemory(), starter.Characters, inventory, "", "") - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(stateio.NewMemory(), starter.Costumes) - if err != nil { - t.Fatal(err) - } - store.characters = characters - store.collection = collection -} -func TestFieldDeckSeedAndSave(t *testing.T) { - s := seeded(t) - code, b, ok, e := s.Handle("/FieldDeckInfo", req(1)) - if e != nil || !ok || code != 273 { - t.Fatalf("info %d %t %v", code, ok, e) - } - n := 0 - if e = wire.Walk(b, func(f wire.Field) error { - if f.Number == 1 { - n++ - } - return nil - }); e != nil || n != 5 { - t.Fatalf("seed field deck: %d %v", n, e) - } - // FieldDeckDBInfo is sequence#1, character#2, costume#3. - field := triple(1, 99, 199) - code, _, ok, e = s.Handle("/FieldDeckSave", req(2, field)) - if e != nil || !ok || code != 274 { - t.Fatalf("save: %d %t %v", code, ok, e) - } - _, b, _, _ = s.Handle("/FieldDeckInfo", req(3)) - var got uint64 - _ = wire.Walk(b, func(f wire.Field) error { - if f.Number == 1 { - got, _, _ = wire.Varint(f.Value, 2) - } - return nil - }) - if got != 99 { - t.Fatalf("saved char=%d", got) - } -} -func TestDeckPersistenceAndCommands(t *testing.T) { - seed, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json")) - if e != nil { - t.Fatal(e) - } - storage := stateio.NewMemory() - s, e := OpenStore(storage, seed) - if e != nil { - t.Fatal(e) - } - code, _, _, e := s.Handle("/DeckSave", req(1, triple(100, 2, 1))) - if e != nil || code != 10 { - t.Fatalf("deck save: %d %v", code, e) - } - way := wire.AppendVarint(nil, 2, 21) - if e = s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) { - return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 212}}}, nil - }, func(uint64, bool) error { return nil }); e != nil { - t.Fatal(e) - } - way = wire.AppendVarint(way, 3, 1) - if code, _, _, e = s.Handle("/WaypointSave", req(2, way)); e != nil || code != 32 { - t.Fatalf("way: %d %v", code, e) - } - use := wire.AppendVarint(nil, 1, 200) - use = wire.AppendVarint(use, 2, 100) - if code, _, _, e = s.Handle("/CostumeUse", req(3, wire.AppendBytes(nil, 2, use))); e != nil || code != 41 { - t.Fatalf("use: %d %v", code, e) - } - if _, _, handled, err := s.Handle("/PackBuy", req(4, wire.AppendVarint(nil, 2, 21))); handled || err != nil { - t.Fatal("pack purchase must be handled by world") - } - var b []byte - reopened, e := OpenStore(storage, seed) - if e != nil { - t.Fatal(e) - } - _, b, _, e = reopened.Handle("/DeckInfo", req(5)) - if e != nil { - t.Fatal(e) - } - entry, _, _ := wire.Bytes(b, 1) - id, _, _ := wire.Varint(entry, 1) - if id != 100 { - t.Fatalf("persist deck=%d", id) - } -} -func TestRejectsInvalidMutations(t *testing.T) { - s := seeded(t) - if _, _, _, e := s.Handle("/DeckSave", req(1, triple(1, 2, 1), triple(3, 4, 1))); e == nil { - t.Fatal("duplicate slots accepted") - } - if _, _, _, e := s.Handle("/WaypointSave", req(1, wire.AppendVarint(nil, 2, 21))); e == nil { - t.Fatal("waypoint missing id accepted") - } -} - -func TestDeckSaveValidatesFormationShapeAndOwnership(t *testing.T) { - valid := [][]byte{ - triple(101, 0, 1), triple(102, 1, 2), triple(103, 2, 3), - triple(104, 3, 4), triple(105, 4, 5), - } - tests := []struct { - name string - entries [][]byte - }{ - {name: "six characters", entries: append(append([][]byte{}, valid...), triple(101, 5, 1))}, - {name: "duplicate character", entries: [][]byte{triple(101, 0, 1), triple(101, 1, 2)}}, - {name: "duplicate position", entries: [][]byte{triple(101, 0, 1), triple(102, 0, 2)}}, - {name: "duplicate sequence", entries: [][]byte{triple(101, 0, 1), triple(102, 1, 1)}}, - {name: "position outside twelve cells", entries: [][]byte{triple(101, 12, 1)}}, - {name: "sequence outside formation", entries: [][]byte{triple(101, 0, 6)}}, - {name: "unknown character", entries: [][]byte{triple(999, 0, 1)}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - s := seeded(t) - attachFormationOwnership(t, s) - if _, _, handled, err := s.Handle("/DeckSave", req(1, test.entries...)); err == nil || !handled { - t.Fatalf("invalid deck accepted handled=%v err=%v", handled, err) - } - if len(s.state.Deck) != 0 { - t.Fatalf("invalid deck mutated state: %+v", s.state.Deck) - } - }) - } - s := seeded(t) - attachFormationOwnership(t, s) - if code, _, handled, err := s.Handle("/DeckSave", req(2, valid...)); err != nil || !handled || code != 10 || len(s.state.Deck) != 5 { - t.Fatalf("valid deck rejected code=%d handled=%v deck=%+v err=%v", code, handled, s.state.Deck, err) - } - unassigned := [][]byte{ - triple(101, ^uint64(0), 1), triple(102, ^uint64(0), 2), triple(103, ^uint64(0), 3), - triple(104, ^uint64(0), 4), triple(105, ^uint64(0), 5), - } - if code, _, handled, err := s.Handle("/DeckSave", req(3, unassigned...)); err != nil || !handled || code != 10 { - t.Fatalf("official unassigned positions rejected code=%d handled=%v err=%v", code, handled, err) - } -} - -func TestFieldDeckSaveValidatesFormationShapeAndOwnership(t *testing.T) { - valid := [][]byte{ - triple(1, 101, 201), triple(2, 102, 202), triple(3, 103, 203), - triple(4, 104, 204), triple(5, 105, 205), - } - tests := []struct { - name string - entries [][]byte - }{ - {name: "six characters", entries: append(append([][]byte{}, valid...), triple(1, 101, 201))}, - {name: "duplicate character", entries: [][]byte{triple(1, 101, 201), triple(2, 101, 202)}}, - {name: "duplicate costume", entries: [][]byte{triple(1, 101, 201), triple(2, 102, 201)}}, - {name: "duplicate sequence", entries: [][]byte{triple(1, 101, 201), triple(1, 102, 202)}}, - {name: "sequence outside formation", entries: [][]byte{triple(6, 101, 201)}}, - {name: "unknown character", entries: [][]byte{triple(1, 999, 201)}}, - {name: "unknown costume", entries: [][]byte{triple(1, 101, 999)}}, - {name: "costume owned by another character", entries: [][]byte{triple(1, 101, 202)}}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - s := seeded(t) - attachFormationOwnership(t, s) - before := append([]FieldEntry(nil), s.state.FieldDeck...) - if _, _, handled, err := s.Handle("/FieldDeckSave", req(1, test.entries...)); err == nil || !handled { - t.Fatalf("invalid field deck accepted handled=%v err=%v", handled, err) - } - if len(s.state.FieldDeck) != len(before) || s.state.FieldDeck[0] != before[0] { - t.Fatalf("invalid field deck mutated state: %+v", s.state.FieldDeck) - } - }) - } - s := seeded(t) - attachFormationOwnership(t, s) - if code, _, handled, err := s.Handle("/FieldDeckSave", req(2, valid...)); err != nil || !handled || code != 274 || len(s.state.FieldDeck) != 5 { - t.Fatalf("valid field deck rejected code=%d handled=%v deck=%+v err=%v", code, handled, s.state.FieldDeck, err) - } - // A zero costume index means to use the character's current costume; the - // client explicitly supports this fallback when resolving its field leader. - if code, _, handled, err := s.Handle("/FieldDeckSave", req(3, triple(1, 101, 0))); err != nil || !handled || code != 274 { - t.Fatalf("current-costume fallback rejected code=%d handled=%v err=%v", code, handled, err) - } -} - -func TestDeckSavesRequireRequestSequence(t *testing.T) { - s := seeded(t) - if _, _, handled, err := s.Handle("/DeckSave", triple(101, 0, 1)); err == nil || !handled { - t.Fatalf("DeckSave without seq accepted handled=%v err=%v", handled, err) - } - if _, _, handled, err := s.Handle("/FieldDeckSave", triple(1, 101, 201)); err == nil || !handled { - t.Fatalf("FieldDeckSave without seq accepted handled=%v err=%v", handled, err) - } -} - -func TestSaveFieldCharControlDeckTypeAcceptsProtoDefaultBattle(t *testing.T) { - s := seeded(t) - // FCCD_BATTLE=0 is omitted by proto3 serialization. This is the exact - // shape sent when the client leaves story control after the final quest. - code, _, handled, err := s.Handle("/SaveFieldCharControlDeckType", req(1)) - if err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != 0 { - t.Fatalf("battle save code=%d handled=%v type=%d err=%v", code, handled, s.state.FieldCharControlDeckType, err) - } - for seq, value := range []uint64{1, 2} { - request := req(uint64(seq+2), wire.AppendVarint(nil, 2, value)) - if code, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", request); err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != value { - t.Fatalf("type %d save code=%d handled=%v stored=%d err=%v", value, code, handled, s.state.FieldCharControlDeckType, err) - } - } - if _, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", req(9, wire.AppendVarint(nil, 2, 3))); !handled || err == nil { - t.Fatalf("unknown enum accepted handled=%v err=%v", handled, err) - } -} - -func TestTotalBattlePowerKeepsHighest(t *testing.T) { - s := seeded(t) - for seq, power := range []uint64{1300, 900} { - request := req(uint64(seq+1), wire.AppendVarint(nil, 2, power)) - code, response, ok, err := s.Handle("/SaveTotalBattlePower", request) - if err != nil || !ok || code != 258 { - t.Fatalf("save power: code=%d ok=%v err=%v", code, ok, err) - } - highest, found, err := wire.Varint(response, 1) - if err != nil || !found || highest != 1300 { - t.Fatalf("highest=%d found=%v err=%v", highest, found, err) - } - } -} - -func TestPortraitChangeEchoesAndStoresCostume(t *testing.T) { - s := seeded(t) - request := req(1, wire.AppendVarint(nil, 2, 3501)) - code, response, ok, err := s.Handle("/UserPortraitChange", request) - if err != nil || !ok || code != 75 { - t.Fatalf("portrait: code=%d ok=%v err=%v", code, ok, err) - } - id, found, err := wire.Varint(response, 1) - if err != nil || !found || id != 3501 || s.state.PortraitCostumeID != 3501 { - t.Fatalf("portrait response=%d found=%v stored=%d err=%v", id, found, s.state.PortraitCostumeID, err) - } -} - -func TestDeckCharAutoReviveUsesCurrentFormationWithoutInventingRevives(t *testing.T) { - s := seeded(t) - if code, _, handled, err := s.Handle("/DeckSave", req(1, triple(535607162, 10, 1), triple(535604120, 1, 2))); code != 10 || !handled || err != nil { - t.Fatalf("save code=%d handled=%v err=%v", code, handled, err) - } - code, response, handled, err := s.Handle("/DeckCharAutoRevive", req(2)) - if err != nil || !handled || code != 373 { - t.Fatalf("auto revive code=%d handled=%v err=%v", code, handled, err) - } - var battle, field, revive int - if err := wire.Walk(response, func(f wire.Field) error { - switch f.Number { - case 1: - battle++ - if battle == 1 { - index, _, _ := wire.Varint(f.Value, 1) - if index != 535607162 { - t.Errorf("first battle character %d", index) - } - } - case 2: - field++ - case 3, 5: - revive++ - } - return nil - }); err != nil || battle != 2 || field != 5 || revive != 0 { - t.Fatalf("formation battle=%d field=%d invented revive=%d err=%v", battle, field, revive, err) - } - mode, _, _ := wire.Varint(response, 4) - catalyst, _, _ := wire.Varint(response, 7) - if mode != 2 || catalyst != 200 { - t.Fatalf("official formation mode=%d catalyst=%d", mode, catalyst) - } - if _, _, handled, err := s.Handle("/DeckCharAutoRevive", req(3, wire.AppendVarint(nil, 2, 535604120))); !handled || err == nil { - t.Fatalf("unverified caster incorrectly accepted handled=%v err=%v", handled, err) - } - if _, _, handled, err := s.Handle("/DeckCharAutoRevive", nil); !handled || err == nil { - t.Fatalf("missing sequence incorrectly accepted handled=%v err=%v", handled, err) - } -} - -func TestDeckCharAutoRevivePreservesExplicitZeroCatalyst(t *testing.T) { - seed, err := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json")) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - s, err := OpenStore(storage, seed) - if err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/DeckSave", req(1, triple(88, 11, 1))); err != nil { - t.Fatal(err) - } - s.state.AutoReviveCatalyst = 0 - if err := s.commit(s.state); err != nil { - t.Fatal(err) - } - reopened, err := OpenStore(storage, seed) - if err != nil || reopened.state.AutoReviveCatalyst != 0 || reopened.state.Deck[0].CharacterInvenIndex != 88 { - t.Fatalf("reloaded state=%+v err=%v", reopened.state, err) - } -} - -var testPresetDesign = func() gamedata.PresetDesign { - d := gamedata.PresetDesign{BaseCount: 5, Maximum: 12, PriceType: 4, Price: 2000, Icons: map[uint64]bool{}} - for id := uint64(1); id <= 21; id++ { - d.Icons[id] = true - } - return d -}() diff --git a/go/internal/server/deck/field_settings_test.go b/go/internal/server/deck/field_settings_test.go deleted file mode 100644 index 1f23473..0000000 --- a/go/internal/server/deck/field_settings_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" - "time" -) - -func TestTalentSlotsRestoreOwnershipAndClearSlots(t *testing.T) { - f := newPresetFixture(t) - design := &gamedata.FieldSettingsDesign{TalentSlots: 3, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 9}} - if e := f.deck.AttachFieldSettings(design); e != nil { - t.Fatal(e) - } - save := req(1, wire.AppendVarint(nil, 2, 360), wire.AppendVarint(nil, 2, 0), wire.AppendVarint(nil, 2, 350)) - if _, _, _, e := f.deck.Handle("/TalentSlotSave", save); e != nil { - t.Fatal(e) - } - for _, bad := range [][]uint64{{350, 350, 0}, {999, 0, 0}, {100, 0, 0}, {350}} { - var r []byte - for _, id := range bad { - r = wire.AppendVarint(r, 2, id) - } - if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(2, r)); e == nil { - t.Fatal("invalid slots accepted", bad) - } - } - if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(3, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 200))); e == nil { - t.Fatal("wrong talent caster accepted") - } - if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 100))); e != nil { - t.Fatal(e) - } - reopened, e := OpenStore(f.storage, f.seed, f.deck.presetDesign) - if e != nil { - t.Fatal(e) - } - reopened.characters = f.characters - if e = reopened.AttachFieldSettings(design); e != nil { - t.Fatal(e) - } - ids, e := reopened.TalentCharacterIDs() - if e != nil || ids[0] != 360 || ids[1] != 0 || ids[2] != 350 { - t.Fatal(ids, e) - } - on, caster, e := reopened.AutoReviveSettings() - if e != nil || !on || caster != 100 { - t.Fatal(on, caster, e) - } - _, b, _, e := reopened.Handle("/DeckInfo", req(5)) - if e != nil { - t.Fatal(e) - } - n := 0 - if err := wire.Walk(b, func(f wire.Field) error { - if f.Number == 2 { - n++ - } - return nil - }); err != nil { - t.Fatal(err) - } - if n != 3 { - t.Fatal("DeckInfo missing empty quick slot", n) - } -} - -func TestCharmFieldAndTalentSlotsExpireWithoutPermanentOwnership(t *testing.T) { - f := newPresetFixture(t) - base := f.characters.RawAll() - index := player.CharmCharacterIndexBase + 11 - install := func(expiry uint64) { - m := stateio.NewMemory() - inv, e := player.OpenInventory(m, &player.Starter{Version: f.seed.Version}) - if e != nil { - t.Fatal(e) - } - chars := append(append([]player.Character(nil), base...), player.Character{InvenIndex: index, ID: 9010, Level: 1, CostumeID: 90101, UseCostume: 0, ExpiryTime: expiry}) - s, e := player.OpenCharacterStore(m, chars, inv, "", "") - if e != nil { - t.Fatal(e) - } - f.deck.characters = s - } - install(uint64(time.Now().Add(time.Hour).UnixMilli())) - d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9010: 10}, CharacterTemporaryPack: map[uint64]int{9010: 99}} - if err := f.deck.AttachFieldSettingsPack(func() (int, error) { return 4, nil }); err != nil { - t.Fatal(err) - } - if e := f.deck.AttachFieldSettings(d); e != nil { - t.Fatal(e) - } - if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(1, wire.AppendVarint(nil, 2, 9010))); e != nil { - t.Fatal(e) - } - if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(2, triple(1, index, 0))); e != nil { - t.Fatal(e) - } - if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(3, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, index))); e == nil { - t.Fatal("charm recovery caster accepted") - } - install(uint64(time.Now().Add(-time.Second).UnixMilli())) - ids, e := f.deck.TalentCharacterIDs() - if e != nil || ids[0] != 0 { - t.Fatal("expired charm quick slot leaked", ids, e) - } - if len(f.deck.CurrentFieldDeck()) != 0 { - t.Fatal("expired charm field party leaked") - } -} - -func TestTemporaryFieldAndTalentSlotsFollowPack(t *testing.T) { - f := newPresetFixture(t) - c := player.Character{InvenIndex: player.StoryCharacterIndexBase + 99, ID: 9001, Level: 1, CostumeID: 90011, UseCostume: 9999} - if e := f.characters.EnsureStoryCharacters([]player.Character{c}); e != nil { - t.Fatal(e) - } - pack := 4 - d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9001: 9}, CharacterTemporaryPack: map[uint64]int{9001: 4}} - if err := f.deck.AttachFieldSettingsPack(func() (int, error) { return pack, nil }); err != nil { - t.Fatal(err) - } - if e := f.deck.AttachFieldSettings(d); e != nil { - t.Fatal(e) - } - if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(1, wire.AppendVarint(nil, 2, c.ID))); e != nil { - t.Fatal(e) - } - if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(2, triple(1, c.InvenIndex, c.UseCostume))); e != nil { - t.Fatal(e) - } - pack = 5 - ids, e := f.deck.TalentCharacterIDs() - if e != nil || ids[0] != 0 { - t.Fatal("temporary quick slot leaked", ids, e) - } - if len(f.deck.CurrentFieldDeck()) != 0 { - t.Fatal("temporary field party leaked") - } - if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(3, triple(1, c.InvenIndex, c.UseCostume))); e == nil { - t.Fatal("foreign-pack temporary party accepted") - } - if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4)); e != nil { - t.Fatal("inactive quick slots blocked unrelated setting", e) - } -} - -func TestMalformedSettingsDoNotPersist(t *testing.T) { - f := newPresetFixture(t) - d := &gamedata.FieldSettingsDesign{TalentSlots: 2, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 9}} - if e := f.deck.AttachFieldSettings(d); e != nil { - t.Fatal(e) - } - for _, bad := range [][]byte{req(0, wire.AppendVarint(nil, 2, 350)), req(1, wire.AppendBytes(nil, 2, []byte{0x80})), req(2, wire.AppendFixed64(nil, 2, 350)), req(^uint64(0), wire.AppendVarint(nil, 2, 350)), req(3, wire.AppendVarint(nil, 2, ^uint64(0)), wire.AppendVarint(nil, 2, 0))} { - if _, _, _, e := f.deck.Handle("/TalentSlotSave", bad); e == nil { - t.Fatal("malformed talent request accepted") - } - if _, ok, e := f.storage.LoadEntry("deck", "field_settings", "state"); e != nil || ok { - t.Fatal("invalid talent request wrote settings", e) - } - } - if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 2, 2))); e == nil { - t.Fatal("invalid protobuf bool accepted") - } - if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(5, triple(2, 100, 1001))); e == nil { - t.Fatal("field gap accepted") - } -} diff --git a/go/internal/server/deck/preset.go b/go/internal/server/deck/preset.go deleted file mode 100644 index 5cd592c..0000000 --- a/go/internal/server/deck/preset.go +++ /dev/null @@ -1,888 +0,0 @@ -package deck - -import ( - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "maps" - "slices" - "sort" - "strconv" - "strings" - "unicode" - "unicode/utf8" - - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type Preset struct { - Name string `json:"name"` - ResourceID uint64 `json:"resource_id"` - ResourceColor uint64 `json:"resource_color"` - Slot uint64 `json:"slot"` - Decks []PresetDeck `json:"decks"` - Blesses []PresetBless `json:"blesses"` -} - -type PresetDeck struct { - Deck DeckEntry `json:"deck"` - CostumeIndex uint64 `json:"costume_index"` - Equipment []PresetEquipmentItem `json:"equipment"` - Team uint64 `json:"team"` -} - -type PresetEquipmentItem struct { - Type uint64 `json:"type"` - Index uint64 `json:"index"` -} - -type PresetBless struct { - DeckType uint64 `json:"deck_type"` - IDs []uint64 `json:"ids"` -} - -type CostumeSetting struct { - CharacterIndex uint64 `json:"character_index"` - Sequence []CostumeSettingItem `json:"sequence"` - BattleMode uint64 `json:"battle_mode"` - MonsterID uint64 `json:"monster_id"` -} - -type CostumeSettingItem struct { - CostumeIndex int64 `json:"costume_index"` - BurstLevel uint64 `json:"burst_level"` -} - -func (s *Store) AttachPresetRuntime(wallet *player.Wallet, characters *player.CharacterStore, equipment *player.EquipmentInventory, collection *player.CollectionStore) error { - if wallet == nil || characters == nil || equipment == nil || collection == nil { - return errors.New("deck: incomplete preset runtime") - } - s.mu.Lock() - defer s.mu.Unlock() - s.wallet, s.characters, s.equipment, s.collection = wallet, characters, equipment, collection - return s.validatePresetOwnershipLocked() -} - -func (s *Store) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - if id == "" || id == s.sessionID { - return - } - s.sessionID = id - s.replies = map[string]deckReply{} -} - -func (s *Store) PresetSlotCount() uint64 { - s.mu.RLock() - defer s.mu.RUnlock() - return s.presetSlots -} - -func (s *Store) loadPresetEntries() error { - rawConfig, found, err := s.storage.LoadEntry("deck", "preset_config", "slots") - if err != nil { - return err - } - if found { - if err := json.Unmarshal(rawConfig, &s.presetSlots); err != nil || s.presetSlots < s.presetDesign.BaseCount || s.presetSlots > s.presetDesign.Maximum { - return errors.New("deck: invalid preset slot configuration") - } - } - raw, err := s.storage.ListEntries("deck", "presets") - if err != nil { - return err - } - for key, payload := range raw { - slot, err := strconv.ParseUint(key, 10, 64) - if err != nil || key != strconv.FormatUint(slot, 10) { - return fmt.Errorf("deck: invalid preset key %q", key) - } - var preset Preset - if err := json.Unmarshal(payload, &preset); err != nil || preset.Slot != slot { - return fmt.Errorf("deck: invalid preset %q", key) - } - if err := s.validatePresetShape(preset, s.presetSlots); err != nil { - return fmt.Errorf("deck: invalid preset %q: %w", key, err) - } - s.presets[slot] = preset - } - rawSettings, err := s.storage.ListEntries("deck", "costume_settings") - if err != nil { - return err - } - for key, payload := range rawSettings { - index, err := strconv.ParseUint(key, 10, 64) - if err != nil || index == 0 || key != strconv.FormatUint(index, 10) { - return fmt.Errorf("deck: invalid costume setting key %q", key) - } - var setting CostumeSetting - if err := json.Unmarshal(payload, &setting); err != nil || setting.CharacterIndex != index { - return fmt.Errorf("deck: invalid costume setting %q", key) - } - s.costumeSettings[index] = setting - } - return nil -} - -func (s *Store) validatePresetOwnershipLocked() error { - if s.characters == nil || s.collection == nil || s.equipment == nil { - return nil - } - for _, preset := range s.presets { - if err := s.validatePresetOwnedLocked(preset); err != nil { - return fmt.Errorf("deck: saved preset %d: %w", preset.Slot, err) - } - } - for _, setting := range s.costumeSettings { - if _, found := s.characters.Find(setting.CharacterIndex); !found { - return fmt.Errorf("deck: costume setting references unknown character %d", setting.CharacterIndex) - } - for _, item := range setting.Sequence { - if item.CostumeIndex > 0 { - if _, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex)); !found { - return fmt.Errorf("deck: costume setting references unknown costume %d", item.CostumeIndex) - } - } - } - } - return nil -} - -func (s *Store) validatePresetShape(p Preset, slotCount uint64) error { - if p.Slot >= slotCount || (p.ResourceID != 0 && !s.presetDesign.Icons[p.ResourceID]) || p.ResourceColor > 5 || !validPresetName(p.Name) || len(p.Decks) > 5 { - return errors.New("invalid metadata or deck count") - } - characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{} - for _, deck := range p.Decks { - if deck.Deck.CharacterInvenIndex == 0 || deck.Deck.CostumeInvenIndex > 11 || deck.Deck.Slot == 0 || deck.Deck.Slot > 5 || deck.Team != 0 || - characters[deck.Deck.CharacterInvenIndex] || positions[deck.Deck.CostumeInvenIndex] || sequences[deck.Deck.Slot] { - return errors.New("invalid deck entry") - } - characters[deck.Deck.CharacterInvenIndex] = true - positions[deck.Deck.CostumeInvenIndex] = true - sequences[deck.Deck.Slot] = true - if len(deck.Equipment) != 5 { - return errors.New("preset deck requires five equipment slots") - } - seen := map[uint64]bool{} - for _, item := range deck.Equipment { - if item.Type >= 5 || seen[item.Type] { - return errors.New("invalid preset equipment slot") - } - seen[item.Type] = true - } - } - return nil -} - -func validPresetName(value string) bool { - if !utf8.ValidString(value) || utf8.RuneCountInString(value) < 1 || utf8.RuneCountInString(value) > 16 || strings.Contains(value, "<") || strings.Contains(value, ">") { - return false - } - for _, r := range value { - if unicode.IsControl(r) { - return false - } - } - return true -} - -func (s *Store) validatePresetOwnedLocked(p Preset) error { - if err := s.validatePresetShape(p, s.presetSlots); err != nil { - return err - } - ownedEquipment := make(map[uint64]player.Equipment) - for _, item := range s.equipment.All() { - ownedEquipment[item.InvenIndex] = item - } - seenEquipment := map[uint64]bool{} - for _, deck := range p.Decks { - if _, found := s.characters.Find(deck.Deck.CharacterInvenIndex); !found { - return fmt.Errorf("unknown character %d", deck.Deck.CharacterInvenIndex) - } - if deck.CostumeIndex != 0 { - costume, found := s.collection.CostumeByIndex(deck.CostumeIndex) - if !found || costume.UseChar != deck.Deck.CharacterInvenIndex { - return fmt.Errorf("unknown costume %d", deck.CostumeIndex) - } - } - for _, reference := range deck.Equipment { - if reference.Index == 0 { - continue - } - if seenEquipment[reference.Index] || ownedEquipment[reference.Index].InvenIndex == 0 { - return fmt.Errorf("invalid or repeated equipment %d", reference.Index) - } - seenEquipment[reference.Index] = true - } - binding := player.PresetEquipmentBinding{CharacterIndex: deck.Deck.CharacterInvenIndex, Equipment: make([]uint64, 5)} - for _, reference := range deck.Equipment { - binding.Equipment[reference.Type] = reference.Index - } - if err := s.equipment.ValidatePresetEquipment([]player.PresetEquipmentBinding{binding}); err != nil { - return err - } - } - return nil -} - -func decodePreset(data []byte) (Preset, error) { - var p Preset - name, _, err := wire.Bytes(data, 1) - if err != nil { - return p, err - } - p.Name = string(name) - p.ResourceID, _, err = wire.Varint(data, 2) - if err != nil { - return p, err - } - p.ResourceColor, _, err = wire.Varint(data, 3) - if err != nil { - return p, err - } - p.Slot, _, err = wire.Varint(data, 4) - if err != nil { - return p, err - } - err = wire.Walk(data, func(field wire.Field) error { - if field.Type != 2 { - return nil - } - switch field.Number { - case 5: - deck, err := decodePresetDeck(field.Value) - if err != nil { - return err - } - p.Decks = append(p.Decks, deck) - case 6: - bless, err := decodePresetBless(field.Value) - if err != nil { - return err - } - p.Blesses = append(p.Blesses, bless) - } - return nil - }) - return p, err -} - -func decodePresetDeck(data []byte) (PresetDeck, error) { - var result PresetDeck - base, found, err := wire.Bytes(data, 1) - if err != nil || !found { - return result, errors.New("deck: preset missing deck base") - } - character, ok, err := wire.Varint(base, 1) - if err != nil || !ok || character == 0 { - return result, errors.New("deck: invalid preset character") - } - position, _, err := wire.Varint(base, 2) - if err != nil { - return result, err - } - sequence, ok, err := wire.Varint(base, 3) - if err != nil || !ok || sequence == 0 { - return result, errors.New("deck: invalid preset sequence") - } - result.Deck = DeckEntry{CharacterInvenIndex: character, CostumeInvenIndex: position, Slot: sequence} - result.CostumeIndex, _, err = wire.Varint(data, 2) - if err != nil { - return result, err - } - result.Team, _, err = wire.Varint(data, 4) - if err != nil { - return result, err - } - err = wire.Walk(data, func(field wire.Field) error { - if field.Number != 3 { - return nil - } - if field.Type != 2 { - return errors.New("deck: invalid preset equipment") - } - typeID, _, err := wire.Varint(field.Value, 1) - if err != nil { - return err - } - index, _, err := wire.Varint(field.Value, 2) - if err != nil { - return err - } - result.Equipment = append(result.Equipment, PresetEquipmentItem{Type: typeID, Index: index}) - return nil - }) - sort.Slice(result.Equipment, func(i, j int) bool { return result.Equipment[i].Type < result.Equipment[j].Type }) - return result, err -} - -func decodePresetBless(data []byte) (PresetBless, error) { - var result PresetBless - result.DeckType, _, _ = wire.Varint(data, 1) - err := wire.Walk(data, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - values, err := repeatedUint64(field) - if err != nil { - return err - } - result.IDs = append(result.IDs, values...) - return nil - }) - return result, err -} - -func repeatedUint64(field wire.Field) ([]uint64, error) { - if field.Type == 0 { - value, count := binary.Uvarint(field.Value) - if count <= 0 { - return nil, wire.ErrMalformed - } - return []uint64{value}, nil - } - if field.Type != 2 { - return nil, wire.ErrMalformed - } - var out []uint64 - for offset := 0; offset < len(field.Value); { - value, count := binary.Uvarint(field.Value[offset:]) - if count <= 0 { - return nil, wire.ErrMalformed - } - out = append(out, value) - offset += count - } - return out, nil -} - -func presetWire(p Preset) []byte { - var out []byte - if p.Name != "" { - out = wire.AppendString(out, 1, p.Name) - } - if p.ResourceID != 0 { - out = wire.AppendVarint(out, 2, p.ResourceID) - } - if p.ResourceColor != 0 { - out = wire.AppendVarint(out, 3, p.ResourceColor) - } - if p.Slot != 0 { - out = wire.AppendVarint(out, 4, p.Slot) - } - for _, deck := range p.Decks { - out = wire.AppendBytes(out, 5, presetDeckWire(deck)) - } - for _, bless := range p.Blesses { - var b []byte - if bless.DeckType != 0 { - b = wire.AppendVarint(b, 1, bless.DeckType) - } - for _, id := range bless.IDs { - b = wire.AppendVarint(b, 2, id) - } - out = wire.AppendBytes(out, 6, b) - } - return out -} - -func presetDeckWire(deck PresetDeck) []byte { - base := wire.AppendVarint(nil, 1, deck.Deck.CharacterInvenIndex) - if deck.Deck.CostumeInvenIndex != 0 { - base = wire.AppendVarint(base, 2, deck.Deck.CostumeInvenIndex) - } - base = wire.AppendVarint(base, 3, deck.Deck.Slot) - out := wire.AppendBytes(nil, 1, base) - if deck.CostumeIndex != 0 { - out = wire.AppendVarint(out, 2, deck.CostumeIndex) - } - for _, item := range deck.Equipment { - var b []byte - if item.Type != 0 { - b = wire.AppendVarint(b, 1, item.Type) - } - if item.Index != 0 { - b = wire.AppendVarint(b, 2, item.Index) - } - out = wire.AppendBytes(out, 3, b) - } - if deck.Team != 0 { - out = wire.AppendVarint(out, 4, deck.Team) - } - return out -} - -func (s *Store) presetCacheKey(kind string, seq uint64) string { - return kind + ":" + s.sessionID + ":" + strconv.FormatUint(seq, 10) -} - -func (s *Store) persistPresetLocked(p Preset) error { - payload, err := json.Marshal(p) - if err != nil { - return err - } - core, err := s.corePayloadLocked() - if err != nil { - return err - } - return s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "presets", Key: strconv.FormatUint(p.Slot, 10), Payload: payload}}) -} - -func (s *Store) corePayloadLocked() ([]byte, error) { - payload, err := json.MarshalIndent(s.state, "", " ") - if err != nil { - return nil, err - } - return append(payload, '\n'), nil -} - -func (s *Store) cachedReplyLocked(kind string, seq uint64) (deckReply, bool) { - reply, found := s.replies[s.presetCacheKey(kind, seq)] - if found { - reply.body = append([]byte(nil), reply.body...) - } - return reply, found -} - -func (s *Store) rememberReplyLocked(kind string, seq uint64, code int, body []byte) { - s.replies[s.presetCacheKey(kind, seq)] = deckReply{code: code, body: append([]byte(nil), body...)} -} - -func requestSequence(request []byte) (uint64, error) { - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 0, errors.New("deck: invalid request sequence") - } - return seq, nil -} - -func (s *Store) handlePresetInfo(request []byte) (int, []byte, bool, error) { - if _, err := requestSequence(request); err != nil { - return 0, nil, true, err - } - s.mu.RLock() - defer s.mu.RUnlock() - slots := make([]uint64, 0, len(s.presets)) - for slot := range s.presets { - if slot < s.presetSlots { - slots = append(slots, slot) - } - } - slices.Sort(slots) - var response []byte - for _, slot := range slots { - response = wire.AppendBytes(response, 1, presetWire(s.presets[slot])) - } - return 178, response, true, nil -} - -func (s *Store) handlePresetSave(request []byte) (int, []byte, bool, error) { - seq, err := requestSequence(request) - if err != nil { - return 0, nil, true, err - } - raw, found, err := wire.Bytes(request, 2) - if err != nil || !found { - return 0, nil, true, errors.New("deck: PresetSave missing preset") - } - preset, err := decodePreset(raw) - if err != nil { - return 0, nil, true, fmt.Errorf("deck: decode preset: %w", err) - } - s.mu.Lock() - defer s.mu.Unlock() - if reply, found := s.cachedReplyLocked("save", seq); found { - return reply.code, reply.body, true, nil - } - if s.characters == nil || s.collection == nil || s.equipment == nil { - return 0, nil, true, errors.New("deck: preset runtime unavailable") - } - if err := s.validatePresetOwnedLocked(preset); err != nil { - return 0, nil, true, fmt.Errorf("deck: invalid preset: %w", err) - } - if err := s.persistPresetLocked(preset); err != nil { - return 0, nil, true, fmt.Errorf("deck: persist preset: %w", err) - } - s.presets[preset.Slot] = preset - s.rememberReplyLocked("save", seq, 179, nil) - return 179, nil, true, nil -} - -func (s *Store) handlePresetAddSlot(request []byte) (int, []byte, bool, error) { - seq, err := requestSequence(request) - if err != nil { - return 0, nil, true, err - } - count, found, err := wire.Varint(request, 2) - if err != nil || !found || count == 0 { - return 0, nil, true, errors.New("deck: PresetAddSlot invalid count") - } - s.mu.Lock() - defer s.mu.Unlock() - if reply, found := s.cachedReplyLocked("add-slot", seq); found { - return reply.code, reply.body, true, nil - } - if s.wallet == nil || s.presetDesign.Validate() != nil { - return 0, nil, true, errors.New("deck: preset wallet/design unavailable") - } - if s.presetSlots > s.presetDesign.Maximum || count > s.presetDesign.Maximum-s.presetSlots { - return 0, nil, true, errors.New("deck: preset slot limit exceeded") - } - if count > ^uint64(0)/s.presetDesign.Price { - return 0, nil, true, errors.New("deck: preset slot price overflow") - } - identity := "preset-slot:" + s.sessionID + ":" + strconv.FormatUint(seq, 10) - var spendErr error - switch s.presetDesign.PriceType { - case 4: - _, spendErr = s.wallet.SpendGoldOnce(identity, count*s.presetDesign.Price) - case 3: - _, spendErr = s.wallet.SpendFreeJewelryOnce(identity, count*s.presetDesign.Price) - case 2: - _, spendErr = s.wallet.SpendJewelryOnce(identity, count*s.presetDesign.Price) - case 12: - _, spendErr = s.wallet.SpendCatalystOnce(identity, count*s.presetDesign.Price) - } - if err := spendErr; err != nil { - return 0, nil, true, fmt.Errorf("deck: buy preset slot: %w", err) - } - next := s.presetSlots + count - payload, err := json.Marshal(next) - if err != nil { - return 0, nil, true, err - } - core, err := s.corePayloadLocked() - if err != nil { - return 0, nil, true, err - } - if err := s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "preset_config", Key: "slots", Payload: payload}}); err != nil { - return 0, nil, true, fmt.Errorf("deck: persist preset slots: %w", err) - } - s.presetSlots = next - s.rememberReplyLocked("add-slot", seq, 180, nil) - return 180, nil, true, nil -} - -func (s *Store) handlePresetInfoChange(request []byte) (int, []byte, bool, error) { - seq, err := requestSequence(request) - if err != nil { - return 0, nil, true, err - } - nameBytes, _, err := wire.Bytes(request, 2) - if err != nil { - return 0, nil, true, errors.New("deck: invalid preset name") - } - resourceID, _, err := wire.Varint(request, 3) - if err != nil { - return 0, nil, true, errors.New("deck: invalid preset icon") - } - color, _, err := wire.Varint(request, 4) - if err != nil { - return 0, nil, true, errors.New("deck: invalid preset color") - } - // Ordinary preset slots are zero-based. Proto3 omits slot=0, so an absent - // field 5 is the first slot rather than a malformed request. - slot, _, err := wire.Varint(request, 5) - if err != nil { - return 0, nil, true, errors.New("deck: invalid preset slot") - } - s.mu.Lock() - defer s.mu.Unlock() - if reply, found := s.cachedReplyLocked("info-change", seq); found { - return reply.code, reply.body, true, nil - } - preset, exists := s.presets[slot] - if !exists { - preset = Preset{Slot: slot, Decks: []PresetDeck{}, Blesses: []PresetBless{}} - } - preset.Name, preset.ResourceID, preset.ResourceColor = string(nameBytes), resourceID, color - if err := s.validatePresetShape(preset, s.presetSlots); err != nil { - return 0, nil, true, fmt.Errorf("deck: invalid preset metadata: %w", err) - } - if err := s.persistPresetLocked(preset); err != nil { - return 0, nil, true, err - } - s.presets[slot] = preset - s.rememberReplyLocked("info-change", seq, 278, nil) - return 278, nil, true, nil -} - -func (s *Store) handlePresetDelete(request []byte) (int, []byte, bool, error) { - seq, err := requestSequence(request) - if err != nil { - return 0, nil, true, err - } - var slots []uint64 - if err := wire.Walk(request, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - values, err := repeatedUint64(field) - if err != nil { - return err - } - slots = append(slots, values...) - return nil - }); err != nil || len(slots) == 0 { - return 0, nil, true, errors.New("deck: invalid preset delete slots") - } - s.mu.Lock() - defer s.mu.Unlock() - if reply, found := s.cachedReplyLocked("delete", seq); found { - return reply.code, reply.body, true, nil - } - seen := make(map[uint64]bool, len(slots)) - changes := make([]stateio.EntryMutation, 0, len(slots)) - for _, slot := range slots { - if slot >= s.presetSlots || seen[slot] { - return 0, nil, true, errors.New("deck: invalid or duplicate preset delete slot") - } - seen[slot] = true - changes = append(changes, stateio.EntryMutation{Bucket: "presets", Key: strconv.FormatUint(slot, 10), Delete: true}) - } - core, err := s.corePayloadLocked() - if err != nil { - return 0, nil, true, err - } - if err := s.storage.SaveWithEntries("deck", core, changes); err != nil { - return 0, nil, true, err - } - for slot := range seen { - delete(s.presets, slot) - } - // 2.35.10 contains the request/response classes but no PacketCode enum - // member. Code zero is the same compatibility fallback used for other - // unnumbered local endpoints; it must not be treated as an official value. - s.rememberReplyLocked("delete", seq, 0, nil) - return 0, nil, true, nil -} - -func (s *Store) handlePresetUse(request []byte) (int, []byte, bool, error) { - seq, err := requestSequence(request) - if err != nil { - return 0, nil, true, err - } - // PresetUse is also zero-based and slot=0 is omitted by proto3. - slot, _, err := wire.Varint(request, 2) - if err != nil { - return 0, nil, true, errors.New("deck: PresetUse missing slot") - } - s.mu.Lock() - defer s.mu.Unlock() - if reply, found := s.cachedReplyLocked("use", seq); found { - return reply.code, reply.body, true, nil - } - preset, found := s.presets[slot] - if !found || len(preset.Decks) == 0 { - return 0, nil, true, errors.New("deck: PresetUse references an empty slot") - } - if s.characters == nil || s.collection == nil || s.equipment == nil { - return 0, nil, true, errors.New("deck: preset runtime unavailable") - } - if err := s.validatePresetOwnedLocked(preset); err != nil { - return 0, nil, true, fmt.Errorf("deck: stale preset: %w", err) - } - deckEntries := make([]DeckEntry, 0, len(preset.Decks)) - assignments := make(map[uint64]uint64, len(preset.Decks)) - bindings := make([]player.PresetEquipmentBinding, 0, len(preset.Decks)) - for _, entry := range preset.Decks { - deckEntries = append(deckEntries, entry.Deck) - assignments[entry.Deck.CharacterInvenIndex] = entry.CostumeIndex - equipment := make([]uint64, 5) - for _, item := range entry.Equipment { - equipment[item.Type] = item.Index - } - bindings = append(bindings, player.PresetEquipmentBinding{CharacterIndex: entry.Deck.CharacterInvenIndex, Equipment: equipment}) - } - if _, err := s.characters.ApplyPresetCostumes(assignments); err != nil { - return 0, nil, true, fmt.Errorf("deck: apply preset costumes: %w", err) - } - affected, err := s.equipment.ApplyPresetEquipment(bindings) - if err != nil { - return 0, nil, true, fmt.Errorf("deck: apply preset equipment: %w", err) - } - next := clone(s.state) - next.Deck = append([]DeckEntry(nil), deckEntries...) - maps.Copy(next.Costumes, assignments) - if err := s.commit(next); err != nil { - return 0, nil, true, fmt.Errorf("deck: persist applied preset: %w", err) - } - var response []byte - for _, entry := range deckEntries { - base := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex) - if entry.CostumeInvenIndex != 0 { - base = wire.AppendVarint(base, 2, entry.CostumeInvenIndex) - } - base = wire.AppendVarint(base, 3, entry.Slot) - response = wire.AppendBytes(response, 1, base) - } - characterSet := make(map[uint64]bool, len(assignments)+len(affected)) - for index := range assignments { - characterSet[index] = true - } - for _, character := range affected { - characterSet[character.InvenIndex] = true - } - indices := make([]uint64, 0, len(characterSet)) - for index := range characterSet { - indices = append(indices, index) - } - slices.Sort(indices) - for _, index := range indices { - if character, found := s.characters.Find(index); found { - response = wire.AppendBytes(response, 2, player.CharacterWire(character)) - } - } - for _, binding := range bindings { - info := wire.AppendVarint(nil, 1, binding.CharacterIndex) - for _, index := range binding.Equipment { - info = wire.AppendVarint(info, 2, index) - } - response = wire.AppendBytes(response, 3, info) - } - s.rememberReplyLocked("use", seq, 409, response) - return 409, response, true, nil -} - -func costumeSettingWire(setting CostumeSetting) []byte { - out := wire.AppendVarint(nil, 1, setting.CharacterIndex) - for _, item := range setting.Sequence { - entry := wire.AppendVarint(nil, 1, uint64(item.CostumeIndex)) - if item.BurstLevel != 0 { - entry = wire.AppendVarint(entry, 2, item.BurstLevel) - } - out = wire.AppendBytes(out, 2, entry) - } - if setting.BattleMode != 0 { - out = wire.AppendVarint(out, 3, setting.BattleMode) - } - if setting.MonsterID != 0 { - out = wire.AppendVarint(out, 4, setting.MonsterID) - } - return out -} - -func decodeCostumeSetting(data []byte) (CostumeSetting, error) { - var result CostumeSetting - character, found, err := wire.Varint(data, 1) - if err != nil || !found || character == 0 { - return result, errors.New("deck: costume setting missing character") - } - result.CharacterIndex = character - result.BattleMode, _, err = wire.Varint(data, 3) - if err != nil { - return result, err - } - result.MonsterID, _, err = wire.Varint(data, 4) - if err != nil { - return result, err - } - err = wire.Walk(data, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - if field.Type != 2 { - return errors.New("deck: invalid costume setting sequence") - } - raw, found, err := wire.Varint(field.Value, 1) - if err != nil || !found { - return errors.New("deck: costume setting entry missing costume") - } - burst, _, err := wire.Varint(field.Value, 2) - if err != nil { - return err - } - result.Sequence = append(result.Sequence, CostumeSettingItem{CostumeIndex: int64(raw), BurstLevel: burst}) - return nil - }) - return result, err -} - -func (s *Store) validateCostumeSettingLocked(setting CostumeSetting) error { - if s.characters == nil || s.collection == nil { - return errors.New("deck: costume setting runtime unavailable") - } - if setting.BattleMode != 0 || setting.MonsterID != 0 { - return errors.New("deck: ordinary costume setting requires normal battle mode") - } - if _, found := s.characters.Find(setting.CharacterIndex); !found { - return fmt.Errorf("deck: unknown costume setting character %d", setting.CharacterIndex) - } - for _, item := range setting.Sequence { - if item.CostumeIndex <= 0 { - if item.CostumeIndex != 0 && item.CostumeIndex != -1 { - return fmt.Errorf("deck: invalid costume setting sentinel %d", item.CostumeIndex) - } - continue - } - costume, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex)) - if !found || costume.UseChar != setting.CharacterIndex { - return fmt.Errorf("deck: costume %d does not belong to character %d", item.CostumeIndex, setting.CharacterIndex) - } - if item.BurstLevel > costume.BurstLevel { - return fmt.Errorf("deck: costume %d burst level exceeds owned level", item.CostumeIndex) - } - } - return nil -} - -func (s *Store) handleCostumeSettingInfo(request []byte) (int, []byte, bool, error) { - if _, err := requestSequence(request); err != nil { - return 0, nil, true, err - } - s.mu.RLock() - defer s.mu.RUnlock() - indices := make([]uint64, 0, len(s.costumeSettings)) - for index := range s.costumeSettings { - indices = append(indices, index) - } - slices.Sort(indices) - var response []byte - for _, index := range indices { - response = wire.AppendBytes(response, 1, costumeSettingWire(s.costumeSettings[index])) - } - return 397, response, true, nil -} - -func (s *Store) handleCostumeSettingSave(request []byte) (int, []byte, bool, error) { - seq, err := requestSequence(request) - if err != nil { - return 0, nil, true, err - } - raw, found, err := wire.Bytes(request, 2) - if err != nil || !found { - return 0, nil, true, errors.New("deck: DeckCostumeSettingSave missing setting") - } - setting, err := decodeCostumeSetting(raw) - if err != nil { - return 0, nil, true, err - } - s.mu.Lock() - defer s.mu.Unlock() - if reply, found := s.cachedReplyLocked("costume-setting-save", seq); found { - return reply.code, reply.body, true, nil - } - if err := s.validateCostumeSettingLocked(setting); err != nil { - return 0, nil, true, err - } - payload, err := json.Marshal(setting) - if err != nil { - return 0, nil, true, err - } - change := stateio.EntryMutation{Bucket: "costume_settings", Key: strconv.FormatUint(setting.CharacterIndex, 10), Payload: payload} - core, err := s.corePayloadLocked() - if err != nil { - return 0, nil, true, err - } - if err := s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{change}); err != nil { - return 0, nil, true, err - } - s.costumeSettings[setting.CharacterIndex] = setting - s.rememberReplyLocked("costume-setting-save", seq, 398, nil) - return 398, nil, true, nil -} diff --git a/go/internal/server/deck/preset_test.go b/go/internal/server/deck/preset_test.go deleted file mode 100644 index 280182b..0000000 --- a/go/internal/server/deck/preset_test.go +++ /dev/null @@ -1,380 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/gamedata" - "reflect" - "sort" - "testing" - - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestPresetChangedDesignControlsBaseLimitAndCurrency(t *testing.T) { - f := newPresetFixture(t) - design := gamedata.PresetDesign{BaseCount: 2, Maximum: 3, PriceType: 3, Price: 17} - d, err := OpenStore(f.storage, f.seed, design) - if err != nil { - t.Fatal(err) - } - w, err := player.OpenWallet(stateio.NewMemory(), player.Currency{FreeJewelry: 100}) - if err != nil { - t.Fatal(err) - } - d.wallet = w - d.BeginSession("changed-design") - if d.PresetSlotCount() != 2 { - t.Fatal("base count ignored") - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1) - if _, _, _, err := d.Handle("/PresetAddSlot", request); err != nil { - t.Fatal(err) - } - if d.PresetSlotCount() != 3 || w.Snapshot().FreeJewelry != 83 || w.Snapshot().Gold != 0 { - t.Fatal("changed price/currency ignored") - } - request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1) - if _, _, _, err := d.Handle("/PresetAddSlot", request); err == nil { - t.Fatal("changed limit ignored") - } - if w.Snapshot().FreeJewelry != 83 { - t.Fatal("invalid purchase charged") - } -} - -type presetFixture struct { - storage *stateio.Memory - seed Seed - deck *Store - wallet *player.Wallet - characters *player.CharacterStore - equipment *player.EquipmentInventory - collection *player.CollectionStore - weapon player.Equipment -} - -func newPresetFixture(t *testing.T) *presetFixture { - t.Helper() - storage := stateio.NewMemory() - seed, err := LoadSeed("../../../seed/v2_35_10/decks.json") - if err != nil { - t.Fatal(err) - } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(storage, []player.Costume{ - {InvenIndex: 1001, ID: 3501, UseChar: 100, BurstLevel: 3}, - {InvenIndex: 2001, ID: 3601, UseChar: 200, BurstLevel: 1}, - }) - if err != nil { - t.Fatal(err) - } - characters, err := player.OpenCharacterStore(storage, []player.Character{ - {InvenIndex: 100, ID: 350, Level: 20, UseCostume: 1001, CostumeID: 3501}, - {InvenIndex: 200, ID: 360, Level: 20, UseCostume: 2001, CostumeID: 3601}, - }, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachCollection(collection); err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(storage) - if err != nil { - t.Fatal(err) - } - if err := equipment.AttachSlots(map[uint64]uint64{10: 0, 11: 1, 12: 2, 13: 3, 14: 4}); err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - weapon, err := equipment.GrantOnce("preset-test-weapon", 10) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Gold: 20000}) - if err != nil { - t.Fatal(err) - } - decks, err := OpenStore(storage, seed, testPresetDesign) - if err != nil { - t.Fatal(err) - } - if err := decks.AttachPresetRuntime(wallet, characters, equipment, collection); err != nil { - t.Fatal(err) - } - decks.BeginSession("preset-test") - return &presetFixture{storage: storage, seed: seed, deck: decks, wallet: wallet, characters: characters, equipment: equipment, collection: collection, weapon: weapon} -} - -func presetRequest(slot, character, costume, equipment uint64, name string) []byte { - base := wire.AppendVarint(nil, 1, character) - base = wire.AppendVarint(base, 2, 0) - base = wire.AppendVarint(base, 3, 1) - deck := wire.AppendBytes(nil, 1, base) - deck = wire.AppendVarint(deck, 2, costume) - for equipmentType := range uint64(5) { - entry := wire.AppendVarint(nil, 1, equipmentType) - if equipmentType == 0 { - entry = wire.AppendVarint(entry, 2, equipment) - } - deck = wire.AppendBytes(deck, 3, entry) - } - preset := wire.AppendString(nil, 1, name) - preset = wire.AppendVarint(preset, 2, 1) - preset = wire.AppendVarint(preset, 4, slot) - preset = wire.AppendBytes(preset, 5, deck) - return wire.AppendBytes(nil, 2, preset) -} - -func TestPresetSaveInfoMetadataDeleteAndRestart(t *testing.T) { - f := newPresetFixture(t) - for seq, slot := range []uint64{2, 0} { - request := req(uint64(seq+1), presetRequest(slot, 100, 1001, f.weapon.InvenIndex, "编队")) - if code, _, handled, err := f.deck.Handle("/PresetSave", request); err != nil || !handled || code != 179 { - t.Fatalf("save slot %d code=%d handled=%v err=%v", slot, code, handled, err) - } - } - code, response, handled, err := f.deck.Handle("/PresetInfo", req(10)) - if err != nil || !handled || code != 178 { - t.Fatalf("info code=%d handled=%v err=%v", code, handled, err) - } - var slots []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 { - slot, _, err := wire.Varint(field.Value, 4) - if err != nil { - return err - } - slots = append(slots, slot) - } - return nil - }); err != nil || !reflect.DeepEqual(slots, []uint64{0, 2}) { - t.Fatalf("ordered slots=%v err=%v", slots, err) - } - - change := wire.AppendString(nil, 2, "主力队") - change = wire.AppendVarint(change, 3, 21) - change = wire.AppendVarint(change, 4, 5) - // slot=0 is absent on the real proto3 wire. - if code, _, _, err := f.deck.Handle("/PresetInfoChange", req(11, change)); err != nil || code != 278 { - t.Fatalf("metadata change code=%d err=%v", code, err) - } - if got := f.deck.presets[0]; got.Name != "主力队" || got.ResourceID != 21 || got.ResourceColor != 5 || len(got.Decks) != 1 { - t.Fatalf("metadata change lost content: %+v", got) - } - - restarted, err := OpenStore(f.storage, f.seed, testPresetDesign) - if err != nil { - t.Fatal(err) - } - if len(restarted.presets) != 2 || restarted.presets[0].Name != "主力队" { - t.Fatalf("restarted presets=%+v", restarted.presets) - } - restarted.BeginSession("restart") - deleteRequest := req(12, wire.AppendVarint(nil, 2, 0), wire.AppendVarint(nil, 2, 2)) - if code, _, handled, err := restarted.Handle("/PresetDelete", deleteRequest); err != nil || !handled || code != 0 { - t.Fatalf("delete code=%d handled=%v err=%v", code, handled, err) - } - if len(restarted.presets) != 0 || restarted.presetSlots != 5 { - t.Fatalf("delete changed slots/content: slots=%d presets=%+v", restarted.presetSlots, restarted.presets) - } -} - -func TestPresetMetadataCanCreateEmptySlot(t *testing.T) { - f := newPresetFixture(t) - change := wire.AppendString(nil, 2, "备用") - change = wire.AppendVarint(change, 3, 2) - change = wire.AppendVarint(change, 4, 1) - change = wire.AppendVarint(change, 5, 4) - if code, _, _, err := f.deck.Handle("/PresetInfoChange", req(1, change)); err != nil || code != 278 { - t.Fatalf("upsert code=%d err=%v", code, err) - } - if got := f.deck.presets[4]; got.Name != "备用" || len(got.Decks) != 0 { - t.Fatalf("empty metadata preset=%+v", got) - } -} - -func TestPresetAddSlotChargesOncePersistsAndCaps(t *testing.T) { - f := newPresetFixture(t) - add := req(20, wire.AppendVarint(nil, 2, 1)) - for attempt := range 2 { - if code, _, _, err := f.deck.Handle("/PresetAddSlot", add); err != nil || code != 180 { - t.Fatalf("add attempt %d code=%d err=%v", attempt, code, err) - } - } - if f.deck.PresetSlotCount() != 6 || f.wallet.Snapshot().Gold != 18000 { - t.Fatalf("slots=%d gold=%d", f.deck.PresetSlotCount(), f.wallet.Snapshot().Gold) - } - restarted, err := OpenStore(f.storage, f.seed, testPresetDesign) - if err != nil { - t.Fatal(err) - } - if restarted.PresetSlotCount() != 6 { - t.Fatalf("restarted slots=%d", restarted.PresetSlotCount()) - } - if err := restarted.AttachPresetRuntime(f.wallet, f.characters, f.equipment, f.collection); err != nil { - t.Fatal(err) - } - restarted.BeginSession("cap") - if _, _, _, err := restarted.Handle("/PresetAddSlot", req(21, wire.AppendVarint(nil, 2, 6))); err != nil { - t.Fatalf("buy remaining slots: %v", err) - } - if _, _, _, err := restarted.Handle("/PresetAddSlot", req(22, wire.AppendVarint(nil, 2, 1))); err == nil { - t.Fatal("accepted slot beyond maximum") - } -} - -func TestPresetUseAppliesDeckCostumeEquipmentWithoutChangingFieldDeck(t *testing.T) { - f := newPresetFixture(t) - wantField := append([]FieldEntry(nil), f.deck.state.FieldDeck...) - if _, _, _, err := f.deck.Handle("/PresetSave", req(1, presetRequest(0, 100, 1001, f.weapon.InvenIndex, "应用"))); err != nil { - t.Fatal(err) - } - code, response, handled, err := f.deck.Handle("/PresetUse", req(2, wire.AppendVarint(nil, 3, 999999999))) - if err != nil || !handled || code != 409 { - t.Fatalf("use code=%d handled=%v err=%v", code, handled, err) - } - if len(f.deck.state.Deck) != 1 || f.deck.state.Deck[0].CharacterInvenIndex != 100 || !reflect.DeepEqual(f.deck.state.FieldDeck, wantField) { - t.Fatalf("deck=%+v field changed=%v", f.deck.state.Deck, !reflect.DeepEqual(f.deck.state.FieldDeck, wantField)) - } - owned := f.equipment.All() - if len(owned) != 1 || owned[0].UseChar != 100 { - t.Fatalf("equipment=%+v", owned) - } - var deckCount, characterCount int - var equipmentValues []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - switch field.Number { - case 1: - deckCount++ - case 2: - characterCount++ - case 3: - return wire.Walk(field.Value, func(nested wire.Field) error { - if nested.Number == 2 { - values, err := repeatedUint64(nested) - equipmentValues = append(equipmentValues, values...) - return err - } - return nil - }) - } - return nil - }); err != nil { - t.Fatal(err) - } - if deckCount != 1 || characterCount == 0 || !reflect.DeepEqual(equipmentValues, []uint64{f.weapon.InvenIndex, 0, 0, 0, 0}) { - t.Fatalf("response deck=%d chars=%d equipment=%v", deckCount, characterCount, equipmentValues) - } - // The client-supplied power is deliberately ignored; replay returns the - // exact same response and does not repeat cross-domain state transitions. - if replayCode, replay, _, replayErr := f.deck.Handle("/PresetUse", req(2)); replayErr != nil || replayCode != 409 || !reflect.DeepEqual(replay, response) { - t.Fatalf("replay code=%d equal=%v err=%v", replayCode, reflect.DeepEqual(replay, response), replayErr) - } -} - -func TestPresetSaveRejectsForgedOwnershipAndEquipmentSlot(t *testing.T) { - f := newPresetFixture(t) - tests := []struct { - name string - character uint64 - costume uint64 - equipment uint64 - }{ - {name: "character", character: 999, costume: 1001, equipment: f.weapon.InvenIndex}, - {name: "costume owner", character: 100, costume: 2001, equipment: f.weapon.InvenIndex}, - {name: "equipment", character: 100, costume: 1001, equipment: 999999}, - } - for seq, test := range tests { - t.Run(test.name, func(t *testing.T) { - if _, _, _, err := f.deck.Handle("/PresetSave", req(uint64(seq+1), presetRequest(0, test.character, test.costume, test.equipment, "伪造"))); err == nil { - t.Fatal("forged preset accepted") - } - }) - } - if len(f.deck.presets) != 0 { - t.Fatalf("rejected save mutated presets=%+v", f.deck.presets) - } -} - -func TestDeckCostumeSettingSentinelsClearAndRestart(t *testing.T) { - f := newPresetFixture(t) - setting := wire.AppendVarint(nil, 1, 100) - for _, item := range []struct { - index int64 - burst uint64 - }{{1001, 3}, {-1, 0}, {0, 0}} { - entry := wire.AppendVarint(nil, 1, uint64(item.index)) - entry = wire.AppendVarint(entry, 2, item.burst) - setting = wire.AppendBytes(setting, 2, entry) - } - if code, _, _, err := f.deck.Handle("/DeckCostumeSettingSave", req(1, wire.AppendBytes(nil, 2, setting))); err != nil || code != 398 { - t.Fatalf("save setting code=%d err=%v", code, err) - } - restarted, err := OpenStore(f.storage, f.seed, testPresetDesign) - if err != nil { - t.Fatal(err) - } - if err := restarted.AttachPresetRuntime(f.wallet, f.characters, f.equipment, f.collection); err != nil { - t.Fatal(err) - } - restarted.BeginSession("costume-restart") - code, response, _, err := restarted.Handle("/DeckCostumeSettingInfo", req(2)) - if err != nil || code != 397 { - t.Fatalf("info setting code=%d err=%v", code, err) - } - var got []int64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number != 1 { - return nil - } - return wire.Walk(field.Value, func(nested wire.Field) error { - if nested.Number == 2 { - value, _, err := wire.Varint(nested.Value, 1) - got = append(got, int64(value)) - return err - } - return nil - }) - }); err != nil || !reflect.DeepEqual(got, []int64{1001, -1, 0}) { - t.Fatalf("setting sequence=%v err=%v", got, err) - } - - clear := wire.AppendVarint(nil, 1, 100) - if _, _, _, err := restarted.Handle("/DeckCostumeSettingSave", req(3, wire.AppendBytes(nil, 2, clear))); err != nil { - t.Fatal(err) - } - if len(restarted.costumeSettings[100].Sequence) != 0 { - t.Fatalf("setting was not cleared: %+v", restarted.costumeSettings[100]) - } -} - -func TestCostumeSettingInfoIsSorted(t *testing.T) { - f := newPresetFixture(t) - for seq, character := range []uint64{200, 100} { - setting := wire.AppendVarint(nil, 1, character) - if _, _, _, err := f.deck.Handle("/DeckCostumeSettingSave", req(uint64(seq+1), wire.AppendBytes(nil, 2, setting))); err != nil { - t.Fatal(err) - } - } - _, response, _, err := f.deck.Handle("/DeckCostumeSettingInfo", req(8)) - if err != nil { - t.Fatal(err) - } - var indices []uint64 - _ = wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 { - index, _, _ := wire.Varint(field.Value, 1) - indices = append(indices, index) - } - return nil - }) - if !sort.SliceIsSorted(indices, func(i, j int) bool { return indices[i] < indices[j] }) || !reflect.DeepEqual(indices, []uint64{100, 200}) { - t.Fatalf("setting order=%v", indices) - } -} diff --git a/go/internal/server/deck/story_party.go b/go/internal/server/deck/story_party.go deleted file mode 100644 index b8b34c9..0000000 --- a/go/internal/server/deck/story_party.go +++ /dev/null @@ -1,24 +0,0 @@ -package deck - -import "bd2server/internal/server/player" - -func (s *Store) CurrentFieldDeck() []FieldEntry { - s.mu.RLock() - defer s.mu.RUnlock() - return s.visibleFieldDeckLocked() -} - -func (s *Store) visibleFieldDeckLocked() []FieldEntry { - var out []FieldEntry - for _, entry := range s.state.FieldDeck { - if s.characters != nil { - c, ok := s.characters.Find(entry.CharacterInvenIndex) - if !ok || player.IsStoryCharacter(c) && !s.temporaryAllowed(c) { - continue - } - } - entry.Slot = uint64(len(out) + 1) - out = append(out, entry) - } - return out -} diff --git a/go/internal/server/deck/waypoint.go b/go/internal/server/deck/waypoint.go deleted file mode 100644 index 6dbd28f..0000000 --- a/go/internal/server/deck/waypoint.go +++ /dev/null @@ -1,139 +0,0 @@ -package deck - -import ( - "encoding/binary" - "errors" - "fmt" - "math" - "slices" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func (s *Store) ConfigureWaypoints(load func(uint64) (gamedata.WaypointPack, error), validate func(uint64, bool) error) error { - if load == nil || validate == nil { - return errors.New("deck: incomplete waypoint runtime") - } - s.mu.Lock() - defer s.mu.Unlock() - s.waypointDesign = load - s.waypointPack = validate - return nil -} - -func (s *Store) ActivatedWaypoint(pack, id uint64) bool { - s.mu.RLock() - defer s.mu.RUnlock() - return hasWaypoint(s.state.Waypoints[pack], id) -} -func hasWaypoint(points []uint64, id uint64) bool { - return slices.Contains(points, id) -} - -func validWaypointState(packs map[uint64][]uint64) error { - for pack, points := range packs { - if pack == 0 || pack > math.MaxInt32 || points == nil { - return errors.New("deck: invalid saved waypoint pack") - } - seen := map[uint64]bool{} - for _, id := range points { - if id == 0 || id > math.MaxInt32 || seen[id] { - return errors.New("deck: invalid saved waypoint activation") - } - seen[id] = true - } - } - return nil -} - -func (s *Store) handleWaypoint(path string, req []byte) (int, []byte, bool, error) { - seq, err := requestSequence(req) - if err != nil { - return 0, nil, true, err - } - pack, found, err := wire.Varint(req, 2) - if err != nil || !found || pack == 0 || pack > math.MaxInt32 { - return 0, nil, true, errors.New("deck: invalid waypoint pack") - } - s.mu.Lock() - defer s.mu.Unlock() - if s.waypointDesign == nil || s.waypointPack == nil { - return 0, nil, true, errors.New("deck: waypoint runtime unavailable") - } - if err = s.waypointPack(pack, path == "/WaypointUse"); err != nil { - return 0, nil, true, err - } - design, err := s.waypointDesign(pack) - if err != nil { - return 0, nil, true, err - } - if path == "/WaypointInfo" { - ids := append([]uint64(nil), s.state.Waypoints[pack]...) - slices.Sort(ids) - var packed []byte - for _, id := range ids { - if _, known := design.Points[id]; !known { - return 0, nil, true, fmt.Errorf("deck: saved waypoint %d absent from pack%d", id, pack) - } - packed = binary.AppendUvarint(packed, id) - } - if len(packed) == 0 { - return 31, nil, true, nil - } - return 31, wire.AppendBytes(nil, 1, packed), true, nil - } - id, found, err := wire.Varint(req, 3) - if err != nil || !found || id == 0 || id > math.MaxInt32 { - return 0, nil, true, errors.New("deck: invalid waypoint") - } - if _, known := design.Points[id]; !known { - return 0, nil, true, errors.New("deck: unknown waypoint") - } - if path == "/WaypointSave" { - if hasWaypoint(s.state.Waypoints[pack], id) { - return 32, nil, true, nil - } - next := clone(s.state) - next.Waypoints[pack] = append(next.Waypoints[pack], id) - err = s.commit(next) - return 32, nil, true, err - } - end, found, err := wire.Varint(req, 4) - if err != nil || !found || end == 0 || end > math.MaxInt32 || end == id { - return 0, nil, true, errors.New("deck: invalid waypoint destination") - } - target, known := design.Points[end] - if !known || target.MapID == 0 || !hasWaypoint(s.state.Waypoints[pack], end) || !hasWaypoint(s.state.Waypoints[pack], id) { - return 0, nil, true, errors.New("deck: waypoint is not activated") - } - moves, found, err := wire.Varint(req, 5) - if err != nil || !found || moves != 1 { - return 0, nil, true, errors.New("deck: invalid waypoint move count") - } - if reply, ok := s.cachedReplyLocked("waypoint-use", seq); ok { - return reply.code, reply.body, true, nil - } - if design.PriceUnit != 0 { - if s.wallet == nil || s.sessionID == "" { - return 0, nil, true, errors.New("deck: waypoint wallet session unavailable") - } - identity := fmt.Sprintf("waypoint:%s:%d", s.sessionID, seq) - switch design.PriceType { - case 4: - _, err = s.wallet.SpendGoldOnce(identity, design.PriceUnit) - case 3: - _, err = s.wallet.SpendFreeJewelryOnce(identity, design.PriceUnit) - case 2: - _, err = s.wallet.SpendJewelryOnce(identity, design.PriceUnit) - default: - err = errors.New("deck: unsupported waypoint currency") - } - if err != nil { - return 0, nil, true, err - } - } - // The client performs its warp and sends SaveUserPosition with scene coordinates. - s.rememberReplyLocked("waypoint-use", seq, 33, nil) - return 33, nil, true, nil -} diff --git a/go/internal/server/deck/waypoint_test.go b/go/internal/server/deck/waypoint_test.go deleted file mode 100644 index d544f7c..0000000 --- a/go/internal/server/deck/waypoint_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package deck - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "errors" - "testing" -) - -func waypointFixture(t *testing.T) (*Store, *stateio.Memory) { - t.Helper() - memory := stateio.NewMemory() - seededStore := seeded(t) - s, err := OpenStore(memory, Seed{Version: seededStore.state.Version, FieldDeck: seededStore.state.FieldDeck, FieldCharControlDeckType: seededStore.state.FieldCharControlDeckType}) - if err != nil { - t.Fatal(err) - } - configureWaypointFixture(t, s) - return s, memory -} -func configureWaypointFixture(t *testing.T, s *Store) { - t.Helper() - if err := s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) { - return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 10}, 2: {ID: 2, MapID: 20}}, PriceType: 4, PriceUnit: 7}, nil - }, func(pack uint64, _ bool) error { - if pack == 3 { - return errors.New("locked pack") - } - return nil - }); err != nil { - t.Fatal(err) - } -} -func waypointReq(seq, pack, start, end uint64) []byte { - body := wire.AppendVarint(nil, 2, pack) - if start != 0 { - body = wire.AppendVarint(body, 3, start) - } - if end != 0 { - body = wire.AppendVarint(body, 4, end) - body = wire.AppendVarint(body, 5, 1) - } - return req(seq, body) -} - -func TestWaypointActivationPersistsAllIDsAndInfoScopesPack(t *testing.T) { - s, memory := waypointFixture(t) - for _, r := range []struct{ seq, pack, id uint64 }{{1, 1, 2}, {2, 1, 1}, {3, 2, 2}, {4, 1, 1}} { - if _, _, _, err := s.Handle("/WaypointSave", waypointReq(r.seq, r.pack, r.id, 0)); err != nil { - t.Fatal(err) - } - } - reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType}) - if err != nil { - t.Fatal(err) - } - configureWaypointFixture(t, reopened) - code, body, _, err := reopened.Handle("/WaypointInfo", waypointReq(5, 1, 0, 0)) - if err != nil || code != 31 || !bytes.Equal(body, []byte{10, 2, 1, 2}) { - t.Fatalf("pack1 info %d %x %v", code, body, err) - } - _, body, _, err = reopened.Handle("/WaypointInfo", waypointReq(6, 2, 0, 0)) - if err != nil || !bytes.Equal(body, []byte{10, 1, 2}) { - t.Fatalf("pack2 info %x %v", body, err) - } - if len(reopened.state.Waypoints[1]) != 2 { - t.Fatal("duplicate activation persisted") - } -} - -func TestWaypointTravelChargesOnceAndRejectsInvalidBeforeSpending(t *testing.T) { - s, memory := waypointFixture(t) - wallet, err := player.OpenWallet(memory, player.Currency{Gold: 14}) - if err != nil { - t.Fatal(err) - } - s.wallet = wallet - s.BeginSession("test-session") - for i := uint64(1); i <= 2; i++ { - if _, _, _, err = s.Handle("/WaypointSave", waypointReq(i, 1, i, 0)); err != nil { - t.Fatal(err) - } - } - for _, request := range [][]byte{waypointReq(3, 3, 1, 2), waypointReq(4, 1, 1, 99), waypointReq(5, 2, 1, 2)} { - if _, _, _, err = s.Handle("/WaypointUse", request); err == nil { - t.Fatal("invalid travel accepted") - } - if wallet.Snapshot().Gold != 14 { - t.Fatal("invalid travel charged") - } - } - valid := waypointReq(6, 1, 1, 2) - for range 2 { - code, _, _, err := s.Handle("/WaypointUse", valid) - if err != nil || code != 33 { - t.Fatalf("travel %d %v", code, err) - } - } - if wallet.Snapshot().Gold != 7 { - t.Fatal("repeat travel double charged") - } - reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType}) - if err != nil { - t.Fatal(err) - } - configureWaypointFixture(t, reopened) - reopened.wallet = wallet - reopened.BeginSession("test-session") - if _, _, _, err = reopened.Handle("/WaypointUse", valid); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 7 { - t.Fatal("restart retry charged") - } - if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(7, 1, 2, 1)); err != nil { - t.Fatal(err) - } - if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(8, 1, 1, 2)); err == nil { - t.Fatal("insufficient balance accepted") - } - if wallet.Snapshot().Gold != 0 { - t.Fatal("failed debit changed wallet") - } -} diff --git a/go/internal/server/dbcrypt/pages.go b/go/internal/server/design/dbcrypt/pages.go similarity index 100% rename from go/internal/server/dbcrypt/pages.go rename to go/internal/server/design/dbcrypt/pages.go diff --git a/go/internal/server/gameconfig/config.go b/go/internal/server/design/gameconfig/config.go similarity index 98% rename from go/internal/server/gameconfig/config.go rename to go/internal/server/design/gameconfig/config.go index 06290bd..be64e3b 100644 --- a/go/internal/server/gameconfig/config.go +++ b/go/internal/server/design/gameconfig/config.go @@ -11,7 +11,7 @@ import ( "path/filepath" "slices" - "bd2server/internal/server/configfile" + "bd2server/internal/server/platform/configfile" ) const FileName = "game.json" diff --git a/go/internal/server/gamedata/achievement.go b/go/internal/server/design/gamedata/achievement.go similarity index 100% rename from go/internal/server/gamedata/achievement.go rename to go/internal/server/design/gamedata/achievement.go diff --git a/go/internal/server/gamedata/achievement_gameplay.go b/go/internal/server/design/gamedata/achievement_gameplay.go similarity index 100% rename from go/internal/server/gamedata/achievement_gameplay.go rename to go/internal/server/design/gamedata/achievement_gameplay.go diff --git a/go/internal/server/gamedata/achievement_level.go b/go/internal/server/design/gamedata/achievement_level.go similarity index 100% rename from go/internal/server/gamedata/achievement_level.go rename to go/internal/server/design/gamedata/achievement_level.go diff --git a/go/internal/server/gamedata/attributes.go b/go/internal/server/design/gamedata/attributes.go similarity index 100% rename from go/internal/server/gamedata/attributes.go rename to go/internal/server/design/gamedata/attributes.go diff --git a/go/internal/server/gamedata/avatar_rewards.go b/go/internal/server/design/gamedata/avatar_rewards.go similarity index 100% rename from go/internal/server/gamedata/avatar_rewards.go rename to go/internal/server/design/gamedata/avatar_rewards.go diff --git a/go/internal/server/gamedata/battle.go b/go/internal/server/design/gamedata/battle.go similarity index 94% rename from go/internal/server/gamedata/battle.go rename to go/internal/server/design/gamedata/battle.go index b519764..0201b2a 100644 --- a/go/internal/server/gamedata/battle.go +++ b/go/internal/server/design/gamedata/battle.go @@ -4,7 +4,7 @@ import ( "encoding/binary" "fmt" - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" _ "modernc.org/sqlite" ) @@ -72,7 +72,12 @@ func readBattleRewards(root, version string, packID, deckID, monsterID int) ([]B } rewards := make([]BattleReward, len(types)) for i := range rewards { - if types[i] == 0 || ids[i] == 0 || counts[i] == 0 { + validID := ids[i] != 0 + switch types[i] { + case 2, 3, 4, 12, 20, 22, 68: + validID = ids[i] == 0 + } + if types[i] == 0 || !validID || counts[i] == 0 { return nil, fmt.Errorf("gamedata: invalid battle deck %d reward", deckID) } rewards[i] = BattleReward{Type: types[i], ID: ids[i], Count: counts[i]} diff --git a/go/internal/server/design/gamedata/battle_difficulty.go b/go/internal/server/design/gamedata/battle_difficulty.go new file mode 100644 index 0000000..85b78bc --- /dev/null +++ b/go/internal/server/design/gamedata/battle_difficulty.go @@ -0,0 +1,126 @@ +package gamedata + +import ( + "database/sql" + "fmt" + "slices" +) + +type QuestBattleDeck struct { + DeckID uint64 + QuestIDs []uint64 +} + +// ResolveQuestBattleDeck applies story difficulty only to monsters bound to +// main quests. Side quests use their authored deck regardless of story level. +func ResolveQuestBattleDeck(root, version string, packID int, monsterID, deckID, difficulty uint64) (QuestBattleDeck, error) { + if monsterID == 0 || monsterID > 2147483647 || deckID == 0 || deckID > 2147483647 || difficulty > 2 { + return QuestBattleDeck{}, fmt.Errorf("gamedata: invalid quest monster/deck/difficulty %d/%d/%d", monsterID, deckID, difficulty) + } + db, cleanup, err := openPackDatabase(root, version, packID) + if err != nil { + return QuestBattleDeck{}, err + } + defer cleanup() + var raw []byte + if err := db.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", monsterID).Scan(&raw); err != nil { + return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d: %w", monsterID, err) + } + quests, err := packedInts(raw, 22) + if err != nil { + return QuestBattleDeck{}, err + } + if len(quests) == 0 { + return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d has no quest range", monsterID) + } + decks, err := packedInts(raw, 2) + if err != nil { + return QuestBattleDeck{}, err + } + group, err := phaseScalar(raw, 21) + if err != nil { + return QuestBattleDeck{}, err + } + common, release, err := OpenDatabase(root, version, "common") + if err != nil { + return QuestBattleDeck{}, err + } + defer release() + var kind uint64 + for i, quest := range quests { + if quest == 0 || quest > 2147483647 { + return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d has invalid quest %d", monsterID, quest) + } + if err := common.QueryRow(fmt.Sprintf("SELECT ProtoBuf FROM QuestTable%d WHERE id=?", packID), quest).Scan(&raw); err != nil { + return QuestBattleDeck{}, fmt.Errorf("gamedata: monster %d quest %d: %w", monsterID, quest, err) + } + questKind, err := phaseScalar(raw, 63) + if err != nil { + return QuestBattleDeck{}, err + } + if questKind > 1 || (i > 0 && questKind != kind) { + return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d has unsupported or mixed quest types", monsterID) + } + kind = questKind + } + if kind == 1 { + difficulty = 0 + } + selected, err := battleDeckForDifficultyFromDB(db, deckID, difficulty) + if err != nil { + return QuestBattleDeck{}, err + } + if group == 0 { + allowed := slices.Contains(decks, deckID) + if kind == 0 && selected > difficulty*100000 { + allowed = allowed || slices.Contains(decks, selected-difficulty*100000) + } + if !allowed { + return QuestBattleDeck{}, fmt.Errorf("gamedata: quest monster %d does not contain deck %d", monsterID, deckID) + } + } else if _, err := battleDeckPhasesFromDB(db, monsterID, selected); err != nil { + return QuestBattleDeck{}, err + } + return QuestBattleDeck{DeckID: selected, QuestIDs: quests}, nil +} + +func openPackDatabase(root, version string, packID int) (*sql.DB, func(), error) { + if packID <= 0 { + return nil, nil, fmt.Errorf("gamedata: invalid battle pack %d", packID) + } + return OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) +} + +func battleDeckForDifficultyFromDB(db *sql.DB, deckID, difficulty uint64) (uint64, error) { + if deckID == 0 || deckID > 2147483647 || difficulty > 2 { + return 0, fmt.Errorf("gamedata: unsupported battle deck/difficulty %d/%d", deckID, difficulty) + } + var data []byte + if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&data); err != nil { + return 0, fmt.Errorf("gamedata: battle deck %d: %w", deckID, err) + } + actual, err := phaseScalar(data, 24) + if err != nil { + return 0, err + } + if actual == difficulty { + return deckID, nil + } + // Only an ordinary deck can be promoted. Changing a previously selected + // difficulty deck would conceal stale client quest state. + if actual != 0 || difficulty == 0 { + return 0, fmt.Errorf("gamedata: deck %d difficulty %d differs from selected %d", deckID, actual, difficulty) + } + selected := deckID + difficulty*100000 + if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", selected).Scan(&data); err != nil { + return 0, fmt.Errorf("gamedata: difficulty deck %d: %w", selected, err) + } + actual, err = phaseScalar(data, 24) + if err != nil { + return 0, err + } + if actual != difficulty { + return 0, fmt.Errorf("gamedata: difficulty deck %d has difficulty %d, want %d", selected, actual, difficulty) + } + return selected, nil +} diff --git a/go/internal/server/gamedata/battle_phase.go b/go/internal/server/design/gamedata/battle_phase.go similarity index 98% rename from go/internal/server/gamedata/battle_phase.go rename to go/internal/server/design/gamedata/battle_phase.go index 45bc15a..3aeecf6 100644 --- a/go/internal/server/gamedata/battle_phase.go +++ b/go/internal/server/design/gamedata/battle_phase.go @@ -4,7 +4,7 @@ import ( "database/sql" "fmt" - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" ) // BattlePhase identifies a row in the pack's PhaseBattleTable. ID is the diff --git a/go/internal/server/gamedata/buff_reward_design.go b/go/internal/server/design/gamedata/buff_reward_design.go similarity index 100% rename from go/internal/server/gamedata/buff_reward_design.go rename to go/internal/server/design/gamedata/buff_reward_design.go diff --git a/go/internal/server/gamedata/calendar_ids.go b/go/internal/server/design/gamedata/calendar_ids.go similarity index 100% rename from go/internal/server/gamedata/calendar_ids.go rename to go/internal/server/design/gamedata/calendar_ids.go diff --git a/go/internal/server/gamedata/cash_bonuses.go b/go/internal/server/design/gamedata/cash_bonuses.go similarity index 100% rename from go/internal/server/gamedata/cash_bonuses.go rename to go/internal/server/design/gamedata/cash_bonuses.go diff --git a/go/internal/server/gamedata/cash_entitlements.go b/go/internal/server/design/gamedata/cash_entitlements.go similarity index 100% rename from go/internal/server/gamedata/cash_entitlements.go rename to go/internal/server/design/gamedata/cash_entitlements.go diff --git a/go/internal/server/gamedata/cash_mail.go b/go/internal/server/design/gamedata/cash_mail.go similarity index 100% rename from go/internal/server/gamedata/cash_mail.go rename to go/internal/server/design/gamedata/cash_mail.go diff --git a/go/internal/server/gamedata/cash_products.go b/go/internal/server/design/gamedata/cash_products.go similarity index 99% rename from go/internal/server/gamedata/cash_products.go rename to go/internal/server/design/gamedata/cash_products.go index 06dea77..a410133 100644 --- a/go/internal/server/gamedata/cash_products.go +++ b/go/internal/server/design/gamedata/cash_products.go @@ -5,7 +5,7 @@ import ( "fmt" "math" - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" ) // CashProductKey is the official three-part identity; an SDK SKU can be shared diff --git a/go/internal/server/gamedata/cash_rewards.go b/go/internal/server/design/gamedata/cash_rewards.go similarity index 100% rename from go/internal/server/gamedata/cash_rewards.go rename to go/internal/server/design/gamedata/cash_rewards.go diff --git a/go/internal/server/gamedata/char_awake.go b/go/internal/server/design/gamedata/char_awake.go similarity index 100% rename from go/internal/server/gamedata/char_awake.go rename to go/internal/server/design/gamedata/char_awake.go diff --git a/go/internal/server/gamedata/clear_packages.go b/go/internal/server/design/gamedata/clear_packages.go similarity index 100% rename from go/internal/server/gamedata/clear_packages.go rename to go/internal/server/design/gamedata/clear_packages.go diff --git a/go/internal/server/gamedata/content_open.go b/go/internal/server/design/gamedata/content_open.go similarity index 100% rename from go/internal/server/gamedata/content_open.go rename to go/internal/server/design/gamedata/content_open.go diff --git a/go/internal/server/gamedata/content_ticket.go b/go/internal/server/design/gamedata/content_ticket.go similarity index 100% rename from go/internal/server/gamedata/content_ticket.go rename to go/internal/server/design/gamedata/content_ticket.go diff --git a/go/internal/server/gamedata/cooking_recipes.go b/go/internal/server/design/gamedata/cooking_recipes.go similarity index 100% rename from go/internal/server/gamedata/cooking_recipes.go rename to go/internal/server/design/gamedata/cooking_recipes.go diff --git a/go/internal/server/gamedata/costume_burst.go b/go/internal/server/design/gamedata/costume_burst.go similarity index 100% rename from go/internal/server/gamedata/costume_burst.go rename to go/internal/server/design/gamedata/costume_burst.go diff --git a/go/internal/server/gamedata/costume_potential.go b/go/internal/server/design/gamedata/costume_potential.go similarity index 100% rename from go/internal/server/gamedata/costume_potential.go rename to go/internal/server/design/gamedata/costume_potential.go diff --git a/go/internal/server/gamedata/database.go b/go/internal/server/design/gamedata/database.go similarity index 98% rename from go/internal/server/gamedata/database.go rename to go/internal/server/design/gamedata/database.go index 319c8fc..5b7c0d9 100644 --- a/go/internal/server/gamedata/database.go +++ b/go/internal/server/design/gamedata/database.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "bd2server/internal/server/dbcrypt" + "bd2server/internal/server/design/dbcrypt" ) // DatabaseName maps a logical client DB name to its GameData archive entry. diff --git a/go/internal/server/gamedata/database_cache.go b/go/internal/server/design/gamedata/database_cache.go similarity index 100% rename from go/internal/server/gamedata/database_cache.go rename to go/internal/server/design/gamedata/database_cache.go diff --git a/go/internal/server/gamedata/dispatch.go b/go/internal/server/design/gamedata/dispatch.go similarity index 100% rename from go/internal/server/gamedata/dispatch.go rename to go/internal/server/design/gamedata/dispatch.go diff --git a/go/internal/server/gamedata/equipment_collaboration.go b/go/internal/server/design/gamedata/equipment_collaboration.go similarity index 100% rename from go/internal/server/gamedata/equipment_collaboration.go rename to go/internal/server/design/gamedata/equipment_collaboration.go diff --git a/go/internal/server/gamedata/equipment_craft.go b/go/internal/server/design/gamedata/equipment_craft.go similarity index 100% rename from go/internal/server/gamedata/equipment_craft.go rename to go/internal/server/design/gamedata/equipment_craft.go diff --git a/go/internal/server/gamedata/equipment_gacha.go b/go/internal/server/design/gamedata/equipment_gacha.go similarity index 100% rename from go/internal/server/gamedata/equipment_gacha.go rename to go/internal/server/design/gamedata/equipment_gacha.go diff --git a/go/internal/server/gamedata/equipment_grades.go b/go/internal/server/design/gamedata/equipment_grades.go similarity index 100% rename from go/internal/server/gamedata/equipment_grades.go rename to go/internal/server/design/gamedata/equipment_grades.go diff --git a/go/internal/server/gamedata/equipment_option_reroll.go b/go/internal/server/design/gamedata/equipment_option_reroll.go similarity index 100% rename from go/internal/server/gamedata/equipment_option_reroll.go rename to go/internal/server/design/gamedata/equipment_option_reroll.go diff --git a/go/internal/server/gamedata/equipment_slots.go b/go/internal/server/design/gamedata/equipment_slots.go similarity index 100% rename from go/internal/server/gamedata/equipment_slots.go rename to go/internal/server/design/gamedata/equipment_slots.go diff --git a/go/internal/server/gamedata/equipment_smelting.go b/go/internal/server/design/gamedata/equipment_smelting.go similarity index 100% rename from go/internal/server/gamedata/equipment_smelting.go rename to go/internal/server/design/gamedata/equipment_smelting.go diff --git a/go/internal/server/gamedata/equipment_stats.go b/go/internal/server/design/gamedata/equipment_stats.go similarity index 100% rename from go/internal/server/gamedata/equipment_stats.go rename to go/internal/server/design/gamedata/equipment_stats.go diff --git a/go/internal/server/gamedata/equipment_upgrade.go b/go/internal/server/design/gamedata/equipment_upgrade.go similarity index 100% rename from go/internal/server/gamedata/equipment_upgrade.go rename to go/internal/server/design/gamedata/equipment_upgrade.go diff --git a/go/internal/server/gamedata/event_actions.go b/go/internal/server/design/gamedata/event_actions.go similarity index 98% rename from go/internal/server/gamedata/event_actions.go rename to go/internal/server/design/gamedata/event_actions.go index a5ab718..3d3131c 100644 --- a/go/internal/server/gamedata/event_actions.go +++ b/go/internal/server/design/gamedata/event_actions.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "fmt" "sort" ) diff --git a/go/internal/server/gamedata/event_ap.go b/go/internal/server/design/gamedata/event_ap.go similarity index 100% rename from go/internal/server/gamedata/event_ap.go rename to go/internal/server/design/gamedata/event_ap.go diff --git a/go/internal/server/gamedata/event_battle_challenge.go b/go/internal/server/design/gamedata/event_battle_challenge.go similarity index 98% rename from go/internal/server/gamedata/event_battle_challenge.go rename to go/internal/server/design/gamedata/event_battle_challenge.go index 10521cd..34fba5c 100644 --- a/go/internal/server/gamedata/event_battle_challenge.go +++ b/go/internal/server/design/gamedata/event_battle_challenge.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "encoding/binary" "fmt" ) diff --git a/go/internal/server/gamedata/event_exchange.go b/go/internal/server/design/gamedata/event_exchange.go similarity index 100% rename from go/internal/server/gamedata/event_exchange.go rename to go/internal/server/design/gamedata/event_exchange.go diff --git a/go/internal/server/gamedata/event_field_pack.go b/go/internal/server/design/gamedata/event_field_pack.go similarity index 100% rename from go/internal/server/gamedata/event_field_pack.go rename to go/internal/server/design/gamedata/event_field_pack.go diff --git a/go/internal/server/gamedata/event_games.go b/go/internal/server/design/gamedata/event_games.go similarity index 100% rename from go/internal/server/gamedata/event_games.go rename to go/internal/server/design/gamedata/event_games.go diff --git a/go/internal/server/gamedata/event_owned_item.go b/go/internal/server/design/gamedata/event_owned_item.go similarity index 100% rename from go/internal/server/gamedata/event_owned_item.go rename to go/internal/server/design/gamedata/event_owned_item.go diff --git a/go/internal/server/gamedata/event_play.go b/go/internal/server/design/gamedata/event_play.go similarity index 100% rename from go/internal/server/gamedata/event_play.go rename to go/internal/server/design/gamedata/event_play.go diff --git a/go/internal/server/gamedata/event_play_field.go b/go/internal/server/design/gamedata/event_play_field.go similarity index 100% rename from go/internal/server/gamedata/event_play_field.go rename to go/internal/server/design/gamedata/event_play_field.go diff --git a/go/internal/server/gamedata/event_tasks.go b/go/internal/server/design/gamedata/event_tasks.go similarity index 100% rename from go/internal/server/gamedata/event_tasks.go rename to go/internal/server/design/gamedata/event_tasks.go diff --git a/go/internal/server/gamedata/field_action_objects.go b/go/internal/server/design/gamedata/field_action_objects.go similarity index 100% rename from go/internal/server/gamedata/field_action_objects.go rename to go/internal/server/design/gamedata/field_action_objects.go diff --git a/go/internal/server/gamedata/field_buffs.go b/go/internal/server/design/gamedata/field_buffs.go similarity index 97% rename from go/internal/server/gamedata/field_buffs.go rename to go/internal/server/design/gamedata/field_buffs.go index b277fba..a1a6bd7 100644 --- a/go/internal/server/gamedata/field_buffs.go +++ b/go/internal/server/design/gamedata/field_buffs.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "encoding/binary" "fmt" "math" diff --git a/go/internal/server/gamedata/field_monster_maps.go b/go/internal/server/design/gamedata/field_monster_maps.go similarity index 97% rename from go/internal/server/gamedata/field_monster_maps.go rename to go/internal/server/design/gamedata/field_monster_maps.go index 10f24af..d4afde0 100644 --- a/go/internal/server/gamedata/field_monster_maps.go +++ b/go/internal/server/design/gamedata/field_monster_maps.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "fmt" ) diff --git a/go/internal/server/gamedata/field_monsters.go b/go/internal/server/design/gamedata/field_monsters.go similarity index 83% rename from go/internal/server/gamedata/field_monsters.go rename to go/internal/server/design/gamedata/field_monsters.go index c646ae1..9fac58b 100644 --- a/go/internal/server/gamedata/field_monsters.go +++ b/go/internal/server/design/gamedata/field_monsters.go @@ -9,12 +9,12 @@ import ( // FieldMonsterDesign keeps the regeneration group requested by MonsterInfo, // not the unrelated event monster group used by FieldEventSpawnReward. type FieldMonsterDesign struct { - ID, GroupID, QuestID int - BattleDeck uint64 - BattleDecks []uint64 - LifeSeconds uint64 - RegenSeconds, ResetType, Type, FieldBuff, UseBattleSkip uint64 - Reward Reward + ID, GroupID, QuestID int + BattleDeck uint64 + BattleDecks []uint64 + LifeSeconds uint64 + RegenSeconds, ResetType, Type, FieldBuff, UseBattleSkip, CrashType uint64 + Reward Reward } func LoadFieldMonsters(root, version string, pack int) ([]FieldMonsterDesign, error) { @@ -100,7 +100,7 @@ func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) { } } g.ID = id - for f, dst := range map[int]*uint64{30: &g.Type, 6: &g.FieldBuff, 31: &g.UseBattleSkip, 27: &g.Reward.Count, 28: &g.Reward.ID, 29: &g.Reward.Type} { + for f, dst := range map[int]*uint64{30: &g.Type, 6: &g.FieldBuff, 15: &g.CrashType, 31: &g.UseBattleSkip, 27: &g.Reward.Count, 28: &g.Reward.ID, 29: &g.Reward.Type} { v, e := optionalScalar(raw, f) if e != nil { return nil, e diff --git a/go/internal/server/gamedata/field_objects.go b/go/internal/server/design/gamedata/field_objects.go similarity index 100% rename from go/internal/server/gamedata/field_objects.go rename to go/internal/server/design/gamedata/field_objects.go diff --git a/go/internal/server/gamedata/field_pack.go b/go/internal/server/design/gamedata/field_pack.go similarity index 100% rename from go/internal/server/gamedata/field_pack.go rename to go/internal/server/design/gamedata/field_pack.go diff --git a/go/internal/server/gamedata/field_research.go b/go/internal/server/design/gamedata/field_research.go similarity index 99% rename from go/internal/server/gamedata/field_research.go rename to go/internal/server/design/gamedata/field_research.go index e661b70..6a9ef90 100644 --- a/go/internal/server/gamedata/field_research.go +++ b/go/internal/server/design/gamedata/field_research.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "database/sql" "fmt" ) diff --git a/go/internal/server/gamedata/field_reset.go b/go/internal/server/design/gamedata/field_reset.go similarity index 98% rename from go/internal/server/gamedata/field_reset.go rename to go/internal/server/design/gamedata/field_reset.go index 04ed632..be6b866 100644 --- a/go/internal/server/gamedata/field_reset.go +++ b/go/internal/server/design/gamedata/field_reset.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "fmt" "strconv" "strings" diff --git a/go/internal/server/gamedata/field_settings.go b/go/internal/server/design/gamedata/field_settings.go similarity index 100% rename from go/internal/server/gamedata/field_settings.go rename to go/internal/server/design/gamedata/field_settings.go diff --git a/go/internal/server/design/gamedata/field_traps.go b/go/internal/server/design/gamedata/field_traps.go new file mode 100644 index 0000000..aa9b5a0 --- /dev/null +++ b/go/internal/server/design/gamedata/field_traps.go @@ -0,0 +1,175 @@ +package gamedata + +import ( + "fmt" + "slices" +) + +type FieldTrap struct { + ID, MapID, SwitchID, ResetType, Type int + FieldBuff, CoolSeconds, RegenSeconds uint64 + DefaultEnabled bool + QuestRange []uint64 + Maps []int +} +type FieldTrapTrigger struct { + ID, Type, AutoInteraction int + QuestRange []uint64 + Maps []int +} +type FieldTrapSwitch struct { + ID, ObjectType, OrderType int + Objects []uint64 +} +type FieldTrapDesign struct { + Traps map[int]FieldTrap + Triggers map[int]FieldTrapTrigger + Switches map[int]FieldTrapSwitch + MapIDs []int + StoryModeImmune bool +} + +func LoadFieldTraps(root, version string, pack int) (FieldTrapDesign, error) { + d := FieldTrapDesign{Traps: map[int]FieldTrap{}, Triggers: map[int]FieldTrapTrigger{}, Switches: map[int]FieldTrapSwitch{}} + db, done, err := OpenDatabase(root, version, fmt.Sprintf("pack%d", pack)) + if err != nil { + return d, err + } + defer done() + for _, table := range []string{"FieldTrapTable", "TriggerTable", "FieldObjectSwitchTable"} { + rows, e := db.Query("SELECT id,ProtoBuf FROM " + table + " ORDER BY id") + if e != nil { + return d, e + } + for rows.Next() { + var id int + var raw []byte + if e = rows.Scan(&id, &raw); e != nil { + _ = rows.Close() + return d, e + } + values := map[int]uint64{} + for _, field := range []int{2, 3, 4, 5, 8, 9, 11, 13, 14, 17, 18} { + if table == "TriggerTable" && (field == 2 || field == 4 || field == 13) || table == "FieldObjectSwitchTable" && field == 4 || table == "FieldTrapTable" && field == 11 { + continue + } + values[field], e = optionalScalar(raw, field) + if e != nil { + _ = rows.Close() + return d, e + } + } + switch table { + case "FieldTrapTable": + q, e := packedInts(raw, 11) + if e != nil { + _ = rows.Close() + return d, e + } + if values[3] > 1 || values[14] > 1 || values[4] > 0x7fffffff || values[13] > 0x7fffffff { + _ = rows.Close() + return d, fmt.Errorf("gamedata: invalid trap %d pack %d", id, pack) + } + d.Traps[id] = FieldTrap{ID: id, MapID: int(values[8]), SwitchID: int(values[17]), ResetType: int(values[14]), Type: int(values[18]), FieldBuff: values[5], CoolSeconds: values[4], RegenSeconds: values[13], DefaultEnabled: values[3] == 1, QuestRange: q} + case "TriggerTable": + q, e := packedInts(raw, 12) + if e != nil { + _ = rows.Close() + return d, e + } + d.Triggers[id] = FieldTrapTrigger{ID: id, Type: int(values[11]), AutoInteraction: int(values[9]), QuestRange: q} + case "FieldObjectSwitchTable": + objects, e := packedInts(raw, 4) + if e != nil { + _ = rows.Close() + return d, e + } + d.Switches[id] = FieldTrapSwitch{ID: id, ObjectType: int(values[2]), OrderType: int(values[5]), Objects: objects} + } + } + e = rows.Err() + _ = rows.Close() + if e != nil { + return d, e + } + } + common, closeCommon, err := openStatDatabase(root, version) + if err != nil { + return d, err + } + defer closeCommon() + var packRaw []byte + if err := common.QueryRow("SELECT ProtoBuf FROM PackTable WHERE id=?", pack).Scan(&packRaw); err != nil { + return d, err + } + packType, err := optionalScalar(packRaw, 55) + if err != nil { + return d, err + } + // FieldDeckPacket.IsUsingStoryMode includes PackInfo.IsNewFieldSpecPack. + d.StoryModeImmune = packType == 0 || packType == 1000 || packType == 1 && pack >= 1010 || packType == 6 && pack >= 2009 + mapScenes := map[string]int{} + rows, err := common.Query("SELECT id,mapScenePath FROM MapTable WHERE packId=? ORDER BY id", pack) + if err != nil { + return d, err + } + for rows.Next() { + var id int + var scene string + if err = rows.Scan(&id, &scene); err != nil { + _ = rows.Close() + return d, err + } + mapScenes[scene] = id + d.MapIDs = append(d.MapIDs, id) + } + err = rows.Err() + _ = rows.Close() + if err != nil { + return d, err + } + scenes, closeScenes, err := OpenDatabase(root, version, "FieldObjectSceneData") + if err != nil { + return d, err + } + defer closeScenes() + rows, err = scenes.Query(fmt.Sprintf("SELECT tableId,sceneName,objectType FROM FieldObjectSceneData%d WHERE objectType IN (7,15,16)", pack)) + if err != nil { + return d, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var id, kind int + var scene string + if err = rows.Scan(&id, &scene, &kind); err != nil { + return d, err + } + if id == 0 { + continue + } + mapID, found := mapScenes[scene] + if !found { + continue + } + if kind == 15 { + trigger, found := d.Triggers[id] + if !found { + return d, fmt.Errorf("gamedata: missing trigger %d pack %d", id, pack) + } + if !slices.Contains(trigger.Maps, mapID) { + trigger.Maps = append(trigger.Maps, mapID) + } + d.Triggers[id] = trigger + } else { + trap, found := d.Traps[id] + if !found { + return d, fmt.Errorf("gamedata: missing trap %d pack %d", id, pack) + } + if !slices.Contains(trap.Maps, mapID) { + trap.Maps = append(trap.Maps, mapID) + } + d.Traps[id] = trap + } + } + return d, rows.Err() +} diff --git a/go/internal/server/gamedata/first_gacha.go b/go/internal/server/design/gamedata/first_gacha.go similarity index 100% rename from go/internal/server/gamedata/first_gacha.go rename to go/internal/server/design/gamedata/first_gacha.go diff --git a/go/internal/server/gamedata/food.go b/go/internal/server/design/gamedata/food.go similarity index 100% rename from go/internal/server/gamedata/food.go rename to go/internal/server/design/gamedata/food.go diff --git a/go/internal/server/gamedata/friendship.go b/go/internal/server/design/gamedata/friendship.go similarity index 100% rename from go/internal/server/gamedata/friendship.go rename to go/internal/server/design/gamedata/friendship.go diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/design/gamedata/gacha.go similarity index 99% rename from go/internal/server/gamedata/gacha.go rename to go/internal/server/design/gamedata/gacha.go index 187e27d..9247b60 100644 --- a/go/internal/server/gamedata/gacha.go +++ b/go/internal/server/design/gamedata/gacha.go @@ -14,7 +14,7 @@ import ( _ "modernc.org/sqlite" - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" ) const ( diff --git a/go/internal/server/gamedata/gacha_cash.go b/go/internal/server/design/gamedata/gacha_cash.go similarity index 100% rename from go/internal/server/gamedata/gacha_cash.go rename to go/internal/server/design/gamedata/gacha_cash.go diff --git a/go/internal/server/gamedata/gacha_character.go b/go/internal/server/design/gamedata/gacha_character.go similarity index 100% rename from go/internal/server/gamedata/gacha_character.go rename to go/internal/server/design/gamedata/gacha_character.go diff --git a/go/internal/server/gamedata/gacha_grades.go b/go/internal/server/design/gamedata/gacha_grades.go similarity index 100% rename from go/internal/server/gamedata/gacha_grades.go rename to go/internal/server/design/gamedata/gacha_grades.go diff --git a/go/internal/server/gamedata/gacha_infinite_metadata.go b/go/internal/server/design/gamedata/gacha_infinite_metadata.go similarity index 100% rename from go/internal/server/gamedata/gacha_infinite_metadata.go rename to go/internal/server/design/gamedata/gacha_infinite_metadata.go diff --git a/go/internal/server/gamedata/growth.go b/go/internal/server/design/gamedata/growth.go similarity index 100% rename from go/internal/server/gamedata/growth.go rename to go/internal/server/design/gamedata/growth.go diff --git a/go/internal/server/gamedata/hunting.go b/go/internal/server/design/gamedata/hunting.go similarity index 100% rename from go/internal/server/gamedata/hunting.go rename to go/internal/server/design/gamedata/hunting.go diff --git a/go/internal/server/gamedata/hunting_ap.go b/go/internal/server/design/gamedata/hunting_ap.go similarity index 96% rename from go/internal/server/gamedata/hunting_ap.go rename to go/internal/server/design/gamedata/hunting_ap.go index c9024ca..55e43a5 100644 --- a/go/internal/server/gamedata/hunting_ap.go +++ b/go/internal/server/design/gamedata/hunting_ap.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "fmt" "strconv" "strings" diff --git a/go/internal/server/gamedata/immortal.go b/go/internal/server/design/gamedata/immortal.go similarity index 100% rename from go/internal/server/gamedata/immortal.go rename to go/internal/server/design/gamedata/immortal.go diff --git a/go/internal/server/gamedata/inn.go b/go/internal/server/design/gamedata/inn.go similarity index 100% rename from go/internal/server/gamedata/inn.go rename to go/internal/server/design/gamedata/inn.go diff --git a/go/internal/server/gamedata/inventory_slots.go b/go/internal/server/design/gamedata/inventory_slots.go similarity index 100% rename from go/internal/server/gamedata/inventory_slots.go rename to go/internal/server/design/gamedata/inventory_slots.go diff --git a/go/internal/server/gamedata/item_craft.go b/go/internal/server/design/gamedata/item_craft.go similarity index 100% rename from go/internal/server/gamedata/item_craft.go rename to go/internal/server/design/gamedata/item_craft.go diff --git a/go/internal/server/design/gamedata/item_stacks.go b/go/internal/server/design/gamedata/item_stacks.go new file mode 100644 index 0000000..f2fea71 --- /dev/null +++ b/go/internal/server/design/gamedata/item_stacks.go @@ -0,0 +1,60 @@ +package gamedata + +import ( + "fmt" + "math" +) + +type ItemStackDesign struct { + Limits map[[2]uint64]uint64 +} + +func LoadItemStackDesign(root, version string) (*ItemStackDesign, error) { + db, release, err := OpenDatabase(root, version, "common") + if err != nil { + return nil, err + } + defer release() + design := &ItemStackDesign{Limits: map[[2]uint64]uint64{}} + for _, source := range []struct { + name string + kind, idField, limitField uint64 + }{ + {"FoodTable", 5, 7, 16}, {"ResourceTable", 8, 4, 12}, + {"RandomBoxTable", 9, 4, 12}, {"UseItemTable", 14, 3, 10}, + } { + rows, err := db.Query("SELECT id,ProtoBuf FROM " + source.name + " ORDER BY id") + if err != nil { + return nil, err + } + for rows.Next() { + var id uint64 + var raw []byte + if err := rows.Scan(&id, &raw); err != nil { + _ = rows.Close() + return nil, err + } + protoID, err := optionalScalar(raw, int(source.idField)) + if err != nil || id == 0 || protoID != id { + _ = rows.Close() + return nil, fmt.Errorf("gamedata: invalid %s stack identity %d", source.name, id) + } + limit, err := optionalScalar(raw, int(source.limitField)) + if err != nil || limit > math.MaxInt32 { + _ = rows.Close() + return nil, fmt.Errorf("gamedata: invalid %s stack limit %d", source.name, id) + } + if limit > 0 { + design.Limits[[2]uint64{source.kind, id}] = limit + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + } + return design, nil +} diff --git a/go/internal/server/gamedata/limited_costume.go b/go/internal/server/design/gamedata/limited_costume.go similarity index 100% rename from go/internal/server/gamedata/limited_costume.go rename to go/internal/server/design/gamedata/limited_costume.go diff --git a/go/internal/server/gamedata/login_passes.go b/go/internal/server/design/gamedata/login_passes.go similarity index 100% rename from go/internal/server/gamedata/login_passes.go rename to go/internal/server/design/gamedata/login_passes.go diff --git a/go/internal/server/gamedata/mini_content.go b/go/internal/server/design/gamedata/mini_content.go similarity index 100% rename from go/internal/server/gamedata/mini_content.go rename to go/internal/server/design/gamedata/mini_content.go diff --git a/go/internal/server/gamedata/mini_hub.go b/go/internal/server/design/gamedata/mini_hub.go similarity index 100% rename from go/internal/server/gamedata/mini_hub.go rename to go/internal/server/design/gamedata/mini_hub.go diff --git a/go/internal/server/gamedata/missions.go b/go/internal/server/design/gamedata/missions.go similarity index 100% rename from go/internal/server/gamedata/missions.go rename to go/internal/server/design/gamedata/missions.go diff --git a/go/internal/server/gamedata/monster_hunt.go b/go/internal/server/design/gamedata/monster_hunt.go similarity index 100% rename from go/internal/server/gamedata/monster_hunt.go rename to go/internal/server/design/gamedata/monster_hunt.go diff --git a/go/internal/server/gamedata/npc_reputation.go b/go/internal/server/design/gamedata/npc_reputation.go similarity index 100% rename from go/internal/server/gamedata/npc_reputation.go rename to go/internal/server/design/gamedata/npc_reputation.go diff --git a/go/internal/server/gamedata/npc_shop.go b/go/internal/server/design/gamedata/npc_shop.go similarity index 100% rename from go/internal/server/gamedata/npc_shop.go rename to go/internal/server/design/gamedata/npc_shop.go diff --git a/go/internal/server/gamedata/overwhelm.go b/go/internal/server/design/gamedata/overwhelm.go similarity index 100% rename from go/internal/server/gamedata/overwhelm.go rename to go/internal/server/design/gamedata/overwhelm.go diff --git a/go/internal/server/gamedata/pack_detail.go b/go/internal/server/design/gamedata/pack_detail.go similarity index 59% rename from go/internal/server/gamedata/pack_detail.go rename to go/internal/server/design/gamedata/pack_detail.go index dfca2cc..4bd54bc 100644 --- a/go/internal/server/gamedata/pack_detail.go +++ b/go/internal/server/design/gamedata/pack_detail.go @@ -7,7 +7,7 @@ import ( // PackDetailDesign identifies the monsters whose state is consumed by the // client's PackMapRewardInfo. Ordinary story battle rewards are excluded. -type PackDetailDesign struct{ RegenMonsterIDs []int } +type PackDetailDesign struct{ RegenMonsterIDs, MapIDs []int } func LoadPackDetailDesign(root, version string, packID int) (PackDetailDesign, error) { if packID <= 0 { @@ -18,7 +18,42 @@ func LoadPackDetailDesign(root, version string, packID int) (PackDetailDesign, e return PackDetailDesign{}, err } defer release() - return loadPackDetailDesign(db) + design, err := loadPackDetailDesign(db) + if err != nil { + return design, err + } + placements, err := LoadFieldMonsterMaps(root, version, packID) + if err != nil { + return design, err + } + placed := design.RegenMonsterIDs[:0] + for _, id := range design.RegenMonsterIDs { + if len(placements[id]) > 0 { + placed = append(placed, id) + } + } + design.RegenMonsterIDs = placed + common, closeCommon, err := openStatDatabase(root, version) + if err != nil { + return design, err + } + defer closeCommon() + rows, err := common.Query("SELECT id FROM MapTable WHERE packId=? ORDER BY id", packID) + if err != nil { + return design, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var mapID int + if err := rows.Scan(&mapID); err != nil { + return design, err + } + design.MapIDs = append(design.MapIDs, mapID) + } + if err := rows.Err(); err != nil { + return design, err + } + return design, nil } func loadPackDetailDesign(db *sql.DB) (PackDetailDesign, error) { diff --git a/go/internal/server/gamedata/pack_jam.go b/go/internal/server/design/gamedata/pack_jam.go similarity index 100% rename from go/internal/server/gamedata/pack_jam.go rename to go/internal/server/design/gamedata/pack_jam.go diff --git a/go/internal/server/gamedata/pack_recovery.go b/go/internal/server/design/gamedata/pack_recovery.go similarity index 100% rename from go/internal/server/gamedata/pack_recovery.go rename to go/internal/server/design/gamedata/pack_recovery.go diff --git a/go/internal/server/gamedata/pack_summary.go b/go/internal/server/design/gamedata/pack_summary.go similarity index 100% rename from go/internal/server/gamedata/pack_summary.go rename to go/internal/server/design/gamedata/pack_summary.go diff --git a/go/internal/server/gamedata/pack_transition.go b/go/internal/server/design/gamedata/pack_transition.go similarity index 100% rename from go/internal/server/gamedata/pack_transition.go rename to go/internal/server/design/gamedata/pack_transition.go diff --git a/go/internal/server/gamedata/pictorial.go b/go/internal/server/design/gamedata/pictorial.go similarity index 100% rename from go/internal/server/gamedata/pictorial.go rename to go/internal/server/design/gamedata/pictorial.go diff --git a/go/internal/server/gamedata/preset.go b/go/internal/server/design/gamedata/preset.go similarity index 100% rename from go/internal/server/gamedata/preset.go rename to go/internal/server/design/gamedata/preset.go diff --git a/go/internal/server/gamedata/promotion.go b/go/internal/server/design/gamedata/promotion.go similarity index 100% rename from go/internal/server/gamedata/promotion.go rename to go/internal/server/design/gamedata/promotion.go diff --git a/go/internal/server/gamedata/quest_costume.go b/go/internal/server/design/gamedata/quest_costume.go similarity index 100% rename from go/internal/server/gamedata/quest_costume.go rename to go/internal/server/design/gamedata/quest_costume.go diff --git a/go/internal/server/gamedata/quest_difficulty.go b/go/internal/server/design/gamedata/quest_difficulty.go similarity index 100% rename from go/internal/server/gamedata/quest_difficulty.go rename to go/internal/server/design/gamedata/quest_difficulty.go diff --git a/go/internal/server/gamedata/quest_formation.go b/go/internal/server/design/gamedata/quest_formation.go similarity index 100% rename from go/internal/server/gamedata/quest_formation.go rename to go/internal/server/design/gamedata/quest_formation.go diff --git a/go/internal/server/gamedata/quests.go b/go/internal/server/design/gamedata/quests.go similarity index 100% rename from go/internal/server/gamedata/quests.go rename to go/internal/server/design/gamedata/quests.go diff --git a/go/internal/server/gamedata/random_box.go b/go/internal/server/design/gamedata/random_box.go similarity index 100% rename from go/internal/server/gamedata/random_box.go rename to go/internal/server/design/gamedata/random_box.go diff --git a/go/internal/server/gamedata/recruit.go b/go/internal/server/design/gamedata/recruit.go similarity index 100% rename from go/internal/server/gamedata/recruit.go rename to go/internal/server/design/gamedata/recruit.go diff --git a/go/internal/server/gamedata/recruit_npc.go b/go/internal/server/design/gamedata/recruit_npc.go similarity index 100% rename from go/internal/server/gamedata/recruit_npc.go rename to go/internal/server/design/gamedata/recruit_npc.go diff --git a/go/internal/server/gamedata/reward_costume.go b/go/internal/server/design/gamedata/reward_costume.go similarity index 100% rename from go/internal/server/gamedata/reward_costume.go rename to go/internal/server/design/gamedata/reward_costume.go diff --git a/go/internal/server/gamedata/reward_equipment.go b/go/internal/server/design/gamedata/reward_equipment.go similarity index 100% rename from go/internal/server/gamedata/reward_equipment.go rename to go/internal/server/design/gamedata/reward_equipment.go diff --git a/go/internal/server/gamedata/reward_graph.go b/go/internal/server/design/gamedata/reward_graph.go similarity index 100% rename from go/internal/server/gamedata/reward_graph.go rename to go/internal/server/design/gamedata/reward_graph.go diff --git a/go/internal/server/gamedata/reward_skin.go b/go/internal/server/design/gamedata/reward_skin.go similarity index 100% rename from go/internal/server/gamedata/reward_skin.go rename to go/internal/server/design/gamedata/reward_skin.go diff --git a/go/internal/server/design/gamedata/source.go b/go/internal/server/design/gamedata/source.go new file mode 100644 index 0000000..bc0224d --- /dev/null +++ b/go/internal/server/design/gamedata/source.go @@ -0,0 +1,115 @@ +package gamedata + +import "sync" + +type designEntry[V any] struct { + once sync.Once + value V + err error +} +type designIndex[K comparable, V any] struct { + mu sync.Mutex + entries map[K]*designEntry[V] +} + +func (i *designIndex[K, V]) get(key K, load func() (V, error)) (V, error) { + i.mu.Lock() + if i.entries == nil { + i.entries = make(map[K]*designEntry[V]) + } + entry := i.entries[key] + if entry == nil { + entry = &designEntry[V]{} + i.entries[key] = entry + } + i.mu.Unlock() + entry.once.Do(func() { entry.value, entry.err = load() }) + if entry.err != nil { + i.mu.Lock() + if i.entries[key] == entry { + delete(i.entries, key) + } + i.mu.Unlock() + } + return entry.value, entry.err +} + +// Source owns version-scoped immutable rules shared by all player instances. +// Callers must copy a rule before changing any map, slice or pointed-to value. +type Source struct { + root, version string + fieldObjects designIndex[int, FieldObjectDesign] + fieldResearch designIndex[int, FieldResearchDesign] + packDetail designIndex[int, PackDetailDesign] + fieldMonsters designIndex[int, []FieldMonsterDesign] + fieldMonsterMaps designIndex[int, map[int][]int] + fieldTraps designIndex[int, FieldTrapDesign] + nPCReputation designIndex[int, NPCReputationDesign] + inns designIndex[int, []InnRule] + waypoint designIndex[uint64, WaypointPack] + huntingPack designIndex[int, *HuntingPack] + monsterHunt designIndex[uint64, *MonsterHunt] + dispatch designIndex[[2]uint64, *DispatchDesign] + eventGames designIndex[[2]uint64, *EventGame] + eventFields designIndex[uint64, *EventField] + overwhelm designIndex[[2]int, OverwhelmQuestRule] + recruit designIndex[[2]uint64, RecruitNPC] + battleRewards designIndex[[2]uint64, []BattleReward] +} + +func NewSource(root, version string) *Source { return &Source{root: root, version: version} } +func (s *Source) FieldObjects(key int) (FieldObjectDesign, error) { + return s.fieldObjects.get(key, func() (FieldObjectDesign, error) { return LoadFieldObjects(s.root, s.version, key) }) +} +func (s *Source) FieldResearch(key int) (FieldResearchDesign, error) { + return s.fieldResearch.get(key, func() (FieldResearchDesign, error) { return LoadFieldResearch(s.root, s.version, key) }) +} +func (s *Source) PackDetail(key int) (PackDetailDesign, error) { + return s.packDetail.get(key, func() (PackDetailDesign, error) { return LoadPackDetailDesign(s.root, s.version, key) }) +} +func (s *Source) FieldMonsters(key int) ([]FieldMonsterDesign, error) { + return s.fieldMonsters.get(key, func() ([]FieldMonsterDesign, error) { return LoadFieldMonsters(s.root, s.version, key) }) +} +func (s *Source) FieldMonsterMaps(key int) (map[int][]int, error) { + return s.fieldMonsterMaps.get(key, func() (map[int][]int, error) { return LoadFieldMonsterMaps(s.root, s.version, key) }) +} +func (s *Source) FieldTraps(key int) (FieldTrapDesign, error) { + return s.fieldTraps.get(key, func() (FieldTrapDesign, error) { return LoadFieldTraps(s.root, s.version, key) }) +} +func (s *Source) NPCReputation(key int) (NPCReputationDesign, error) { + return s.nPCReputation.get(key, func() (NPCReputationDesign, error) { return LoadNPCReputation(s.root, s.version, key) }) +} +func (s *Source) Inns(key int) ([]InnRule, error) { + return s.inns.get(key, func() ([]InnRule, error) { return LoadInns(s.root, s.version, key) }) +} +func (s *Source) Waypoint(key uint64) (WaypointPack, error) { + return s.waypoint.get(key, func() (WaypointPack, error) { return LoadWaypointPack(s.root, s.version, key) }) +} +func (s *Source) HuntingPack(key int) (*HuntingPack, error) { + return s.huntingPack.get(key, func() (*HuntingPack, error) { return LoadHuntingPack(s.root, s.version, key) }) +} +func (s *Source) MonsterHunt(key uint64) (*MonsterHunt, error) { + return s.monsterHunt.get(key, func() (*MonsterHunt, error) { return LoadMonsterHunt(s.root, s.version, key) }) +} + +func (s *Source) DispatchDesign(group, id uint64) (*DispatchDesign, error) { + return s.dispatch.get([2]uint64{group, id}, func() (*DispatchDesign, error) { return LoadDispatchDesign(s.root, s.version, group, id) }) +} +func (s *Source) EventGame(kind, id uint64) (*EventGame, error) { + return s.eventGames.get([2]uint64{kind, id}, func() (*EventGame, error) { return LoadEventGame(s.root, s.version, kind, id) }) +} +func (s *Source) EventField(catalog *EventPlayCatalog, id uint64) (*EventField, error) { + return s.eventFields.get(id, func() (*EventField, error) { return catalog.Field(id) }) +} +func (s *Source) OverwhelmQuest(pack, quest int) (OverwhelmQuestRule, error) { + return s.overwhelm.get([2]int{pack, quest}, func() (OverwhelmQuestRule, error) { return LoadOverwhelmQuest(s.root, s.version, pack, quest) }) +} +func (s *Source) RecruitNPC(pack int, npc uint64) (RecruitNPC, error) { + return s.recruit.get([2]uint64{uint64(pack), npc}, func() (RecruitNPC, error) { return LoadRecruitNPC(s.root, s.version, pack, npc) }) +} +func (s *Source) BattleRewards(pack int, deck uint64) ([]BattleReward, error) { + return s.battleRewards.get([2]uint64{uint64(pack), deck}, func() ([]BattleReward, error) { return BattleDeckRewards(s.root, s.version, pack, deck) }) +} + +func (s *Source) Root() string { return s.root } +func (s *Source) Version() string { return s.version } diff --git a/go/internal/server/gamedata/stat_design.go b/go/internal/server/design/gamedata/stat_design.go similarity index 99% rename from go/internal/server/gamedata/stat_design.go rename to go/internal/server/design/gamedata/stat_design.go index e45ec23..f9a70a3 100644 --- a/go/internal/server/gamedata/stat_design.go +++ b/go/internal/server/design/gamedata/stat_design.go @@ -6,7 +6,7 @@ import ( "fmt" "math" - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" _ "modernc.org/sqlite" ) diff --git a/go/internal/server/gamedata/story_catalog.go b/go/internal/server/design/gamedata/story_catalog.go similarity index 100% rename from go/internal/server/gamedata/story_catalog.go rename to go/internal/server/design/gamedata/story_catalog.go diff --git a/go/internal/server/gamedata/story_characters.go b/go/internal/server/design/gamedata/story_characters.go similarity index 100% rename from go/internal/server/gamedata/story_characters.go rename to go/internal/server/design/gamedata/story_characters.go diff --git a/go/internal/server/gamedata/talent_dispatch.go b/go/internal/server/design/gamedata/talent_dispatch.go similarity index 98% rename from go/internal/server/gamedata/talent_dispatch.go rename to go/internal/server/design/gamedata/talent_dispatch.go index 7179c8d..38d79ea 100644 --- a/go/internal/server/gamedata/talent_dispatch.go +++ b/go/internal/server/design/gamedata/talent_dispatch.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "fmt" "time" ) diff --git a/go/internal/server/gamedata/talent_growth.go b/go/internal/server/design/gamedata/talent_growth.go similarity index 100% rename from go/internal/server/gamedata/talent_growth.go rename to go/internal/server/design/gamedata/talent_growth.go diff --git a/go/internal/server/gamedata/talent_use.go b/go/internal/server/design/gamedata/talent_use.go similarity index 99% rename from go/internal/server/gamedata/talent_use.go rename to go/internal/server/design/gamedata/talent_use.go index 726b571..fbf28d2 100644 --- a/go/internal/server/gamedata/talent_use.go +++ b/go/internal/server/design/gamedata/talent_use.go @@ -1,7 +1,7 @@ package gamedata import ( - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" "database/sql" "encoding/binary" "fmt" diff --git a/go/internal/server/gamedata/today_quests.go b/go/internal/server/design/gamedata/today_quests.go similarity index 100% rename from go/internal/server/gamedata/today_quests.go rename to go/internal/server/design/gamedata/today_quests.go diff --git a/go/internal/server/gamedata/validate.go b/go/internal/server/design/gamedata/validate.go similarity index 100% rename from go/internal/server/gamedata/validate.go rename to go/internal/server/design/gamedata/validate.go diff --git a/go/internal/server/gamedata/waypoint.go b/go/internal/server/design/gamedata/waypoint.go similarity index 100% rename from go/internal/server/gamedata/waypoint.go rename to go/internal/server/design/gamedata/waypoint.go diff --git a/go/internal/server/design/gamedata/world_rules.go b/go/internal/server/design/gamedata/world_rules.go new file mode 100644 index 0000000..f39edc9 --- /dev/null +++ b/go/internal/server/design/gamedata/world_rules.go @@ -0,0 +1,90 @@ +package gamedata + +import "fmt" + +type WorldRules struct { + Story *StoryCatalog + Packs map[int]map[int]QuestDesign + Transitions map[int]PackTransition + QuestCostumes *RegularGachaCatalog + FieldPacks map[int]FieldPack + SummaryTargets map[int]bool + Jam *PackJamDesign + Difficulties map[int]map[int]bool + Characters *StoryCharacterCatalog +} + +func LoadWorldRules(root, version string, placeholder uint64) (*WorldRules, error) { + story, err := LoadStoryCatalog(root, version) + if err != nil { + return nil, err + } + if err := orderMainQuests(story); err != nil { + return nil, err + } + rules := &WorldRules{Story: story, Packs: map[int]map[int]QuestDesign{}, Transitions: map[int]PackTransition{}} + var packs []int + for id, pack := range story.Packs { + rules.Packs[id] = pack.Quests + rules.Transitions[id] = PackTransition{PackID: id, NextPackID: pack.NextPackID} + packs = append(packs, id) + } + rules.QuestCostumes, err = LoadQuestCostumes(root, version, rules.Packs, story) + if err != nil { + return nil, err + } + rules.FieldPacks, err = LoadFieldPacks(root, version) + if err != nil { + return nil, err + } + rules.SummaryTargets, err = LoadPackSummaryTargets(root, version) + if err != nil { + return nil, err + } + rules.Jam, err = LoadPackJamDesign(root, version) + if err != nil { + return nil, err + } + rules.Difficulties, err = LoadQuestDifficulties(root, version) + if err != nil { + return nil, err + } + rules.Characters, err = LoadStoryCharacterCatalog(root, version, packs, placeholder) + if err != nil { + return nil, err + } + return rules, nil +} + +// orderMainQuests follows the actual QuestTable links. Subquests never become +// a predecessor merely because their numeric ID lies between two main quests. +func orderMainQuests(story *StoryCatalog) error { + for id, pack := range story.Packs { + start := 0 + for _, qid := range pack.MainQuestIDs { + if pack.Quests[qid].PriorQuestID == 0 { + if start != 0 { + return fmt.Errorf("world: pack %d has multiple main quest roots", id) + } + start = qid + } + } + var ordered []int + seen := map[int]bool{} + for qid := start; qid != 0; { + q, exists := pack.Quests[qid] + if !exists || q.Type != 0 || seen[qid] { + return fmt.Errorf("world: invalid main quest chain pack %d quest %d", id, qid) + } + seen[qid] = true + ordered = append(ordered, qid) + qid = q.NextQuestID + } + if len(ordered) != len(pack.MainQuestIDs) { + return fmt.Errorf("world: disconnected main quest chain pack %d", id) + } + pack.MainQuestIDs = ordered + story.Packs[id] = pack + } + return nil +} diff --git a/go/internal/server/schedule/schedule.go b/go/internal/server/design/schedule/schedule.go similarity index 58% rename from go/internal/server/schedule/schedule.go rename to go/internal/server/design/schedule/schedule.go index aa2bcfb..2fb7556 100644 --- a/go/internal/server/schedule/schedule.go +++ b/go/internal/server/design/schedule/schedule.go @@ -3,11 +3,9 @@ package schedule import ( - "bd2server/internal/server/versionconfig" + "bd2server/internal/server/platform/versionconfig" "errors" "math" - - "bd2server/internal/server/wire" ) // Season is the named semantic form of Proto.Net.SeasonInfo. @@ -33,7 +31,7 @@ type RegularSeason struct { // Service contains the configured regular-content calendar for this fixed // client/GameData version. It is server configuration, not a captured packet: -// Handle encodes every protobuf field from these named values. +// Values remain immutable after validation. type Service struct { Version string `json:"version"` CalculateMilliseconds uint64 `json:"calculate_milliseconds"` @@ -66,47 +64,3 @@ func (s *Service) Validate() error { } return nil } - -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/ScheduleInfo" { - return 0, nil, false, nil - } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 0, nil, true, errors.New("schedule: invalid request sequence") - } - if s == nil || s.CalculateMilliseconds == 0 || len(s.Contents) == 0 { - return 0, nil, true, errors.New("schedule: unavailable calendar") - } - response := wire.AppendVarint(nil, 1, s.CalculateMilliseconds) - for _, content := range s.Contents { - entry := wire.AppendVarint(nil, 1, content.ID) - entry = wire.AppendBytes(entry, 2, encodeSeason(content.Current)) - entry = wire.AppendBytes(entry, 3, encodeSeason(content.Next)) - response = wire.AppendBytes(response, 2, entry) - } - for _, regular := range s.Regular { - entry := wire.AppendVarint(nil, 1, regular.ContentID) - if regular.Season != 0 { - entry = wire.AppendVarint(entry, 2, regular.Season) - } - response = wire.AppendBytes(response, 3, entry) - } - return 117, response, true, nil -} - -func encodeSeason(season Season) []byte { - result := wire.AppendVarint(nil, 1, season.ID) - result = wire.AppendVarint(result, 2, season.StartMilliseconds) - result = wire.AppendVarint(result, 3, season.EndMilliseconds) - if season.Error { - result = wire.AppendVarint(result, 4, 1) - } - if season.Return { - result = wire.AppendVarint(result, 5, 1) - } - if season.RankRewardGroupID != 0 { - result = wire.AppendVarint(result, 6, season.RankRewardGroupID) - } - return result -} diff --git a/go/internal/server/domain/battle/battle.go b/go/internal/server/domain/battle/battle.go new file mode 100644 index 0000000..0733c03 --- /dev/null +++ b/go/internal/server/domain/battle/battle.go @@ -0,0 +1,220 @@ +// Package battle implements a deterministic local battle session. The client +// remains authoritative for turn simulation; the server validates sequencing +// and echoes the submitted combat state without capture replay. +package battle + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" +) + +type Service struct { + states map[string]*battleState + transientVersion uint64 + + gameDataRoot string + gameDataVersion string + currentPack func(command.Context) (int, error) + currentDifficulty func(command.Context) (uint64, error) + loadDifficultyDeck func(string, string, int, uint64, uint64, uint64) (gamedata.QuestBattleDeck, error) + validateQuest func(command.Context, int, []uint64) error + grantRewards func(command.Context, string, []gamedata.Reward) ([]byte, error) + loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error) + loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) + buffs func(command.Context) ([]gamedata.PictorialBuffStat, error) + onTutorialWin func(ctx command.Context) error + onMonsterWin func(ctx command.Context) error + commitHealth func(ctx command.Context, _ map[uint64]uint64) error + hunting HuntingRuntime + monsterHunt MonsterHuntRuntime + eventBattles []EventBattleRuntime + fieldMonsters FieldMonsterRuntime + consumeFieldBuff func(ctx command.Context, _ string) error +} +type FieldMonsterRuntime interface { + BeginFieldMonsterBattle(ctx command.Context, _ int, _ uint64, _ uint64) (string, bool, error) + CompleteFieldMonsterBattle(ctx command.Context, _ int, _ uint64, _ string) ([]byte, error) +} + +func (s *Service) AttachFieldMonsters(runtime FieldMonsterRuntime) { s.fieldMonsters = runtime } + +func (s *Service) AttachFieldBuffConsume(consume func(ctx command.Context, _ string) error) { + s.consumeFieldBuff = consume +} + +// EventBattleRuntime owns event stage eligibility, costs and settlement while +// the normal battle service transports the client's turn simulation. +type EventBattleRuntime interface { + HandlesBattle(mode uint64) bool + EnterBattle(ctx command.Context, request []byte, receipt string) ([]byte, error) + CompleteBattle(ctx command.Context, request []byte, receipt string) ([]byte, error) +} + +func (s *Service) AttachEventBattle(runtime EventBattleRuntime) { + if runtime != nil { + s.eventBattles = append(s.eventBattles, runtime) + } +} + +func (s *Service) eventBattle(mode uint64) EventBattleRuntime { + for _, runtime := range s.eventBattles { + if runtime.HandlesBattle(mode) { + return runtime + } + } + return nil +} + +type MonsterHuntRuntime interface { + EnterBattle(ctx command.Context, request []byte, receipt string) ([]byte, error) + CompleteBattle(ctx command.Context, request []byte, receipt string) ([]byte, error) +} + +func (s *Service) AttachMonsterHunt(runtime MonsterHuntRuntime) { + s.monsterHunt = runtime +} + +func isMonsterHunt(mode uint64) bool { return mode == 8 || mode == 24 } + +// HuntingRuntime validates the active hunting ground and settles each won +// encounter with the account's persistent AP, monsters and reward ledger. +type HuntingRuntime interface { + ValidateBattle(ctx command.Context, pack int, mode, monster, deck uint64) error + CompleteBattle(ctx command.Context, pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) +} + +const huntingGroundMode = 5 + +func (s *Service) AttachHunting(runtime HuntingRuntime) { + s.hunting = runtime +} + +type battleState struct { + entered bool + index uint64 + round uint64 + monster uint64 + deck uint64 + pack int + mode uint64 + enterReceipt string + fieldInstance string + initialBlue [][]byte + phases []gamedata.BattlePhase + phase int + phaseStarted bool + phaseSeq uint64 + phaseReply []byte + endSeq uint64 + endRequest []byte + endReply []byte +} + +// BeginLogin creates the transient battle state owned by this login identity. +// Persistent rewards require a successful BattleEnd command transaction. +func (s *Service) BeginLogin(ctx command.Context) { + id := ctx.SessionID + + if id == "" { + return + } + if s.states == nil { + s.states = make(map[string]*battleState) + } + if s.states[id] == nil { + s.transientVersion++ + if len(s.states) >= 1024 { + for key := range s.states { + if key != id { + delete(s.states, key) + break + } + } + } + s.states[id] = &battleState{} + } +} + +func (s *Service) TransientVersion() uint64 { + + return s.transientVersion +} + +func (s *Service) AttachMonsterWinMission(callback func(ctx command.Context) error) { + s.onMonsterWin = callback +} + +func (s *Service) AttachTutorialWin(callback func(ctx command.Context) error) { + s.onTutorialWin = callback +} + +// AttachCommittedHealth persists only completed battle results. Round state +// remains transient, so reconnect rolls back an unfinished battle. +// The callback may normalize values to field HP; the response uses those +// committed values rather than echoing battle-only HP buffs. +func (s *Service) AttachCommittedHealth(callback func(ctx command.Context, _ map[uint64]uint64) error) { + s.commitHealth = callback +} + +func (s *Service) Active(ctx command.Context) bool { + + return s.stateLocked(ctx).entered +} + +func NewService(gameDataRoot, gameDataVersion string, currentPack func(command.Context) (int, error)) *Service { + return &Service{ + gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, + currentPack: currentPack, loadRewards: gamedata.BattleDeckRewards, + states: make(map[string]*battleState), + } +} + +func (s *Service) AttachRewards(grant func(command.Context, string, []gamedata.Reward) ([]byte, error)) { + s.grantRewards = grant +} + +func (s *Service) AttachQuestBattleValidation(validate func(command.Context, int, []uint64) error) { + s.validateQuest = validate +} + +// AttachCurrentDifficulty selects the GameData quest deck for the active pack. +func (s *Service) AttachCurrentDifficulty(resolve func(command.Context) (uint64, error)) { + s.currentDifficulty = resolve +} + +func (s *Service) AttachPictorialBuffs(buffs func(command.Context) ([]gamedata.PictorialBuffStat, error)) { + s.buffs = buffs +} + +// The local engine does not simulate combat. With verification disabled +// and no battle_result the client explicitly preserves its blue team; +// fabricating a verified result would overwrite HP, SP and action state. +// Dynamic official turn/SP values are not available in this request. + +// Packet code 142 follows BattleVerify(141). State 3 is the protocol's +// explicit SUCCESS value; PVE does not require authoritative team lists. + +// The local engine is the normal deterministic engine. + +// Retry requests carry the current deck; the response must restore +// the first phase's deck, as PhaseBattleManager.ApplyRetryResponse does. + +//nolint:staticcheck // QF1003 + +// Stable per-battle/round seed; reproducible across retries. + +// Monster Hunt owns its remaining HP, progression and daily/season +// rewards. Field HP and ordinary pack rewards must not settle here. + +func (s *Service) stateLocked(ctx command.Context) *battleState { + if s.states == nil { + s.states = make(map[string]*battleState) + } + key := ctx.SessionID + state := s.states[key] + if state == nil { + state = &battleState{} + s.states[key] = state + } + return state +} diff --git a/go/internal/server/hunting/ap_refresh.go b/go/internal/server/domain/battle/hunting/ap_refresh.go similarity index 58% rename from go/internal/server/hunting/ap_refresh.go rename to go/internal/server/domain/battle/hunting/ap_refresh.go index bf358a8..3dd26d6 100644 --- a/go/internal/server/hunting/ap_refresh.go +++ b/go/internal/server/domain/battle/hunting/ap_refresh.go @@ -1,10 +1,10 @@ package hunting import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" "encoding/json" "fmt" "time" @@ -16,8 +16,7 @@ type apClock struct { } func (s *Service) AttachAPRefresh(design gamedata.HuntingAPDesign) error { - s.mu.Lock() - defer s.mu.Unlock() + if design.Max == 0 || design.Max > 2147483647 || design.ResetSeconds < 0 || design.ResetSeconds >= 86400 { return fmt.Errorf("hunting: invalid AP refresh design") } @@ -27,7 +26,7 @@ func (s *Service) AttachAPRefresh(design gamedata.HuntingAPDesign) error { } // The client clock is UTC+9. DailyResetTime is expressed in that clock. -func (s *Service) refreshAP() error { +func (s *Service) refreshAP(ctx command.Context) error { if s.apDesign == nil { return nil } @@ -35,7 +34,7 @@ func (s *Service) refreshAP() error { offset := (s.apDesign.ResetSeconds - 9*3600) * 1000 day := (now - offset) / 86400000 nextTime := (day+1)*86400000 + offset - b, e := s.storage.Load("huntingapclock") + b, e := s.storage.Load(ctx.State, "huntingapclock") if e != nil { return e } @@ -55,7 +54,7 @@ func (s *Service) refreshAP() error { } next := s.clone() next.Free = s.apDesign.Max - if e = s.persist(next); e != nil { + if e = s.persist(ctx, next); e != nil { return e } clock.Next = nextTime @@ -64,26 +63,5 @@ func (s *Service) refreshAP() error { if e != nil { return e } - return s.storage.Save("huntingapclock", raw) -} - -func (s *Service) APChargeInfo() ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - if e := s.refreshAP(); e != nil { - return nil, e - } - raw, e := s.storage.Load("huntingapclock") - if e != nil || raw == nil { - return nil, e - } - var clock apClock - if e = json.Unmarshal(raw, &clock); e != nil { - return nil, e - } - b := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000)) - item := wire.AppendVarint(nil, 3, 21) - item = wire.AppendVarint(item, 4, s.state.Free) - b = wire.AppendBytes(b, 2, item) - return wire.AppendBytes(nil, 1, b), nil + return s.storage.Save(ctx.State, "huntingapclock", raw) } diff --git a/go/internal/server/domain/battle/hunting/dispatch.go b/go/internal/server/domain/battle/hunting/dispatch.go new file mode 100644 index 0000000..e5181ca --- /dev/null +++ b/go/internal/server/domain/battle/hunting/dispatch.go @@ -0,0 +1,46 @@ +package hunting + +import ( + "bd2server/internal/server/design/gamedata" + "time" +) + +type dispatchReceipt struct { + Request, Response []byte + Code int +} +type dispatchJob struct { + Group, ID, Count, Start, End, Free, Bonus uint64 + Rewards []gamedata.BattleReward + Runs [][]gamedata.BattleReward +} +type dispatchState struct { + Version string `json:"version"` + Jobs map[string]dispatchJob `json:"jobs"` + Receipts map[string]dispatchReceipt `json:"receipts"` +} + +func (s *Service) AttachDispatchEligibility(check func(*gamedata.DispatchDesign) error) { + + s.dispatchEligibility = check +} + +func dispatchPlayed(j dispatchJob, seconds uint64) uint64 { + if seconds == 0 { + return j.Count + } + now := uint64(time.Now().UnixMilli()) + if now <= j.Start { + return 0 + } + n := min((now-j.Start)/(seconds*1000), j.Count) + return n +} + +func dispatchCompletedRewards(j dispatchJob, n uint64) []gamedata.BattleReward { + var out []gamedata.BattleReward + for i := uint64(0); i < n && i < uint64(len(j.Runs)); i++ { + out = append(out, j.Runs[i]...) + } + return out +} diff --git a/go/internal/server/domain/battle/hunting/hunting.go b/go/internal/server/domain/battle/hunting/hunting.go new file mode 100644 index 0000000..7ff040d --- /dev/null +++ b/go/internal/server/domain/battle/hunting/hunting.go @@ -0,0 +1,210 @@ +// Package hunting owns ordinary HuntingGround progress and repeatable rewards. +package hunting + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + "maps" + "math" + "slices" + "strconv" + + "time" +) + +const BattleMode uint64 = 5 + +type packState struct { + Current, Highest uint64 + Auto bool + Defeated []uint64 +} +type snapshot struct { + Version string `json:"version"` + Free uint64 `json:"free"` + Bonus uint64 `json:"bonus"` + Packs map[string]packState `json:"packs"` + Receipts map[string]bool `json:"receipts"` +} + +// Rules supplies shared immutable hunting and dispatch designs. +type Rules interface { + HuntingPack(int) (*gamedata.HuntingPack, error) + DispatchDesign(uint64, uint64) (*gamedata.DispatchDesign, error) +} + +type Service struct { + storage stateio.Store + inventory *assets.Inventory + wallet *assets.Wallet + state snapshot + currentPack func(command.Context) (int, error) + load func(int) (*gamedata.HuntingPack, error) + dispatchLoad func(uint64, uint64) (*gamedata.DispatchDesign, error) + dispatchEligibility func(*gamedata.DispatchDesign) error + eligibility func(ctx command.Context, _ int, _ uint64) error + grant func(command.Context, string, []gamedata.Reward) ([]byte, error) + apDesign *gamedata.HuntingAPDesign + now func() time.Time +} + +func Open(ctx command.Context, store stateio.Store, rules Rules, inventory *assets.Inventory, wallet *assets.Wallet, currentPack func(command.Context) (int, error), free, bonus uint64) (*Service, error) { + if store == nil || rules == nil || inventory == nil || wallet == nil || currentPack == nil { + return nil, fmt.Errorf("hunting: invalid configuration") + } + s := &Service{storage: store, inventory: inventory, wallet: wallet, currentPack: currentPack, state: snapshot{versionconfig.State(), free, bonus, map[string]packState{}, map[string]bool{}}} + s.dispatchLoad = rules.DispatchDesign + s.load = rules.HuntingPack + b, err := store.Load(ctx.State, "hunting") + if err != nil { + return nil, err + } + if b != nil { + if err := stateio.RequireExactJSONObject(b, "version", "free", "bonus", "packs", "receipts"); err != nil { + return nil, err + } + if err := json.Unmarshal(b, &s.state); err != nil { + return nil, err + } + if s.state.Version != versionconfig.State() || s.state.Packs == nil || s.state.Receipts == nil { + return nil, fmt.Errorf("hunting: incompatible state") + } + } + if s.state.Free > math.MaxInt32 || s.state.Bonus > math.MaxInt32 { + return nil, fmt.Errorf("hunting: AP exceeds protocol range") + } + for key, st := range s.state.Packs { + pack, err := strconv.Atoi(key) + if err != nil || pack <= 0 || strconv.Itoa(pack) != key { + return nil, fmt.Errorf("hunting: invalid saved pack") + } + d, err := s.load(pack) + if err != nil { + return nil, err + } + if _, ok := ground(d, st.Current); !ok { + return nil, fmt.Errorf("hunting: saved current ground missing from GameData") + } + if st.Highest != 0 { + if _, ok := ground(d, st.Highest); !ok { + return nil, fmt.Errorf("hunting: saved highest ground missing from GameData") + } + } + g, _ := ground(d, st.Current) + seen := map[uint64]bool{} + for _, id := range st.Defeated { + member := id == g.BossID + for _, candidate := range g.Monsters { + member = member || id == candidate + } + if !member || seen[id] { + return nil, fmt.Errorf("hunting: invalid defeated monster") + } + seen[id] = true + } + } + for key, claimed := range s.state.Receipts { + if key == "" || !claimed { + return nil, fmt.Errorf("hunting: invalid receipt") + } + } + return s, nil +} +func (s *Service) HuntingAP(ctx command.Context) (uint64, uint64, error) { + + if e := s.refreshAP(ctx); e != nil { + return 0, 0, e + } + return s.state.Free, s.state.Bonus, nil +} + +func ground(d *gamedata.HuntingPack, id uint64) (gamedata.HuntingGround, bool) { + for _, g := range d.Grounds { + if g.ID == id { + return g, true + } + } + return gamedata.HuntingGround{}, false +} + +func (s *Service) ValidateBattle(ctx command.Context, pack int, mode, monster, deck uint64) error { + if mode != BattleMode { + return nil + } + + if e := s.refreshAP(ctx); e != nil { + return e + } + _, _, _, err := s.validate(pack, monster, deck) + return err +} +func (s *Service) validate(pack int, monster, deck uint64) (*gamedata.HuntingPack, gamedata.HuntingGround, gamedata.HuntingMonster, error) { + d, err := s.load(pack) + if err != nil { + return nil, gamedata.HuntingGround{}, gamedata.HuntingMonster{}, err + } + st := s.state.Packs[strconv.Itoa(pack)] + g, ok := ground(d, st.Current) + if !ok { + return nil, g, gamedata.HuntingMonster{}, fmt.Errorf("hunting: battle before ground enter") + } + member := monster == g.BossID + for _, id := range g.Monsters { + member = member || id == monster + } + m := d.Monsters[monster] + if monster == g.BossID { + for _, id := range g.Monsters { + done := false + for _, dead := range st.Defeated { + done = done || dead == id + } + if !done { + return nil, g, m, fmt.Errorf("hunting: boss locked until ordinary monsters are defeated") + } + } + } + if slices.Contains(st.Defeated, monster) { + return nil, g, m, fmt.Errorf("hunting: defeated monster requires reentry") + } + known := false + for _, id := range m.Decks { + known = known || id == deck + } + if !member || !known { + return nil, g, m, fmt.Errorf("hunting: monster/deck does not belong to selected ground") + } + cost := d.NormalAP + if m.Type == 1 { + cost = d.BossAP + } + if s.state.Free < cost && s.state.Bonus < cost-s.state.Free { + return nil, g, m, fmt.Errorf("hunting: insufficient hunting AP") + } + return d, g, m, nil +} + +func (s *Service) clone() snapshot { + next := s.state + next.Packs = map[string]packState{} + maps.Copy(next.Packs, s.state.Packs) + next.Receipts = map[string]bool{} + maps.Copy(next.Receipts, s.state.Receipts) + return next +} +func (s *Service) persist(ctx command.Context, next snapshot) error { + b, err := json.Marshal(next) + if err != nil { + return err + } + if err := s.storage.Save(ctx.State, "hunting", b); err != nil { + return err + } + s.state = next + return nil +} diff --git a/go/internal/server/domain/battle/hunting/progress.go b/go/internal/server/domain/battle/hunting/progress.go new file mode 100644 index 0000000..130967e --- /dev/null +++ b/go/internal/server/domain/battle/hunting/progress.go @@ -0,0 +1,157 @@ +package hunting + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "bytes" + "encoding/json" + "fmt" +) + +type battleReceipt struct { + Fingerprint string + Bundle []byte + Monsters [][]byte +} +type battleLedger struct { + Version string `json:"version"` + Receipts map[string]battleReceipt `json:"receipts"` +} + +func (s *Service) loadBattleReceipts(ctx command.Context) (map[string]battleReceipt, error) { + b, e := s.storage.Load(ctx.State, "huntingbattle") + if e != nil { + return nil, e + } + if b == nil { + return map[string]battleReceipt{}, nil + } + if e = stateio.RequireExactJSONObject(b, "version", "receipts"); e != nil { + return nil, e + } + var l battleLedger + d := json.NewDecoder(bytes.NewReader(b)) + d.DisallowUnknownFields() + if e = d.Decode(&l); e != nil { + return nil, e + } + if l.Version != versionconfig.State() || l.Receipts == nil { + return nil, fmt.Errorf("hunting: invalid battle ledger") + } + return l.Receipts, nil +} +func (s *Service) saveBattleReceipts(ctx command.Context, rs map[string]battleReceipt) error { + b, e := json.Marshal(battleLedger{versionconfig.State(), rs}) + if e != nil { + return e + } + return s.storage.Save(ctx.State, "huntingbattle", b) +} +func (s *Service) AttachEligibility(check func(ctx command.Context, _ int, _ uint64) error) { + + s.eligibility = check +} +func (s *Service) AttachRewards(grant func(command.Context, string, []gamedata.Reward) ([]byte, error)) { + + s.grant = grant +} + +// EnsureForPack initializes the ordinary stage when the world loads a pack. + +// Ground mutations have their own persisted session+sequence replay ledger. + +func (s *Service) reload(ctx command.Context) error { + b, e := s.storage.Load(ctx.State, "hunting") + if e != nil { + return e + } + if b == nil { + return nil + } + var st snapshot + if e = json.Unmarshal(b, &st); e != nil { + return e + } + s.state = st + return nil +} +func (s *Service) CanExchangeAP(ctx command.Context, costs, rewards []gamedata.Reward) error { + + if e := s.reload(ctx); e != nil { + return e + } + if e := s.refreshAP(ctx); e != nil { + return e + } + _, e := s.exchangeAP(costs, rewards) + return e +} +func (s *Service) exchangeAP(costs, rewards []gamedata.Reward) (snapshot, error) { + st := s.clone() + apply := func(rs []gamedata.Reward, cost bool) error { + for _, r := range rs { + if (r.Type != 21 && r.Type != 23) || r.ID != 0 || r.Count == 0 { + return fmt.Errorf("hunting: invalid AP exchange") + } + p := &st.Free + if r.Type == 23 { + p = &st.Bonus + } + if cost { + if *p < r.Count { + return fmt.Errorf("hunting: insufficient AP") + } + *p -= r.Count + } else { + if r.Count > 2147483647-*p { + return fmt.Errorf("hunting: AP overflow") + } + *p += r.Count + } + } + return nil + } + if e := apply(costs, true); e != nil { + return st, e + } + return st, apply(rewards, false) +} +func (s *Service) ExchangeAPOnce(ctx command.Context, identity string, costs, rewards []gamedata.Reward) error { + + if identity == "" { + return fmt.Errorf("hunting: missing AP identity") + } + if e := s.reload(ctx); e != nil { + return e + } + key := "apexchange:" + identity + fpb, e := json.Marshal([][]gamedata.Reward{costs, rewards}) + if e != nil { + return e + } + rs, e := s.loadBattleReceipts(ctx) + if e != nil { + return e + } + fp := string(fpb) + if r, ok := rs[key]; ok { + if r.Fingerprint != fp { + return fmt.Errorf("hunting: AP identity conflict") + } + return nil + } + if e := s.refreshAP(ctx); e != nil { + return e + } + st, e := s.exchangeAP(costs, rewards) + if e != nil { + return e + } + if e = s.persist(ctx, st); e != nil { + return e + } + rs[key] = battleReceipt{Fingerprint: fp} + return s.saveBattleReceipts(ctx, rs) +} diff --git a/go/internal/server/domain/battle/hunting/protocol_adapter.go b/go/internal/server/domain/battle/hunting/protocol_adapter.go new file mode 100644 index 0000000..adf2859 --- /dev/null +++ b/go/internal/server/domain/battle/hunting/protocol_adapter.go @@ -0,0 +1,770 @@ +package hunting + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "bytes" + "crypto/rand" + "encoding/binary" + "encoding/json" + "fmt" + "maps" + "math" + "math/big" + "sort" + "strconv" + "strings" + "time" +) + +func (s *Service) EnsureForPack(ctx command.Context, pack int) ([]byte, error) { + + d, e := s.load(pack) + if e != nil { + return nil, e + } + if len(d.Grounds) == 0 { + return nil, nil + } + st := s.state.Packs[strconv.Itoa(pack)] + if st.Current == 0 { + st.Current = d.Grounds[0].ID + next := s.clone() + next.Packs[strconv.Itoa(pack)] = st + if e = s.persist(ctx, next); e != nil { + return nil, e + } + } + return s.info(pack, d) +} + +func (s *Service) SnapshotForPack(ctx command.Context, pack int) ([]byte, error) { + + d, e := s.load(pack) + if e != nil { + return nil, e + } + if len(d.Grounds) == 0 { + return nil, nil + } + return s.info(pack, d) +} + +func (s *Service) handleGroundSession(ctx command.Context, path string, req []byte, session string) (int, []byte, bool, error) { + if path != "/HuntingGroundEnter" { + return s.handleGroundBase(ctx, path, req) + } + seq, found, e := wire.Varint(req, 1) + if e != nil || !found || seq == 0 { + return 110, nil, true, fmt.Errorf("hunting: missing sequence") + } + key := "enter:" + session + ":" + strconv.FormatUint(seq, 10) + rs, e := s.loadBattleReceipts(ctx) + if e != nil { + return 110, nil, true, e + } + fp := fmt.Sprintf("%x", req) + if r, ok := rs[key]; ok { + if r.Fingerprint != fp { + return 110, nil, true, fmt.Errorf("hunting: enter sequence conflict") + } + return 110, r.Bundle, true, nil + } + code, out, ok, e := s.handleGroundBase(ctx, path, req) + if e != nil || !ok { + return code, out, ok, e + } + rs[key] = battleReceipt{Fingerprint: fp, Bundle: out} + if e = s.saveBattleReceipts(ctx, rs); e != nil { + return code, nil, true, e + } + return code, out, ok, nil +} + +// A catalog entry is not an account's active hunting run. The detail +// endpoint preserves an explicitly present empty message until entry. + +func (s *Service) info(pack int, d *gamedata.HuntingPack) ([]byte, error) { + st := s.state.Packs[strconv.Itoa(pack)] + initial := st.Current == 0 + if st.Current == 0 { + st.Current = d.Grounds[0].ID + } + g, ok := ground(d, st.Current) + if !ok { + return nil, fmt.Errorf("hunting: saved ground missing from GameData") + } + b := wire.AppendVarint(nil, 2, st.Current) + if st.Highest != 0 { + b = wire.AppendVarint(b, 3, st.Highest) + } + b = wire.AppendVarint(b, 5, uint64(pack)) + if st.Auto { + b = wire.AppendVarint(b, 1, 1) + } + var monsterInfos [][]byte + if initial { + // The initial catalog lists ordinary monsters only. A boss is not an + // active account encounter simply because its design row exists. + for _, id := range g.Monsters { + monsterInfos = append(monsterInfos, monsterWire(d.Monsters[id], true)) + } + } else { + monsterInfos = monstersWithState(d, g, st.Defeated) + } + for _, m := range monsterInfos { + b = wire.AppendBytes(b, 4, m) + } + return b, nil +} + +func monsterWire(m gamedata.HuntingMonster, active bool) []byte { + b := wire.AppendVarint(nil, 1, m.ID) + b = wire.AppendVarint(b, 2, m.Decks[0]) + if active { + b = wire.AppendVarint(b, 6, 1) + } + return b +} + +func monstersWithState(d *gamedata.HuntingPack, g gamedata.HuntingGround, defeated []uint64) [][]byte { + dead := map[uint64]bool{} + for _, id := range defeated { + dead[id] = true + } + boss := len(g.Monsters) == 0 + all := true + for _, id := range g.Monsters { + all = all && dead[id] + } + boss = boss || all + out := make([][]byte, 0, len(g.Monsters)+1) + for _, id := range g.Monsters { + out = append(out, monsterWire(d.Monsters[id], !dead[id])) + } + if boss { + out = append(out, monsterWire(d.Monsters[g.BossID], true)) + } + return out +} + +func (s *Service) CompleteBattle(ctx command.Context, pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) { + if mode != BattleMode { + return nil, nil, nil + } + + if receipt == "" { + return nil, nil, fmt.Errorf("hunting: missing battle receipt") + } + fingerprint := fmt.Sprintf("%d:%d:%d:%d", pack, mode, monster, deck) + ledger, err := s.loadBattleReceipts(ctx) + if err != nil { + return nil, nil, err + } + if saved, ok := ledger[receipt]; ok { + if saved.Fingerprint != fingerprint { + return nil, nil, fmt.Errorf("hunting: battle receipt conflict") + } + return saved.Bundle, saved.Monsters, nil + } + d, g, m, err := s.validate(pack, monster, deck) + if err != nil { + return nil, nil, err + } + rewards := m.Rewards[deck] + identity := "hunting:" + receipt + currency := make([]gamedata.Reward, 0) + stack := make([]gamedata.BattleReward, 0) + for _, r := range rewards { + if r.Type == 2 || r.Type == 3 || r.Type == 4 || r.Type == 12 || r.Type == 20 { + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + } else { + stack = append(stack, r) + } + } + var grantedBundle []byte + var items []assets.Item + if s.grant != nil { + rs := make([]gamedata.Reward, len(rewards)) + for i, r := range rewards { + rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 + } + + grantedBundle, err = s.grant(ctx, identity, rs) + + if err != nil { + return nil, nil, err + } + } else { + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":currency", currency); err != nil { + return nil, nil, err + } + items, err = s.inventory.GrantOnce(ctx, identity+":items", stack) + if err != nil { + return nil, nil, err + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + } + next := s.clone() + cost := d.NormalAP + if m.Type == 1 { + cost = d.BossAP + } + if next.Free >= cost { + next.Free -= cost + } else { + next.Bonus -= cost - next.Free + next.Free = 0 + } + st := next.Packs[strconv.Itoa(pack)] + if monster == g.BossID { + if st.Highest < g.ID { + st.Highest = g.ID + } + st.Defeated = nil + } else { + st.Defeated = append(append([]uint64(nil), st.Defeated...), monster) + } + next.Packs[strconv.Itoa(pack)] = st + next.Receipts[receipt] = true + var bundle []byte + if s.grant != nil { + bundle = grantedBundle + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, view) + } + for _, r := range currency { + if s.grant != nil { + break + } + b := wire.AppendVarint(nil, 3, r.Type) + b = wire.AppendVarint(b, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, b) + } + updates := [][]byte{monsterWire(m, false)} + if monster == g.BossID { + for _, id := range g.Monsters { + updates = append(updates, monsterWire(d.Monsters[id], true)) + } + } else if len(st.Defeated) == len(g.Monsters) { + updates = append(updates, monsterWire(d.Monsters[g.BossID], true)) + } + if err := s.persist(ctx, next); err != nil { + return nil, nil, err + } + latest, err := s.loadBattleReceipts(ctx) + if err != nil { + return nil, nil, err + } + maps.Copy(ledger, latest) + ledger[receipt] = battleReceipt{Fingerprint: fingerprint, Bundle: bundle, Monsters: updates} + if err := s.saveBattleReceipts(ctx, ledger); err != nil { + return nil, nil, err + } + return bundle, updates, nil +} + +func packed(req []byte, n int) ([]uint64, error) { + var out []uint64 + err := wire.Walk(req, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + out = append(out, v) + return nil + } + if f.Type != 2 { + return fmt.Errorf("hunting: invalid repeated integer") + } + for raw := f.Value; len(raw) > 0; { + v, n := binary.Uvarint(raw) + if n <= 0 { + return fmt.Errorf("hunting: invalid packed integer") + } + out = append(out, v) + raw = raw[n:] + } + return nil + }) + return out, err +} + +func (s *Service) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + if strings.HasPrefix(path, "/HuntDispatch") { + return s.handleDispatch(ctx, path, req, commandSession) + } + return s.handleGroundSession(ctx, path, req, commandSession) +} + +func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, session string) (int, []byte, bool, error) { + code := map[string]int{"/HuntDispatchInfo": 189, "/HuntDispatchStart": 190, "/HuntDispatchEnd": 191, "/HuntDispatchRewardPreview": 194, "/HuntDispatch": 0}[path] + if path != "/HuntDispatchInfo" && path != "/HuntDispatchStart" && path != "/HuntDispatchEnd" && path != "/HuntDispatchRewardPreview" && path != "/HuntDispatch" { + return 0, nil, false, nil + } + + if e := s.refreshAP(ctx); e != nil { + return code, nil, true, e + } + seq, found, err := wire.Varint(req, 1) + if err != nil || !found || seq == 0 { + return code, nil, true, fmt.Errorf("hunting: missing sequence") + } + ds := dispatchState{Version: versionconfig.State(), Jobs: map[string]dispatchJob{}, Receipts: map[string]dispatchReceipt{}} + raw, err := s.storage.Load(ctx.State, "huntdispatch") + if err != nil { + return code, nil, true, err + } + if raw != nil { + if err = stateio.RequireExactJSONObject(raw, "version", "jobs", "receipts"); err != nil { + return code, nil, true, err + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(&ds); err != nil { + return code, nil, true, err + } + if ds.Version != versionconfig.State() || ds.Jobs == nil || ds.Receipts == nil { + return code, nil, true, fmt.Errorf("hunting: invalid dispatch state") + } + } + if path == "/HuntDispatchInfo" { + var out []byte + keys := make([]string, 0, len(ds.Jobs)) + for k := range ds.Jobs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = wire.AppendBytes(out, 1, dispatchJobWire(ds.Jobs[k])) + } + // A client without a remembered dispatch pack needs a default. This + // server selects the smallest cleared ordinary hunting pack. + var minimum uint64 + for key, state := range s.state.Packs { + if state.Highest == 0 { + continue + } + pack, err := strconv.ParseUint(key, 10, 32) + if err != nil || pack == 0 { + return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch pack") + } + if minimum == 0 || pack < minimum { + minimum = pack + } + } + if minimum != 0 { + out = wire.AppendVarint(out, 2, minimum) + } + return code, out, true, nil + } + for k, j := range ds.Jobs { + if k != fmt.Sprintf("%d:%d", j.Group, j.ID) || j.Group == 0 || j.ID == 0 || j.Count == 0 || j.Count > 20 || j.Start == 0 || j.End < j.Start || len(j.Runs) != int(j.Count) || j.Free > 2147483647 || j.Bonus > 2147483647 { + return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch job") + } + } + for k, r := range ds.Receipts { + if k == "" || len(r.Request) == 0 || r.Response == nil { + return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch receipt") + } + } + key := session + ":" + strconv.FormatUint(seq, 10) + if r, ok := ds.Receipts[key]; ok { + if !bytes.Equal(r.Request, append([]byte(path), req...)) { + return code, nil, true, fmt.Errorf("hunting: sequence reused with different dispatch request") + } + return r.Code, r.Response, true, nil + } + group, _, e := wire.Varint(req, 2) + id, _, e2 := wire.Varint(req, 3) + if e != nil || e2 != nil || group == 0 || id == 0 { + return code, nil, true, fmt.Errorf("hunting: invalid dispatch ID") + } + jobKey := fmt.Sprintf("%d:%d", group, id) + job, exists := ds.Jobs[jobKey] + d, err := s.dispatchLoad(group, id) + if err != nil { + return code, nil, true, err + } + var out []byte + if path == "/HuntDispatchRewardPreview" { + if !exists { + return code, nil, true, fmt.Errorf("hunting: dispatch not started") + } + played := dispatchPlayed(job, d.ClearTime) + out = wire.AppendVarint(out, 1, played) + preview := dispatchCompletedRewards(job, played) + remaining := d.AP * (job.Count - played) + bonus := min(remaining, job.Bonus) + free := remaining - bonus + if free > 0 { + preview = append(preview, gamedata.BattleReward{Type: 21, Count: free}) + } + if bonus > 0 { + preview = append(preview, gamedata.BattleReward{Type: 23, Count: bonus}) + } + bundle := dispatchPreview(preview) + for _, r := range preview { + v := wire.AppendVarint(nil, 2, r.ID) + v = wire.AppendVarint(v, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, v) + } + out = wire.AppendBytes(out, 2, bundle) + return code, out, true, nil + } + if path == "/HuntDispatchEnd" { + if !exists { + return code, nil, true, fmt.Errorf("hunting: dispatch not started") + } + played := dispatchPlayed(job, d.ClearTime) + completed := dispatchCompletedRewards(job, played) + remaining := d.AP * (job.Count - played) + refundBonus := min(remaining, job.Bonus) + refundFree := remaining - refundBonus + next := s.clone() + next.Free += refundFree + next.Bonus += refundBonus + if e := s.persist(ctx, next); e != nil { + return code, nil, true, e + } + if refundFree > 0 { + completed = append(completed, gamedata.BattleReward{Type: 21, Count: refundFree}) + } + if refundBonus > 0 { + completed = append(completed, gamedata.BattleReward{Type: 23, Count: refundBonus}) + } + bundle, e := s.dispatchGrant(ctx, "dispatch:"+key, completed) + if e != nil { + return code, nil, true, e + } + out = wire.AppendBytes(out, 1, bundle) + delete(ds.Jobs, jobKey) + } else { + count, _, e := wire.Varint(req, 4) + if e != nil || count == 0 || count > 20 { + return code, nil, true, fmt.Errorf("hunting: dispatch count must be 1..20") + } + if exists { + return code, nil, true, fmt.Errorf("hunting: dispatch already running") + } + for _, active := range ds.Jobs { + design, e := s.dispatchLoad(active.Group, active.ID) + if e != nil { + return code, nil, true, e + } + if design.TypeGroup == d.TypeGroup { + return code, nil, true, fmt.Errorf("hunting: dispatch category already running") + } + } + if s.dispatchEligibility != nil { + if e = s.dispatchEligibility(d); e != nil { + return code, nil, true, e + } + } else { + if d.TypeGroup != 0 { + return code, nil, true, fmt.Errorf("hunting: SkyWay dispatch eligibility unavailable") + } + st := s.state.Packs[strconv.Itoa(int(d.Pack))] + if st.Highest < d.GroundID { + return code, nil, true, fmt.Errorf("hunting: dispatch requires cleared difficulty") + } + } + if d.AP > uint64(2147483647)/count { + return code, nil, true, fmt.Errorf("hunting: dispatch AP overflow") + } + cost := d.AP * count + if s.state.Free < cost && s.state.Bonus < cost-s.state.Free { + return code, nil, true, fmt.Errorf("hunting: insufficient hunting AP") + } + rewards, e := d.Roll(count, func(n uint64) (uint64, error) { + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + }) + if e != nil { + return code, nil, true, e + } + next := s.clone() + free := min(cost, next.Free) + bonus := cost - free + next.Free -= free + next.Bonus -= bonus + if path == "/HuntDispatchStart" { + now := uint64(time.Now().UnixMilli()) + runs := make([][]gamedata.BattleReward, count) + for i := range runs { + runs[i], e = d.Roll(1, func(n uint64) (uint64, error) { + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + }) + if e != nil { + return code, nil, true, e + } + } + job = dispatchJob{Group: group, ID: id, Count: count, Start: now, End: now + d.ClearTime*count*1000, Free: free, Bonus: bonus, Rewards: rewards, Runs: runs} + ds.Jobs[jobKey] = job + out = wire.AppendBytes(out, 1, dispatchJobWire(job)) + } else { + beforeFree, beforeBonus := s.state.Free, s.state.Bonus + bundle, e := s.dispatchGrant(ctx, "dispatch:"+key, rewards) + if e != nil { + return code, nil, true, e + } + out = wire.AppendBytes(out, 1, bundle) + if s.state.Free >= beforeFree { + next.Free += s.state.Free - beforeFree + } else { + delta := beforeFree - s.state.Free + if next.Free < delta { + return code, nil, true, fmt.Errorf("hunting: AP grant conflict") + } + next.Free -= delta + } + if s.state.Bonus >= beforeBonus { + next.Bonus += s.state.Bonus - beforeBonus + } else { + delta := beforeBonus - s.state.Bonus + if next.Bonus < delta { + return code, nil, true, fmt.Errorf("hunting: AP grant conflict") + } + next.Bonus -= delta + } + } + if e = s.persist(ctx, next); e != nil { + return code, nil, true, e + } + } + ds.Receipts[key] = dispatchReceipt{append([]byte(path), req...), out, code} + b, e := json.Marshal(ds) + if e != nil { + return code, nil, true, e + } + if e = s.storage.Save(ctx.State, "huntdispatch", b); e != nil { + return code, nil, true, e + } + return code, out, true, nil +} + +func dispatchJobWire(j dispatchJob) []byte { + var out []byte + for n, v := range map[int]uint64{1: j.Group, 2: j.ID, 3: j.Count, 4: j.Start, 5: j.End, 6: j.Free, 7: j.Bonus} { + out = wire.AppendVarint(out, n, v) + } + return out +} + +func dispatchPreview(rs []gamedata.BattleReward) []byte { + var out []byte + for _, r := range rs { + v := wire.AppendVarint(nil, 2, r.ID) + v = wire.AppendVarint(v, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + out = wire.AppendBytes(out, 6, v) + } + return out +} + +func (s *Service) dispatchGrant(ctx command.Context, identity string, rs []gamedata.BattleReward) ([]byte, error) { + if s.grant != nil { + var rewards []gamedata.Reward + var ap []gamedata.BattleReward + for _, r := range rs { + if r.Type == 21 || r.Type == 23 { + ap = append(ap, r) + } else { + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + } + } + + bundle, err := s.grant(ctx, identity, rewards) + + if err != nil { + return nil, err + } + bundle = append(bundle, dispatchPreview(ap)...) + for _, r := range ap { + v := wire.AppendVarint(nil, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, v) + } + return bundle, nil + } + + var currency []gamedata.Reward + var items []gamedata.BattleReward + for _, r := range rs { + switch r.Type { + case 21, 23: + continue + case 2, 3, 4, 12, 20: + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + default: + items = append(items, r) + } + } + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":currency", currency); err != nil { + return nil, err + } + granted, err := s.inventory.GrantOnce(ctx, identity+":items", items) + if err != nil { + return nil, err + } + if len(granted) == 0 { + granted = s.inventory.GrantedItems(identity + ":items") + } + out := dispatchPreview(rs) + for _, r := range rs { + if r.Type == 21 || r.Type == 23 { + v := wire.AppendVarint(nil, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + out = wire.AppendBytes(out, 1, v) + } + } + for _, r := range currency { + v := wire.AppendVarint(nil, 2, r.ID) + v = wire.AppendVarint(v, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + out = wire.AppendBytes(out, 1, v) + } + for _, item := range granted { + out = wire.AppendBytes(out, 1, assets.ItemWire(item)) + } + return out, nil +} + +func (s *Service) APChargeInfo(ctx command.Context) ([]byte, error) { + + if e := s.refreshAP(ctx); e != nil { + return nil, e + } + raw, e := s.storage.Load(ctx.State, "huntingapclock") + if e != nil || raw == nil { + return nil, e + } + var clock apClock + if e = json.Unmarshal(raw, &clock); e != nil { + return nil, e + } + b := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000)) + item := wire.AppendVarint(nil, 3, 21) + item = wire.AppendVarint(item, 4, s.state.Free) + b = wire.AppendBytes(b, 2, item) + return wire.AppendBytes(nil, 1, b), nil +} +func (s *Service) handleGroundBase(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + if path == "/HuntDispatch" || path == "/HuntDispatchInfo" || path == "/HuntDispatchStart" || path == "/HuntDispatchEnd" || path == "/HuntDispatchRewardPreview" { + return s.handleDispatch(ctx, path, req, ctx.SessionID) + } + if path != "/HuntingGroundInfo" && path != "/HuntingGroundInfoList" && path != "/HuntingGroundEnter" { + return 0, nil, false, nil + } + + seq, found, err := wire.Varint(req, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("hunting: missing sequence") + } + if path == "/HuntingGroundInfoList" { + ids, err := packed(req, 2) + if err != nil { + return 387, nil, true, err + } + var response []byte + seen := map[uint64]bool{} + for _, id := range ids { + if id == 0 || id > math.MaxInt32 || seen[id] { + return 387, nil, true, fmt.Errorf("hunting: invalid pack list") + } + seen[id] = true + d, err := s.load(int(id)) + if err != nil { + return 387, nil, true, err + } + if len(d.Grounds) == 0 { + response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 5, id)) + continue + } + info, err := s.info(int(id), d) + if err != nil { + return 387, nil, true, err + } + response = wire.AppendBytes(response, 1, info) + } + return 387, response, true, nil + } + pack, found, err := wire.Varint(req, 2) + if err != nil || !found || pack == 0 || pack > math.MaxInt32 { + return 134, nil, true, fmt.Errorf("hunting: invalid pack") + } + d, err := s.load(int(pack)) + if err != nil { + return 134, nil, true, err + } + if path == "/HuntingGroundInfo" { + + if s.state.Packs[strconv.Itoa(int(pack))].Current == 0 { + return 134, wire.AppendBytes(nil, 1, nil), true, nil + } + b, err := s.info(int(pack), d) + return 134, wire.AppendBytes(nil, 1, b), true, err + } + if len(d.Grounds) == 0 { + return 110, nil, true, fmt.Errorf("hunting: pack has no hunting ground") + } + current, err := s.currentPack(ctx) + if err != nil || current != int(pack) { + return 110, nil, true, fmt.Errorf("hunting: enter pack is not current") + } + id, _, err := wire.Varint(req, 3) + if err != nil { + return 110, nil, true, err + } + auto, _, err := wire.Varint(req, 4) + if err != nil || auto > 1 { + return 110, nil, true, fmt.Errorf("hunting: invalid auto flag") + } + st := s.state.Packs[strconv.Itoa(int(pack))] + g, ok := ground(d, id) + if !ok { + return 110, nil, true, fmt.Errorf("hunting: unknown ground") + } + if s.eligibility != nil { + if err := s.eligibility(ctx, int(pack), g.Difficulty); err != nil { + return 110, nil, true, err + } + } else if id != d.Grounds[0].ID { + return 110, nil, true, fmt.Errorf("hunting: main quest difficulty eligibility unavailable") + } + if st.Current != id { + st.Defeated = nil + } + st.Current, st.Auto = id, auto != 0 + next := s.clone() + next.Packs[strconv.Itoa(int(pack))] = st + if err := s.persist(ctx, next); err != nil { + return 110, nil, true, err + } + var out []byte + for _, m := range monstersWithState(d, g, st.Defeated) { + out = wire.AppendBytes(out, 1, m) + } + return 110, out, true, nil +} diff --git a/go/internal/server/domain/battle/monsterhunt/monsterhunt.go b/go/internal/server/domain/battle/monsterhunt/monsterhunt.go new file mode 100644 index 0000000..56106cc --- /dev/null +++ b/go/internal/server/domain/battle/monsterhunt/monsterhunt.go @@ -0,0 +1,177 @@ +// Package monsterhunt owns the configured Fiend Hunt seasons and local player progress. +package monsterhunt + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/protocol/staticdata" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + "strconv" + + "time" +) + +const BattleMode uint64 = 8 +const PracticeMode uint64 = 24 + +type Season struct { + ID, Hunt, Start, End, Calculate, RankGroup uint64 + Independent bool +} +type user struct { + Season, Hunt, Level, StartHP, HighestHP, CurrentDamage, DailyDamage, HighestDate, DailyLevel, DailyDate uint64 + Played, Claimed bool + ClearLevel uint64 +} +type reply struct{ Request, Response []byte } +type snapshot struct { + Version string `json:"version"` + Slots uint64 `json:"slots"` + Users map[string]user `json:"users"` + Decks [][]byte `json:"decks"` + Settings [][]byte `json:"settings"` + Presets map[string][]byte `json:"presets"` + Replies map[string]reply `json:"replies"` +} +type encounter struct{ Season, Hunt, Level, HP, Mode, BeforeLevel, BeforeHP, Team uint64 } +type Service struct { + storage stateio.Store + seed *readonly.Seed + seasons []Season + state snapshot + inventory *assets.Inventory + wallet *assets.Wallet + characters *roster.CharacterStore + equipment *assets.EquipmentInventory + collection *roster.CollectionStore + presets *gamedata.PresetDesign + baseSlots, maxSlots uint64 + now func() time.Time + active map[string]encounter + transientVersion uint64 + load func(uint64) (*gamedata.MonsterHunt, error) + rewardGrant func(ctx command.Context, _ string, _ []gamedata.Reward) ([]byte, error) +} + +// RuleLoader returns shared immutable hunt rules. +type RuleLoader func(uint64) (*gamedata.MonsterHunt, error) + +// Rules holds the shared season calendar and preset configuration. +type Rules struct { + seasons []Season + seed *readonly.Seed + presets *gamedata.PresetDesign + load RuleLoader +} + +func NewRules(seasons []Season, seed *readonly.Seed, presets *gamedata.PresetDesign, load RuleLoader) (*Rules, error) { + if len(seasons) == 0 || seed == nil || presets == nil || load == nil { + return nil, fmt.Errorf("monsterhunt: incomplete rules") + } + for _, c := range seasons { + if c.ID == 0 || c.Hunt == 0 || c.Start > c.End { + return nil, fmt.Errorf("monsterhunt: invalid configured season") + } + if _, err := load(c.Hunt); err != nil { + return nil, err + } + } + return &Rules{seasons: append([]Season(nil), seasons...), seed: seed, presets: presets, load: load}, nil +} + +func (s *Service) AttachRewards(grant func(ctx command.Context, _ string, _ []gamedata.Reward) ([]byte, error)) { + s.rewardGrant = grant +} +func (s *Service) AttachCharacters(ctx command.Context, c *roster.CharacterStore) { s.characters = c } + +func (s *Service) PresetSlotCount() uint64 { return s.state.Slots } + +func (s *Service) TransientVersion() uint64 { + + return s.transientVersion +} + +func (s *Service) current() Season { + // Future rows are public calendar data, not an instruction to switch the + // player's current hunt before its window begins. Regular client schedules + // have a single slot; independent hunts use their separate client list. + now := uint64(s.now().UnixMilli()) + var regular []Season + for _, c := range s.seasons { + if !c.Independent { + regular = append(regular, c) + } + } + if len(regular) == 0 { + regular = s.seasons + } + return selectSeason(regular, now) +} + +func selectSeason(rows []Season, now uint64) Season { + var active, begun, future Season + for _, c := range rows { + if c.Start <= now { + if begun.ID == 0 || c.Start > begun.Start || c.Start == begun.Start && c.ID > begun.ID { + begun = c + } + if now < c.End && (active.ID == 0 || c.Start > active.Start || c.Start == active.Start && c.ID > active.ID) { + active = c + } + } else if future.ID == 0 || c.Start < future.Start || c.Start == future.Start && c.ID < future.ID { + future = c + } + } + if active.ID != 0 { + return active + } + if begun.ID != 0 { + return begun + } + return future +} + +func (s *Service) playing(c Season) bool { + n := uint64(s.now().UnixMilli()) + return n >= c.Start && n < c.End +} +func (s *Service) getUser(c Season) (user, error) { + u, ok := s.state.Users[strconv.FormatUint(c.ID, 10)] + if ok { + if u.DailyDate != 0 && u.DailyDate/86400000 != uint64(s.now().UnixMilli())/86400000 { + u.DailyDamage = 0 + u.DailyLevel = 0 + } + return u, nil + } + d, e := s.load(c.Hunt) + if e != nil { + return u, e + } + hp, e := d.HP(1) + return user{Season: c.ID, Hunt: c.Hunt, Level: 1, StartHP: hp, HighestHP: hp}, e +} + +func (s *Service) save(ctx command.Context, next snapshot) error { + b, e := json.Marshal(next) + if e != nil { + return e + } + if e = s.storage.Save(ctx.State, "monsterhunt", b); e != nil { + return e + } + s.state = next + return nil +} +func (s *Service) clone() snapshot { + raw, _ := json.Marshal(s.state) + var n snapshot + _ = json.Unmarshal(raw, &n) + return n +} + +var codes = map[string]int{"/MonsterHuntScheduleInfo": 0, "/MonsterHuntUserInfo": 196, "/MonsterHuntRankInfo": 197, "/MonsterHuntDeckInfo": 263, "/MonsterHuntDeckSave": 264, "/MonsterHuntChangeTeam": 265, "/MonsterHuntQuickBattle": 266, "/MonsterHuntPresetSlotAdd": 400, "/MonsterHuntPresetInfo": 405, "/MonsterHuntPresetSave": 406, "/MonsterHuntPresetUse": 410, "/MonsterHuntPresetDelete": 0, "/MonsterHuntPresetInfoChange": 0, "/MonsterHuntSeasonReward": 0} diff --git a/go/internal/server/monsterhunt/monsterhunt.go b/go/internal/server/domain/battle/monsterhunt/protocol_adapter.go similarity index 66% rename from go/internal/server/monsterhunt/monsterhunt.go rename to go/internal/server/domain/battle/monsterhunt/protocol_adapter.go index 20b8244..ef67fde 100644 --- a/go/internal/server/monsterhunt/monsterhunt.go +++ b/go/internal/server/domain/battle/monsterhunt/protocol_adapter.go @@ -1,7 +1,14 @@ -// Package monsterhunt owns the configured Fiend Hunt seasons and local player progress. package monsterhunt import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/staticdata" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" "bytes" "encoding/binary" "encoding/json" @@ -10,122 +17,215 @@ import ( "sort" "strconv" "strings" - "sync" "time" "unicode/utf8" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/readonly" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" ) -const BattleMode uint64 = 8 -const PracticeMode uint64 = 24 - -type season struct { - ID, Hunt, Start, End, Calculate, RankGroup uint64 - Independent bool -} -type user struct { - Season, Hunt, Level, StartHP, HighestHP, CurrentDamage, DailyDamage, HighestDate, DailyLevel, DailyDate uint64 - Played, Claimed bool - ClearLevel uint64 -} -type reply struct{ Request, Response []byte } -type snapshot struct { - Version string `json:"version"` - Slots uint64 `json:"slots"` - Users map[string]user `json:"users"` - Decks [][]byte `json:"decks"` - Settings [][]byte `json:"settings"` - Presets map[string][]byte `json:"presets"` - Replies map[string]reply `json:"replies"` -} -type encounter struct{ Season, Hunt, Level, HP, Mode, BeforeLevel, BeforeHP, Team uint64 } -type Service struct { - mu sync.Mutex - storage stateio.Store - root, version string - seed *readonly.Seed - seasons []season - state snapshot - inventory *player.Inventory - wallet *player.Wallet - characters *player.CharacterStore - equipment *player.EquipmentInventory - collection *player.CollectionStore - presets *gamedata.PresetDesign - baseSlots, maxSlots uint64 - now func() time.Time - active map[string]encounter - load func(uint64) (*gamedata.MonsterHunt, error) - rewardGrant func(string, []gamedata.Reward) ([]byte, error) -} - -func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) { - if storage == nil || seed == nil || inventory == nil || wallet == nil { - return nil, fmt.Errorf("monsterhunt: incomplete configuration") - } - s := &Service{storage: storage, root: root, version: version, seed: seed, inventory: inventory, wallet: wallet, now: time.Now, active: map[string]encounter{}} - designs := map[uint64]*gamedata.MonsterHunt{} - s.load = func(id uint64) (*gamedata.MonsterHunt, error) { - if d, ok := designs[id]; ok { - return d, nil - } - d, e := gamedata.LoadMonsterHunt(root, version, id) - if e == nil { - designs[id] = d - } - return d, e - } - for _, f := range seed.Responses["/MonsterHuntScheduleInfo"].Fields { - if f.Number != 1 || f.Type != 2 { - continue - } - var c season - for _, v := range f.Fields { - switch v.Number { - case 1: - for _, x := range v.Fields { - switch x.Number { - case 1: - c.ID = x.Varint - case 2: - c.Start = x.Varint - case 3: - c.End = x.Varint - } - } - case 2: - c.Hunt = v.Varint - case 4: - c.Calculate = v.Varint - case 6: - c.Independent = v.Varint != 0 - case 7: - c.RankGroup = v.Varint +func validateRequest(req []byte) error { + return wire.Walk(req, func(f wire.Field) error { + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + if v > math.MaxInt32 { + return fmt.Errorf("monsterhunt: integer outside protocol range") } } - if c.ID == 0 || c.Hunt == 0 || c.Start > c.End { - return nil, fmt.Errorf("monsterhunt: invalid configured season") + return nil + }) +} + +func (s *Service) ValidatePack(ctx command.Context, pack int, req []byte) error { + + id, e := scalar(req, 6) + if e != nil { + return e + } + d, e := s.load(id) + if e != nil { + return e + } + if pack <= 0 || uint64(pack) != d.PackID { + return fmt.Errorf("monsterhunt: hunt does not belong to current pack") + } + return nil +} + +func (s *Service) rankWire(u user) []byte { + b := wire.AppendVarint(nil, 7, 1) + b = wire.AppendDouble(b, 8, s.score(u)) + return wire.AppendDouble(b, 10, 100) +} + +func (s *Service) grant(ctx command.Context, identity string, rewards []gamedata.BattleReward) ([]byte, error) { + if s.rewardGrant != nil { + rs := make([]gamedata.Reward, len(rewards)) + for i, r := range rewards { + rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 } - s.seasons = append(s.seasons, c) + return s.rewardGrant(ctx, identity, rs) } - if len(s.seasons) == 0 { - return nil, fmt.Errorf("monsterhunt: configured schedule missing") + var currency []gamedata.Reward + var stack []gamedata.BattleReward + for _, r := range rewards { + switch r.Type { + case 2, 3, 4, 12, 20: + currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + default: + stack = append(stack, r) + } } - d, e := gamedata.LoadMonsterHuntPresetDesign(root, version) + if _, e := s.wallet.GrantQuestOnce(ctx, identity+":currency", currency); e != nil { + return nil, e + } + items, e := s.inventory.GrantOnce(ctx, identity+":items", stack) if e != nil { return nil, e } - s.presets = d - s.baseSlots = d.BaseCount - s.maxSlots = d.Maximum + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + var bundle []byte + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + v := wire.AppendVarint(nil, 2, item.ID) + v = wire.AppendVarint(v, 3, item.Type) + v = wire.AppendVarint(v, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, v) + } + for _, r := range currency { + v := wire.AppendVarint(nil, 3, r.Type) + v = wire.AppendVarint(v, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, v) + } + return bundle, nil +} + +func (s *Service) presetBindings(ctx command.Context, p []byte) (map[uint64]uint64, []assets.PresetEquipmentBinding, error) { + assignments := map[uint64]uint64{} + var bindings []assets.PresetEquipmentBinding + entries, e := messages(p, 5) + if e != nil { + return nil, nil, e + } + for _, entry := range entries { + base, _, e := wire.Bytes(entry, 1) + if e != nil { + return nil, nil, e + } + index, _ := scalar(base, 1) + costume, _ := scalar(entry, 2) + if _, duplicate := assignments[index]; duplicate { + return nil, nil, fmt.Errorf("monsterhunt: duplicate preset character") + } + assignments[index] = costume + if costume != 0 && s.collection != nil { + c, found := s.collection.CostumeByIndex(costume) + if !found || c.UseChar != index { + return nil, nil, fmt.Errorf("monsterhunt: preset costume not owned by character") + } + } + binding := assets.PresetEquipmentBinding{CharacterIndex: index, Equipment: make([]uint64, 5)} + equipment, e := messages(entry, 3) + if e != nil { + return nil, nil, e + } + seen := map[uint64]bool{} + for _, item := range equipment { + t, _ := scalar(item, 1) + id, _ := scalar(item, 2) + if t >= 5 || seen[t] { + return nil, nil, fmt.Errorf("monsterhunt: invalid preset equipment slot") + } + seen[t] = true + binding.Equipment[t] = id + } + bindings = append(bindings, binding) + } + if len(bindings) > 0 && s.equipment != nil { + if e = s.equipment.ValidatePresetEquipment(ctx, bindings); e != nil { + return nil, nil, e + } + } + return assignments, bindings, nil +} + +func (s *Service) applyPreset(ctx command.Context, p []byte) ([]byte, error) { + if s.characters == nil || s.equipment == nil || s.collection == nil { + return nil, fmt.Errorf("monsterhunt: preset ownership runtime unavailable") + } + assignments, bindings, e := s.presetBindings(ctx, p) + if e != nil { + return nil, e + } + if _, e = s.characters.ApplyPresetCostumes(ctx, assignments); e != nil { + return nil, e + } + if len(bindings) > 0 { + if _, e = s.equipment.ApplyPresetEquipment(ctx, bindings); e != nil { + return nil, e + } + } + var out []byte + for _, binding := range bindings { + c, ok := s.characters.Find(ctx, binding.CharacterIndex) + if ok { + out = wire.AppendBytes(out, 2, roster.CharacterWire(c)) + } + b := wire.AppendVarint(nil, 1, binding.CharacterIndex) + for _, id := range binding.Equipment { + b = wire.AppendVarint(b, 2, id) + } + out = wire.AppendBytes(out, 3, b) + } + return out, nil +} + +func (s *Service) validateSettings(ctx command.Context, settings [][]byte) error { + for _, setting := range settings { + index, e := scalar(setting, 1) + if e != nil || index == 0 { + return fmt.Errorf("monsterhunt: invalid costume setting character") + } + if s.characters != nil { + if _, ok := s.characters.Find(ctx, index); !ok { + return fmt.Errorf("monsterhunt: setting character not owned") + } + } + seq, e := messages(setting, 2) + if e != nil { + return e + } + mode, _ := scalar(setting, 3) + if mode != BattleMode && mode != PracticeMode { + return fmt.Errorf("monsterhunt: invalid setting battle mode") + } + for _, item := range seq { + costume, _, e := wire.Varint(item, 1) + if e != nil { + return e + } + if costume == 0 || costume == ^uint64(0) { + continue + } + if s.collection != nil { + c, ok := s.collection.CostumeByIndex(costume) + if !ok || c.UseChar != index { + return fmt.Errorf("monsterhunt: setting costume not owned") + } + } + } + } + return nil +} + +func Open(ctx command.Context, storage stateio.Store, rules *Rules, inventory *assets.Inventory, wallet *assets.Wallet) (*Service, error) { + if storage == nil || rules == nil || rules.seed == nil || rules.presets == nil || rules.load == nil || len(rules.seasons) == 0 || inventory == nil || wallet == nil { + return nil, fmt.Errorf("monsterhunt: incomplete configuration") + } + s := &Service{storage: storage, seed: rules.seed, seasons: rules.seasons, presets: rules.presets, load: rules.load, inventory: inventory, wallet: wallet, now: time.Now, active: map[string]encounter{}} + s.baseSlots = rules.presets.BaseCount + s.maxSlots = rules.presets.Maximum s.state = snapshot{Version: versionconfig.State(), Slots: s.baseSlots, Users: map[string]user{}, Presets: map[string][]byte{}, Replies: map[string]reply{}} - raw, e := storage.Load("monsterhunt") + raw, e := storage.Load(ctx.State, "monsterhunt") if e != nil { return nil, e } @@ -140,15 +240,10 @@ func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inve return nil, fmt.Errorf("monsterhunt: incompatible state") } } - for _, c := range s.seasons { - if _, e = s.load(c.Hunt); e != nil { - return nil, e - } - } - if e = s.validateDecks(s.state.Decks); e != nil { + if e = s.validateDecks(ctx, s.state.Decks); e != nil { return nil, e } - if e = s.validateSettings(s.state.Settings); e != nil { + if e = s.validateSettings(ctx, s.state.Settings); e != nil { return nil, e } for key, u := range s.state.Users { @@ -169,85 +264,37 @@ func Open(storage stateio.Store, root, version string, seed *readonly.Seed, inve if key != strconv.FormatUint(slot, 10) { return nil, fmt.Errorf("monsterhunt: invalid saved preset key") } - if e = s.validatePreset(p); e != nil { + if e = s.validatePreset(ctx, p); e != nil { return nil, e } } return s, nil } -func (s *Service) AttachRewards(grant func(string, []gamedata.Reward) ([]byte, error)) { - s.rewardGrant = grant -} -func (s *Service) AttachCharacters(c *player.CharacterStore) { s.characters = c } -func (s *Service) AttachPresetRuntime(c *player.CharacterStore, e *player.EquipmentInventory, col *player.CollectionStore) error { + +func (s *Service) AttachPresetRuntime(ctx command.Context, c *roster.CharacterStore, e *assets.EquipmentInventory, col *roster.CollectionStore) error { if c == nil || e == nil || col == nil { return fmt.Errorf("monsterhunt: incomplete preset runtime") } s.characters = c s.equipment = e s.collection = col - if err := s.validateDecks(s.state.Decks); err != nil { + if err := s.validateDecks(ctx, s.state.Decks); err != nil { return err } - if err := s.validateSettings(s.state.Settings); err != nil { + if err := s.validateSettings(ctx, s.state.Settings); err != nil { return err } for _, p := range s.state.Presets { - if err := s.validatePreset(p); err != nil { + if err := s.validatePreset(ctx, p); err != nil { return err } } return nil } -func (s *Service) PresetSlotCount() uint64 { s.mu.Lock(); defer s.mu.Unlock(); return s.state.Slots } -func (s *Service) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - // Encounters are keyed by session-specific receipts and survive repeated activation. -} -func (s *Service) current() season { - // Future rows are public calendar data, not an instruction to switch the - // player's current hunt before its window begins. Regular client schedules - // have a single slot; independent hunts use their separate client list. - now := uint64(s.now().UnixMilli()) - var regular []season - for _, c := range s.seasons { - if !c.Independent { - regular = append(regular, c) - } - } - if len(regular) == 0 { - regular = s.seasons - } - return selectSeason(regular, now) -} -func selectSeason(rows []season, now uint64) season { - var active, begun, future season - for _, c := range rows { - if c.Start <= now { - if begun.ID == 0 || c.Start > begun.Start || c.Start == begun.Start && c.ID > begun.ID { - begun = c - } - if now < c.End && (active.ID == 0 || c.Start > active.Start || c.Start == active.Start && c.ID > active.ID) { - active = c - } - } else if future.ID == 0 || c.Start < future.Start || c.Start == future.Start && c.ID < future.ID { - future = c - } - } - if active.ID != 0 { - return active - } - if begun.ID != 0 { - return begun - } - return future -} - -func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) { +func (s *Service) scheduleInfo(ctx command.Context, req []byte) (int, []byte, bool, error) { current := s.current() - var independent []season + var independent []Season for _, row := range s.seasons { if row.Independent { independent = append(independent, row) @@ -289,29 +336,10 @@ func (s *Service) scheduleInfo(req []byte) (int, []byte, bool, error) { projected := &readonly.Seed{Version: s.seed.Version, Responses: map[string]readonly.Response{ "/MonsterHuntScheduleInfo": {PacketCode: response.PacketCode, Fields: fields}, }} - return projected.Handle("/MonsterHuntScheduleInfo", req) + return projected.Handle(ctx, "/MonsterHuntScheduleInfo", req) } -func (s *Service) playing(c season) bool { - n := uint64(s.now().UnixMilli()) - return n >= c.Start && n < c.End -} -func (s *Service) getUser(c season) (user, error) { - u, ok := s.state.Users[strconv.FormatUint(c.ID, 10)] - if ok { - if u.DailyDate != 0 && u.DailyDate/86400000 != uint64(s.now().UnixMilli())/86400000 { - u.DailyDamage = 0 - u.DailyLevel = 0 - } - return u, nil - } - d, e := s.load(c.Hunt) - if e != nil { - return u, e - } - hp, e := d.HP(1) - return user{Season: c.ID, Hunt: c.Hunt, Level: 1, StartHP: hp, HighestHP: hp}, e -} -func (s *Service) encodeUser(u user, c season) []byte { + +func (s *Service) encodeUser(u user, c Season) []byte { var b []byte for n, v := range map[int]uint64{1: u.Season, 2: u.Hunt, 3: u.Level, 4: u.StartHP, 6: u.HighestHP, 7: u.HighestDate, 8: u.CurrentDamage, 9: u.DailyDamage, 11: u.DailyLevel, 12: u.DailyDate} { if n == 3 { @@ -329,23 +357,7 @@ func (s *Service) encodeUser(u user, c season) []byte { } return b } -func (s *Service) save(next snapshot) error { - b, e := json.Marshal(next) - if e != nil { - return e - } - if e = s.storage.Save("monsterhunt", b); e != nil { - return e - } - s.state = next - return nil -} -func (s *Service) clone() snapshot { - raw, _ := json.Marshal(s.state) - var n snapshot - _ = json.Unmarshal(raw, &n) - return n -} + func scalar(b []byte, n int) (uint64, error) { v, _, e := wire.Varint(b, n) if e != nil || v > math.MaxInt64 { @@ -353,6 +365,7 @@ func scalar(b []byte, n int) (uint64, error) { } return v, nil } + func messages(b []byte, n int) ([][]byte, error) { var out [][]byte e := wire.Walk(b, func(f wire.Field) error { @@ -366,6 +379,7 @@ func messages(b []byte, n int) ([][]byte, error) { }) return out, e } + func packed(b []byte, n int) ([]uint64, error) { var out []uint64 e := wire.Walk(b, func(f wire.Field) error { @@ -392,19 +406,14 @@ func packed(b []byte, n int) ([]uint64, error) { }) return out, e } -func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { - return s.HandleSession(path, req, "local") -} -var codes = map[string]int{"/MonsterHuntScheduleInfo": 0, "/MonsterHuntUserInfo": 196, "/MonsterHuntRankInfo": 197, "/MonsterHuntDeckInfo": 263, "/MonsterHuntDeckSave": 264, "/MonsterHuntChangeTeam": 265, "/MonsterHuntQuickBattle": 266, "/MonsterHuntPresetSlotAdd": 400, "/MonsterHuntPresetInfo": 405, "/MonsterHuntPresetSave": 406, "/MonsterHuntPresetUse": 410, "/MonsterHuntPresetDelete": 0, "/MonsterHuntPresetInfoChange": 0, "/MonsterHuntSeasonReward": 0} - -func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { +func (s *Service) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID code, ok := codes[path] if !ok { return 0, nil, false, nil } - s.mu.Lock() - defer s.mu.Unlock() + seq, e := scalar(req, 1) if e != nil || seq == 0 || seq > math.MaxInt32 { return code, nil, true, fmt.Errorf("monsterhunt: invalid sequence") @@ -412,7 +421,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ if e = validateRequest(req); e != nil { return code, nil, true, e } - key := session + ":" + path + ":" + strconv.FormatUint(seq, 10) + key := commandSession + ":" + path + ":" + strconv.FormatUint(seq, 10) if r, found := s.state.Replies[key]; found { if !bytes.Equal(req, r.Request) { return code, nil, true, fmt.Errorf("monsterhunt: sequence reused with different request") @@ -420,7 +429,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ return code, r.Response, true, nil } if path == "/MonsterHuntScheduleInfo" { - return s.scheduleInfo(req) + return s.scheduleInfo(ctx, req) } c := s.current() u, e := s.getUser(c) @@ -449,20 +458,20 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ case "/MonsterHuntDeckSave": next.Decks, e = messages(req, 2) if e == nil { - e = s.validateDecks(next.Decks) + e = s.validateDecks(ctx, next.Decks) } if e == nil { next.Settings, e = messages(req, 3) } if e == nil { - e = s.validateSettings(next.Settings) + e = s.validateSettings(ctx, next.Settings) } mutation = true case "/MonsterHuntChangeTeam": var d []byte d, _, e = wire.Bytes(req, 2) if e == nil { - e = s.validateDecks([][]byte{d}) + e = s.validateDecks(ctx, [][]byte{d}) } mode, _ := scalar(req, 4) if mode != BattleMode && mode != PracticeMode { @@ -472,7 +481,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ found := false team, _ := scalar(d, 1) for receipt, a := range s.active { - if !strings.HasPrefix(receipt, session+":") || a.Mode != mode { + if !strings.HasPrefix(receipt, commandSession+":") || a.Mode != mode { continue } found = true @@ -537,7 +546,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ var p []byte p, _, e = wire.Bytes(req, 2) if e == nil { - e = s.validatePreset(p) + e = s.validatePreset(ctx, p) } if e == nil { slot, _ := scalar(p, 4) @@ -562,7 +571,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ p, _, e = wire.ReplaceVarint(p, b, v) } if e == nil { - e = s.validatePreset(p) + e = s.validatePreset(ctx, p) } next.Presets[strconv.FormatUint(slot, 10)] = p mutation = true @@ -592,13 +601,13 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ cost := count * s.presets.Price switch s.presets.PriceType { case 4: - _, e = s.wallet.SpendGoldOnce("monsterhunt:"+key, cost) + _, e = s.wallet.SpendGoldOnce(ctx, "monsterhunt:"+key, cost) case 3: - _, e = s.wallet.SpendFreeJewelryOnce("monsterhunt:"+key, cost) + _, e = s.wallet.SpendFreeJewelryOnce(ctx, "monsterhunt:"+key, cost) case 2: - _, e = s.wallet.SpendJewelryOnce("monsterhunt:"+key, cost) + _, e = s.wallet.SpendJewelryOnce(ctx, "monsterhunt:"+key, cost) case 12: - _, e = s.wallet.SpendCatalystOnce("monsterhunt:"+key, cost) + _, e = s.wallet.SpendCatalystOnce(ctx, "monsterhunt:"+key, cost) default: e = fmt.Errorf("monsterhunt: unsupported preset currency") } @@ -615,7 +624,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ } next.Decks, e = s.presetDecks(p) if e == nil { - out, e = s.applyPreset(p) + out, e = s.applyPreset(ctx, p) } if e == nil { for _, v := range next.Decks { @@ -634,7 +643,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ e = loadErr break } - bundle, grantErr := s.grant("monsterhunt:"+key, dailyDifference(d, u.DailyLevel, u.ClearLevel)) + bundle, grantErr := s.grant(ctx, "monsterhunt:"+key, dailyDifference(d, u.DailyLevel, u.ClearLevel)) if grantErr != nil { e = grantErr break @@ -659,7 +668,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ break } rewards := s.rankRewards(d, c.RankGroup) - bundle, grantErr := s.grant("monsterhunt:"+key, rewards) + bundle, grantErr := s.grant(ctx, "monsterhunt:"+key, rewards) if grantErr != nil { e = grantErr break @@ -677,17 +686,18 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ } if mutation { next.Replies[key] = reply{append([]byte(nil), req...), out} - if e = s.save(next); e != nil { + if e = s.save(ctx, next); e != nil { return code, nil, true, e } if updateReceipt != "" { s.active[updateReceipt] = updateEncounter + s.transientVersion++ } } return code, out, true, nil } -func (s *Service) validateDecks(ds [][]byte) error { +func (s *Service) validateDecks(ctx command.Context, ds [][]byte) error { seen := map[uint64]bool{} for _, d := range ds { t, e := scalar(d, 1) @@ -709,7 +719,7 @@ func (s *Service) validateDecks(ds [][]byte) error { return fmt.Errorf("monsterhunt: invalid deck character") } if s.characters != nil { - if _, found := s.characters.Find(i); !found { + if _, found := s.characters.Find(ctx, i); !found { return fmt.Errorf("monsterhunt: character not owned") } } @@ -719,7 +729,8 @@ func (s *Service) validateDecks(ds [][]byte) error { } return nil } -func (s *Service) validatePreset(p []byte) error { + +func (s *Service) validatePreset(ctx command.Context, p []byte) error { slot, e := scalar(p, 4) if e != nil || slot >= s.state.Slots { return fmt.Errorf("monsterhunt: invalid preset slot") @@ -737,11 +748,12 @@ func (s *Service) validatePreset(p []byte) error { if e != nil { return e } - if _, _, e = s.presetBindings(p); e != nil { + if _, _, e = s.presetBindings(ctx, p); e != nil { return e } - return s.validateDecks(d) + return s.validateDecks(ctx, d) } + func (s *Service) presetDecks(p []byte) ([][]byte, error) { entries, e := messages(p, 5) if e != nil { @@ -768,9 +780,8 @@ func (s *Service) presetDecks(p []byte) ([][]byte, error) { return out, nil } -func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Service) EnterBattle(ctx command.Context, req []byte, receipt string) ([]byte, error) { + mode, e := scalar(req, 5) if e != nil || mode != BattleMode && mode != PracticeMode { return nil, fmt.Errorf("monsterhunt: invalid battle mode") @@ -816,15 +827,16 @@ func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) { hp = u.StartHP } s.active[receipt] = encounter{c.ID, id, level, hp, mode, u.Level, u.StartHP, 1} + s.transientVersion++ if mode == PracticeMode { u.Level = level u.StartHP = hp } return wire.AppendBytes(nil, 5, s.encodeUser(u, c)), nil } -func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() + +func (s *Service) CompleteBattle(ctx command.Context, req []byte, receipt string) ([]byte, error) { + key := "battle:" + receipt if r, ok := s.state.Replies[key]; ok { if !bytes.Equal(r.Request, req) { @@ -842,6 +854,7 @@ func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { } if a.Mode == PracticeMode { delete(s.active, receipt) + s.transientVersion++ return nil, nil } c := s.current() @@ -897,7 +910,7 @@ func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { out = wire.AppendVarint(out, 27, clear) var bundle []byte for level := previousClear + 1; level <= clear; level++ { - b, e := s.grant(fmt.Sprintf("monsterhunt:season:%d:clear:%d", c.ID, level), d.Rewards[level].Clear) + b, e := s.grant(ctx, fmt.Sprintf("monsterhunt:season:%d:clear:%d", c.ID, level), d.Rewards[level].Clear) if e != nil { return nil, e } @@ -909,7 +922,7 @@ func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { } day := uint64(s.now().UnixMilli()) / 86400000 if damage > 0 && (u.DailyDate/86400000 != day || a.Level > u.DailyLevel) { - bundle, e := s.grant("monsterhunt:"+key+":daily", dailyDifference(d, u.DailyLevel, a.Level)) + bundle, e := s.grant(ctx, "monsterhunt:"+key+":daily", dailyDifference(d, u.DailyLevel, a.Level)) if e != nil { return nil, e } @@ -924,9 +937,10 @@ func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { next := s.clone() next.Users[strconv.FormatUint(c.ID, 10)] = u next.Replies[key] = reply{append([]byte(nil), req...), out} - if e = s.save(next); e != nil { + if e = s.save(ctx, next); e != nil { return nil, e } delete(s.active, receipt) + s.transientVersion++ return out, nil } diff --git a/go/internal/server/domain/battle/monsterhunt/rewards.go b/go/internal/server/domain/battle/monsterhunt/rewards.go new file mode 100644 index 0000000..4a954f2 --- /dev/null +++ b/go/internal/server/domain/battle/monsterhunt/rewards.go @@ -0,0 +1,50 @@ +package monsterhunt + +import ( + "bd2server/internal/server/design/gamedata" +) + +// Daily payout is the best reward achieved that UTC day. Improvements pay +// only positive per-item differences from the already paid level. +func dailyDifference(d *gamedata.MonsterHunt, previous, next uint64) []gamedata.BattleReward { + paid := map[[2]uint64]uint64{} + for _, r := range d.Rewards[previous].Daily { + paid[[2]uint64{r.Type, r.ID}] += r.Count + } + var out []gamedata.BattleReward + for _, r := range d.Rewards[next].Daily { + n := paid[[2]uint64{r.Type, r.ID}] + if r.Count > n { + r.Count -= n + out = append(out, r) + } + } + return out +} + +// This server has one persistent account state: a submitted participant ranks +// first among the local participants. No official leaderboard is imported. +func (s *Service) score(u user) float64 { + progress := float64(0) + if u.HighestHP > 0 { + progress = float64(u.HighestHP-u.StartHP) / float64(u.HighestHP) * 10000 + } + return float64(u.ClearLevel*1000000) + progress +} + +func (s *Service) rankRewards(d *gamedata.MonsterHunt, group uint64) []gamedata.BattleReward { + var selected *gamedata.MonsterHuntRankReward + for _, r := range d.Ranks[group] { + threshold := float64(1) + if r.Type == 1 { + threshold = 100 + } + if r.Ranking >= threshold && (selected == nil || r.Type < selected.Type || r.Type == selected.Type && r.Ranking < selected.Ranking) { + selected = &r + } + } + if selected == nil { + return nil + } + return selected.Rewards +} diff --git a/go/internal/server/battle/battle.go b/go/internal/server/domain/battle/protocol_adapter.go similarity index 65% rename from go/internal/server/battle/battle.go rename to go/internal/server/domain/battle/protocol_adapter.go index b44daab..804a093 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/domain/battle/protocol_adapter.go @@ -1,180 +1,15 @@ -// Package battle implements a deterministic local battle session. The client -// remains authoritative for turn simulation; the server validates sequencing -// and echoes the submitted combat state without capture replay. package battle import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" "bytes" "errors" "fmt" "math" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" ) -type Service struct { - mu sync.Mutex - states map[string]*battleState - activeSession string - gameDataRoot string - gameDataVersion string - inventory *player.Inventory - currentPack func() (int, error) - currentDifficulty func() (uint64, error) - loadDifficultyDeck func(string, string, int, uint64, uint64) (uint64, error) - loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error) - loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) - buffs func() ([]gamedata.PictorialBuffStat, error) - onTutorialWin func() error - onMonsterWin func() error - commitHealth func(map[uint64]uint64) error - hunting HuntingRuntime - monsterHunt MonsterHuntRuntime - eventBattles []EventBattleRuntime - fieldMonsters FieldMonsterRuntime - consumeFieldBuff func(string) error -} -type FieldMonsterRuntime interface { - BeginFieldMonsterBattle(int, uint64, uint64) (string, bool, error) - CompleteFieldMonsterBattle(int, uint64, string) ([]byte, error) -} - -func (s *Service) AttachFieldMonsters(runtime FieldMonsterRuntime) { s.fieldMonsters = runtime } - -func (s *Service) AttachFieldBuffConsume(consume func(string) error) { s.consumeFieldBuff = consume } - -// EventBattleRuntime owns event stage eligibility, costs and settlement while -// the normal battle service transports the client's turn simulation. -type EventBattleRuntime interface { - HandlesBattle(mode uint64) bool - EnterBattle(request []byte, receipt string) ([]byte, error) - CompleteBattle(request []byte, receipt string) ([]byte, error) -} - -func (s *Service) AttachEventBattle(runtime EventBattleRuntime) { - if runtime != nil { - s.eventBattles = append(s.eventBattles, runtime) - } -} - -func (s *Service) eventBattle(mode uint64) EventBattleRuntime { - for _, runtime := range s.eventBattles { - if runtime.HandlesBattle(mode) { - return runtime - } - } - return nil -} - -type MonsterHuntRuntime interface { - EnterBattle(request []byte, receipt string) ([]byte, error) - CompleteBattle(request []byte, receipt string) ([]byte, error) -} - -func (s *Service) AttachMonsterHunt(runtime MonsterHuntRuntime) { - s.monsterHunt = runtime -} - -func isMonsterHunt(mode uint64) bool { return mode == 8 || mode == 24 } - -// HuntingRuntime validates the active hunting ground and settles each won -// encounter with the account's persistent AP, monsters and reward ledger. -type HuntingRuntime interface { - ValidateBattle(pack int, mode, monster, deck uint64) error - CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) -} - -const huntingGroundMode = 5 - -func (s *Service) AttachHunting(runtime HuntingRuntime) { - s.hunting = runtime -} - -type battleState struct { - entered bool - index uint64 - round uint64 - monster uint64 - deck uint64 - pack int - mode uint64 - enterReceipt string - fieldInstance string - initialBlue [][]byte - phases []gamedata.BattlePhase - phase int - phaseStarted bool - phaseSeq uint64 - phaseReply []byte - endSeq uint64 - endRequest []byte - endReply []byte -} - -// BeginSession discards an unfinished battle when LoginUser creates a new -// game session. Persistent rewards are written only by a successful BattleEnd -// request transaction, so reconnect returns to the last pre-battle commit. -func (s *Service) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - if id == "" { - return - } - if s.states == nil { - s.states = make(map[string]*battleState) - } - if s.states[id] == nil { - if len(s.states) >= 1024 { - for key := range s.states { - if key != id { - delete(s.states, key) - break - } - } - } - s.states[id] = &battleState{} - } - s.activeSession = id -} - -func (s *Service) AttachMonsterWinMission(callback func() error) { s.onMonsterWin = callback } - -func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback } - -// AttachCommittedHealth persists only completed battle results. Round state -// remains transient, so reconnect rolls back an unfinished battle. -// The callback may normalize values to field HP; the response uses those -// committed values rather than echoing battle-only HP buffs. -func (s *Service) AttachCommittedHealth(callback func(map[uint64]uint64) error) { - s.commitHealth = callback -} - -func (s *Service) Active() bool { - s.mu.Lock() - defer s.mu.Unlock() - return s.stateLocked().entered -} - -func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory, currentPack func() (int, error)) *Service { - return &Service{ - gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, - inventory: inventory, currentPack: currentPack, loadRewards: gamedata.BattleDeckRewards, - states: make(map[string]*battleState), - } -} - -// AttachCurrentDifficulty selects the GameData quest deck for the active pack. -func (s *Service) AttachCurrentDifficulty(resolve func() (uint64, error)) { - s.currentDifficulty = resolve -} - -func (s *Service) AttachPictorialBuffs(buffs func() ([]gamedata.PictorialBuffStat, error)) { - s.buffs = buffs -} - func checkSeq(request []byte) error { seq, found, err := wire.Varint(request, 1) if err != nil || !found || seq == 0 { @@ -183,16 +18,15 @@ func checkSeq(request []byte) error { return nil } -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { if path != "/BattleEnter" && path != "/BattleStart" && path != "/BattleRetry" && path != "/BattleVerifyState" && path != "/BattleEnd" && path != "/BattleExit" && path != "/BattlePhaseChange" { return 0, nil, false, nil } if err := checkSeq(request); err != nil { return 0, nil, true, err } - s.mu.Lock() - defer s.mu.Unlock() - state := s.stateLocked() + + state := s.stateLocked(ctx) switch path { case "/BattlePhaseChange": if !state.entered { @@ -222,6 +56,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) state.phase++ state.index, state.deck, state.phaseStarted = next.DeckID, next.DeckID, false state.phaseSeq, state.phaseReply = seq, append([]byte(nil), response...) + s.transientVersion++ return 632, response, true, nil case "/BattleVerifyState": if !state.entered { @@ -241,14 +76,14 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } packID := 0 if s.currentPack != nil { - packID, err = s.currentPack() + packID, err = s.currentPack(ctx) if err != nil { return 0, nil, true, fmt.Errorf("battle: resolve current pack: %w", err) } if packID <= 0 { return 0, nil, true, errors.New("battle: current pack is invalid") } - } else if s.inventory != nil && s.gameDataRoot != "" { + } else if s.gameDataRoot != "" { return 0, nil, true, errors.New("battle: current pack resolver is unavailable") } monster, _, _ := wire.Varint(request, 3) @@ -256,7 +91,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) eventRuntime := s.eventBattle(mode) if eventRuntime != nil { seq, _, _ := wire.Varint(request, 1) - huntResponse, err = eventRuntime.EnterBattle(request, fmt.Sprintf("%s:%d", s.activeSession, seq)) + huntResponse, err = eventRuntime.EnterBattle(ctx, request, fmt.Sprintf("%s:%d", ctx.SessionID, seq)) if err != nil { return 0, nil, true, fmt.Errorf("battle: enter event: %w", err) } @@ -266,14 +101,14 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, errors.New("battle: monster hunt runtime unavailable") } if validator, ok := s.monsterHunt.(interface { - ValidatePack(int, []byte) error + ValidatePack(ctx command.Context, _ int, _ []byte) error }); ok { - if err := validator.ValidatePack(packID, request); err != nil { + if err := validator.ValidatePack(ctx, packID, request); err != nil { return 0, nil, true, err } } seq, _, _ := wire.Varint(request, 1) - huntResponse, err = s.monsterHunt.EnterBattle(request, fmt.Sprintf("%s:%d", s.activeSession, seq)) + huntResponse, err = s.monsterHunt.EnterBattle(ctx, request, fmt.Sprintf("%s:%d", ctx.SessionID, seq)) if err != nil { return 0, nil, true, fmt.Errorf("battle: enter monster hunt: %w", err) } @@ -282,28 +117,35 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if s.hunting == nil { return 0, nil, true, errors.New("battle: hunting runtime unavailable") } - if err := s.hunting.ValidateBattle(packID, mode, monster, deck); err != nil { + if err := s.hunting.ValidateBattle(ctx, packID, mode, monster, deck); err != nil { return 0, nil, true, err } } if mode == 1 && s.currentDifficulty != nil { - difficulty, resolveErr := s.currentDifficulty() + difficulty, resolveErr := s.currentDifficulty(ctx) if resolveErr != nil { return 0, nil, true, fmt.Errorf("battle: resolve difficulty: %w", resolveErr) } loader := s.loadDifficultyDeck if loader == nil { - loader = gamedata.BattleDeckForDifficulty + loader = gamedata.ResolveQuestBattleDeck } - deck, err = loader(s.gameDataRoot, s.gameDataVersion, packID, deck, difficulty) + selection, err := loader(s.gameDataRoot, s.gameDataVersion, packID, monster, deck, difficulty) if err != nil { return 0, nil, true, fmt.Errorf("battle: select difficulty deck: %w", err) } + if s.validateQuest == nil { + return 0, nil, true, errors.New("battle: quest ownership validator unavailable") + } + if err := s.validateQuest(ctx, packID, selection.QuestIDs); err != nil { + return 0, nil, true, fmt.Errorf("battle: quest ownership: %w", err) + } + deck = selection.DeckID } var phases []gamedata.BattlePhase fieldInstance := "" if mode == 2 && eventRuntime == nil && s.fieldMonsters != nil && monster != 0 { - instance, _, e := s.fieldMonsters.BeginFieldMonsterBattle(packID, monster, deck) + instance, _, e := s.fieldMonsters.BeginFieldMonsterBattle(ctx, packID, monster, deck) if e != nil { return 0, nil, true, e } @@ -325,7 +167,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) response := wire.AppendVarint(nil, 2, deck) response = append(response, huntResponse...) if s.buffs != nil { - buffs, err := s.buffs() + buffs, err := s.buffs(ctx) if err != nil { return 0, nil, true, fmt.Errorf("battle: account pictorial buffs: %w", err) } @@ -341,9 +183,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) // The local engine is the normal deterministic engine. response = wire.AppendVarint(response, 6, 1) seq, _, _ := wire.Varint(request, 1) - identity := fmt.Sprintf("%s:%d", s.activeSession, seq) + identity := fmt.Sprintf("%s:%d", ctx.SessionID, seq) if s.consumeFieldBuff != nil { - if err := s.consumeFieldBuff(identity); err != nil { + if err := s.consumeFieldBuff(ctx, identity); err != nil { return 0, nil, true, fmt.Errorf("battle: consume field buff: %w", err) } } @@ -353,6 +195,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) state.enterReceipt = identity state.fieldInstance = fieldInstance state.phases, state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = phases, 0, false, 0, nil + s.transientVersion++ return 52, response, true, nil case "/BattleRetry": if !state.entered { @@ -383,6 +226,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) state.deck = state.phases[0].DeckID } state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = 0, false, 0, nil + s.transientVersion++ return 58, response, true, nil case "/BattleStart": if !state.entered { @@ -422,6 +266,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if nextRound == 1 { state.initialBlue = initialBlue } + s.transientVersion++ // Stable per-battle/round seed; reproducible across retries. seed := index*7919 + state.round*104729 response = wire.AppendVarint(response, 3, seed) @@ -446,25 +291,27 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } response := wire.AppendVarint(nil, 1, result) if eventRuntime := s.eventBattle(state.mode); eventRuntime != nil { - extra, err := eventRuntime.CompleteBattle(request, state.enterReceipt) + extra, err := eventRuntime.CompleteBattle(ctx, request, state.enterReceipt) if err != nil { return 0, nil, true, fmt.Errorf("battle: settle event: %w", err) } response = append(response, extra...) state.rememberEnd(request, response) state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil + s.transientVersion++ return 15, response, true, nil } if isMonsterHunt(state.mode) { // Monster Hunt owns its remaining HP, progression and daily/season // rewards. Field HP and ordinary pack rewards must not settle here. - extra, err := s.monsterHunt.CompleteBattle(request, state.enterReceipt) + extra, err := s.monsterHunt.CompleteBattle(ctx, request, state.enterReceipt) if err != nil { return 0, nil, true, fmt.Errorf("battle: settle monster hunt: %w", err) } response = append(response, extra...) state.rememberEnd(request, response) state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil + s.transientVersion++ return 15, response, true, nil } var resultCharacters [][]byte @@ -505,7 +352,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, err } if s.commitHealth != nil && len(finishedHealth) != 0 { - if err := s.commitHealth(finishedHealth); err != nil { + if err := s.commitHealth(ctx, finishedHealth); err != nil { return 0, nil, true, fmt.Errorf("battle: persist completed character health: %w", err) } } @@ -522,8 +369,8 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) rewardBundle := false if result == 1 && state.mode == huntingGroundMode { seq, _, _ := wire.Varint(request, 1) - bundle, monsters, err := s.hunting.CompleteBattle(state.pack, state.mode, state.monster, state.deck, - fmt.Sprintf("%s:%d", s.activeSession, seq)) + bundle, monsters, err := s.hunting.CompleteBattle(ctx, state.pack, state.mode, state.monster, state.deck, + fmt.Sprintf("%s:%d", ctx.SessionID, seq)) if err != nil { return 0, nil, true, fmt.Errorf("battle: settle hunting encounter: %w", err) } @@ -533,7 +380,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) response = wire.AppendBytes(response, 5, bundle) rewardBundle = true } - if result == 1 && state.mode != huntingGroundMode && s.inventory != nil && state.monster != 0 && s.gameDataRoot != "" { + if result == 1 && state.mode != huntingGroundMode && state.monster != 0 && s.gameDataRoot != "" { if state.pack <= 0 { return 0, nil, true, errors.New("battle: victory has no locked pack") } @@ -549,20 +396,23 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if state.fieldInstance != "" { rewardIdentity = state.fieldInstance } - items, grantErr := s.inventory.GrantOnce(rewardIdentity, rewards) + if s.grantRewards == nil { + return 0, nil, true, errors.New("battle: reward runtime unavailable") + } + definitions := make([]gamedata.Reward, len(rewards)) + for i, reward := range rewards { + definitions[i] = gamedata.Reward(reward) + } + bundle, grantErr := s.grantRewards(ctx, rewardIdentity, definitions) if grantErr != nil { return 0, nil, true, grantErr } - var bundle []byte - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - } if len(bundle) != 0 { response = wire.AppendBytes(response, 5, bundle) rewardBundle = true } if state.fieldInstance != "" { - monsterRow, e := s.fieldMonsters.CompleteFieldMonsterBattle(state.pack, state.monster, state.fieldInstance) + monsterRow, e := s.fieldMonsters.CompleteFieldMonsterBattle(ctx, state.pack, state.monster, state.fieldInstance) if e != nil { return 0, nil, true, e } @@ -570,12 +420,12 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } } if result == 1 && state.monster != 0 && s.onMonsterWin != nil { - if err := s.onMonsterWin(); err != nil { + if err := s.onMonsterWin(ctx); err != nil { return 0, nil, true, fmt.Errorf("battle: monster win mission: %w", err) } } if result == 1 && s.onTutorialWin != nil { - if err := s.onTutorialWin(); err != nil { + if err := s.onTutorialWin(ctx); err != nil { return 0, nil, true, fmt.Errorf("battle: update tutorial kill mission: %w", err) } } @@ -587,9 +437,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } state.rememberEnd(request, response) state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil + s.transientVersion++ return 15, response, true, nil case "/BattleExit": state.entered, state.index, state.round, state.deck, state.pack, state.initialBlue = false, 0, 0, 0, 0, nil + s.transientVersion++ return 388, nil, true, nil } panic("unreachable") @@ -600,19 +452,3 @@ func (state *battleState) rememberEnd(request, response []byte) { state.endRequest = append([]byte(nil), request...) state.endReply = append([]byte(nil), response...) } - -func (s *Service) stateLocked() *battleState { - if s.states == nil { - s.states = make(map[string]*battleState) - } - key := s.activeSession - if key == "" { - key = "__direct_test__" - } - state := s.states[key] - if state == nil { - state = &battleState{} - s.states[key] = state - } - return state -} diff --git a/go/internal/server/domain/command/context.go b/go/internal/server/domain/command/context.go new file mode 100644 index 0000000..7d18b95 --- /dev/null +++ b/go/internal/server/domain/command/context.go @@ -0,0 +1,16 @@ +// Package command identifies an authorized player command independently of transport and runtime. +package command + +import ( + "bd2server/internal/server/storage/stateio" + "context" +) + +type Identity struct{ AccountID, SessionID, RequestID string } +type Context struct { + Identity + Cancellation context.Context + State stateio.AtomicEntryStore +} + +func (c Context) Cancelled() bool { return c.Cancellation != nil && c.Cancellation.Err() != nil } diff --git a/go/internal/server/domain/commerce/attendance.go b/go/internal/server/domain/commerce/attendance.go new file mode 100644 index 0000000..a8c7979 --- /dev/null +++ b/go/internal/server/domain/commerce/attendance.go @@ -0,0 +1,25 @@ +package commerce + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" +) + +type attendanceHandler interface { + Handle(ctx command.Context, _ 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"` +} diff --git a/go/internal/server/domain/commerce/bonuses.go b/go/internal/server/domain/commerce/bonuses.go new file mode 100644 index 0000000..ae41907 --- /dev/null +++ b/go/internal/server/domain/commerce/bonuses.go @@ -0,0 +1,99 @@ +package commerce + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" +) + +type bonusGroup struct{ ProductGroup, ContentsGroup uint64 } +type bonusPurchases interface { + LifetimePurchaseTotal(ctx command.Context, _ []gamedata.CashProductKey) (uint64, error) +} +type bonusState struct { + Claims map[string]bool `json:"claims"` + Receipts map[string]purchaseReceipt `json:"receipts"` +} +type CashBonuses struct { + store stateio.Store + economy Economy + counts bonusPurchases + groups map[bonusGroup][]gamedata.CashProductKey + rewards map[bonusGroup][]gamedata.CashBonusReward + ordered []bonusGroup + claimIDs map[string]bool +} + +func bonusClaimID(group bonusGroup, id uint64) string { + return fmt.Sprintf("%d:%d:%d", group.ProductGroup, group.ContentsGroup, id) +} + +func NewCashBonuses(ctx command.Context, store stateio.Store, economy Economy, counts bonusPurchases, design *gamedata.CashBonusCatalog, packages []gamedata.CashPackageDesign) (*CashBonuses, error) { + if store == nil || economy == nil || counts == nil || design == nil { + return nil, fmt.Errorf("commerce: missing cash bonus dependency") + } + s := &CashBonuses{store: store, economy: economy, counts: counts, groups: map[bonusGroup][]gamedata.CashProductKey{}, rewards: map[bonusGroup][]gamedata.CashBonusReward{}, claimIDs: map[string]bool{}} + seen := map[gamedata.CashProductKey]bool{} + for _, p := range packages { + // EPackages.BonusBundleGroup=8; unrelated packages can reuse a contents ID. + if p.PackageType != 8 || len(design.Groups[p.ContentsGroupID]) == 0 { + continue + } + k := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup} + if seen[k] { + return nil, fmt.Errorf("commerce: duplicate cash bonus product %+v", k) + } + seen[k] = true + group := bonusGroup{p.GroupID, p.ContentsGroupID} + s.groups[group] = append(s.groups[group], k) + s.rewards[group] = design.Groups[p.ContentsGroupID] + } + for group, rows := range s.rewards { + s.ordered = append(s.ordered, group) + for _, row := range rows { + s.claimIDs[bonusClaimID(group, row.ID)] = true + } + } + sort.Slice(s.ordered, func(i, j int) bool { + if s.ordered[i].ProductGroup != s.ordered[j].ProductGroup { + return s.ordered[i].ProductGroup < s.ordered[j].ProductGroup + } + return s.ordered[i].ContentsGroup < s.ordered[j].ContentsGroup + }) + _, err := s.load(ctx) + return s, err +} + +func (s *CashBonuses) load(ctx command.Context) (bonusState, error) { + v := bonusState{Claims: map[string]bool{}, Receipts: map[string]purchaseReceipt{}} + raw, err := s.store.Load(ctx.State, "commerce_cash_bonuses") + if err != nil || raw == nil { + return v, err + } + if err = stateio.RequireExactJSONObject(raw, "claims", "receipts"); err != nil { + return v, err + } + if err = json.Unmarshal(raw, &v); err != nil { + return v, err + } + if v.Claims == nil || v.Receipts == nil { + return v, fmt.Errorf("commerce: malformed cash bonus state") + } + for id, claimed := range v.Claims { + if !claimed || !s.claimIDs[id] { + return v, fmt.Errorf("commerce: invalid cash bonus claim %s", id) + } + } + for id, r := range v.Receipts { + digest, err := hex.DecodeString(r.Digest) + if id == "" || err != nil || len(digest) != sha256.Size || len(r.Response) == 0 { + return v, fmt.Errorf("commerce: invalid cash bonus receipt") + } + } + return v, nil +} diff --git a/go/internal/server/commerce/catalog.go b/go/internal/server/domain/commerce/catalog.go similarity index 98% rename from go/internal/server/commerce/catalog.go rename to go/internal/server/domain/commerce/catalog.go index 6835c73..e9a8811 100644 --- a/go/internal/server/commerce/catalog.go +++ b/go/internal/server/domain/commerce/catalog.go @@ -6,8 +6,8 @@ import ( "math" "sort" - "bd2server/internal/server/gameconfig" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gameconfig" + "bd2server/internal/server/design/gamedata" ) type Product struct { diff --git a/go/internal/server/domain/commerce/clear_packages.go b/go/internal/server/domain/commerce/clear_packages.go new file mode 100644 index 0000000..a947768 --- /dev/null +++ b/go/internal/server/domain/commerce/clear_packages.go @@ -0,0 +1,85 @@ +package commerce + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + + "time" +) + +type ClearPackageInventory interface { + All(ctx command.Context) []assets.Item +} +type clearClaim struct{ Kind, GroupID, TicketID, TargetID, Level uint64 } +type clearClaimReceipt struct { + Claim clearClaim `json:"claim"` + Response []byte `json:"response"` +} +type ClearPackages struct { + store stateio.Store + economy Economy + items ClearPackageInventory + design map[clearClaim]gamedata.ClearPackageRewardDesign + packCleared func(uint64, uint64) bool + towerCleared func(uint64, uint64) bool + now func() time.Time +} + +func NewClearPackages(ctx command.Context, store stateio.Store, design *gamedata.ClearPackageCatalog, economy Economy, items ClearPackageInventory) (*ClearPackages, error) { + if store == nil || design == nil || economy == nil || items == nil { + return nil, fmt.Errorf("commerce: invalid clear package dependencies") + } + s := &ClearPackages{store: store, economy: economy, items: items, design: map[clearClaim]gamedata.ClearPackageRewardDesign{}, now: time.Now} + for _, r := range design.Rewards { + k := clearClaim{r.Kind, r.GroupID, r.TicketID, r.TargetID, r.Level} + if _, ok := s.design[k]; ok { + return nil, fmt.Errorf("commerce: duplicate clear reward") + } + s.design[k] = r + } + _, err := s.load(ctx) + return s, err +} +func (s *ClearPackages) AttachProgress(pack, tower func(uint64, uint64) bool) { + s.packCleared = pack + s.towerCleared = tower +} +func (s *ClearPackages) load(ctx command.Context) (map[string]clearClaimReceipt, error) { + v := map[string]clearClaimReceipt{} + raw, err := s.store.Load(ctx.State, "commerce_clear_claims") + if err != nil || raw == nil { + return v, err + } + err = json.Unmarshal(raw, &v) + if err == nil && v == nil { + err = fmt.Errorf("commerce: invalid clear claim state") + } + if err == nil { + for identity, receipt := range v { + if identity != clearClaimID(receipt.Claim) || len(receipt.Response) == 0 { + err = fmt.Errorf("commerce: invalid saved clear claim") + break + } + if _, ok := s.design[receipt.Claim]; !ok { + err = fmt.Errorf("commerce: unknown saved clear claim") + break + } + } + } + return v, err +} +func clearClaimID(c clearClaim) string { + return fmt.Sprintf("clear-package:%d:%d:%d:%d:%d", c.Kind, c.GroupID, c.TicketID, c.TargetID, c.Level) +} +func (s *ClearPackages) entitled(ctx command.Context, ticket uint64) bool { + for _, item := range s.items.All(ctx) { + if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(s.now().UnixMilli())) { + return true + } + } + return false +} diff --git a/go/internal/server/domain/commerce/delivery.go b/go/internal/server/domain/commerce/delivery.go new file mode 100644 index 0000000..87130e9 --- /dev/null +++ b/go/internal/server/domain/commerce/delivery.go @@ -0,0 +1,25 @@ +package commerce + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "fmt" + "time" +) + +type CashMailIssuer interface { + IssueCashOnce(ctx command.Context, _ string, _ uint64, _ []gamedata.Reward, _ time.Time) error +} + +func (e *EntitlementEconomy) AttachCashMail(issuer CashMailIssuer) error { + if issuer == nil { + return fmt.Errorf("commerce: cash mail issuer unavailable") + } + if _, ok := e.graph.(interface { + ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error) + }); !ok { + return fmt.Errorf("commerce: cash delivery resolver unavailable") + } + e.mail = issuer + return nil +} diff --git a/go/internal/server/domain/commerce/entitlements.go b/go/internal/server/domain/commerce/entitlements.go new file mode 100644 index 0000000..196b66b --- /dev/null +++ b/go/internal/server/domain/commerce/entitlements.go @@ -0,0 +1,92 @@ +package commerce + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/json" + "fmt" + "strconv" + + "time" +) + +type grantedResolver interface { + ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error) +} +type entitlementReceipt struct { + Definition []byte `json:"definition"` + Bundle []byte `json:"bundle"` +} +type cashSubscription struct { + Start, Expiry int64 + Claimed uint64 + LastDay string +} +type entitlementState struct { + Receipts map[string]entitlementReceipt `json:"receipts"` + Subscriptions map[string]cashSubscription `json:"subscriptions"` +} +type EntitlementEconomy struct { + store stateio.Store + base Economy + graph grantedResolver + items *assets.Inventory + design *gamedata.CashEntitlementDesign + now func() time.Time + resetSeconds int64 + mail CashMailIssuer +} + +func NewEntitlementEconomy(ctx command.Context, store stateio.Store, base Economy, graph grantedResolver, items *assets.Inventory, design *gamedata.CashEntitlementDesign) (*EntitlementEconomy, error) { + if store == nil || base == nil || graph == nil || items == nil || design == nil { + return nil, fmt.Errorf("commerce: missing entitlement dependency") + } + e := &EntitlementEconomy{store: store, base: base, graph: graph, items: items, design: design, now: time.Now} + _, err := e.load(ctx) + return e, err +} +func (e *EntitlementEconomy) SetClock(now func() time.Time, resetSeconds int64) { + e.now = now + e.resetSeconds = resetSeconds +} +func (e *EntitlementEconomy) day() string { + return e.now().UTC().Add(-time.Duration(e.resetSeconds) * time.Second).Format("2006-01-02") +} +func (e *EntitlementEconomy) load(ctx command.Context) (entitlementState, error) { + s := entitlementState{Receipts: map[string]entitlementReceipt{}, Subscriptions: map[string]cashSubscription{}} + raw, err := e.store.Load(ctx.State, "commerce_entitlements") + if err != nil || raw == nil { + return s, err + } + if err = stateio.RequireExactJSONObject(raw, "receipts", "subscriptions"); err != nil { + return s, err + } + if err = json.Unmarshal(raw, &s); err != nil { + return s, err + } + if s.Receipts == nil || s.Subscriptions == nil { + return s, fmt.Errorf("commerce: malformed entitlement state") + } + for id, r := range s.Receipts { + if id == "" || len(r.Definition) != sha256.Size { + return s, fmt.Errorf("commerce: malformed entitlement receipt") + } + } + for id, sub := range s.Subscriptions { + ticket, err := strconv.ParseUint(id, 10, 64) + if err != nil || len(e.design.Attendance[ticket]) == 0 || sub.Start <= 0 || sub.Expiry != 0 && sub.Expiry < sub.Start || sub.Claimed == 0 || sub.LastDay == "" { + return s, fmt.Errorf("commerce: malformed subscription") + } + } + return s, nil +} +func (e *EntitlementEconomy) save(ctx command.Context, s entitlementState) error { + b, err := json.Marshal(s) + if err != nil { + return err + } + return e.store.Save(ctx.State, "commerce_entitlements", b) +} diff --git a/go/internal/server/domain/commerce/gacha/first_gacha.go b/go/internal/server/domain/commerce/gacha/first_gacha.go new file mode 100644 index 0000000..943d119 --- /dev/null +++ b/go/internal/server/domain/commerce/gacha/first_gacha.go @@ -0,0 +1,38 @@ +package gacha + +import ( + "bd2server/internal/server/design/gamedata" + + assets "bd2server/internal/server/domain/inventory" + "errors" +) + +const firstGachaResponseDigest = "first-gacha-confirm-v1" + +type firstGachaPreview struct { + sequence uint64 + rewards []gamedata.FirstGachaReward + equipment map[int]assets.Equipment + response []byte +} + +func firstGachaEquipment(catalog *gamedata.EquipmentGachaCatalog, id, sortID uint64) (assets.Equipment, error) { + if catalog == nil { + return assets.Equipment{}, errors.New("gacha: first gacha equipment catalog missing") + } + main, sub, private, err := catalog.RollOptions(id) + if err != nil { + return assets.Equipment{}, err + } + entry := assets.Equipment{ID: id, SortID: sortID, Rank: []uint64{0, 0, 0}} + for _, option := range main { + entry.MainOption = append(entry.MainOption, assets.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + } + for _, option := range sub { + entry.SubOption = append(entry.SubOption, assets.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + } + if private != nil { + entry.PrivateOption = &assets.EquipmentOption{GroupID: private.GroupID, ID: private.ID} + } + return entry, nil +} diff --git a/go/internal/server/domain/commerce/gacha/gacha.go b/go/internal/server/domain/commerce/gacha/gacha.go new file mode 100644 index 0000000..2769951 --- /dev/null +++ b/go/internal/server/domain/commerce/gacha/gacha.go @@ -0,0 +1,209 @@ +// Package gacha implements server-authoritative draws from verified GameData, +// including the starter reroll and scheduled daily-free batches. +package gacha + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "errors" + "fmt" + + "time" +) + +func (s *Service) infiniteGrant() string { + return fmt.Sprintf("cash-product:%d:%d", s.design.ProductGroupID, s.design.ProductID) +} + +type Service struct { + design *gamedata.InfiniteGachaDesign + first *gamedata.FirstGachaDesign + regular *gamedata.RegularGachaCatalog + collection *roster.CollectionStore + wallet *assets.Wallet + inventory *assets.Inventory + equipmentCatalog *gamedata.EquipmentGachaCatalog + equipmentInventory *assets.EquipmentInventory + onPreview func(ctx command.Context) error + onDraw func(ctx command.Context, _ uint64) error + schedule *ScheduleSeed + previewEventIndex uint64 + + firstPreviews map[string]firstGachaPreview + transientVersion uint64 + now func() time.Time +} + +func (s *Service) TransientVersion() uint64 { + + return s.transientVersion +} + +func (s *Service) AttachDrawMission(callback func(ctx command.Context, _ uint64) error) { + s.onDraw = callback +} + +func (s *Service) AttachPreviewMission(callback func(ctx command.Context) error) { + s.onPreview = callback +} +func (s *Service) AttachInventory(inventory *assets.Inventory) { s.inventory = inventory } +func (s *Service) AttachPreviewEventIndex(ctx command.Context, eventIndex uint64) error { + if eventIndex == 0 { + return errors.New("gacha: invalid preview event index") + } + s.previewEventIndex = eventIndex + return nil +} +func (s *Service) AttachSchedule(ctx command.Context, seed *ScheduleSeed) error { + if err := seed.Validate(seedVersion(seed)); err != nil { + return err + } + copy := *seed + copy.Schedules = append([]ScheduleWindow(nil), seed.Schedules...) + copy.StepUps = append([]ScheduleWindow(nil), seed.StepUps...) + s.schedule = © + return nil +} +func (s *Service) AttachEquipmentGacha(catalog *gamedata.EquipmentGachaCatalog, inventory *assets.EquipmentInventory) { + s.equipmentCatalog, s.equipmentInventory = catalog, inventory +} + +func (s *Service) AttachFirstGacha(ctx command.Context, design *gamedata.FirstGachaDesign) error { + if design == nil || design.GachaID == 0 || design.Count == 0 || design.Group.GachaSubType != 3 { + return errors.New("gacha: invalid first gacha design") + } + s.first = design + + s.firstPreviews = make(map[string]firstGachaPreview) + + return nil +} + +// FirstGachaCompleted reflects UserDBInfo.IsFirstGacha. The official symbol +// map names the client-side property IsDoneFirstGachaPick, and the client sets +// it after a GachaSubType=3 purchase succeeds. +func (s *Service) FirstGachaCompleted() bool { + return s.collection.FirstGachaCompleted() +} + +func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularGachaCatalog, collection *roster.CollectionStore, wallet *assets.Wallet) (*Service, error) { + if design == nil || regular == nil || collection == nil || wallet == nil { + return nil, errors.New("gacha: invalid service configuration") + } + return &Service{design: design, regular: regular, collection: collection, wallet: wallet, now: time.Now}, nil +} + +func (s *Service) dailyFreeAllowance(groupID, base uint64) (uint64, bool, error) { + if s.schedule == nil { + return 0, false, nil + } + now := s.now().UnixMilli() + for _, entry := range s.schedule.Schedules { + if entry.GroupID != groupID || now < 0 || uint64(now) < entry.StartTime || uint64(now) > entry.EndTime { + continue + } + if entry.FreeCountBonus { + bonus := s.regular.GachaEventAddFreeCount() + if ^uint64(0)-base < bonus { + return 0, false, errors.New("gacha: daily free allowance overflows") + } + base += bonus + } + return base, true, nil + } + return 0, false, nil +} + +func (s *Service) stepForGacha(gachaID uint64) (uint64, gamedata.GachaStepDesign, bool) { + step, ok := s.regular.StepForGacha(gachaID) + if !ok { + return 0, gamedata.GachaStepDesign{}, false + } + for _, group := range s.regular.StepUps() { + for _, candidate := range group.Steps { + if candidate.GachaID == gachaID && candidate == step { + return group.ID, step, true + } + } + } + return 0, gamedata.GachaStepDesign{}, false +} + +func costumeFixedStates(fixed gamedata.GachaFixedDesign, result gamedata.GachaFixedRoll) []roster.GachaFixedState { + states := make([]roster.GachaFixedState, 0, 2) + if fixed.CostumeGrade4Count != 0 { + states = append(states, roster.GachaFixedState{ + FixedID: fixed.ID, Type: 0, Count: result.CostumeGrade4Count, ApplySort: result.CostumeGrade4Sort, + }) + } + if fixed.CostumeGrade5Count != 0 { + states = append(states, roster.GachaFixedState{ + FixedID: fixed.ID, Type: 1, Count: result.CostumeGrade5Count, ApplySort: result.CostumeGrade5Sort, + }) + } + return states +} + +func (s *Service) completeSharedFixedStates(fixedID uint64, updates []roster.GachaFixedState) []roster.GachaFixedState { + if fixedID == 0 || s.equipmentCatalog == nil { + return updates + } + known := false + for _, fixed := range s.equipmentCatalog.FixedDesigns() { + if fixed.ID == fixedID { + known = true + break + } + } + if !known { + return updates + } + states := make([]roster.GachaFixedState, 4) + for fixedType := range states { + states[fixedType] = roster.GachaFixedState{ + FixedID: fixedID, Type: uint64(fixedType), + Count: s.collection.GachaFixedCount(fixedID, uint64(fixedType)), ApplySort: -1, + } + } + for _, update := range updates { + if update.FixedID != fixedID || update.Type >= uint64(len(states)) { + return updates + } + states[update.Type] = update + } + return states +} + +func (s *Service) loginIdentity(ctx command.Context) string { + return ctx.SessionID +} + +func (s *Service) requestIdentity(ctx command.Context, gachaID, seq uint64) string { + return fmt.Sprintf("regular-gacha:%d:session:%s:seq:%d", gachaID, ctx.SessionID, seq) +} + +func (s *Service) creditOverflow(ctx command.Context, identity string, grant roster.CollectionGrant) error { + var mileage uint64 + for _, exchange := range grant.Exchanges { + if exchange.ExchangeItemType != 20 { + return fmt.Errorf("gacha: unsupported costume overflow type %d", exchange.ExchangeItemType) + } + if ^uint64(0)-mileage < exchange.ExchangeCount { + return errors.New("gacha: costume overflow mileage overflow") + } + mileage += exchange.ExchangeCount + } + if mileage == 0 { + return nil + } + _, err := s.wallet.GrantMileageOnce(ctx, identity, mileage) + return err +} + +// The client converts Unix time to UTC+9 and applies GameData's +// 09:00:00 DailyResetTime. That boundary is exactly 00:00 UTC. Keeping the +// key independent of the host OS timezone prevents an early reset on a +// Chinese development machine. +func dailyResetKey(now time.Time) string { return now.UTC().Format("2006-01-02") } diff --git a/go/internal/server/gacha/gacha.go b/go/internal/server/domain/commerce/gacha/protocol_adapter.go similarity index 73% rename from go/internal/server/gacha/gacha.go rename to go/internal/server/domain/commerce/gacha/protocol_adapter.go index 48441b3..da3fb53 100644 --- a/go/internal/server/gacha/gacha.go +++ b/go/internal/server/domain/commerce/gacha/protocol_adapter.go @@ -1,112 +1,25 @@ -// Package gacha implements server-authoritative draws from verified GameData, -// including the starter reroll and scheduled daily-free batches. package gacha import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/protocol/wire" "crypto/sha256" "encoding/binary" "errors" "fmt" "slices" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" ) -func (s *Service) infiniteGrant() string { - return fmt.Sprintf("cash-product:%d:%d", s.design.ProductGroupID, s.design.ProductID) -} - -type Service struct { - design *gamedata.InfiniteGachaDesign - first *gamedata.FirstGachaDesign - regular *gamedata.RegularGachaCatalog - collection *player.CollectionStore - wallet *player.Wallet - inventory *player.Inventory - equipmentCatalog *gamedata.EquipmentGachaCatalog - equipmentInventory *player.EquipmentInventory - onPreview func() error - onDraw func(uint64) error - schedule *ScheduleSeed - previewEventIndex uint64 - sessionMu sync.RWMutex - sessionID string - firstMu sync.Mutex - firstPreviews map[string]firstGachaPreview - now func() time.Time -} - -func (s *Service) AttachDrawMission(callback func(uint64) error) { s.onDraw = callback } - -func (s *Service) AttachPreviewMission(callback func() error) { s.onPreview = callback } -func (s *Service) AttachInventory(inventory *player.Inventory) { s.inventory = inventory } -func (s *Service) AttachPreviewEventIndex(eventIndex uint64) error { - if eventIndex == 0 { - return errors.New("gacha: invalid preview event index") - } - s.previewEventIndex = eventIndex - return nil -} -func (s *Service) AttachSchedule(seed *ScheduleSeed) error { - if err := seed.Validate(seedVersion(seed)); err != nil { - return err - } - copy := *seed - copy.Schedules = append([]ScheduleWindow(nil), seed.Schedules...) - copy.StepUps = append([]ScheduleWindow(nil), seed.StepUps...) - s.schedule = © - return nil -} -func (s *Service) AttachEquipmentGacha(catalog *gamedata.EquipmentGachaCatalog, inventory *player.EquipmentInventory) { - s.equipmentCatalog, s.equipmentInventory = catalog, inventory -} - -func (s *Service) AttachFirstGacha(design *gamedata.FirstGachaDesign) error { - if design == nil || design.GachaID == 0 || design.Count == 0 || design.Group.GachaSubType != 3 { - return errors.New("gacha: invalid first gacha design") - } - s.first = design - s.firstMu.Lock() - s.firstPreviews = make(map[string]firstGachaPreview) - s.firstMu.Unlock() - return nil -} - -// FirstGachaCompleted reflects UserDBInfo.IsFirstGacha. The official symbol -// map names the client-side property IsDoneFirstGachaPick, and the client sets -// it after a GachaSubType=3 purchase succeeds. -func (s *Service) FirstGachaCompleted() bool { - return s.collection.FirstGachaCompleted() -} - -// BeginSession activates the transport's current login. The transport calls -// it before every authenticated request, not only when the login is created. -// Request sequence numbers restart with the client, so the login ID remains -// part of durable idempotency keys. In-memory first-gacha previews are already -// keyed by that ID and must survive activation: preview and confirmation are -// separate requests, and clearing here would discard the result immediately -// before the client confirms it. -func (s *Service) BeginSession(id string) { - s.sessionMu.Lock() - s.sessionID = id - s.sessionMu.Unlock() -} - -func NewService(design *gamedata.InfiniteGachaDesign, regular *gamedata.RegularGachaCatalog, collection *player.CollectionStore, wallet *player.Wallet) (*Service, error) { - if design == nil || regular == nil || collection == nil || wallet == nil { - return nil, errors.New("gacha: invalid service configuration") - } - return &Service{design: design, regular: regular, collection: collection, wallet: wallet, now: time.Now}, nil -} - -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { if path != "/GachaInfo" && path != "/GachaBuy" && path != "/GachaMultiBuy" && path != "/GachaPointExchange" && path != "/GachaPointManualExchange" && path != "/GachaBuyPreview" && path != "/GachaBuyPreviewLock" && path != "/GachaSelectionSave" && path != "/CashShopPurchaseCountInfo" && path != "/CashShopBuy" { return 0, nil, false, nil } + if ctx.SessionID == "" && path != "/GachaInfo" && path != "/CashShopPurchaseCountInfo" { + return 0, nil, true, fmt.Errorf("gacha: %s requires an authenticated session", path) + } seq, found, err := wire.Varint(request, 1) if err != nil || !found || seq == 0 { return 0, nil, true, fmt.Errorf("gacha: %s invalid sequence", path) @@ -115,27 +28,27 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) case "/GachaInfo": return 145, s.gachaInfo(), true, nil case "/GachaBuyPreview": - return s.preview(request) + return s.preview(ctx, request) case "/GachaBuyPreviewLock": - return s.lockPreview(request) + return s.lockPreview(ctx, request) case "/GachaBuy": - return s.buy(request, seq) + return s.buy(ctx, request, seq) case "/GachaMultiBuy": - return s.multiBuy(request, seq) + return s.multiBuy(ctx, request, seq) case "/GachaPointExchange": - return s.pointExchange(request, seq) + return s.pointExchange(ctx, request, seq) case "/GachaPointManualExchange": - return s.manualPointExchange(request, seq) + return s.manualPointExchange(ctx, request, seq) case "/GachaSelectionSave": - return s.saveSelection(request) + return s.saveSelection(ctx, request) case "/CashShopPurchaseCountInfo": var response []byte - for _, count := range s.PurchaseCountDBInfos() { + for _, count := range s.PurchaseCountDBInfos(ctx) { response = wire.AppendBytes(response, 1, count) } return 432, response, true, nil case "/CashShopBuy": - return s.confirm(request) + return s.confirm(ctx, request) default: return 0, nil, false, nil } @@ -144,7 +57,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) // PurchaseCountDBInfos returns the same persisted cash-product purchase state // used by /CashShopPurchaseCountInfo. LoginUser consumes this method too, so // both protocol surfaces remain consistent after a restart. -func (s *Service) PurchaseCountDBInfos() [][]byte { +func (s *Service) PurchaseCountDBInfos(ctx command.Context) [][]byte { products := []struct { group, id uint64 bought bool @@ -177,8 +90,8 @@ func (s *Service) PurchaseCountDBInfos() [][]byte { return result } -func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) { - var selections []player.GachaSelection +func (s *Service) saveSelection(ctx command.Context, request []byte) (int, []byte, bool, error) { + var selections []roster.GachaSelection if err := wire.Walk(request, func(field wire.Field) error { if field.Number != 2 { return nil @@ -198,7 +111,7 @@ func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) { if err != nil || !itemFound { return errors.New("gacha: selection item missing") } - selections = append(selections, player.GachaSelection{GroupID: groupID, Slot: slot, ItemID: itemID}) + selections = append(selections, roster.GachaSelection{GroupID: groupID, Slot: slot, ItemID: itemID}) return nil }); err != nil { return 198, nil, true, err @@ -237,18 +150,18 @@ func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) { seenSlots[selection.Slot] = true seenItems[selection.ItemID] = true } - if err := s.collection.SaveGachaSelections(groupID, selections, changeLimit); err != nil { + if err := s.collection.SaveGachaSelections(ctx, groupID, selections, changeLimit); err != nil { return 198, nil, true, err } return 198, nil, true, nil } -func (s *Service) lockPreview(request []byte) (int, []byte, bool, error) { +func (s *Service) lockPreview(ctx command.Context, request []byte) (int, []byte, bool, error) { eventIndex, found, err := wire.Varint(request, 2) if err != nil || !found || eventIndex == 0 { return 175, nil, true, errors.New("gacha: missing preview event index") } - if err := s.collection.LockPreview(eventIndex); err != nil { + if err := s.collection.LockPreview(ctx, eventIndex); err != nil { return 175, nil, true, err } // no separate PacketCodeTypeProto member for the lock RPC. @@ -257,7 +170,7 @@ func (s *Service) lockPreview(request []byte) (int, []byte, bool, error) { return 175, nil, true, nil } -func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error) { +func (s *Service) multiBuy(ctx command.Context, request []byte, seq uint64) (int, []byte, bool, error) { const maxBatchSize = 256 var ids []uint64 appendID := func(id uint64) error { @@ -299,13 +212,13 @@ func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error if len(ids) == 0 { return 146, nil, true, errors.New("gacha: empty daily free batch") } - identity := fmt.Sprintf("multi-gacha:session:%s:seq:%d", s.loginIdentity(), seq) + identity := fmt.Sprintf("multi-gacha:session:%s:seq:%d", s.loginIdentity(ctx), seq) var canonical []byte for _, id := range ids { canonical = binary.AppendUvarint(canonical, id) } digest := fmt.Sprintf("%x", sha256.Sum256(canonical)) - if response, found, err := s.collection.GachaBatchResponse(identity, digest); found || err != nil { + if response, found, err := s.collection.GachaBatchResponse(ctx, identity, digest); found || err != nil { return 146, response, true, err } // Validate the complete batch before a single allowance or reward changes. @@ -348,7 +261,7 @@ func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error for slot, id := range ids { subrequest := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id) slotIdentity := fmt.Sprintf("%s:slot:%d:id:%d", identity, slot, id) - _, single, _, err := s.buyWithIdentity(subrequest, seq, slotIdentity) + _, single, _, err := s.buyWithIdentity(ctx, subrequest, seq, slotIdentity) if err != nil { return 146, nil, true, fmt.Errorf("gacha: daily free batch slot %d: %w", slot, err) } @@ -373,38 +286,17 @@ func (s *Service) multiBuy(request []byte, seq uint64) (int, []byte, bool, error } response = wire.AppendBytes(response, 1, result) } - if err := s.collection.RecordGachaBatch(identity, digest, response); err != nil { + if err := s.collection.RecordGachaBatch(ctx, identity, digest, response); err != nil { return 146, nil, true, err } return 146, response, true, nil } -func (s *Service) dailyFreeAllowance(groupID, base uint64) (uint64, bool, error) { - if s.schedule == nil { - return 0, false, nil - } - now := s.now().UnixMilli() - for _, entry := range s.schedule.Schedules { - if entry.GroupID != groupID || now < 0 || uint64(now) < entry.StartTime || uint64(now) > entry.EndTime { - continue - } - if entry.FreeCountBonus { - bonus := s.regular.GachaEventAddFreeCount() - if ^uint64(0)-base < bonus { - return 0, false, errors.New("gacha: daily free allowance overflows") - } - base += bonus - } - return base, true, nil - } - return 0, false, nil +func (s *Service) buy(ctx command.Context, request []byte, seq uint64) (int, []byte, bool, error) { + return s.buyWithIdentity(ctx, request, seq, "") } -func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) { - return s.buyWithIdentity(request, seq, "") -} - -func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (int, []byte, bool, error) { +func (s *Service) buyWithIdentity(ctx command.Context, request []byte, seq uint64, identity string) (int, []byte, bool, error) { id, found, err := wire.Varint(request, 2) if err != nil || !found || id == 0 { return 146, nil, true, errors.New("gacha: missing gacha id") @@ -413,7 +305,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( if err != nil || buyType > 3 { return 146, nil, true, fmt.Errorf("gacha: unsupported buy type %d", buyType) } - var tickets []player.Item + var tickets []assets.Item if err := wire.Walk(request, func(field wire.Field) error { if field.Number != 4 { return nil @@ -421,7 +313,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( if field.Type != 2 { return errors.New("gacha: invalid ticket info") } - var item player.Item + var item assets.Item var found bool var parseErr error if item.InvenIndex, found, parseErr = wire.Varint(field.Value, 1); parseErr != nil || !found { @@ -448,12 +340,12 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( return 146, nil, true, err } if s.first != nil && id == s.first.GachaID { - return s.confirmFirstGacha(request, seq, buyType, tickets, identity) + return s.confirmFirstGacha(ctx, request, seq, buyType, tickets, identity) } design, ok := s.regular.Gacha(id) if !ok && s.equipmentCatalog != nil { if equipment, found := s.equipmentCatalog.Gacha(id); found { - return s.buyEquipmentWithIdentity(seq, buyType, tickets, equipment, identity) + return s.buyEquipmentWithIdentity(ctx, seq, buyType, tickets, equipment, identity) } } if !ok { @@ -494,7 +386,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( } } if identity == "" { - identity = s.requestIdentity(id, seq) + identity = s.requestIdentity(ctx, id, seq) } grant, already := s.collection.Grant(identity) if isMoonrise && !already { @@ -542,7 +434,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( return 146, nil, true, errors.New("gacha: invalid ticket count") } var roll []uint64 - var fixedStates []player.GachaFixedState + var fixedStates []roster.GachaFixedState var selectionApplySortIDs []uint64 if isMoonrise { roll, err = design.RollSpecialSelection(moonriseSelected) @@ -585,16 +477,16 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( if s.inventory == nil { return 146, nil, true, errors.New("gacha: inventory not attached") } - if err := s.inventory.CanConsume(tickets); err != nil { + if err := s.inventory.CanConsume(ctx, tickets); err != nil { return 146, nil, true, err } } - var moonriseCost []player.Item + var moonriseCost []assets.Item if isMoonrise { if s.inventory == nil { return 146, nil, true, errors.New("gacha: inventory not attached") } - moonriseCost, err = s.inventory.SelectMutable(design.PriceType, design.PriceID, design.Price) + moonriseCost, err = s.inventory.SelectMutable(ctx, design.PriceType, design.PriceID, design.Price) if err != nil { return 146, nil, true, err } @@ -610,7 +502,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( chargePaid = design.PriceType == 2 } if charge != 0 { - currency := s.wallet.Snapshot() + currency := s.wallet.Snapshot(ctx) if chargePaid && currency.Jewelry < charge { return 146, nil, true, errors.New("gacha: insufficient paid jewelry") } @@ -619,7 +511,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( } } if len(tickets) != 0 { - if err := s.inventory.Consume(tickets); err != nil { + if err := s.inventory.Consume(ctx, tickets); err != nil { return 146, nil, true, err } } @@ -627,27 +519,27 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( case isDailyFree: // The daily allowance is the complete cost. case isDailyPaid: - if _, err := s.wallet.SpendJewelryOnce(identity, charge); err != nil { + if _, err := s.wallet.SpendJewelryOnce(ctx, identity, charge); err != nil { return 146, nil, true, err } case isMoonrise: - if err := s.inventory.Consume(moonriseCost); err != nil { + if err := s.inventory.Consume(ctx, moonriseCost); err != nil { return 146, nil, true, err } default: if charge != 0 { if chargePaid { - if _, err := s.wallet.SpendJewelryOnce(identity, charge); err != nil { + if _, err := s.wallet.SpendJewelryOnce(ctx, identity, charge); err != nil { return 146, nil, true, err } } else { - if _, err := s.wallet.SpendFreeJewelryOnce(identity, charge); err != nil { + if _, err := s.wallet.SpendFreeJewelryOnce(ctx, identity, charge); err != nil { return 146, nil, true, err } } } } - purchase := player.GachaPurchase{Group: group, BuyType: buyType, Fixed: fixedStates, SelectionApplySortIDs: selectionApplySortIDs} + purchase := roster.GachaPurchase{Group: group, BuyType: buyType, Fixed: fixedStates, SelectionApplySortIDs: selectionApplySortIDs} if isMoonrise { purchase.CompletionGrant = completionGrant } @@ -659,12 +551,12 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( purchase.StepUpGroupID = stepUpGroupID purchase.StepUpStep = stepDesign.Step } - grant, err = s.collection.GrantRegularPurchase(identity, roll, s.regular, purchase) + grant, err = s.collection.GrantRegularPurchase(ctx, identity, roll, s.regular, purchase) if err != nil { return 146, nil, true, err } } - if err := s.creditOverflow(identity, grant); err != nil { + if err := s.creditOverflow(ctx, identity, grant); err != nil { return 146, nil, true, err } response := wire.AppendBytes(nil, 1, s.rewardBundle(grant)) @@ -676,32 +568,17 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) ( response = wire.AppendVarint(response, 4, sortID) } if !already && s.onDraw != nil { - if err := s.onDraw(uint64(design.Count)); err != nil { + if err := s.onDraw(ctx, uint64(design.Count)); err != nil { return 146, nil, true, fmt.Errorf("gacha: draw mission: %w", err) } } return 146, response, true, nil } -func (s *Service) stepForGacha(gachaID uint64) (uint64, gamedata.GachaStepDesign, bool) { - step, ok := s.regular.StepForGacha(gachaID) - if !ok { - return 0, gamedata.GachaStepDesign{}, false - } - for _, group := range s.regular.StepUps() { - for _, candidate := range group.Steps { - if candidate.GachaID == gachaID && candidate == step { - return group.ID, step, true - } - } - } - return 0, gamedata.GachaStepDesign{}, false -} - // Scheduled equipment draws update GachaUser/GachaFixed accounting. Standalone // ticket-only draws have no schedule group and persist only their idempotency // marker plus the generated equipment instances. -func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player.Item, design gamedata.EquipmentGacha, identity string) (int, []byte, bool, error) { +func (s *Service) buyEquipmentWithIdentity(ctx command.Context, seq, buyType uint64, tickets []assets.Item, design gamedata.EquipmentGacha, identity string) (int, []byte, bool, error) { if s.equipmentInventory == nil { return 146, nil, true, errors.New("gacha: equipment inventory not attached") } @@ -738,10 +615,10 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player } } if identity == "" { - identity = s.requestIdentity(design.ID, seq) + identity = s.requestIdentity(ctx, design.ID, seq) } grant, already := s.collection.Grant(identity) - var entries []player.Equipment + var entries []assets.Equipment if already { for sort := 0; sort < design.Count; sort++ { entry, found := s.equipmentInventory.Granted(fmt.Sprintf("%s:equip:%d", identity, sort)) @@ -765,7 +642,7 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player if s.inventory == nil { return 146, nil, true, errors.New("gacha: inventory not attached") } - if err := s.inventory.Consume(tickets); err != nil { + if err := s.inventory.Consume(ctx, tickets); err != nil { return 146, nil, true, err } } @@ -773,7 +650,7 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player if design.TicketOnly { return 146, nil, true, errors.New("gacha: ticket-only equipment draw cannot use diamonds") } - if _, err := s.wallet.SpendFreeJewelryOnce(identity, design.Price/uint64(design.Count)*remain); err != nil { + if _, err := s.wallet.SpendFreeJewelryOnce(ctx, identity, design.Price/uint64(design.Count)*remain); err != nil { return 146, nil, true, err } } @@ -795,9 +672,9 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player if err != nil { return 146, nil, true, err } - var fixedStates []player.GachaFixedState + var fixedStates []roster.GachaFixedState if !design.TicketOnly { - fixedStates = []player.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}} + fixedStates = []roster.GachaFixedState{{FixedID: fixed.ID, Type: 2, Count: state.SRCount, ApplySort: state.SRSort}, {FixedID: fixed.ID, Type: 3, Count: state.URCount, ApplySort: state.URSort}} fixedStates = s.completeSharedFixedStates(fixed.ID, fixedStates) } for sort, equipmentID := range roll { @@ -805,30 +682,30 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player if err != nil { return 146, nil, true, err } - entry := player.Equipment{ID: equipmentID, SortID: uint64(sort), Rank: []uint64{0, 0, 0}} + entry := assets.Equipment{ID: equipmentID, SortID: uint64(sort), Rank: []uint64{0, 0, 0}} for _, o := range main { - entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + entry.MainOption = append(entry.MainOption, assets.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) } for _, o := range sub { - entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + entry.SubOption = append(entry.SubOption, assets.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) } if private != nil { - entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID} + entry.PrivateOption = &assets.EquipmentOption{GroupID: private.GroupID, ID: private.ID} } - saved, err := s.equipmentInventory.GrantGeneratedOnce(fmt.Sprintf("%s:equip:%d", identity, sort), entry) + saved, err := s.equipmentInventory.GrantGeneratedOnce(ctx, fmt.Sprintf("%s:equip:%d", identity, sort), entry) if err != nil { return 146, nil, true, err } entries = append(entries, saved) } if design.TicketOnly { - grant, err = s.collection.GrantEquipmentDraw(identity) + grant, err = s.collection.GrantEquipmentDraw(ctx, identity) } else { - purchase := player.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates} + purchase := roster.GachaPurchase{Group: gamedata.GachaGroupDesign{ID: group.ID, PointCount: group.PointCount}, BuyType: buyType, Fixed: fixedStates} if isDailyFree { purchase.DailyKey, purchase.DailyLimit = dailyResetKey(s.now()), freeDailyLimit } - grant, err = s.collection.GrantEquipmentPurchase(identity, uint64(len(entries)), purchase) + grant, err = s.collection.GrantEquipmentPurchase(ctx, identity, uint64(len(entries)), purchase) } if err != nil { return 146, nil, true, err @@ -836,7 +713,7 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player } bundle := []byte{} for _, entry := range entries { - bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry)) + bundle = wire.AppendBytes(bundle, 4, assets.EquipmentWire(entry)) } response := wire.AppendBytes(nil, 1, bundle) response = wire.AppendVarint(response, 2, grant.GachaPoint) @@ -844,14 +721,14 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player response = wire.AppendBytes(response, 3, gachaFixedWire(fixed)) } if !already && s.onDraw != nil { - if err := s.onDraw(uint64(design.Count)); err != nil { + if err := s.onDraw(ctx, uint64(design.Count)); err != nil { return 146, nil, true, fmt.Errorf("gacha: equipment draw mission: %w", err) } } return 146, response, true, nil } -func gachaFixedWire(fixed player.GachaFixedState) []byte { +func gachaFixedWire(fixed roster.GachaFixedState) []byte { info := wire.AppendVarint(nil, 1, fixed.FixedID) info = wire.AppendVarint(info, 2, fixed.Type) info = wire.AppendVarint(info, 3, fixed.Count) @@ -860,52 +737,7 @@ func gachaFixedWire(fixed player.GachaFixedState) []byte { return wire.AppendVarint(info, 4, uint64(int64(fixed.ApplySort))) } -func costumeFixedStates(fixed gamedata.GachaFixedDesign, result gamedata.GachaFixedRoll) []player.GachaFixedState { - states := make([]player.GachaFixedState, 0, 2) - if fixed.CostumeGrade4Count != 0 { - states = append(states, player.GachaFixedState{ - FixedID: fixed.ID, Type: 0, Count: result.CostumeGrade4Count, ApplySort: result.CostumeGrade4Sort, - }) - } - if fixed.CostumeGrade5Count != 0 { - states = append(states, player.GachaFixedState{ - FixedID: fixed.ID, Type: 1, Count: result.CostumeGrade5Count, ApplySort: result.CostumeGrade5Sort, - }) - } - return states -} - -func (s *Service) completeSharedFixedStates(fixedID uint64, updates []player.GachaFixedState) []player.GachaFixedState { - if fixedID == 0 || s.equipmentCatalog == nil { - return updates - } - known := false - for _, fixed := range s.equipmentCatalog.FixedDesigns() { - if fixed.ID == fixedID { - known = true - break - } - } - if !known { - return updates - } - states := make([]player.GachaFixedState, 4) - for fixedType := range states { - states[fixedType] = player.GachaFixedState{ - FixedID: fixedID, Type: uint64(fixedType), - Count: s.collection.GachaFixedCount(fixedID, uint64(fixedType)), ApplySort: -1, - } - } - for _, update := range updates { - if update.FixedID != fixedID || update.Type >= uint64(len(states)) { - return updates - } - states[update.Type] = update - } - return states -} - -func (s *Service) manualPointExchange(request []byte, seq uint64) (int, []byte, bool, error) { +func (s *Service) manualPointExchange(ctx command.Context, request []byte, seq uint64) (int, []byte, bool, error) { groupID, found, err := wire.Varint(request, 2) if err != nil || !found || groupID == 0 { return 188, nil, true, errors.New("gacha: missing point exchange group") @@ -918,12 +750,12 @@ func (s *Service) manualPointExchange(request []byte, seq uint64) (int, []byte, if !ok || group.PointCount == 0 { return 188, nil, true, fmt.Errorf("gacha: group %d does not support point exchange", groupID) } - identity := fmt.Sprintf("gacha-point-manual:%s:%d:seq:%d", s.loginIdentity(), groupID, seq) - exchange, err := s.collection.ExchangeGachaPoint(identity, groupID, count) + identity := fmt.Sprintf("gacha-point-manual:%s:%d:seq:%d", s.loginIdentity(ctx), groupID, seq) + exchange, err := s.collection.ExchangeGachaPoint(ctx, identity, groupID, count) if err != nil { return 188, nil, true, err } - if _, err := s.wallet.GrantHopePowderOnce(identity, exchange.Count); err != nil { + if _, err := s.wallet.GrantHopePowderOnce(ctx, identity, exchange.Count); err != nil { return 188, nil, true, err } // Manual conversion grants account currency type 22, not a stackable @@ -934,7 +766,7 @@ func (s *Service) manualPointExchange(request []byte, seq uint64) (int, []byte, return 188, wire.AppendBytes(nil, 1, bundle), true, nil } -func (s *Service) pointExchange(request []byte, seq uint64) (int, []byte, bool, error) { +func (s *Service) pointExchange(ctx command.Context, request []byte, seq uint64) (int, []byte, bool, error) { groupID, found, err := wire.Varint(request, 2) if err != nil || !found || groupID == 0 { return 147, nil, true, errors.New("gacha: missing point exchange group") @@ -945,7 +777,7 @@ func (s *Service) pointExchange(request []byte, seq uint64) (int, []byte, bool, } if s.equipmentCatalog != nil { if group, ok := s.equipmentCatalog.Group(groupID); ok { - return s.pointExchangeEquipment(group, selectedItemID, seq) + return s.pointExchangeEquipment(ctx, group, selectedItemID, seq) } } group, ok := s.regular.Group(groupID) @@ -966,18 +798,18 @@ func (s *Service) pointExchange(request []byte, seq uint64) (int, []byte, bool, if _, ok := s.regular.Character(costumeID); !ok { return 147, nil, true, fmt.Errorf("gacha: pickup costume %d has no character design", costumeID) } - identity := fmt.Sprintf("gacha-point-costume:%s:%d:%d:seq:%d", s.loginIdentity(), groupID, costumeID, seq) - grant, err := s.collection.GrantGachaPointCostume(identity, group, costumeID, s.regular) + identity := fmt.Sprintf("gacha-point-costume:%s:%d:%d:seq:%d", s.loginIdentity(ctx), groupID, costumeID, seq) + grant, err := s.collection.GrantGachaPointCostume(ctx, identity, group, costumeID, s.regular) if err != nil { return 147, nil, true, err } - if err := s.creditOverflow(identity, grant); err != nil { + if err := s.creditOverflow(ctx, identity, grant); err != nil { return 147, nil, true, err } return 147, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil } -func (s *Service) pointExchangeEquipment(group gamedata.EquipmentGachaGroup, selectedItemID, seq uint64) (int, []byte, bool, error) { +func (s *Service) pointExchangeEquipment(ctx command.Context, group gamedata.EquipmentGachaGroup, selectedItemID, seq uint64) (int, []byte, bool, error) { if s.equipmentInventory == nil { return 147, nil, true, errors.New("gacha: equipment inventory not attached") } @@ -990,14 +822,14 @@ func (s *Service) pointExchangeEquipment(group gamedata.EquipmentGachaGroup, sel if selectedItemID != 0 { return 147, nil, true, fmt.Errorf("gacha: equipment group %d does not support pickup selection", group.ID) } - identity := fmt.Sprintf("gacha-point-equipment:%s:%d:%d:seq:%d", s.loginIdentity(), group.ID, group.PickUpItemID, seq) + identity := fmt.Sprintf("gacha-point-equipment:%s:%d:%d:seq:%d", s.loginIdentity(ctx), group.ID, group.PickUpItemID, seq) equipmentIdentity := identity + ":equip" if _, already := s.collection.Grant(identity); already { entry, found := s.equipmentInventory.Granted(equipmentIdentity) if !found { return 147, nil, true, fmt.Errorf("gacha: equipment exchange retry %s is missing its instance", identity) } - bundle := wire.AppendBytes(nil, 4, player.EquipmentWire(entry)) + bundle := wire.AppendBytes(nil, 4, assets.EquipmentWire(entry)) return 147, wire.AppendBytes(nil, 1, bundle), true, nil } if s.collection.GachaUser(group.ID).Point < group.PickUpExchangeCost { @@ -1007,55 +839,34 @@ func (s *Service) pointExchangeEquipment(group gamedata.EquipmentGachaGroup, sel if err != nil { return 147, nil, true, err } - entry := player.Equipment{ID: group.PickUpItemID, Rank: []uint64{0, 0, 0}} + entry := assets.Equipment{ID: group.PickUpItemID, Rank: []uint64{0, 0, 0}} for _, option := range main { - entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + entry.MainOption = append(entry.MainOption, assets.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) } for _, option := range sub { - entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + entry.SubOption = append(entry.SubOption, assets.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) } if private != nil { - entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID} + entry.PrivateOption = &assets.EquipmentOption{GroupID: private.GroupID, ID: private.ID} } - saved, err := s.equipmentInventory.GrantGeneratedOnce(equipmentIdentity, entry) + saved, err := s.equipmentInventory.GrantGeneratedOnce(ctx, equipmentIdentity, entry) if err != nil { return 147, nil, true, err } - if _, err := s.collection.GrantGachaPointEquipment(identity, group.ID, group.PickUpExchangeCost); err != nil { + if _, err := s.collection.GrantGachaPointEquipment(ctx, identity, group.ID, group.PickUpExchangeCost); err != nil { return 147, nil, true, err } - bundle := wire.AppendBytes(nil, 4, player.EquipmentWire(saved)) + bundle := wire.AppendBytes(nil, 4, assets.EquipmentWire(saved)) return 147, wire.AppendBytes(nil, 1, bundle), true, nil } -func (s *Service) loginIdentity() string { - s.sessionMu.RLock() - defer s.sessionMu.RUnlock() - if s.sessionID == "" { - return "local" - } - return s.sessionID -} - -func (s *Service) requestIdentity(gachaID, seq uint64) string { - s.sessionMu.RLock() - sessionID := s.sessionID - s.sessionMu.RUnlock() - if sessionID == "" { - // Unit tests and embedders without a session coordinator retain the - // original stable key. Production always supplies BeginSession. - return fmt.Sprintf("regular-gacha:%d:seq:%d", gachaID, seq) - } - return fmt.Sprintf("regular-gacha:%d:session:%s:seq:%d", gachaID, sessionID, seq) -} - -func (s *Service) preview(request []byte) (int, []byte, bool, error) { +func (s *Service) preview(ctx command.Context, request []byte) (int, []byte, bool, error) { id, found, err := wire.Varint(request, 2) if err != nil || !found { return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id) } if s.first != nil && id == s.first.GachaID { - return s.previewFirstGacha(request) + return s.previewFirstGacha(ctx, request) } if _, bought := s.collection.Grant(s.infiniteGrant()); bought { return 175, nil, true, errors.New("gacha: infinite product already purchased") @@ -1078,11 +889,11 @@ func (s *Service) preview(request []byte) (int, []byte, bool, error) { if err != nil { return 0, nil, true, err } - if err := s.collection.SetPreview(s.previewEventIndex, roll); err != nil { + if err := s.collection.SetPreview(ctx, s.previewEventIndex, roll); err != nil { return 0, nil, true, err } if s.onPreview != nil { - if err := s.onPreview(); err != nil { + if err := s.onPreview(ctx); err != nil { return 0, nil, true, fmt.Errorf("gacha: update preview mission: %w", err) } } @@ -1097,7 +908,7 @@ func (s *Service) preview(request []byte) (int, []byte, bool, error) { return 175, wire.AppendBytes(nil, 1, bundle), true, nil } -func (s *Service) confirm(request []byte) (int, []byte, bool, error) { +func (s *Service) confirm(ctx command.Context, request []byte) (int, []byte, bool, error) { group, found, err := wire.Varint(request, 3) if err != nil || !found { return 0, nil, true, fmt.Errorf("gacha: unsupported cash product group %d", group) @@ -1143,11 +954,11 @@ func (s *Service) confirm(request []byte) (int, []byte, bool, error) { if s.inventory == nil { return 61, nil, true, errors.New("gacha: inventory not attached") } - items, err := s.inventory.GrantOnce(ticketGrant, cashGroup.CashRewards) + items, err := s.inventory.GrantOnce(ctx, ticketGrant, cashGroup.CashRewards) if err != nil { return 61, nil, true, err } - if err := s.collection.RecordGrantMarker(productGrant); err != nil { + if err := s.collection.RecordGrantMarker(ctx, productGrant); err != nil { return 61, nil, true, err } if len(items) == 0 { @@ -1155,53 +966,35 @@ func (s *Service) confirm(request []byte) (int, []byte, bool, error) { } var bundle []byte for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) } return 61, wire.AppendBytes(nil, 1, bundle), true, nil } if group != s.design.ProductGroupID || productID != s.design.ProductID || saleGroup != s.design.SaleGroup { return 0, nil, true, fmt.Errorf("gacha: unsupported cash product group=%d product=%d", group, productID) } - grant, err := s.collection.ConfirmInfinite(s.infiniteGrant(), s.design) + grant, err := s.collection.ConfirmInfinite(ctx, s.infiniteGrant(), s.design) if err != nil { return 0, nil, true, err } - if err := s.creditOverflow(s.infiniteGrant(), grant); err != nil { + if err := s.creditOverflow(ctx, s.infiniteGrant(), grant); err != nil { return 61, nil, true, err } return 61, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil } -func (s *Service) creditOverflow(identity string, grant player.CollectionGrant) error { - var mileage uint64 - for _, exchange := range grant.Exchanges { - if exchange.ExchangeItemType != 20 { - return fmt.Errorf("gacha: unsupported costume overflow type %d", exchange.ExchangeItemType) - } - if ^uint64(0)-mileage < exchange.ExchangeCount { - return errors.New("gacha: costume overflow mileage overflow") - } - mileage += exchange.ExchangeCount - } - if mileage == 0 { - return nil - } - _, err := s.wallet.GrantMileageOnce(identity, mileage) - return err -} - -func (s *Service) rewardBundle(grant player.CollectionGrant) []byte { +func (s *Service) rewardBundle(grant roster.CollectionGrant) []byte { var bundle []byte - newCharacters := make(map[uint64]player.Character, len(grant.CharacterIndices)) + newCharacters := make(map[uint64]roster.Character, len(grant.CharacterIndices)) for _, index := range grant.CharacterIndices { if character, found := s.collection.CharacterByIndex(index); found { newCharacters[character.ConnectPotentialCostume] = character - bundle = wire.AppendBytes(bundle, 2, player.CharacterWire(character)) + bundle = wire.AppendBytes(bundle, 2, roster.CharacterWire(character)) } } for _, index := range grant.CostumeIndices { if costume, found := s.collection.CostumeByIndex(index); found { - bundle = wire.AppendBytes(bundle, 3, player.CostumeWire(costume)) + bundle = wire.AppendBytes(bundle, 3, roster.CostumeWire(costume)) } } for position, costumeID := range grant.ViewCostumeIDs { @@ -1354,8 +1147,133 @@ func (s *Service) gachaInfo() []byte { return out } -// The client converts Unix time to UTC+9 and applies GameData's -// 09:00:00 DailyResetTime. That boundary is exactly 00:00 UTC. Keeping the -// key independent of the host OS timezone prevents an early reset on a -// Chinese development machine. -func dailyResetKey(now time.Time) string { return now.UTC().Format("2006-01-02") } +func (s *Service) previewFirstGacha(ctx command.Context, request []byte) (int, []byte, bool, error) { + if s.first == nil { + return 175, nil, true, errors.New("gacha: first gacha is not configured") + } + if s.FirstGachaCompleted() { + return 175, nil, true, errors.New("gacha: first gacha already completed") + } + for _, field := range []int{3, 4} { + value, _, err := wire.Varint(request, field) + if err != nil || value != 0 { + return 175, nil, true, errors.New("gacha: first gacha preview cannot use a cash product") + } + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 175, nil, true, errors.New("gacha: invalid first gacha preview sequence") + } + session := s.loginIdentity(ctx) + + if previous, found := s.firstPreviews[session]; found && previous.sequence == seq { + return 175, append([]byte(nil), previous.response...), true, nil + } + rewards, err := s.first.Roll() + if err != nil { + return 175, nil, true, err + } + preview := firstGachaPreview{sequence: seq, rewards: append([]gamedata.FirstGachaReward(nil), rewards...), equipment: make(map[int]assets.Equipment)} + var bundle []byte + for sortID, reward := range rewards { + switch reward.Type { + case 11: + costume := wire.AppendVarint(nil, 2, reward.ID) + if sortID != 0 { + costume = wire.AppendVarint(costume, 6, uint64(sortID)) + } + bundle = wire.AppendBytes(bundle, 3, costume) + case 10: + entry, err := firstGachaEquipment(s.first.EquipmentCatalog(), reward.ID, uint64(sortID)) + if err != nil { + return 175, nil, true, err + } + preview.equipment[sortID] = entry + bundle = wire.AppendBytes(bundle, 4, assets.EquipmentWire(entry)) + default: + return 175, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type) + } + } + preview.response = wire.AppendBytes(nil, 1, bundle) + s.firstPreviews[session] = preview + s.transientVersion++ + if s.onPreview != nil { + if err := s.onPreview(ctx); err != nil { + delete(s.firstPreviews, session) + s.transientVersion++ + return 175, nil, true, fmt.Errorf("gacha: update first preview mission: %w", err) + } + } + return 175, append([]byte(nil), preview.response...), true, nil +} + +func (s *Service) confirmFirstGacha(ctx command.Context, _ []byte, seq, buyType uint64, tickets []assets.Item, explicitIdentity string) (int, []byte, bool, error) { + if s.first == nil || s.equipmentInventory == nil { + return 146, nil, true, errors.New("gacha: first gacha runtime is not attached") + } + if buyType != 1 || len(tickets) != 0 { + return 146, nil, true, errors.New("gacha: first gacha requires normal free confirmation") + } + identity := explicitIdentity + if identity == "" { + identity = s.requestIdentity(ctx, s.first.GachaID, seq) + } + responseIdentity := identity + ":response" + if response, found, err := s.collection.GachaBatchResponse(ctx, responseIdentity, firstGachaResponseDigest); found || err != nil { + return 146, response, true, err + } + if s.FirstGachaCompleted() { + return 146, nil, true, errors.New("gacha: first gacha already completed") + } + session := s.loginIdentity(ctx) + + preview, found := s.firstPreviews[session] + + if !found || len(preview.rewards) != s.first.Count { + return 146, nil, true, errors.New("gacha: first gacha confirmation requires a preview") + } + var costumeIDs, costumeSortIDs []uint64 + var equipment []assets.Equipment + for sortID, reward := range preview.rewards { + switch reward.Type { + case 11: + costumeIDs = append(costumeIDs, reward.ID) + costumeSortIDs = append(costumeSortIDs, uint64(sortID)) + case 10: + candidate, ok := preview.equipment[sortID] + if !ok || candidate.ID != reward.ID { + return 146, nil, true, fmt.Errorf("gacha: first preview missing equipment slot %d", sortID) + } + saved, err := s.equipmentInventory.GrantGeneratedOnce(ctx, fmt.Sprintf("%s:first-equip:%d", identity, sortID), candidate) + if err != nil { + return 146, nil, true, err + } + equipment = append(equipment, saved) + default: + return 146, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type) + } + } + grant, err := s.collection.GrantRegularPurchase(ctx, identity, costumeIDs, s.first.CostumeCatalog(), roster.GachaPurchase{ + Group: s.first.Group, BuyType: 1, RewardCount: uint64(s.first.Count), RewardSortIDs: costumeSortIDs, + }) + if err != nil { + return 146, nil, true, err + } + if err := s.creditOverflow(ctx, identity, grant); err != nil { + return 146, nil, true, err + } + bundle := s.rewardBundle(grant) + for _, entry := range equipment { + bundle = wire.AppendBytes(bundle, 4, assets.EquipmentWire(entry)) + } + response := wire.AppendBytes(nil, 1, bundle) + if err := s.collection.RecordGachaBatch(ctx, responseIdentity, firstGachaResponseDigest, response); err != nil { + return 146, nil, true, err + } + if s.onPreview != nil { + if err := s.onPreview(ctx); err != nil { + return 146, nil, true, fmt.Errorf("gacha: update first gacha mission: %w", err) + } + } + return 146, response, true, nil +} diff --git a/go/internal/server/gacha/schedule.go b/go/internal/server/domain/commerce/gacha/schedule.go similarity index 98% rename from go/internal/server/gacha/schedule.go rename to go/internal/server/domain/commerce/gacha/schedule.go index d01c33f..049a386 100644 --- a/go/internal/server/gacha/schedule.go +++ b/go/internal/server/domain/commerce/gacha/schedule.go @@ -4,7 +4,7 @@ import ( "fmt" "math" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" ) // ScheduleSeed contains server-owned, versioned dynamic gacha facts. GameData diff --git a/go/internal/server/domain/commerce/login_pass.go b/go/internal/server/domain/commerce/login_pass.go new file mode 100644 index 0000000..12ce34c --- /dev/null +++ b/go/internal/server/domain/commerce/login_pass.go @@ -0,0 +1,74 @@ +package commerce + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + + "time" +) + +type loginPassProgress struct { + Free, Premium uint64 + LastDay string +} +type loginPassReceipt struct { + Bundle []byte + Rewarded map[uint64]bool +} +type loginPassState struct { + Progress map[uint64]loginPassProgress + Receipts map[string]loginPassReceipt +} +type LoginPasses struct { + store stateio.Store + design *gamedata.LoginPassCatalog + economy Economy + items ClearPackageInventory + available func(ctx command.Context, _ uint64) bool + now func() time.Time + resetSeconds int64 +} + +func NewLoginPasses(ctx command.Context, store stateio.Store, design *gamedata.LoginPassCatalog, economy Economy, items ClearPackageInventory, available func(ctx command.Context, _ uint64) bool) (*LoginPasses, error) { + if store == nil || design == nil || economy == nil || items == nil || available == nil { + return nil, fmt.Errorf("commerce: invalid login-pass dependencies") + } + s := &LoginPasses{store: store, design: design, economy: economy, items: items, available: available, now: time.Now} + _, err := s.load(ctx) + return s, err +} +func (s *LoginPasses) SetClock(now func() time.Time, resetSeconds int64) { + s.now = now + s.resetSeconds = resetSeconds +} +func (s *LoginPasses) load(ctx command.Context) (loginPassState, error) { + v := loginPassState{Progress: map[uint64]loginPassProgress{}, Receipts: map[string]loginPassReceipt{}} + raw, err := s.store.Load(ctx.State, "commerce_login_passes") + if err != nil || raw == nil { + return v, err + } + if err = json.Unmarshal(raw, &v); err != nil { + return v, err + } + if v.Progress == nil || v.Receipts == nil { + return v, fmt.Errorf("commerce: invalid login-pass state") + } + for group, p := range v.Progress { + rows, ok := s.design.Groups[group] + if !ok || p.Free > uint64(len(rows)) || p.Premium > p.Free || p.Free > 0 && p.LastDay == "" { + return v, fmt.Errorf("commerce: invalid login-pass progress") + } + } + return v, nil +} +func (s *LoginPasses) paid(ctx command.Context, ticket uint64) bool { + for _, i := range s.items.All(ctx) { + if i.Type == 19 && i.ID == ticket && i.Count > 0 && (i.ExpiryTime == 0 || i.ExpiryTime > uint64(s.now().UnixMilli())) { + return true + } + } + return false +} diff --git a/go/internal/server/commerce/native_products.go b/go/internal/server/domain/commerce/native_products.go similarity index 76% rename from go/internal/server/commerce/native_products.go rename to go/internal/server/domain/commerce/native_products.go index c9fac79..8a6a229 100644 --- a/go/internal/server/commerce/native_products.go +++ b/go/internal/server/domain/commerce/native_products.go @@ -4,7 +4,8 @@ import ( "fmt" "math" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" ) // Native prices belong to GameData; cash quotes remain server policy. @@ -22,7 +23,7 @@ func nativePrice(d gamedata.CashProductDesign, count uint64) (uint64, error) { } type nativeCostResolver interface { - NativePurchaseCosts(gamedata.Reward) ([]gamedata.Reward, error) + NativePurchaseCosts(ctx command.Context, _ gamedata.Reward) ([]gamedata.Reward, error) } // AttachSpecialProducts reserves exact keys whose rewards depend on a prior @@ -39,9 +40,9 @@ func (s *Service) AttachSpecialProducts(keys []gamedata.CashProductKey) error { return nil } -func (e *EntitlementEconomy) NativePurchaseCosts(cost gamedata.Reward) ([]gamedata.Reward, error) { +func (e *EntitlementEconomy) NativePurchaseCosts(ctx command.Context, cost gamedata.Reward) ([]gamedata.Reward, error) { if resolver, ok := e.base.(nativeCostResolver); ok { - return resolver.NativePurchaseCosts(cost) + return resolver.NativePurchaseCosts(ctx, cost) } return []gamedata.Reward{cost}, nil } diff --git a/go/internal/server/domain/commerce/npcinn/inn.go b/go/internal/server/domain/commerce/npcinn/inn.go new file mode 100644 index 0000000..ce3f800 --- /dev/null +++ b/go/internal/server/domain/commerce/npcinn/inn.go @@ -0,0 +1,35 @@ +// Package npcinn implements motel opening and paid/free recovery. Each request +// runs within the account transaction, including currency, health and receipts. +package npcinn + +import "bd2server/internal/server/domain/command" + +import ( + "bd2server/internal/server/design/gamedata" + + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/storage/stateio" + "errors" +) + +type Context func(ctx command.Context, pack, npc uint64) (gamedata.InnRule, uint64, error) +type reply struct { + Digest string + Body []byte +} +type Service struct { + store stateio.Store + characters *roster.CharacterStore + wallet *assets.Wallet + context Context + level func(ctx command.Context) (uint64, error) + battle func(ctx command.Context) bool +} + +func New(store stateio.Store, characters *roster.CharacterStore, wallet *assets.Wallet, context Context, level func(ctx command.Context) (uint64, error), battle func(ctx command.Context) bool) (*Service, error) { + if store == nil || characters == nil || wallet == nil || context == nil || level == nil || battle == nil { + return nil, errors.New("npcinn: incomplete dependencies") + } + return &Service{store: store, characters: characters, wallet: wallet, context: context, level: level, battle: battle}, nil +} diff --git a/go/internal/server/npcinn/inn.go b/go/internal/server/domain/commerce/npcinn/protocol_adapter.go similarity index 64% rename from go/internal/server/npcinn/inn.go rename to go/internal/server/domain/commerce/npcinn/protocol_adapter.go index c851296..d738e73 100644 --- a/go/internal/server/npcinn/inn.go +++ b/go/internal/server/domain/commerce/npcinn/protocol_adapter.go @@ -1,8 +1,9 @@ -// Package npcinn implements motel opening and paid/free recovery. Each request -// runs within the account transaction, including currency, health and receipts. package npcinn import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/protocol/wire" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -10,59 +11,29 @@ import ( "errors" "fmt" "math" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" ) -type Context func(pack, npc uint64) (gamedata.InnRule, uint64, error) -type reply struct { - Digest string - Body []byte -} -type Service struct { - mu sync.Mutex - store stateio.Store - characters *player.CharacterStore - wallet *player.Wallet - context Context - level func() (uint64, error) - battle func() bool - session string -} - -func New(store stateio.Store, characters *player.CharacterStore, wallet *player.Wallet, context Context, level func() (uint64, error), battle func() bool) (*Service, error) { - if store == nil || characters == nil || wallet == nil || context == nil || level == nil || battle == nil { - return nil, errors.New("npcinn: incomplete dependencies") - } - return &Service{store: store, characters: characters, wallet: wallet, context: context, level: level, battle: battle}, nil -} -func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { code := 109 if path == "/CharAllRevival" { code = 11 } else if path != "/InnOpen" { return 0, nil, false, nil } - s.mu.Lock() - defer s.mu.Unlock() + fail := func(e error) (int, []byte, bool, error) { return code, nil, true, e } seq, ok, err := wire.Varint(request, 1) - if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.session == "" { + if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || ctx.SessionID == "" { return fail(errors.New("npcinn: invalid sequence or session")) } if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil { return fail(err) } - if s.battle() { + if s.battle(ctx) { return fail(errors.New("npcinn: unavailable during battle")) } if path == "/InnOpen" { - _, state, err := s.context(0, 0) + _, state, err := s.context(ctx, 0, 0) if err != nil { return fail(err) } @@ -76,12 +47,12 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if err != nil || npc == 0 || npc > math.MaxInt32 { return fail(errors.New("npcinn: invalid NPC")) } - keyBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", s.session, path, seq))) + keyBytes := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", ctx.SessionID, path, seq))) key := hex.EncodeToString(keyBytes[:]) hash := sha256.Sum256(request) digest := hex.EncodeToString(hash[:]) var replies map[string]reply - data, err := s.store.Load("npcinn") + data, err := s.store.Load(ctx.State, "npcinn") if err != nil { return fail(err) } @@ -97,11 +68,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } return code, prior.Body, true, nil } - rule, state, err := s.context(pack, npc) + rule, state, err := s.context(ctx, pack, npc) if err != nil { return fail(err) } - level, err := s.level() + level, err := s.level(ctx) if err != nil { return fail(err) } @@ -140,19 +111,19 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } rate := rule.ItemCount * (100 - discount) const divisor uint64 = 4000 - available := s.wallet.Snapshot().Gold + available := s.wallet.Snapshot(ctx).Gold var total uint64 - var targets []player.Character + var targets []roster.Character for _, idx := range indices { - c, found := s.characters.Find(idx) + c, found := s.characters.Find(ctx, idx) if !found { return fail(errors.New("npcinn: recovery character not owned")) } - maximum, err := s.characters.MaxHealth(idx) + maximum, err := s.characters.MaxHealth(ctx, idx) if err != nil { return fail(err) } - current, err := s.characters.CurrentHealth(idx) + current, err := s.characters.CurrentHealth(ctx, idx) if err != nil { return fail(err) } @@ -191,23 +162,23 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) targets = append(targets, c) } if total > 0 { - if _, err = s.wallet.SpendGoldOnce("npcinn:"+key, total); err != nil { + if _, err = s.wallet.SpendGoldOnce(ctx, "npcinn:"+key, total); err != nil { return fail(err) } } var out []byte for _, c := range targets { - if err = s.characters.SetCurrentHealth(c.InvenIndex, c.HP); err != nil { + if err = s.characters.SetCurrentHealth(ctx, c.InvenIndex, c.HP); err != nil { return fail(err) } - out = wire.AppendBytes(out, 1, player.CharacterWire(c)) + out = wire.AppendBytes(out, 1, roster.CharacterWire(c)) } replies[key] = reply{Digest: digest, Body: out} data, err = json.Marshal(replies) if err != nil { return fail(err) } - if err = s.store.Save("npcinn", data); err != nil { + if err = s.store.Save(ctx.State, "npcinn", data); err != nil { return fail(err) } return code, out, true, nil diff --git a/go/internal/server/npcshop/shop.go b/go/internal/server/domain/commerce/npcshop/protocol_adapter.go similarity index 57% rename from go/internal/server/npcshop/shop.go rename to go/internal/server/domain/commerce/npcshop/protocol_adapter.go index 923b95f..55687d1 100644 --- a/go/internal/server/npcshop/shop.go +++ b/go/internal/server/domain/commerce/npcshop/protocol_adapter.go @@ -1,155 +1,19 @@ -// Package npcshop implements the ordinary NPC buy/sell market from GameData. package npcshop import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/wire" "bytes" "crypto/sha256" "encoding/binary" - "encoding/json" "fmt" "math" "slices" "strconv" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" ) -type Economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} -type receipt struct { - Digest []byte - Response []byte -} -type count struct { - Period string - Bought uint64 -} -type snapshot struct { - Counts map[string]count `json:"counts"` - Receipts map[string]receipt `json:"receipts"` -} -type Service struct { - mu sync.Mutex - design gamedata.NPCShopDesign - store stateio.Store - economy Economy - items *player.Inventory - available func(uint64) bool - now func() time.Time - session func() string - reputation func(uint64) (uint64, uint64, error) - talentDiscount func(uint64, uint64) (uint64, error) - quotedSeed *uint64 -} - -func New(d gamedata.NPCShopDesign, store stateio.Store, e Economy, items *player.Inventory, available func(uint64) bool) (*Service, error) { - if len(d.Shops) == 0 || store == nil || e == nil || items == nil || available == nil { - return nil, fmt.Errorf("npcshop: invalid configuration") - } - s := &Service{design: d, store: store, economy: e, items: items, available: available, now: time.Now} - _, err := s.load() - return s, err -} -func (s *Service) SetSessionSource(source func() string) { s.session = source } -func (s *Service) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - if s.session != nil && s.session() == id { - return - } - s.session = func() string { return id } - s.quotedSeed = nil -} - -// SetReputationSource supplies the world's persisted pack reputation and -// GameData-derived shop discount. Normal reputation is the initial fallback. -func (s *Service) SetReputationSource(source func(uint64) (uint64, uint64, error)) { - s.reputation = source -} -func (s *Service) SetTalentDiscountSource(source func(uint64, uint64) (uint64, error)) { - s.talentDiscount = source -} -func (s *Service) talentDiscountFor(shop gamedata.NPCShop) (uint64, error) { - if s.talentDiscount == nil { - return 0, nil - } - var best uint64 - for _, npc := range s.design.ShopNPCs[shop.ID] { - n, e := s.talentDiscount(shop.PackID, npc) - if e != nil { - return 0, e - } - if n > 100 { - return 0, fmt.Errorf("npcshop: invalid talent discount") - } - if n > best { - best = n - } - } - return best, nil -} -func (s *Service) reputationFor(pack uint64) (uint64, uint64, error) { - if s.reputation != nil { - return s.reputation(pack) - } - return 1, 0, nil -} -func (s *Service) load() (snapshot, error) { - v := snapshot{Counts: map[string]count{}, Receipts: map[string]receipt{}} - b, e := s.store.Load("npc_shop") - if e != nil || len(b) == 0 { - return v, e - } - if e = stateio.RequireExactJSONObject(b, "counts", "receipts"); e != nil { - return v, e - } - if e = json.Unmarshal(b, &v); e != nil { - return v, e - } - if v.Counts == nil || v.Receipts == nil { - return v, fmt.Errorf("npcshop: invalid state") - } - return v, nil -} -func (s *Service) save(v snapshot) error { - b, e := json.Marshal(v) - if e != nil { - return e - } - return s.store.Save("npc_shop", b) -} -func key(shop, product uint64) string { return fmt.Sprintf("%d:%d", shop, product) } -func (s *Service) period(shop gamedata.NPCShop) (string, int64) { - t := s.now().UTC() - day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) - switch shop.ResetType { - case 1: - return day.Format("2006-01-02"), day.AddDate(0, 0, 1).Unix() - t.Unix() - case 2: - day = day.AddDate(0, 0, -(int(day.Weekday())+6)%7) - return day.Format("2006-01-02"), day.AddDate(0, 0, 7).Unix() - t.Unix() - case 3: - day = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) - return day.Format("2006-01"), day.AddDate(0, 1, 0).Unix() - t.Unix() - default: - return "lifetime", 0 - } -} -func (s *Service) seed() uint64 { return uint64(s.now().UTC().Unix()/86400) % math.MaxInt32 } -func (s *Service) bought(v snapshot, shop, product uint64) uint64 { - period, _ := s.period(s.design.Shops[shop]) - c := v.Counts[key(shop, product)] - if c.Period != period { - return 0 - } - return c.Bought -} func (s *Service) shopWire(v snapshot, id uint64) []byte { _, remaining := s.period(s.design.Shops[id]) b := wire.AppendVarint(nil, 1, id) @@ -168,10 +32,11 @@ func (s *Service) shopWire(v snapshot, id uint64) []byte { } return b } -func (s *Service) allShops(v snapshot, field int) []byte { + +func (s *Service) allShops(ctx command.Context, v snapshot, field int) []byte { ids := make([]uint64, 0) for id, r := range s.design.Shops { - if s.available(r.PackID) { + if s.available(ctx, r.PackID) { ids = append(ids, id) } } @@ -182,7 +47,9 @@ func (s *Service) allShops(v snapshot, field int) []byte { } return b } + func scalar(b []byte, f int) (uint64, error) { v, _, e := wire.Varint(b, f); return v, e } + func messages(b []byte, f int) ([][]byte, error) { var out [][]byte e := wire.Walk(b, func(v wire.Field) error { @@ -197,7 +64,8 @@ func messages(b []byte, f int) ([][]byte, error) { }) return out, e } -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { + +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { codes := map[string]int{"/ShopInfo": 53, "/ShopBuy": 54, "/ShopSell": 55, "/ShopOpen": 107, "/ShopReputationInfo": 520} code, ok := codes[path] if !ok { @@ -207,14 +75,13 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if e != nil || seq == 0 || seq > math.MaxInt32 { return 0, nil, true, fmt.Errorf("npcshop: invalid sequence") } - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.load() + + v, e := s.load(ctx) if e != nil { return 0, nil, true, e } if path == "/ShopInfo" { - b := s.allShops(v, 1) + b := s.allShops(ctx, v, 1) seed := s.seed() s.quotedSeed = &seed return code, wire.AppendVarint(b, 2, seed), true, nil @@ -224,16 +91,16 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) packs := map[uint64]bool{} isTalent := false for _, r := range s.design.Shops { - if s.available(r.PackID) { - discount, e := s.talentDiscountFor(r) + if s.available(ctx, r.PackID) { + discount, e := s.talentDiscountFor(ctx, r) if e != nil { return 0, nil, true, e } isTalent = isTalent || discount > 0 } - if !packs[r.PackID] && s.available(r.PackID) { + if !packs[r.PackID] && s.available(ctx, r.PackID) { packs[r.PackID] = true - state, _, err := s.reputationFor(r.PackID) + state, _, err := s.reputationFor(ctx, r.PackID) if err != nil { return 0, nil, true, err } @@ -249,19 +116,19 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } if path == "/ShopReputationInfo" { pack, err := scalar(request, 2) - if err != nil || !s.available(pack) { + if err != nil || !s.available(ctx, pack) { return 0, nil, true, fmt.Errorf("npcshop: unavailable reputation pack") } - state, _, err := s.reputationFor(pack) + state, _, err := s.reputationFor(ctx, pack) if err != nil { return 0, nil, true, err } return code, wire.AppendVarint(nil, 1, state), true, nil } - if s.session == nil || s.session() == "" { + if ctx.SessionID == "" { return 0, nil, true, fmt.Errorf("npcshop: missing session identity") } - identity := "npcshop:" + s.session() + ":" + path + ":" + strconv.FormatUint(seq, 10) + identity := "npcshop:" + ctx.SessionID + ":" + path + ":" + strconv.FormatUint(seq, 10) digest := sha256.Sum256(request) if r, exists := v.Receipts[identity]; exists { if !bytes.Equal(r.Digest, digest[:]) { @@ -270,25 +137,25 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return code, r.Response, true, nil } var costs, rewards []gamedata.Reward - var sold []player.Item + var sold []assets.Item changed := map[uint64]bool{} if path == "/ShopBuy" { - costs, rewards, changed, e = s.buy(request, &v) + costs, rewards, changed, e = s.buy(ctx, request, &v) if e == nil { costs, sold, e = s.purchaseMaterials(request, costs) } } else { - sold, rewards, e = s.sell(request) + sold, rewards, e = s.sell(ctx, request) } if e != nil { return 0, nil, true, e } if len(sold) > 0 { - if e = s.items.CanConsume(sold); e != nil { + if e = s.items.CanConsume(ctx, sold); e != nil { return 0, nil, true, e } } - bundle, e := s.economy.Apply(identity, costs, rewards) + bundle, e := s.economy.Apply(ctx, identity, costs, rewards) if e != nil { return 0, nil, true, e } @@ -296,7 +163,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) // Consume the concrete stacks the client removes, rather than letting a // type/id-only debit choose a different stack of the same resource. if len(sold) > 0 { - if e = s.items.Consume(sold); e != nil { + if e = s.items.Consume(ctx, sold); e != nil { return 0, nil, true, e } } @@ -310,12 +177,13 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) b = wire.AppendBytes(b, 2, s.shopWire(v, id)) } v.Receipts[identity] = receipt{Digest: digest[:], Response: b} - if e = s.save(v); e != nil { + if e = s.save(ctx, v); e != nil { return 0, nil, true, e } return code, b, true, nil } -func (s *Service) buy(request []byte, v *snapshot) ([]gamedata.Reward, []gamedata.Reward, map[uint64]bool, error) { + +func (s *Service) buy(ctx command.Context, request []byte, v *snapshot) ([]gamedata.Reward, []gamedata.Reward, map[uint64]bool, error) { groups, e := messages(request, 2) if e != nil || len(groups) == 0 { return nil, nil, nil, fmt.Errorf("npcshop: empty purchase") @@ -326,17 +194,17 @@ func (s *Service) buy(request []byte, v *snapshot) ([]gamedata.Reward, []gamedat for _, g := range groups { shop, e := scalar(g, 1) r, exists := s.design.Shops[shop] - if e != nil || !exists || !s.available(r.PackID) { + if e != nil || !exists || !s.available(ctx, r.PackID) { return nil, nil, nil, fmt.Errorf("npcshop: unavailable shop %d", shop) } - state, discount, err := s.reputationFor(r.PackID) + state, discount, err := s.reputationFor(ctx, r.PackID) if err != nil { return nil, nil, nil, err } if state == 0 || discount > 100 { return nil, nil, nil, fmt.Errorf("npcshop: unavailable reputation") } - talentDiscount, e := s.talentDiscountFor(r) + talentDiscount, e := s.talentDiscountFor(ctx, r) if e != nil { return nil, nil, nil, e } @@ -395,21 +263,22 @@ func (s *Service) buy(request []byte, v *snapshot) ([]gamedata.Reward, []gamedat } return costs, rewards, changed, nil } -func (s *Service) sell(request []byte) ([]player.Item, []gamedata.Reward, error) { + +func (s *Service) sell(ctx command.Context, request []byte) ([]assets.Item, []gamedata.Reward, error) { id, e := scalar(request, 2) shop, exists := s.design.Shops[id] - if e != nil || !exists || !s.available(shop.PackID) { + if e != nil || !exists || !s.available(ctx, shop.PackID) { return nil, nil, fmt.Errorf("npcshop: unavailable sell shop") } rows, e := messages(request, 3) if e != nil || len(rows) == 0 { return nil, nil, fmt.Errorf("npcshop: empty sale") } - inventory := map[uint64]player.Item{} - for _, item := range s.items.All() { + inventory := map[uint64]assets.Item{} + for _, item := range s.items.All(ctx) { inventory[item.InvenIndex] = item } - var costs []player.Item + var costs []assets.Item var rewards []gamedata.Reward seen := map[uint64]bool{} for _, b := range rows { @@ -458,7 +327,7 @@ func (s *Service) sell(request []byte) ([]player.Item, []gamedata.Reward, error) // Resource currency is removed from the exact inventory rows named by the // client after independently deriving the required type/id/count from design. -func (s *Service) purchaseMaterials(request []byte, costs []gamedata.Reward) ([]gamedata.Reward, []player.Item, error) { +func (s *Service) purchaseMaterials(request []byte, costs []gamedata.Reward) ([]gamedata.Reward, []assets.Item, error) { need := map[[2]uint64]uint64{} var currency []gamedata.Reward for _, c := range costs { @@ -472,7 +341,7 @@ func (s *Service) purchaseMaterials(request []byte, costs []gamedata.Reward) ([] if e != nil { return nil, nil, e } - var selected []player.Item + var selected []assets.Item got := map[[2]uint64]uint64{} seen := map[uint64]bool{} for _, b := range rows { @@ -486,7 +355,7 @@ func (s *Service) purchaseMaterials(request []byte, costs []gamedata.Reward) ([] seen[index] = true k := [2]uint64{typ, id} got[k] += n - selected = append(selected, player.Item{InvenIndex: index, ID: id, Type: typ, Count: n}) + selected = append(selected, assets.Item{InvenIndex: index, ID: id, Type: typ, Count: n}) } if len(got) != len(need) { return nil, nil, fmt.Errorf("npcshop: purchase material mismatch") @@ -498,40 +367,3 @@ func (s *Service) purchaseMaterials(request []byte, costs []gamedata.Reward) ([] } return currency, selected, nil } - -// Client ShopPacket.GetShopRandomValue uses the first WELL512 value for a -// versioned product and server-supplied daily seed. Client Rate is verified, -// never accepted as the authority for wallet arithmetic. -func (s *Service) rate(p gamedata.NPCProduct, shop, tab uint64) uint64 { - if p.PremiumPriceType == 1 && p.HighShop == shop && p.HighDay == uint64(s.now().UTC().Day()) { - return 100 + p.HighPremium - } - seed := s.marketSeed() + shop + tab + p.Reward.Type + p.Reward.ID - a := seed - b := seed + 13*90 - c := a ^ b ^ (a << 16) ^ (b << 15) - b = seed + 9*90 - b ^= b >> 11 - a = c ^ b - // WELL512's mask is the unsigned 32-bit pattern 0xDA442D24. The - // decompiler displays its signed int32 spelling, -633066204, inside - // an ulong cast; sign-extending that spelling changes the RNG result. - d := a ^ ((a << 5) & uint64(0xda442d24)) - a = seed + 15*90 - value := a ^ c ^ d ^ (a << 2) ^ (c << 18) ^ (b << 28) - lo := 100 - p.Discount - span := p.Discount + p.Premium - if span == 0 { - span = 1 - } - return lo + value%span -} - -// The client keeps ShopRandSeed until the next ShopInfo response. Advancing -// the server clock alone must not change a price already displayed in its UI. -func (s *Service) marketSeed() uint64 { - if s.quotedSeed != nil { - return *s.quotedSeed - } - return s.seed() -} diff --git a/go/internal/server/domain/commerce/npcshop/shop.go b/go/internal/server/domain/commerce/npcshop/shop.go new file mode 100644 index 0000000..bb20e7f --- /dev/null +++ b/go/internal/server/domain/commerce/npcshop/shop.go @@ -0,0 +1,177 @@ +// Package npcshop implements the ordinary NPC buy/sell market from GameData. +package npcshop + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + "math" + + "time" +) + +type Economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} +type receipt struct { + Digest []byte + Response []byte +} +type count struct { + Period string + Bought uint64 +} +type snapshot struct { + Counts map[string]count `json:"counts"` + Receipts map[string]receipt `json:"receipts"` +} +type Service struct { + design gamedata.NPCShopDesign + store stateio.Store + economy Economy + items *assets.Inventory + available func(ctx command.Context, _ uint64) bool + now func() time.Time + + reputation func(ctx command.Context, _ uint64) (uint64, uint64, error) + talentDiscount func(ctx command.Context, _ uint64, _ uint64) (uint64, error) + quotedSeed *uint64 +} + +func New(ctx command.Context, d gamedata.NPCShopDesign, store stateio.Store, e Economy, items *assets.Inventory, available func(ctx command.Context, _ uint64) bool) (*Service, error) { + if len(d.Shops) == 0 || store == nil || e == nil || items == nil || available == nil { + return nil, fmt.Errorf("npcshop: invalid configuration") + } + s := &Service{design: d, store: store, economy: e, items: items, available: available, now: time.Now} + _, err := s.load(ctx) + return s, err +} + +func (s *Service) BeginLogin(ctx command.Context) { + + s.quotedSeed = nil +} + +// SetReputationSource supplies the world's persisted pack reputation and +// GameData-derived shop discount. Normal reputation is the initial fallback. +func (s *Service) SetReputationSource(source func(ctx command.Context, _ uint64) (uint64, uint64, error)) { + s.reputation = source +} +func (s *Service) SetTalentDiscountSource(source func(ctx command.Context, _ uint64, _ uint64) (uint64, error)) { + s.talentDiscount = source +} +func (s *Service) talentDiscountFor(ctx command.Context, shop gamedata.NPCShop) (uint64, error) { + if s.talentDiscount == nil { + return 0, nil + } + var best uint64 + for _, npc := range s.design.ShopNPCs[shop.ID] { + n, e := s.talentDiscount(ctx, shop.PackID, npc) + if e != nil { + return 0, e + } + if n > 100 { + return 0, fmt.Errorf("npcshop: invalid talent discount") + } + if n > best { + best = n + } + } + return best, nil +} +func (s *Service) reputationFor(ctx command.Context, pack uint64) (uint64, uint64, error) { + if s.reputation != nil { + return s.reputation(ctx, pack) + } + return 1, 0, nil +} +func (s *Service) load(ctx command.Context) (snapshot, error) { + v := snapshot{Counts: map[string]count{}, Receipts: map[string]receipt{}} + b, e := s.store.Load(ctx.State, "npc_shop") + if e != nil || len(b) == 0 { + return v, e + } + if e = stateio.RequireExactJSONObject(b, "counts", "receipts"); e != nil { + return v, e + } + if e = json.Unmarshal(b, &v); e != nil { + return v, e + } + if v.Counts == nil || v.Receipts == nil { + return v, fmt.Errorf("npcshop: invalid state") + } + return v, nil +} +func (s *Service) save(ctx command.Context, v snapshot) error { + b, e := json.Marshal(v) + if e != nil { + return e + } + return s.store.Save(ctx.State, "npc_shop", b) +} +func key(shop, product uint64) string { return fmt.Sprintf("%d:%d", shop, product) } +func (s *Service) period(shop gamedata.NPCShop) (string, int64) { + t := s.now().UTC() + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + switch shop.ResetType { + case 1: + return day.Format("2006-01-02"), day.AddDate(0, 0, 1).Unix() - t.Unix() + case 2: + day = day.AddDate(0, 0, -(int(day.Weekday())+6)%7) + return day.Format("2006-01-02"), day.AddDate(0, 0, 7).Unix() - t.Unix() + case 3: + day = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC) + return day.Format("2006-01"), day.AddDate(0, 1, 0).Unix() - t.Unix() + default: + return "lifetime", 0 + } +} +func (s *Service) seed() uint64 { return uint64(s.now().UTC().Unix()/86400) % math.MaxInt32 } +func (s *Service) bought(v snapshot, shop, product uint64) uint64 { + period, _ := s.period(s.design.Shops[shop]) + c := v.Counts[key(shop, product)] + if c.Period != period { + return 0 + } + return c.Bought +} + +// Client ShopPacket.GetShopRandomValue uses the first WELL512 value for a +// versioned product and server-supplied daily seed. Client Rate is verified, +// never accepted as the authority for wallet arithmetic. +func (s *Service) rate(p gamedata.NPCProduct, shop, tab uint64) uint64 { + if p.PremiumPriceType == 1 && p.HighShop == shop && p.HighDay == uint64(s.now().UTC().Day()) { + return 100 + p.HighPremium + } + seed := s.marketSeed() + shop + tab + p.Reward.Type + p.Reward.ID + a := seed + b := seed + 13*90 + c := a ^ b ^ (a << 16) ^ (b << 15) + b = seed + 9*90 + b ^= b >> 11 + a = c ^ b + // WELL512's mask is the unsigned 32-bit pattern 0xDA442D24. The + // decompiler displays its signed int32 spelling, -633066204, inside + // an ulong cast; sign-extending that spelling changes the RNG result. + d := a ^ ((a << 5) & uint64(0xda442d24)) + a = seed + 15*90 + value := a ^ c ^ d ^ (a << 2) ^ (c << 18) ^ (b << 28) + lo := 100 - p.Discount + span := p.Discount + p.Premium + if span == 0 { + span = 1 + } + return lo + value%span +} + +// The client keeps ShopRandSeed until the next ShopInfo response. Advancing +// the server clock alone must not change a price already displayed in its UI. +func (s *Service) marketSeed() uint64 { + if s.quotedSeed != nil { + return *s.quotedSeed + } + return s.seed() +} diff --git a/go/internal/server/domain/commerce/pack_info.go b/go/internal/server/domain/commerce/pack_info.go new file mode 100644 index 0000000..f4ae5c0 --- /dev/null +++ b/go/internal/server/domain/commerce/pack_info.go @@ -0,0 +1,6 @@ +package commerce + +type PackInfoHandler struct { + World attendanceHandler + Claims *ClearPackages +} diff --git a/go/internal/server/domain/commerce/protocol_adapter.go b/go/internal/server/domain/commerce/protocol_adapter.go new file mode 100644 index 0000000..3e3ddaa --- /dev/null +++ b/go/internal/server/domain/commerce/protocol_adapter.go @@ -0,0 +1,1406 @@ +package commerce + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/staticdata" + "bd2server/internal/server/protocol/wire" + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "slices" + "sort" + "strconv" + "strings" + "time" +) + +func (s *Service) predecessorBought(ctx command.Context, v purchaseState, key gamedata.CashProductKey) bool { + prior := s.predecessors[key] + if len(prior) == 0 { + return true + } + for _, p := range prior { + if v.Counts[skuKey(p)].Lifetime > 0 { + return true + } + } + if s.legacy != nil { + for _, raw := range s.legacy.PurchaseCountDBInfos(ctx) { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + n, _, _ := wire.Varint(raw, 4) + if n > 0 { + for _, p := range prior { + if p.GroupID == g && p.ProductID == id && p.SaleGroup == sale { + return true + } + } + } + } + } + return false +} + +// AttachShopSeed retains versioned dynamic windows and event identities. It +// must run before serving sessions; absent windows never authorize timed goods. +func (s *Service) AttachShopSeed(ctx command.Context, seed *readonly.Seed) error { + if seed == nil { + return fmt.Errorf("commerce: missing shop seed") + } + _, raw, handled, err := seed.Handle(ctx, "/CashShopInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil || !handled { + return fmt.Errorf("commerce: invalid shop seed: %w", err) + } + windows := map[gamedata.CashProductKey][2]uint64{} + var products [][]byte + err = wire.Walk(raw, func(f wire.Field) error { + if f.Number != 1 { + return nil + } + if f.Type != 2 { + return fmt.Errorf("commerce: invalid shop product") + } + g, _, e := wire.Varint(f.Value, 1) + if e != nil { + return e + } + id, _, e := wire.Varint(f.Value, 2) + if e != nil { + return e + } + sale, _, e := wire.Varint(f.Value, 3) + if e != nil { + return e + } + start, _, e := wire.Varint(f.Value, 4) + if e != nil { + return e + } + end, _, e := wire.Varint(f.Value, 5) + if e != nil { + return e + } + k := gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale} + if _, ok := windows[k]; ok { + return fmt.Errorf("commerce: duplicate shop schedule") + } + if end != 0 && start >= end { + return fmt.Errorf("commerce: invalid shop window") + } + windows[k] = [2]uint64{start, end} + products = append(products, append([]byte(nil), f.Value...)) + return nil + }) + if err != nil { + return err + } + s.shopProducts = products + s.shopWindows = windows + return nil +} + +func (s *Service) shopInfo() []byte { + var response []byte + for _, raw := range s.shopProducts { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + _, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}) + if ok { + response = wire.AppendBytes(response, 1, raw) + } + } + t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second) + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) + daily := day.AddDate(0, 0, 1) + days := (8 - int(day.Weekday())) % 7 + if days == 0 { + days = 7 + } + weekly := day.AddDate(0, 0, days) + monthly := time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC) + for i, reset := range []time.Time{daily, weekly, monthly} { + response = wire.AppendVarint(response, i+2, uint64(reset.Add(time.Duration(s.resetSeconds)*time.Second).UnixMilli())) + } + return response +} + +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + switch path { + case "/CashShopInfo": + if s.shopWindows == nil { + return 0, nil, false, nil + } + if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 { + return 60, nil, true, fmt.Errorf("commerce: invalid shop sequence") + } + return 60, s.shopInfo(), true, nil + case "/CashShopPurchaseCountInfo": + if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 { + return 432, nil, true, fmt.Errorf("commerce: invalid count sequence") + } + infos, err := s.purchaseCountDBInfos(ctx) + if err != nil { + return 0, nil, true, err + } + var b []byte + for _, i := range infos { + b = wire.AppendBytes(b, 1, i) + } + return 432, b, true, nil + case "/CashShopBuy": + return s.buy(ctx, commandSession, request) + default: + return 0, nil, false, nil + } +} + +func parseBuy(request []byte) (uint64, []buyLine, []string, error) { + seq, found, e := wire.Varint(request, 1) + if e != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, nil, fmt.Errorf("commerce: invalid sequence") + } + group, found, e := wire.Varint(request, 3) + if e != nil || !found || group == 0 { + return 0, nil, nil, fmt.Errorf("commerce: invalid product group") + } + var lines []buyLine + var billing []string + seen := map[gamedata.CashProductKey]bool{} + e = wire.Walk(request, func(f wire.Field) error { + switch f.Number { + case 4: + if f.Type != 2 { + return fmt.Errorf("commerce: invalid buy info") + } + id, ok, err := wire.Varint(f.Value, 1) + if err != nil || !ok || id == 0 { + return fmt.Errorf("commerce: invalid product id") + } + sale, _, err := wire.Varint(f.Value, 2) + if err != nil { + return err + } + n, ok, err := wire.Varint(f.Value, 3) + if err != nil || !ok || n == 0 || n > math.MaxInt32 { + return fmt.Errorf("commerce: invalid buy count") + } + k := gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale} + if seen[k] { + return fmt.Errorf("commerce: duplicate buy info") + } + seen[k] = true + lines = append(lines, buyLine{k, n}) + case 7: + if f.Type != 2 { + return fmt.Errorf("commerce: invalid billing info") + } + return wire.Walk(f.Value, func(b wire.Field) error { + if b.Number == 1 || b.Number == 2 { + if b.Type != 2 { + return fmt.Errorf("commerce: invalid billing identity") + } + if len(b.Value) > 16384 { + return fmt.Errorf("commerce: excessive billing identity") + } + if len(b.Value) > 0 { + h := sha256.Sum256(b.Value) + billing = append(billing, fmt.Sprintf("%d:%x", b.Number, h)) + } + } + return nil + }) + } + return nil + }) + if e == nil && len(lines) == 0 { + e = fmt.Errorf("commerce: empty purchase") + } + return seq, lines, billing, e +} + +func (s *Service) buy(ctx command.Context, session string, request []byte) (int, []byte, bool, error) { + if session == "" { + return 61, nil, true, fmt.Errorf("commerce: authenticated session required") + } + seq, lines, billing, e := parseBuy(request) + if e != nil { + return 61, nil, true, e + } + var native, cash bool + for _, line := range lines { + if design, ok := s.catalog.Design(line.Key); ok { + if design.PriceType == 1 { + cash = true + } else { + native = true + } + } + } + if native && cash { + return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase") + } + if !cash { + // Native BillingInfo contains display identifiers (often a product ID), + // not a unique payment receipt. Request identity provides replay safety. + billing = nil + } + h := sha256.Sum256(request) + digest := hex.EncodeToString(h[:]) + sessionHash := sha256.Sum256([]byte(session)) + identity := fmt.Sprintf("commerce:%x:%d", sessionHash, seq) + + v, e := s.load(ctx) + if e != nil { + return 61, nil, true, e + } + if r, ok := v.Receipts[identity]; ok { + if r.Digest != digest { + return 61, nil, true, fmt.Errorf("commerce: sequence reused with different purchase") + } + return 61, append([]byte(nil), r.Response...), true, nil + } + for _, b := range billing { + if _, ok := v.Billing[b]; ok { + return 61, nil, true, fmt.Errorf("commerce: billing receipt already used") + } + } + // Validate every line before charging the first one. + designs := make([]gamedata.CashProductDesign, len(lines)) + quotes := make([]Product, len(lines)) + for i, l := range lines { + if !s.predecessorBought(ctx, v, l.Key) { + return 61, nil, true, fmt.Errorf("commerce: preceding package purchase required") + } + d, ok := s.catalog.Design(l.Key) + if !ok { + return 61, nil, true, fmt.Errorf("commerce: unknown product") + } + if !s.available(d) { + return 61, nil, true, fmt.Errorf("commerce: product is not currently available") + } + if s.specialProducts[l.Key] && (len(lines) != 1 || l.Count != 1) { + return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product") + } + var q Product + if d.PriceType == 1 { + var err error + q, err = s.catalog.Quote(l.Key, l.Count) + if err != nil { + return 61, nil, true, err + } + } else { + cost, err := nativePrice(d, l.Count) + if err != nil { + return 61, nil, true, err + } + q = Product{Enabled: true, ItemType: d.PriceType, Cost: cost} + } + if !q.Enabled { + return 61, nil, true, fmt.Errorf("commerce: product disabled") + } + if len(lines) > 1 && d.BulkOrderAvailability != 1 { + return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled") + } + n := s.count(v, d) + if s.legacy != nil { + for _, raw := range s.legacy.PurchaseCountDBInfos(ctx) { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + legacyCount, _, _ := wire.Varint(raw, 4) + if g == l.Key.GroupID && id == l.Key.ProductID && sale == l.Key.SaleGroup && legacyCount > n { + n = legacyCount + } + } + } + if d.PurchaseLimitType > 4 { + return 61, nil, true, fmt.Errorf("commerce: unknown purchase limit") + } + if n > math.MaxInt32-l.Count { + return 61, nil, true, fmt.Errorf("commerce: purchase count overflow") + } + if d.PurchaseLimitType != 0 && (d.PurchaseLimitCount == 0 || n+l.Count > d.PurchaseLimitCount) { + return 61, nil, true, fmt.Errorf("commerce: purchase limit exceeded") + } + designs[i] = d + quotes[i] = q + } + if cash { + e = validateAcceptedQuote(request, quotes) + } + if e != nil { + return 61, nil, true, e + } + var bundle []byte + for i, l := range lines { + d, q := designs[i], quotes[i] + operation := fmt.Sprintf("%s:%d", identity, i) + var costs []gamedata.Reward + if q.Cost > 0 { + if d.PriceType != 1 { + costs = []gamedata.Reward{{Type: d.PriceType, ID: d.PriceID, Count: q.Cost}} + if resolver, ok := s.economy.(nativeCostResolver); ok { + var err error + costs, err = resolver.NativePurchaseCosts(ctx, costs[0]) + if err != nil { + return 61, nil, true, err + } + } + } else { + var typ uint64 + switch q.Currency { + case "paid_diamonds": + typ = 2 + case "diamonds": + typ = 3 + case "gold": + typ = 4 + default: + return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency) + } + costs = []gamedata.Reward{{Type: typ, Count: q.Cost}} + } + } + var special []byte + handled := false + // Delegate must only select known special products. Parent account transaction + // guarantees its grant and the subsequent debit commit together. + if s.delegate != nil && (d.PriceType == 1 || s.specialProducts[l.Key]) { + special, handled, e = s.delegate(ctx, l.Key, request) + if e != nil { + return 61, nil, true, e + } + if handled && (len(lines) != 1 || l.Count != 1) { + return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product") + } + } + var rewards []gamedata.Reward + if !handled { + if d.RandomBoxID == 0 { + return 61, nil, true, fmt.Errorf("commerce: product reward missing") + } + rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.RandomBoxID, Count: l.Count}) + if d.BonusRandomBoxID != 0 { + rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count}) + } + } + var b []byte + var err error + if delivery, ok := s.economy.(interface { + ApplyPurchase(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) + }); ok { + b, err = delivery.ApplyPurchase(ctx, operation, costs, rewards) + } else { + b, err = s.economy.Apply(ctx, operation, costs, rewards) + } + if err != nil { + return 61, nil, true, err + } + bundle = append(bundle, b...) + bundle = append(bundle, special...) + if s.hook != nil { + if err = s.hook(ctx, operation, d, l.Count); err != nil { + return 61, nil, true, err + } + } + old := v.Counts[skuKey(l.Key)] + if old.Lifetime > math.MaxUint64-l.Count { + return 61, nil, true, fmt.Errorf("commerce: lifetime purchase count overflow") + } + v.Counts[skuKey(l.Key)] = purchaseCount{Count: s.count(v, d) + l.Count, Period: s.period(d.PurchaseLimitType), Lifetime: old.Lifetime + l.Count} + } + response := wire.AppendBytes(nil, 1, bundle) + v.Receipts[identity] = purchaseReceipt{digest, response} + for _, b := range billing { + v.Billing[b] = identity + } + encoded, e := json.Marshal(v) + if e == nil { + e = s.store.Save(ctx.State, "commerce", encoded) + } + return 61, response, true, e +} + +// The receipt repeats the client-visible quote for stale-catalog detection. +// Prices always come from Catalog, and a mismatch never changes the debit. +func validateAcceptedQuote(request []byte, quotes []Product) error { + var pay, receipt string + seen := false + err := wire.Walk(request, func(f wire.Field) error { + if f.Number != 7 { + return nil + } + if seen || f.Type != 2 { + return fmt.Errorf("commerce: invalid billing info") + } + seen = true + return wire.Walk(f.Value, func(b wire.Field) error { + if b.Number == 1 { + if b.Type != 2 || pay != "" { + return fmt.Errorf("commerce: invalid pay id") + } + pay = string(b.Value) + } + if b.Number == 2 { + if b.Type != 2 || receipt != "" { + return fmt.Errorf("commerce: invalid receipt") + } + receipt = string(b.Value) + } + return nil + }) + }) + if err != nil { + return err + } + parts := strings.Split(receipt, ":") + if len(parts) != 4 || parts[0] != "bd2-local-commerce-v1" || parts[1] != pay { + return fmt.Errorf("commerce: local billing quote required") + } + payID, err := strconv.ParseUint(pay, 10, 64) + if err != nil || payID == 0 || strconv.FormatUint(payID, 10) != pay { + return fmt.Errorf("commerce: invalid local pay id") + } + typ, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil || strconv.FormatUint(typ, 10) != parts[2] { + return fmt.Errorf("commerce: invalid accepted quote currency") + } + amount, err := strconv.ParseUint(parts[3], 10, 64) + if err != nil || strconv.FormatUint(amount, 10) != parts[3] { + return fmt.Errorf("commerce: invalid accepted quote amount") + } + var expected uint64 + for _, q := range quotes { + if q.ItemType != typ || q.Cost > math.MaxInt32-expected { + return fmt.Errorf("commerce: accepted quote currency mismatch") + } + expected += q.Cost + } + if expected != amount { + return fmt.Errorf("commerce: purchase quote changed; refresh the shop") + } + return nil +} + +func (s *Service) purchaseCountDBInfos(ctx command.Context) ([][]byte, error) { + + v, e := s.load(ctx) + if e != nil { + return nil, e + } + counts := map[gamedata.CashProductKey]uint64{} + if s.legacy != nil { + for _, raw := range s.legacy.PurchaseCountDBInfos(ctx) { + g, _, _ := wire.Varint(raw, 1) + id, _, _ := wire.Varint(raw, 2) + sale, _, _ := wire.Varint(raw, 3) + n, _, _ := wire.Varint(raw, 4) + counts[gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}] = n + } + } + for _, d := range s.catalog.Designs() { + if n := s.count(v, d); n > counts[d.Key] { + counts[d.Key] = n + } + } + keys := make([]gamedata.CashProductKey, 0, len(counts)) + for k := range counts { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + a, b := keys[i], keys[j] + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + if a.ProductID != b.ProductID { + return a.ProductID < b.ProductID + } + return a.SaleGroup < b.SaleGroup + }) + var out [][]byte + for _, k := range keys { + b := wire.AppendVarint(nil, 1, k.GroupID) + b = wire.AppendVarint(b, 2, k.ProductID) + b = wire.AppendVarint(b, 3, k.SaleGroup) + b = wire.AppendVarint(b, 4, counts[k]) + out = append(out, b) + } + return out, nil +} + +func (s *Service) PurchaseCountDBInfos(ctx command.Context) [][]byte { + v, _ := s.purchaseCountDBInfos(ctx) + return v +} + +func (h PackInfoHandler) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/PackInfo" { + return 0, nil, false, nil + } + code, response, handled, err := h.World.Handle(ctx, path, request) + if err != nil || !handled { + return code, response, handled, err + } + pack, evil, err := h.Claims.RewardDBInfos(ctx) + if err != nil { + return code, nil, true, err + } + var result []byte + err = wire.Walk(response, func(field wire.Field) error { + if field.Number != 3 && field.Number != 4 { + result = append(result, response[field.Start:field.End]...) + } + return nil + }) + if err != nil { + return code, nil, true, err + } + for _, info := range pack { + result = wire.AppendBytes(result, 3, info) + } + for _, info := range evil { + result = wire.AppendBytes(result, 4, info) + } + return code, result, true, nil +} + +// ClaimAndInfo is called by AttendanceHandler inside the account transaction. +// Free progression advances once per observed reset day, and buying premium +// catches up the already earned rows without advancing the login day count. +func (s *LoginPasses) ClaimAndInfo(ctx command.Context, identity string) ([]byte, [][]byte, error) { + + if identity == "" { + return nil, nil, fmt.Errorf("commerce: missing login-pass identity") + } + v, err := s.load(ctx) + if err != nil { + return nil, nil, err + } + keys := make([]uint64, 0, len(s.design.Groups)) + for group := range s.design.Groups { + keys = append(keys, group) + } + slices.Sort(keys) + r, seen := v.Receipts[identity] + if !seen { + r = loginPassReceipt{Rewarded: map[uint64]bool{}} + var rewards []gamedata.Reward + day := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second).Format("2006-01-02") + for _, group := range keys { + if !s.available(ctx, group) { + continue + } + rows := s.design.Groups[group] + if len(rows) == 0 { + continue + } + p := v.Progress[group] + if p.LastDay != day && p.Free < uint64(len(rows)) { + rewards = append(rewards, rows[p.Free].Free) + p.Free++ + p.LastDay = day + r.Rewarded[group] = true + } + if s.paid(ctx, rows[0].TicketID) { + for p.Premium < p.Free { + rewards = append(rewards, rows[p.Premium].Premium) + p.Premium++ + r.Rewarded[group] = true + } + } + v.Progress[group] = p + } + if len(rewards) > 0 { + r.Bundle, err = s.economy.Apply(ctx, "login-pass:"+identity, nil, rewards) + if err != nil { + return nil, nil, err + } + } + v.Receipts[identity] = r + raw, err := json.Marshal(v) + if err != nil { + return nil, nil, err + } + if err = s.store.Save(ctx.State, "commerce_login_passes", raw); err != nil { + return nil, nil, err + } + } + var infos [][]byte + for _, group := range keys { + if !s.available(ctx, group) { + continue + } + p := v.Progress[group] + if p.Free == 0 { + continue + } + b := wire.AppendVarint(nil, 1, group) + b = wire.AppendVarint(b, 2, p.Free) + if r.Rewarded[group] { + b = wire.AppendVarint(b, 3, 1) + } + infos = append(infos, b) + } + return append([]byte(nil), r.Bundle...), infos, nil +} + +// AttachEventShopSchedules joins event type 15 to EventShopTable and then to +// CashProductTable. Hub IDs are presentation identities, never product groups. +// All scheduled goods are published; purchase handlers enforce their windows. +// Call once before serving sessions. +func (s *Service) AttachEventShopSchedules(design *gamedata.CashCatalog, schedules []events.Schedule) error { + if design == nil || s.shopWindows == nil { + return fmt.Errorf("commerce: missing event shop catalog or shop seed") + } + shops := map[uint64]uint64{} + groups := map[uint64]bool{} + for _, shop := range design.EventShops { + if shop.ID == 0 || shop.ProductGroupID == 0 || shops[shop.ID] != 0 || groups[shop.ProductGroupID] { + return fmt.Errorf("commerce: invalid or duplicate event shop identity") + } + shops[shop.ID], groups[shop.ProductGroupID] = shop.ProductGroupID, true + } + windows := map[gamedata.CashProductKey][][2]uint64{} + var products [][]byte + for _, raw := range s.shopProducts { + group, _, _ := wire.Varint(raw, 1) + if !groups[group] { + products = append(products, raw) + } + } + rows := append([]events.Schedule(nil), schedules...) + sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID }) + for _, row := range rows { + if row.Type != 15 { + continue + } + group := shops[row.ID] + if group == 0 || row.UID == 0 || row.Start <= 0 || row.Start >= row.End { + return fmt.Errorf("commerce: invalid event shop schedule %d", row.UID) + } + found := false + start, end := uint64(row.Start), uint64(row.End) + for _, product := range s.catalog.Designs() { + key := product.Key + if key.GroupID != group { + continue + } + found = true + for _, prior := range windows[key] { + if start < prior[1] && prior[0] < end { + return fmt.Errorf("commerce: overlapping event shop product windows") + } + } + windows[key] = append(windows[key], [2]uint64{start, end}) + raw := wire.AppendVarint(nil, 1, key.GroupID) + raw = wire.AppendVarint(raw, 2, key.ProductID) + raw = wire.AppendVarint(raw, 3, key.SaleGroup) + raw = wire.AppendVarint(raw, 4, start) + raw = wire.AppendVarint(raw, 5, end) + raw = wire.AppendVarint(raw, 8, row.UID) + products = append(products, raw) + } + if !found { + return fmt.Errorf("commerce: event shop %d has no products", row.ID) + } + } + s.shopProducts, s.eventShopWindows, s.eventShopGroups = products, windows, groups + return nil +} + +func (e *EntitlementEconomy) Apply(ctx command.Context, identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + + return e.apply(ctx, identity, costs, rewards) +} + +func (e *EntitlementEconomy) ApplyResolved(ctx command.Context, identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + + return e.applyPrepared(ctx, identity, costs, rewards, true, nil) +} + +func (e *EntitlementEconomy) apply(ctx command.Context, identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + return e.applyPrepared(ctx, identity, costs, rewards, false, nil) +} + +func (e *EntitlementEconomy) applyPrepared(ctx command.Context, identity string, costs, rewards []gamedata.Reward, resolved bool, mailed []gamedata.BattleReward) ([]byte, error) { + if identity == "" { + return nil, fmt.Errorf("commerce: missing entitlement identity") + } + definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) + digest := sha256.Sum256(definition) + s, err := e.load(ctx) + if err != nil { + return nil, err + } + if r, ok := s.Receipts[identity]; ok { + if !bytes.Equal(r.Definition, digest[:]) { + return nil, fmt.Errorf("commerce: entitlement identity reused") + } + return append([]byte(nil), r.Bundle...), nil + } + input := make([]gamedata.BattleReward, len(rewards)) + for i, r := range rewards { + input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 + } + leaves := input + if !resolved { + leaves, err = e.graph.ResolveGranted(input) + } + if err != nil { + return nil, err + } + // Some product boxes include the first attendance reward (draw-ticket + // subscriptions); others include only the upfront paid currency. Add only + // the missing first-day components on activation, then mark row one claimed. + initial := append([]gamedata.BattleReward(nil), leaves...) + for _, r := range initial { + rows := e.design.Attendance[r.ID] + if r.Type != 19 || len(rows) == 0 { + continue + } + sub := s.Subscriptions[strconv.FormatUint(r.ID, 10)] + if sub.Start != 0 && (sub.Expiry == 0 || sub.Expiry > e.now().UnixMilli()) { + continue + } + first, resolveErr := e.graph.ResolveGranted([]gamedata.BattleReward{rows[0].Reward}) + if resolveErr != nil { + return nil, resolveErr + } + available := map[[2]uint64]uint64{} + for _, leaf := range append(append([]gamedata.BattleReward(nil), leaves...), mailed...) { + k := [2]uint64{leaf.Type, leaf.ID} + available[k] += leaf.Count + } + for _, leaf := range first { + k := [2]uint64{leaf.Type, leaf.ID} + if available[k] < leaf.Count { + leaf.Count -= available[k] + leaves = append(leaves, leaf) + } + } + } + var regular []gamedata.Reward + var special []assets.Item + for _, r := range leaves { + if r.Count == 0 || r.Count > math.MaxInt32 { + return nil, fmt.Errorf("commerce: invalid entitlement quantity") + } + switch { + case r.Type == 62: + if !e.design.AvatarSets[r.ID] { + return nil, fmt.Errorf("commerce: unknown avatar set %d", r.ID) + } + // The shared gameplay economy expands AvatarSetTable members and + // emits real AvatarItem/AvatarMotion/AvatarChar ownership. + regular = append(regular, gamedata.Reward(r)) + case r.Type == 19 && e.design.TicketTypes[r.ID] == 2: + if len(e.design.Attendance[r.ID]) == 0 { + return nil, fmt.Errorf("commerce: subscription reward schedule missing") + } + now := e.now().UnixMilli() + expiry := max(int64(e.items.ContentTicketExpiry(r.ID)), now) + if r.Count > uint64((math.MaxInt64-expiry)/(30*86400000)) { + return nil, fmt.Errorf("commerce: subscription expiry overflow") + } + expiry += int64(r.Count) * 30 * 86400000 + special = append(special, assets.Item{Type: 19, ID: r.ID, Count: r.Count, ExpiryTime: uint64(expiry), TimeValue: uint64(e.now().UnixMilli())}) + key := strconv.FormatUint(r.ID, 10) + sub := s.Subscriptions[key] + if sub.Start == 0 || sub.Expiry <= now { + sub = cashSubscription{Start: now, Claimed: 1, LastDay: e.day()} + } + sub.Expiry = expiry + s.Subscriptions[key] = sub + default: + regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + if r.Type == 19 && e.design.TicketTypes[r.ID] == 3 && len(e.design.Attendance[r.ID]) > 0 { + key := strconv.FormatUint(r.ID, 10) + if _, exists := s.Subscriptions[key]; !exists { + s.Subscriptions[key] = cashSubscription{Start: e.now().UnixMilli(), Claimed: 1, LastDay: e.day()} + } + } + } + } + bundle, err := e.base.Apply(ctx, identity+":base", costs, regular) + if err != nil { + return nil, err + } + items, err := e.items.GrantCommerceOnce(ctx, identity+":special", special) + if err != nil { + return nil, err + } + for _, i := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(i)) + bundle = wire.AppendBytes(bundle, 6, assets.ItemWire(assets.Item{ID: i.ID, Type: i.Type, Count: i.Count})) + } + s.Receipts[identity] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)} + if err = e.save(ctx, s); err != nil { + return nil, err + } + return bundle, nil +} + +// ClaimSubscriptions grants the next row once per server reset day. Missing +// login days are not retroactively claimed. First-row purchase rewards are +// already present in the product box, matching the client's first-row marker. +func (e *EntitlementEconomy) ClaimSubscriptions(ctx command.Context, identity string) ([]byte, error) { + + if identity == "" { + return nil, fmt.Errorf("commerce: missing attendance receipt identity") + } + s, err := e.load(ctx) + if err != nil { + return nil, err + } + receiptKey := "attendance-reply:" + identity + if receipt, ok := s.Receipts[receiptKey]; ok { + return append([]byte(nil), receipt.Bundle...), nil + } + keys := make([]string, 0, len(s.Subscriptions)) + for k := range s.Subscriptions { + keys = append(keys, k) + } + sort.Strings(keys) + var bundle []byte + for _, key := range keys { + sub := s.Subscriptions[key] + ticket, _ := strconv.ParseUint(key, 10, 64) + rows := e.design.Attendance[ticket] + if sub.LastDay == e.day() || sub.Expiry != 0 && sub.Expiry <= e.now().UnixMilli() || sub.Expiry == 0 && sub.Claimed >= uint64(len(rows)) { + continue + } + index := sub.Claimed % uint64(len(rows)) + r := rows[index] + grant, err := e.apply(ctx, fmt.Sprintf("commerce:attendance:%s:%s", key, e.day()), nil, []gamedata.Reward{{Type: r.Reward.Type, ID: r.Reward.ID, Count: r.Reward.Count}}) + if err != nil { + return nil, err + } + bundle = append(bundle, grant...) + // apply persists its receipt; reload before saving progress so it survives. + latest, err := e.load(ctx) + if err != nil { + return nil, err + } + sub.Claimed++ + sub.LastDay = e.day() + latest.Subscriptions[key] = sub + if err = e.save(ctx, latest); err != nil { + return nil, err + } + s = latest + } + latest, err := e.load(ctx) + if err != nil { + return nil, err + } + digest := sha256.Sum256([]byte(receiptKey)) + latest.Receipts[receiptKey] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)} + if err = e.save(ctx, latest); err != nil { + return nil, err + } + return bundle, nil +} + +func (e *EntitlementEconomy) MergeAttendance(ctx command.Context, response []byte) ([]byte, error) { + + s, err := e.load(ctx) + if err != nil { + return nil, err + } + out := append([]byte(nil), response...) + keys := make([]string, 0, len(s.Subscriptions)) + for k := range s.Subscriptions { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + sub := s.Subscriptions[key] + ticket, _ := strconv.ParseUint(key, 10, 64) + if sub.Expiry != 0 { + b := wire.AppendVarint(nil, 1, ticket) + b = wire.AppendVarint(b, 2, uint64(sub.Start)) + b = wire.AppendVarint(b, 3, uint64(sub.Expiry)) + out = wire.AppendBytes(out, 3, b) + } + if typ := e.design.AttendanceTypes[ticket]; typ != 0 { + rewards := []byte{} + n := min(sub.Claimed, uint64(len(e.design.Attendance[ticket]))) + for i := uint64(1); i <= n; i++ { + rewards = wire.AppendVarint(rewards, 1, i) + } + entry := wire.AppendVarint(nil, 1, typ) + entry = wire.AppendBytes(entry, 2, rewards) + out = wire.AppendBytes(out, 7, entry) + } else { + for i := uint64(1); i <= sub.Claimed; i++ { + b := wire.AppendVarint(nil, 1, ticket) + b = wire.AppendVarint(b, 2, i) + b = wire.AppendVarint(b, 3, 1) + out = wire.AppendBytes(out, 4, b) + } + } + } + return out, nil +} + +// ApplyPurchase selects rewards once, activates direct entitlements, and issues +// durable cash mail. All writes run in the enclosing account transaction. +func (e *EntitlementEconomy) ApplyPurchase(ctx command.Context, identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + + if e.mail == nil { + return nil, fmt.Errorf("commerce: cash mail issuer unavailable") + } + definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) + digest := sha256.Sum256(definition) + key := identity + ":delivery" + state, err := e.load(ctx) + if err != nil { + return nil, err + } + if receipt, ok := state.Receipts[key]; ok { + if !bytes.Equal(receipt.Definition, digest[:]) { + return nil, fmt.Errorf("commerce: delivery identity reused") + } + return append([]byte(nil), receipt.Bundle...), nil + } + resolver, ok := e.graph.(interface { + ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error) + }) + if !ok { + return nil, fmt.Errorf("commerce: missing cash delivery resolver") + } + input := make([]gamedata.BattleReward, len(rewards)) + for i, r := range rewards { + input[i] = gamedata.BattleReward(r) + } + plan, err := resolver.ResolveDelivery(input) + if err != nil { + return nil, err + } + direct := make([]gamedata.Reward, len(plan.Direct)) + for i, r := range plan.Direct { + direct[i] = gamedata.Reward(r) + } + var mailed []gamedata.BattleReward + for _, mail := range plan.Mail { + mailed = append(mailed, mail.Rewards...) + } + bundle, err := e.applyPrepared(ctx, identity+":direct", costs, direct, true, mailed) + if err != nil { + return nil, err + } + for i, mail := range plan.Mail { + attachments := make([]gamedata.Reward, len(mail.Rewards)) + for j, r := range mail.Rewards { + attachments[j] = gamedata.Reward(r) + } + if err := e.mail.IssueCashOnce(ctx, fmt.Sprintf("%s:%d", identity, i), mail.TemplateID, attachments, e.now()); err != nil { + return nil, err + } + } + state, err = e.load(ctx) + if err != nil { + return nil, err + } + state.Receipts[key] = entitlementReceipt{Definition: digest[:], Bundle: append([]byte(nil), bundle...)} + if err = e.save(ctx, state); err != nil { + return nil, err + } + return bundle, nil +} + +// Both clear-info fields are ordinary proto3 messages, not a oneof. The native +// client creates an empty message for the inactive kind (CommonPacket), and +// omits scalar zero values such as ClearPackagePack and the normal pack level. +func parseClearClaim(request []byte) (clearClaim, error) { + var scalar [2]uint64 + var rows [2][]byte + var seen [4]bool + err := wire.Walk(request, func(f wire.Field) error { + if f.Number < 1 || f.Number > 4 { + return nil + } + if seen[f.Number-1] { + return fmt.Errorf("commerce: duplicate clear claim field %d", f.Number) + } + seen[f.Number-1] = true + if f.Number <= 2 { + if f.Type != 0 { + return fmt.Errorf("commerce: invalid clear claim scalar %d", f.Number) + } + scalar[f.Number-1], _ = binary.Uvarint(f.Value) + if scalar[f.Number-1] > math.MaxInt32 { + return fmt.Errorf("commerce: clear claim scalar %d exceeds int32", f.Number) + } + } else { + if f.Type != 2 { + return fmt.Errorf("commerce: invalid clear claim row %d", f.Number) + } + rows[f.Number-3] = f.Value + } + return nil + }) + if err != nil { + return clearClaim{}, err + } + if scalar[0] == 0 { + return clearClaim{}, fmt.Errorf("commerce: invalid clear claim sequence") + } + kind := scalar[1] + if kind > 1 { + return clearClaim{}, fmt.Errorf("commerce: invalid clear claim type") + } + if !seen[kind+2] { + return clearClaim{}, fmt.Errorf("commerce: missing clear claim row") + } + var active [4]uint64 + for i, row := range rows { + var values [4]uint64 + var fields [4]bool + if err := wire.Walk(row, func(f wire.Field) error { + if f.Number < 1 || f.Number > 4 { + // Unknown active fields retain normal protobuf compatibility. An + // inactive row must contain only the schema's default scalars. + if uint64(i) != kind { + return fmt.Errorf("commerce: nonempty inactive clear claim row") + } + return nil + } + if fields[f.Number-1] || f.Type != 0 { + return fmt.Errorf("commerce: invalid clear claim row scalar %d", f.Number) + } + fields[f.Number-1] = true + v, _ := binary.Uvarint(f.Value) + if v > math.MaxInt32 { + return fmt.Errorf("commerce: clear claim row scalar %d exceeds int32", f.Number) + } + if uint64(i) != kind && v != 0 { + return fmt.Errorf("commerce: conflicting inactive clear claim row") + } + values[f.Number-1] = v + return nil + }); err != nil { + return clearClaim{}, err + } + if uint64(i) == kind { + active = values + } + } + if active[0] == 0 || active[1] == 0 { + return clearClaim{}, fmt.Errorf("commerce: invalid clear claim identity") + } + return clearClaim{kind, active[0], active[1], active[2], active[3]}, nil +} + +func (s *ClearPackages) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/ClearPackageReward" { + return 0, nil, false, nil + } + claim, err := parseClearClaim(request) + if err != nil { + return 286, nil, true, err + } + d, ok := s.design[claim] + if !ok { + return 286, nil, true, fmt.Errorf("commerce: unknown clear reward") + } + + v, err := s.load(ctx) + if err != nil { + return 286, nil, true, err + } + identity := clearClaimID(claim) + if receipt, ok := v[identity]; ok { + return 286, append([]byte(nil), receipt.Response...), true, nil + } + if d.Type == 1 && !s.entitled(ctx, d.TicketID) { + return 286, nil, true, fmt.Errorf("commerce: clear reward premium ticket required") + } + proof := s.packCleared + if claim.Kind == 1 { + proof = s.towerCleared + } + if proof == nil || !proof(d.TargetID, d.Level) { + return 286, nil, true, fmt.Errorf("commerce: clear reward progression incomplete") + } + rewards := []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}} + var bundle []byte + if delivery, ok := s.economy.(interface { + ApplyPurchase(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) + }); ok { + // Clear reward groups carry MailId just like cash products. The + // delivery resolver selects the versioned mail template and keeps + // mailed contents out of the direct inventory response. + bundle, err = delivery.ApplyPurchase(ctx, identity, nil, rewards) + } else { + bundle, err = s.economy.Apply(ctx, identity, nil, rewards) + } + if err != nil { + return 286, nil, true, err + } + response := wire.AppendBytes(nil, 1, bundle) + v[identity] = clearClaimReceipt{Claim: claim, Response: response} + raw, err := json.Marshal(v) + if err == nil { + err = s.store.Save(ctx.State, "commerce_clear_claims", raw) + } + return 286, response, true, err +} + +// RewardDBInfos is attached to PackInfoResponse fields 3 and 4 so reconnects +// restore claimed reward buttons from server state. +func (s *ClearPackages) RewardDBInfos(ctx command.Context) (pack, evil [][]byte, err error) { + + v, err := s.load(ctx) + if err != nil { + return nil, nil, err + } + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + c := v[k].Claim + b := wire.AppendVarint(nil, 1, c.GroupID) + b = wire.AppendVarint(b, 2, c.TicketID) + b = wire.AppendVarint(b, 3, c.TargetID) + b = wire.AppendVarint(b, 4, c.Level) + if c.Kind == 0 { + pack = append(pack, b) + } else { + evil = append(evil, b) + } + } + return pack, evil, nil +} + +func (s *CashBonuses) rewarded(v bonusState, group bonusGroup, field int) []byte { + var out []byte + for _, row := range s.rewards[group] { + if v.Claims[bonusClaimID(group, row.ID)] { + out = wire.AppendVarint(out, field, row.ID) + } + } + return out +} + +func (s *CashBonuses) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + code := 588 + if path == "/CashBonusReward" { + code = 589 + } else if path != "/CashBonusInfo" { + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 || seq > math.MaxInt32 { + return code, nil, true, fmt.Errorf("commerce: invalid cash bonus sequence") + } + if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil { + return code, nil, true, err + } + + v, err := s.load(ctx) + if err != nil { + return code, nil, true, err + } + if path == "/CashBonusInfo" { + var out []byte + for _, group := range s.ordered { + n, err := s.counts.LifetimePurchaseTotal(ctx, s.groups[group]) + if err != nil { + return code, nil, true, err + } + // Preserve the native empty response for groups with no purchases. + if n == 0 { + continue + } + b := wire.AppendVarint(nil, 1, group.ProductGroup) + b = wire.AppendVarint(b, 2, group.ContentsGroup) + b = wire.AppendVarint(b, 3, n) + b = append(b, s.rewarded(v, group, 4)...) + out = wire.AppendBytes(out, 1, b) + } + return code, out, true, nil + } + if commandSession == "" { + return code, nil, true, fmt.Errorf("commerce: cash bonus session unavailable") + } + identity := fmt.Sprintf("cash-bonus:%x:%d", sha256.Sum256([]byte(commandSession)), seq) + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + if receipt, ok := v.Receipts[identity]; ok { + if receipt.Digest != digest { + return code, nil, true, fmt.Errorf("commerce: conflicting cash bonus replay") + } + return code, append([]byte(nil), receipt.Response...), true, nil + } + groupID, _, err := wire.Varint(request, 2) + if err != nil { + return code, nil, true, err + } + contents, _, err := wire.Varint(request, 3) + if err != nil { + return code, nil, true, err + } + id, _, err := wire.Varint(request, 4) + if err != nil { + return code, nil, true, err + } + group := bonusGroup{groupID, contents} + var chosen *gamedata.CashBonusReward + for _, row := range s.rewards[group] { + if row.ID == id { + copy := row + chosen = © + break + } + } + if chosen == nil { + return code, nil, true, fmt.Errorf("commerce: unknown cash bonus group=%d contents=%d bonus=%d", groupID, contents, id) + } + n, err := s.counts.LifetimePurchaseTotal(ctx, s.groups[group]) + if err != nil { + return code, nil, true, err + } + if n < chosen.RequireCount { + return code, nil, true, fmt.Errorf("commerce: cash bonus purchase threshold not reached group=%d contents=%d bonus=%d count=%d require=%d", groupID, contents, id, n, chosen.RequireCount) + } + claimID := bonusClaimID(group, id) + var bundle []byte + if !v.Claims[claimID] { + bundle, err = s.economy.Apply(ctx, "cash-bonus-grant:"+claimID, nil, []gamedata.Reward{chosen.Reward}) + if err != nil { + return code, nil, true, err + } + v.Claims[claimID] = true + } + response := wire.AppendBytes(nil, 1, bundle) + response = append(response, s.rewarded(v, group, 2)...) + v.Receipts[identity] = purchaseReceipt{Digest: digest, Response: response} + raw, err := json.Marshal(v) + if err == nil { + err = s.store.Save(ctx.State, "commerce_cash_bonuses", raw) + } + return code, response, true, err +} + +func (h AttendanceHandler) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + if path != "/Attendance" { + return 0, nil, false, nil + } + if commandSession == "" || 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(ctx, 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(commandSession)), seq) + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + var receipt attendanceReceipt + previous, err := h.Store.Load(ctx.State, 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(ctx, 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(ctx, 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(ctx.State, key, raw); err != nil { + return code, nil, true, err + } + } + response, err = h.Economy.MergeAttendance(ctx, 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 +} diff --git a/go/internal/server/domain/commerce/service.go b/go/internal/server/domain/commerce/service.go new file mode 100644 index 0000000..fb39efd --- /dev/null +++ b/go/internal/server/domain/commerce/service.go @@ -0,0 +1,242 @@ +package commerce + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + + "time" +) + +// Economy and the receipt store must participate in the caller's account +// transaction. A failed hook, delegate or save then rolls back all purchases. +type Economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} +type CountProvider interface { + PurchaseCountDBInfos(ctx command.Context) [][]byte +} +type PurchaseDelegate func(command.Context, gamedata.CashProductKey, []byte) ([]byte, bool, error) +type PurchaseHook func(command.Context, string, gamedata.CashProductDesign, uint64) error +type purchaseReceipt struct { + Digest string `json:"digest"` + Response []byte `json:"response"` +} +type purchaseCount struct { + Count uint64 `json:"count"` + Period string `json:"period"` + Lifetime uint64 `json:"lifetime"` +} +type purchaseState struct { + Receipts map[string]purchaseReceipt `json:"receipts"` + Billing map[string]string `json:"billing"` + Counts map[string]purchaseCount `json:"counts"` +} +type Service struct { + catalog *Catalog + store stateio.Store + economy Economy + now func() time.Time + resetSeconds int64 + delegate PurchaseDelegate + specialProducts map[gamedata.CashProductKey]bool + hook PurchaseHook + legacy CountProvider + shopProducts [][]byte + shopWindows map[gamedata.CashProductKey][2]uint64 + eventShopWindows map[gamedata.CashProductKey][][2]uint64 + eventShopGroups map[uint64]bool + predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey +} + +func NewService(ctx command.Context, catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) { + if catalog == nil || store == nil || economy == nil { + return nil, fmt.Errorf("commerce: invalid service dependencies") + } + s := &Service{catalog: catalog, store: store, economy: economy, now: time.Now} + _, err := s.load(ctx) + return s, err +} +func (s *Service) AttachDelegate(d PurchaseDelegate) { s.delegate = d } +func (s *Service) AttachPurchaseHook(h PurchaseHook) { s.hook = h } +func (s *Service) AttachLegacyCounts(p CountProvider) { s.legacy = p } + +// AttachPackageRules enforces the client's type-2/type-8 step and relay +// ordering using versioned contentsGroupId/contentsSortId, never SKU numbers. +func (s *Service) AttachPackageRules(packages []gamedata.CashPackageDesign) error { + rules := map[gamedata.CashProductKey][]gamedata.CashProductKey{} + for _, p := range packages { + if (p.PackageType != 2 && p.PackageType != 8) || p.ContentsGroupID == 0 || p.ContentsSortID <= 1 { + continue + } + key := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup} + var prior []gamedata.CashProductKey + for _, q := range packages { + if q.PackageType == p.PackageType && q.ContentsGroupID == p.ContentsGroupID && q.ContentsSortID == p.ContentsSortID-1 { + prior = append(prior, gamedata.CashProductKey{GroupID: q.GroupID, ProductID: q.ID, SaleGroup: q.SaleGroup}) + } + } + // Some current-version packages have a sort label above one but an + // independent contents group. Only a real same-group predecessor is a + // progression rule; do not invent links between adjacent product IDs. + if len(prior) == 0 { + continue + } + rules[key] = prior + } + s.predecessors = rules + return nil +} + +func (s *Service) available(d gamedata.CashProductDesign) bool { + if s.eventShopGroups[d.Key.GroupID] { + now := uint64(s.now().UnixMilli()) + for _, w := range s.eventShopWindows[d.Key] { + if w[0] <= now && now < w[1] { + return true + } + } + return false + } + w, ok := s.shopWindows[d.Key] + if !ok { + return d.TimeLimitType == 0 + } + now := uint64(s.now().UnixMilli()) + return w[0] <= now && (w[1] == 0 || now < w[1]) +} +func (s *Service) IsAvailable(ctx command.Context, key gamedata.CashProductKey) bool { + d, ok := s.catalog.Design(key) + return ok && s.available(d) +} + +func (s *Service) SetClock(now func() time.Time, resetSeconds int64) { + s.now = now + s.resetSeconds = resetSeconds +} +func skuKey(k gamedata.CashProductKey) string { + return fmt.Sprintf("%d:%d:%d", k.GroupID, k.ProductID, k.SaleGroup) +} +func (s *Service) load(ctx command.Context) (purchaseState, error) { + v := purchaseState{Receipts: map[string]purchaseReceipt{}, Billing: map[string]string{}, Counts: map[string]purchaseCount{}} + b, e := s.store.Load(ctx.State, "commerce") + if e != nil || b == nil { + return v, e + } + if e = stateio.RequireExactJSONObject(b, "receipts", "billing", "counts"); e != nil { + return v, e + } + if e = json.Unmarshal(b, &v); e != nil { + return v, e + } + if v.Receipts == nil || v.Billing == nil || v.Counts == nil { + return v, fmt.Errorf("commerce: invalid state") + } + for identity, receipt := range v.Receipts { + digest, err := hex.DecodeString(receipt.Digest) + if identity == "" || err != nil || len(digest) != sha256.Size || len(receipt.Response) == 0 { + return v, fmt.Errorf("commerce: invalid saved receipt") + } + } + for _, identity := range v.Billing { + if _, ok := v.Receipts[identity]; !ok { + return v, fmt.Errorf("commerce: billing identity has no purchase receipt") + } + } + for key, count := range v.Counts { + if key == "" || count.Period == "" || count.Count == 0 || count.Count > math.MaxInt32 || count.Lifetime < count.Count { + return v, fmt.Errorf("commerce: invalid saved purchase count") + } + } + return v, nil +} +func (s *Service) period(typ uint64) string { + t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second) + switch typ { + case 1: + return t.Format("2006-01-02") + case 2: + y, w := t.ISOWeek() + return fmt.Sprintf("%d-W%02d", y, w) + case 3: + return t.Format("2006-01") + default: + return "account" + } +} +func (s *Service) count(v purchaseState, d gamedata.CashProductDesign) uint64 { + c := v.Counts[skuKey(d.Key)] + if c.Period != s.period(d.PurchaseLimitType) { + return 0 + } + return c.Count +} + +type buyLine struct { + Key gamedata.CashProductKey + Count uint64 +} + +// LifetimePurchaseTotal derives cumulative bonuses from committed purchases, +// including previous reset periods, without keeping a second purchase counter. +func (s *Service) LifetimePurchaseTotal(ctx command.Context, keys []gamedata.CashProductKey) (uint64, error) { + + v, err := s.load(ctx) + if err != nil { + return 0, err + } + var total uint64 + for _, k := range keys { + n := v.Counts[skuKey(k)].Lifetime + if n > math.MaxInt32-total { + return 0, fmt.Errorf("commerce: bonus purchase count overflow") + } + total += n + } + return total, nil +} +func (s *Service) HasPurchased(ctx command.Context, k gamedata.CashProductKey) bool { + + v, e := s.load(ctx) + if e != nil { + return false + } + return v.Counts[skuKey(k)].Count > 0 +} + +// ConsumeEntitlement is an authorization callback within the caller's account +// transaction. Each successful cash activation consumes one lifetime purchase. +// It must not be called recursively from the purchase hook or delegate. +func (s *Service) ConsumeEntitlement(ctx command.Context, k gamedata.CashProductKey) bool { + + v, err := s.load(ctx) + if err != nil { + return false + } + n := v.Counts[skuKey(k)].Lifetime + used := map[string]uint64{} + raw, err := s.store.Load(ctx.State, "commerce_pass_receipts") + if err != nil { + return false + } + if raw != nil { + if err = json.Unmarshal(raw, &used); err != nil || used == nil { + return false + } + } + key := skuKey(k) + if used[key] >= n { + return false + } + used[key]++ + raw, err = json.Marshal(used) + if err != nil { + return false + } + return s.store.Save(ctx.State, "commerce_pass_receipts", raw) == nil +} diff --git a/go/internal/server/domain/events/actions/battle.go b/go/internal/server/domain/events/actions/battle.go new file mode 100644 index 0000000..4c26fcf --- /dev/null +++ b/go/internal/server/domain/events/actions/battle.go @@ -0,0 +1,61 @@ +package eventactions + +import ( + "bd2server/internal/server/domain/events" + "crypto/sha256" + "encoding/hex" +) + +func (s *Service) tacticsLines(uid uint64) uint64 { + v, _ := s.registry.Resolve(uid) + g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if !ok || g.V(4) > 2 { + return 0 + } + n := uint64(5) - g.V(4) + clear := s.state.Tactics[uid] + var lines uint64 + for i := range n { + row, col := true, true + for j := range n { + row = row && contains(clear, i*n+j+1) + col = col && contains(clear, j*n+i+1) + } + if row { + lines++ + } + if col { + lines++ + } + } + a, b := true, true + for i := range n { + a = a && contains(clear, i*n+i+1) + b = b && contains(clear, i*n+(n-i)) + } + if a { + lines++ + } + if b { + lines++ + } + return lines +} + +func (s *Service) AssociatedMissionGroup(v events.Schedule) uint64 { + if v.Type == 20 { + r, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if ok { + return r.V(2) + } + } + if v.Type == 23 { + r, ok := s.design.Row("VotingEventTable", 6, v.ID) + if ok { + return r.V(12) + } + } + return 0 +} +func receiptRecord(b []byte, digest string) receipt { return receipt{Digest: digest, Reply: b} } +func battleDigest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) } diff --git a/go/internal/server/domain/events/actions/mini_content.go b/go/internal/server/domain/events/actions/mini_content.go new file mode 100644 index 0000000..9d800ca --- /dev/null +++ b/go/internal/server/domain/events/actions/mini_content.go @@ -0,0 +1,64 @@ +package eventactions + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + "errors" + "fmt" +) + +type MiniContentResolver interface { + ResolveMiniContentUID(ctx command.Context, _ uint64) (uint64, uint64, int64, int64, error) + ListMiniContentRoutes(ctx command.Context) ([]gamedata.MiniContentRoute, error) +} + +func (s *Service) AttachMiniContent(ctx command.Context, resolver MiniContentResolver, design *gamedata.MiniContentDesign) error { + if resolver == nil || design == nil { + return errors.New("eventactions: mini content dependencies missing") + } + routes, err := resolver.ListMiniContentRoutes(ctx) + if err != nil { + return err + } + for _, route := range routes { + if route.ContentType == 14 { + ids := design.Groups[route.ContentID] + if len(ids) == 0 { + return fmt.Errorf("eventactions: published mini story group %d missing", route.ContentID) + } + for _, id := range ids { + if _, exists := design.Stories[id]; !exists { + return fmt.Errorf("eventactions: published daily story %d missing", id) + } + } + } + if route.ContentType == 13 { + found := false + for _, row := range s.design.Tables["NpcQuizTable"] { + if row.V(1) == route.ContentID { + found = true + break + } + } + if !found { + return fmt.Errorf("eventactions: published NPC quiz group %d missing", route.ContentID) + } + } + } + s.miniContent, s.miniDesign = resolver, design + return nil +} + +func (s *Service) resolveQuiz(ctx command.Context, uid uint64) (events.Schedule, error) { + if s.miniContent != nil { + typ, id, start, end, err := s.miniContent.ResolveMiniContentUID(ctx, uid) + if err == nil { + if typ != 13 { + return events.Schedule{}, errors.New("eventactions: content UID is not a quiz") + } + return events.Schedule{UID: uid, ID: id, Start: start, End: end}, nil + } + } + return s.registry.Resolve(uid) +} diff --git a/go/internal/server/domain/events/actions/protocol_adapter.go b/go/internal/server/domain/events/actions/protocol_adapter.go new file mode 100644 index 0000000..de9c865 --- /dev/null +++ b/go/internal/server/domain/events/actions/protocol_adapter.go @@ -0,0 +1,937 @@ +package eventactions + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + "bd2server/internal/server/protocol/wire" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + "sort" + "strconv" + "strings" + "time" +) + +func voteWire(v *vote) []byte { + b := wire.AppendVarint(nil, 1, v.Round) + b = wire.AppendVarint(b, 2, v.Candidate) + b = wire.AppendVarint(b, 3, v.Normal+v.Additional) + b = wire.AppendVarint(b, 4, v.Normal) + return wire.AppendVarint(b, 5, v.Additional) +} + +func (s *Service) voteHandle(ctx command.Context, path string, b []byte, identity string) ([]byte, error) { + v, r, ok := s.voteEvent() + if !ok { + if path == "/CharVoteInfo" || path == "/CharVoteSeasonRanking" { + return nil, nil + } + if path == "/CharVoteTotalRanking" { + return s.totalRanking(val(b, 2)), nil + } + return nil, errors.New("eventactions: voting event inactive") + } + round, rounds := s.rounds(v, r) + ids := s.candidates(r) + ids = s.advancedCandidates(v, r, round, ids) + switch path { + case "/CharVoteInfo": + out := wire.AppendVarint(nil, 1, round) + for _, id := range ids { + out = wire.AppendVarint(out, 2, id) + } + for k, x := range s.state.Votes { + var uid, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID { + out = wire.AppendBytes(out, 3, voteWire(x)) + } + } + for k := range s.state.VoteRewards { + var uid, id uint64 + if _, e := fmt.Sscanf(k, "%d/%d", &uid, &id); e == nil && uid == v.UID { + x := wire.AppendVarint(nil, 1, v.ID) + x = wire.AppendVarint(x, 2, id) + out = wire.AppendBytes(out, 4, x) + } + } + for _, id := range ids { + if s.state.Favorites[id] { + out = wire.AppendVarint(out, 5, id) + } + if s.state.NormalVoted[key(v.UID, id)] { + out = wire.AppendVarint(out, 6, id) + } + } + out = wire.AppendVarint(out, 7, uint64(s.now().UTC().Truncate(24*time.Hour).Add(24*time.Hour).UnixMilli())) + for i, x := range rounds { + start := v.Start + int64(x.V(4))*86400000 + end := v.End + if i+1 < len(rounds) { + end = v.Start + int64(rounds[i+1].V(4))*86400000 + } + a := wire.AppendVarint(nil, 1, x.V(3)) + a = wire.AppendVarint(a, 2, uint64(start)) + a = wire.AppendVarint(a, 3, uint64(end)) + out = wire.AppendBytes(out, 9, a) + } + return out, nil + case "/CharVoteFavoriteAdd", "/CharVoteFavoriteDelete": + id := val(b, 2) + if !contains(ids, id) { + return nil, errors.New("eventactions: unknown candidate") + } + if path == "/CharVoteFavoriteAdd" { + s.state.Favorites[id] = true + } else { + delete(s.state.Favorites, id) + } + return nil, nil + case "/CharVoteSave": + id, count, typ := val(b, 2), val(b, 4), val(b, 5) + if !contains(ids, id) || count == 0 || count > 2147483647 || typ > 1 || round == 0 { + return nil, errors.New("eventactions: invalid vote") + } + normalKey := key(v.UID, id) + if typ == 0 && (s.state.NormalVoted[normalKey] || count != 1) { + return nil, errors.New("eventactions: daily normal vote exhausted") + } + cost := gamedata.Reward{Type: r.V(9), ID: r.V(8), Count: r.V(7) * count} + if typ == 1 { + cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count} + } + // A first vote may use the additional ticket when the normal ticket + // is unavailable; the client still sends vote_type=0. + if typ == 0 { + var submittedID uint64 + _ = wire.Walk(b, func(f wire.Field) error { + if f.Number == 3 && f.Type == 2 { + submittedID = val(f.Value, 2) + } + return nil + }) + if submittedID == r.V(2) { + cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count} + } + } + if cost.Type == 0 || cost.Count == 0 { + return nil, errors.New("eventactions: missing vote cost") + } + vk := key(v.UID, round, id) + current := s.state.Votes[vk] + if current == nil { + current = &vote{Round: round, Candidate: id} + } + var total uint64 + for k, x := range s.state.Votes { + var uid, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID { + total += x.Normal + x.Additional + } + } + var rewards []gamedata.Reward + var newIDs []uint64 + for _, rr := range s.design.Tables["VotingCountRewardTable"] { + if rr.V(1) == r.V(11) && rr.V(6) <= total+count && !s.state.VoteRewards[key(v.UID, rr.V(2))] { + rewards = append(rewards, rr.Rewards...) + newIDs = append(newIDs, rr.V(2)) + } + } + bundle, e := s.economy.Apply(ctx, identity, []gamedata.Reward{cost}, rewards) + if e != nil { + return nil, e + } + s.state.Votes[vk] = current + if typ == 0 { + current.Normal += count + s.state.NormalVoted[normalKey] = true + } else { + current.Additional += count + } + out := wire.AppendBytes(nil, 1, bundle) + for _, id := range newIDs { + s.state.VoteRewards[key(v.UID, id)] = true + x := wire.AppendVarint(nil, 1, v.ID) + x = wire.AppendVarint(x, 2, id) + out = wire.AppendBytes(out, 2, x) + } + out = wire.AppendBytes(out, 4, voteWire(current)) + return out, nil + case "/CharVoteRanking": + wanted := val(b, 2) + if wanted == 0 { + wanted = round + } + out := wire.AppendVarint(nil, 1, v.ID) + out = wire.AppendVarint(out, 2, wanted) + for _, x := range s.rankRows(v.UID, wanted, ids) { + out = wire.AppendBytes(out, 3, x) + } + return out, nil + case "/CharVoteTotalRanking": + return s.totalRanking(val(b, 2)), nil + case "/CharVoteSeasonRanking": + var out []byte + for _, schedule := range s.registry.List() { + if schedule.Type != 23 { + continue + } + for _, id := range ids { + var total uint64 + for k, x := range s.state.Votes { + var uid, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == schedule.UID && c == id { + total += x.Normal + x.Additional + } + } + if total == 0 { + continue + } + x := wire.AppendVarint(nil, 1, schedule.ID) + x = wire.AppendVarint(x, 2, id) + x = wire.AppendVarint(x, 3, total) + x = wire.AppendVarint(x, 4, total) + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + } + return nil, errors.New("eventactions: unknown voting operation") +} + +func (s *Service) rankRows(uid, round uint64, ids []uint64) [][]byte { + type result struct{ id, count, last uint64 } + var rows []result + var totals map[uint64]uint64 + if s.voteTotals != nil { + totals, _ = s.voteTotals(uid, round) + } + for _, id := range ids { + r := result{id: id} + for k, x := range s.state.Votes { + var u, ro, c uint64 + if _, e := fmt.Sscanf(k, "%d/%d/%d", &u, &ro, &c); e == nil && u == uid && c == id && (round == 0 || round == ro) { + r.count += x.Normal + x.Additional + if ro > r.last { + r.last = ro + } + } + } + if totals != nil { + r.count = totals[id] + } + rows = append(rows, r) + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].count == rows[j].count { + return rows[i].id < rows[j].id + } + return rows[i].count > rows[j].count + }) + var out [][]byte + var lastCount, rank uint64 + for i, r := range rows { + if i == 0 || r.count != lastCount { + rank = uint64(i + 1) + } + lastCount = r.count + x := wire.AppendVarint(nil, 1, r.id) + x = wire.AppendVarint(x, 2, r.count) + x = wire.AppendVarint(x, 3, rank) + x = wire.AppendVarint(x, 4, r.last) + out = append(out, x) + } + return out +} + +func (s *Service) advancedCandidates(v events.Schedule, event gamedata.EventActionRow, round uint64, ids []uint64) []uint64 { + for previous := uint64(1); previous < round; previous++ { + var count uint64 + for _, r := range s.design.Tables["VotingRoundTable"] { + if r.V(2) == event.V(14) && r.V(3) == previous { + count = r.V(1) + } + } + if count == 0 { + continue + } + ranked := s.rankRows(v.UID, previous, ids) + var next []uint64 + for _, b := range ranked { + if val(b, 3) <= count { + next = append(next, val(b, 1)) + } + } + ids = next + } + return ids +} + +func (s *Service) totalRanking(event uint64) []byte { + out := wire.AppendVarint(nil, 1, event) + for _, v := range s.registry.List() { + if v.Type != 23 || v.ID != event { + continue + } + r, ok := s.design.Row("VotingEventTable", 6, event) + if !ok { + continue + } + for _, row := range s.rankRows(v.UID, 0, s.candidates(r)) { + out = wire.AppendBytes(out, 2, row) + } + } + return out +} + +func decodeTacticsDeck(request []byte) ([]tacticsDeckEntry, error) { + var entries []tacticsDeckEntry + indices, characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{} + err := wire.Walk(request, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return errors.New("eventactions: malformed tactics deck entry") + } + seen := map[int]bool{} + if err := wire.Walk(f.Value, func(v wire.Field) error { + if v.Number >= 1 && v.Number <= 5 { + if v.Type != 0 || seen[v.Number] { + return errors.New("eventactions: malformed tactics deck field") + } + seen[v.Number] = true + } + return nil + }); err != nil { + return err + } + e := tacticsDeckEntry{val(f.Value, 1), val(f.Value, 2), val(f.Value, 3), val(f.Value, 4), val(f.Value, 5)} + // These indices are client-generated virtual identities, not owned + // inventory. Still enforce their signed protocol range and uniqueness. + if e.index == 0 || e.index > math.MaxInt64 || e.character == 0 || e.character > math.MaxInt32 || e.costume == 0 || e.costume > math.MaxInt32 || e.position >= tacticsGridSize || e.sequence == 0 || e.sequence > tacticsPartySize || indices[e.index] || characters[e.character] || positions[e.position] || sequences[e.sequence] { + return errors.New("eventactions: invalid tactics deck") + } + indices[e.index], characters[e.character], positions[e.position], sequences[e.sequence] = true, true, true, true + entries = append(entries, e) + return nil + }) + if err != nil { + return nil, err + } + if len(entries) == 0 || len(entries) > tacticsPartySize { + return nil, errors.New("eventactions: invalid tactics deck size") + } + for sequence := uint64(1); sequence <= uint64(len(entries)); sequence++ { + if !sequences[sequence] { + return nil, errors.New("eventactions: invalid tactics deck sequence") + } + } + return entries, nil +} + +func (s *Service) validateTacticsDeck(request []byte) error { + entries, err := decodeTacticsDeck(request) + if err != nil { + return err + } + // The request only carries seq and deck_info. The client normally enters + // the stage before saving; bind the saved party to that server context. + if s.state.BattleUID != 0 { + event, err := s.resolve(s.state.BattleUID, 20) + if err != nil { + return err + } + group, ok := s.design.Row("TacticsBingoGroupTable", 3, event.ID) + stage, found := s.row("TacticsBingoTable", 4, 5, group.V(1), s.state.BattleStage) + if !ok || !found || stage.V(2) != s.state.BattleDeck { + return errors.New("eventactions: tactics battle context missing") + } + party, err := s.tacticsBlueParty(stage.V(1)) + if err != nil { + return err + } + if matchesTacticsParty(entries, party) { + return nil + } + return errors.New("eventactions: tactics deck does not match entered stage") + } + // Without a bound stage, accept only a complete authored party belonging + // to a currently active tactics schedule, never an arbitrary mixed roster. + for _, event := range s.registry.List() { + if event.Type != 20 || !s.active(event) { + continue + } + group, ok := s.design.Row("TacticsBingoGroupTable", 3, event.ID) + if !ok { + continue + } + for _, stage := range s.design.Tables["TacticsBingoTable"] { + if stage.V(4) != group.V(1) { + continue + } + party, err := s.tacticsBlueParty(stage.V(1)) + if err != nil { + return err + } + if matchesTacticsParty(entries, party) { + return nil + } + } + } + return errors.New("eventactions: tactics deck has no active authored party") +} + +func val(p []byte, n int) uint64 { v, _, _ := wire.Varint(p, n); return v } + +func (s *Service) Handle(ctx command.Context, path string, b []byte) (int, []byte, bool, error) { + codes := map[string]int{"/FieldEventSpawnInfo": 551, "/FieldEventSpawnStart": 552, "/FieldEventSpawnReward": 553, "/FireWorksInfo": 554, "/FireWorksReward": 555, "/CharVoteInfo": 581, "/CharVoteSave": 582, "/CharVoteRanking": 583, "/CharVoteSeasonRanking": 584, "/CharVoteFavoriteAdd": 585, "/CharVoteFavoriteDelete": 586, "/CharVoteTotalRanking": 587, "/FriendshipSpecialEpisodeInfo": 623, "/FriendshipSpecialEpisodeClear": 624, "/ChargeCostInfo": 123, "/NpcQuizInfo": 535, "/NpcQuizClear": 536, "/TacticsBingoInfo": 546, "/TacticsBingoDeckSave": 550} + code, ok := codes[path] + if path == "/CafeteriaEventNpcInteractionReward" { + code, ok = 414, true + } + if path == "/DailyStoryInfo" { + code, ok = 538, true + } + if path == "/DailyStoryClear" { + code, ok = 539, true + } + if !ok { + return 0, nil, false, nil + } + + seq, found, e := wire.Varint(b, 1) + if e != nil || !found || seq == 0 { + return code, nil, true, errors.New("eventactions: invalid request") + } + if e = wire.Walk(b, func(wire.Field) error { return nil }); e != nil { + return code, nil, true, e + } + sum := sha256.Sum256(append([]byte(path), b...)) + digest := hex.EncodeToString(sum[:]) + rk := ctx.SessionID + ":" + path + ":" + key(seq) + if r, ok := s.state.Receipts[rk]; ok { + if r.Digest != digest { + return code, nil, true, errors.New("eventactions: replay changed") + } + return code, r.Reply, true, nil + } + before, _ := json.Marshal(s.state) + s.roll() + out, e := s.handle(ctx, path, b, rk) + if e != nil { + _ = json.Unmarshal(before, &s.state) + return code, nil, true, e + } + s.state.Receipts[rk] = receipt{digest, out} + if e = s.save(ctx); e != nil { + _ = json.Unmarshal(before, &s.state) + return code, nil, true, e + } + return code, out, true, nil +} + +func (s *Service) spawnWire(p *spawn) []byte { + if p == nil { + return nil + } + b := wire.AppendVarint(nil, 1, uint64(p.Start)) + b = wire.AppendVarint(b, 2, p.UID) + b = wire.AppendVarint(b, 3, p.ID) + b = wire.AppendVarint(b, 4, p.Group) + var ids []string + for k := range p.Caught { + ids = append(ids, k) + } + sort.Strings(ids) + for _, k := range ids { + var g, id uint64 + _, _ = fmt.Sscanf(k, "%d/%d", &g, &id) + x := wire.AppendVarint(nil, 1, p.ID) + x = wire.AppendVarint(x, 2, g) + x = wire.AppendVarint(x, 3, id) + b = wire.AppendBytes(b, 5, x) + } + return b +} + +func (s *Service) handle(ctx command.Context, path string, b []byte, identity string) ([]byte, error) { + switch path { + case "/DailyStoryInfo", "/DailyStoryClear": + return s.dailyStory(ctx, path, b, identity) + case "/CafeteriaEventNpcInteractionReward": + return s.cafeteriaReward(ctx, b, identity) + case "/ChargeCostInfo": + if s.chargeInfo == nil { + return nil, errors.New("eventactions: charge state source unavailable") + } + return s.chargeInfo(ctx) + case "/FieldEventSpawnInfo": + uid := val(b, 2) + if _, e := s.resolve(uid, 21); e != nil { + return nil, e + } + var out []byte + if p := s.state.Spawns[uid]; p != nil { + out = wire.AppendBytes(out, 1, s.spawnWire(p)) + } + out = wire.AppendVarint(out, 2, s.state.DailyNormal) + out = wire.AppendVarint(out, 3, s.state.DailySpecial) + return out, nil + case "/FieldEventSpawnStart": + v, e := s.resolve(val(b, 2), 21) + if e != nil { + return nil, e + } + g, id := val(b, 3), val(b, 4) + r, ok := s.row("FieldSpawnEventTable", 4, 5, g, id) + if !ok || g != v.ID { + return nil, errors.New("eventactions: invalid spawn event") + } + if err := s.spawnWindow(r); err != nil { + return nil, err + } + if p := s.state.Spawns[v.UID]; p != nil { + defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) + expired := defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000 + if expired && (p.Group != g || p.ID != id) { + delete(s.state.Spawns, v.UID) + } else { + if p.Group != g || p.ID != id { + return nil, errors.New("eventactions: spawn already started") + } + return wire.AppendBytes(nil, 1, s.spawnWire(p)), nil + } + } + _ = r + s.state.Spawns[v.UID] = &spawn{v.UID, g, id, s.now().UnixMilli(), map[string]bool{}} + return wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[v.UID])), nil + case "/FieldEventSpawnReward": + v, e := s.resolve(val(b, 6), 21) + if e != nil { + return nil, e + } + p := s.state.Spawns[v.UID] + if p == nil || p.ID != val(b, 2) || p.Group != val(b, 4) { + return nil, errors.New("eventactions: spawn not started") + } + spawnRow, ok := s.row("FieldSpawnEventTable", 4, 5, p.Group, p.ID) + if !ok { + return nil, errors.New("eventactions: spawn definition absent") + } + g, id := val(b, 5), val(b, 3) + r, ok := s.row("FieldEventMonsterTable", 3, 4, g, id) + if !ok || g != spawnRow.V(2) { + return nil, errors.New("eventactions: invalid spawn monster") + } + ck := key(g, id) + if p.Caught[ck] { + // A new transport sequence must not turn a confirmed catch into + // either another grant or an error/recovery loop. + return wire.AppendBytes(nil, 1, nil), nil + } + defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) + if defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000 { + return nil, errors.New("eventactions: spawn time expired") + } + special := r.V(2) != 0 + limited := special && s.state.DailySpecial >= defaults.V(5) || !special && s.state.DailyNormal >= defaults.V(4) + if limited { + // Participation remains available after the daily reward quota. + // Completing the capture without a grant also prevents client retries. + p.Caught[ck] = true + return wire.AppendBytes(nil, 1, nil), nil + } + reward, ok := s.design.SpawnRewards[[2]uint64{r.V(5), r.V(1)}] + if !ok || reward.Count == 0 { + return nil, errors.New("eventactions: spawn reward missing") + } + bundle, e := s.economy.Apply(ctx, identity, nil, []gamedata.Reward{reward}) + if e != nil { + return nil, e + } + p.Caught[ck] = true + if special { + s.state.DailySpecial++ + } else { + s.state.DailyNormal++ + } + if s.progress != nil { + if e = s.progress(ctx, 349, r.V(2), 1); e != nil { + return nil, e + } + } + return wire.AppendBytes(nil, 1, bundle), nil + case "/FireWorksInfo": + var out []byte + for k := range s.state.Claims { + var uid, g uint64 + if _, e := fmt.Sscanf(k, "fire:%d/%d", &uid, &g); e == nil { + out = wire.AppendVarint(out, 1, g) + } + } + return out, nil + case "/FireWorksReward": + v, e := s.resolve(val(b, 2), 22) + if e != nil { + return nil, e + } + g := val(b, 3) + if g != v.ID { + return nil, errors.New("eventactions: wrong fireworks group") + } + ck := "fire:" + key(v.UID, g) + if s.state.Claims[ck] { + return nil, errors.New("eventactions: fireworks already received") + } + var rs []gamedata.Reward + for _, r := range s.design.Tables["FireworksTable"] { + if r.V(4) == g { + rs = append(rs, r.Rewards...) + break + } + } + if len(rs) == 0 { + return nil, errors.New("eventactions: fireworks design missing") + } + bundle, e := s.economy.Apply(ctx, identity, nil, rs) + if e != nil { + return nil, e + } + s.state.Claims[ck] = true + return wire.AppendBytes(nil, 1, bundle), nil + case "/FriendshipSpecialEpisodeInfo": + var out []byte + for k := range s.state.Claims { + var g, id uint64 + if _, e := fmt.Sscanf(k, "friend:%d/%d", &g, &id); e == nil { + x := wire.AppendVarint(nil, 1, g) + x = wire.AppendVarint(x, 2, id) + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + case "/FriendshipSpecialEpisodeClear": + g, id := val(b, 2), val(b, 3) + r, ok := s.row("FriendshipSpecialEpisodeTable", 5, 6, g, id) + if !ok { + return nil, errors.New("eventactions: unknown episode") + } + if s.friendship == nil || s.friendship(g) < r.V(12) { + return nil, errors.New("eventactions: friendship level insufficient") + } + if id > 1 && !s.state.Claims["friend:"+key(g, id-1)] { + return nil, errors.New("eventactions: previous episode incomplete") + } + ck := "friend:" + key(g, id) + if s.state.Claims[ck] { + return nil, errors.New("eventactions: episode already cleared") + } + rs := append([]gamedata.Reward(nil), r.Rewards...) + for _, v := range s.registry.List() { + if v.Type == 24 && v.ID == g && s.active(v) { + rs = append(rs, r.EventRewards...) + break + } + } + bundle, e := s.economy.Apply(ctx, identity, nil, rs) + if e != nil { + return nil, e + } + s.state.Claims[ck] = true + x := wire.AppendVarint(nil, 1, g) + x = wire.AppendVarint(x, 2, id) + out := wire.AppendBytes(nil, 1, bundle) + out = wire.AppendBytes(out, 2, x) + return out, nil + case "/NpcQuizInfo": + uid := val(b, 2) + v, e := s.resolveQuiz(ctx, uid) + if e != nil { + return nil, e + } + var out []byte + for _, r := range s.design.Tables["NpcQuizTable"] { + if r.V(1) != v.ID { + continue + } + if s.state.Claims["quiz:"+key(uid, r.V(1), r.V(2))] { + x := wire.AppendVarint(nil, 1, uid) + x = wire.AppendVarint(x, 2, r.V(1)) + x = wire.AppendVarint(x, 3, r.V(2)) + out = wire.AppendBytes(out, 1, x) + } + } + return out, nil + case "/NpcQuizClear": + uid, g, id := val(b, 2), val(b, 3), val(b, 4) + v, e := s.resolveQuiz(ctx, uid) + if e != nil { + return nil, e + } + if !s.active(v) { + return nil, errors.New("eventactions: quiz event inactive") + } + r, ok := s.row("NpcQuizTable", 1, 2, g, id) + if !ok || g != v.ID || s.now().UnixMilli() < v.Start+int64(r.V(8))*86400000 { + return nil, errors.New("eventactions: quiz locked") + } + ck := "quiz:" + key(uid, g, id) + if s.state.Claims[ck] { + return nil, errors.New("eventactions: quiz already cleared") + } + bundle, e := s.economy.Apply(ctx, identity, nil, r.Rewards) + if e != nil { + return nil, e + } + s.state.Claims[ck] = true + x := wire.AppendVarint(nil, 1, uid) + x = wire.AppendVarint(x, 2, g) + x = wire.AppendVarint(x, 3, id) + out := wire.AppendBytes(nil, 1, bundle) + return wire.AppendBytes(out, 2, x), nil + case "/TacticsBingoInfo": + uid := val(b, 2) + v, e := s.registry.Resolve(uid) + if e != nil { + return nil, e + } + if v.Type != 20 { + return nil, errors.New("eventactions: wrong tactics event") + } + out := wire.AppendVarint(nil, 1, uid) + out = wire.AppendVarint(out, 2, v.ID) + for _, id := range s.state.Tactics[uid] { + out = wire.AppendVarint(out, 3, id) + } + if !s.active(v) { + out = wire.AppendVarint(out, 4, 1) + } + return out, nil + case "/TacticsBingoDeckSave": + if err := s.validateTacticsDeck(b); err != nil { + return nil, err + } + s.state.Deck = append([]byte(nil), b...) + return nil, nil + default: + return s.voteHandle(ctx, path, b, identity) + } +} + +func (s *Service) dailyStory(ctx command.Context, path string, b []byte, identity string) ([]byte, error) { + if path == "/DailyStoryInfo" { + var ids []uint64 + for k, claimed := range s.state.Claims { + if claimed && strings.HasPrefix(k, "daily-story:") { + id, err := strconv.ParseUint(strings.TrimPrefix(k, "daily-story:"), 10, 64) + if err == nil { + ids = append(ids, id) + } + } + } + slices.Sort(ids) + var out []byte + for _, id := range ids { + out = wire.AppendVarint(out, 1, id) + } + return out, nil + } + id := val(b, 2) + if id == 0 || s.miniContent == nil || s.miniDesign == nil { + return nil, errors.New("eventactions: daily story unavailable") + } + reward, ok := s.miniDesign.Stories[id] + if !ok { + return nil, errors.New("eventactions: unknown daily story") + } + ck := fmt.Sprintf("daily-story:%d", id) + if s.state.Claims[ck] { + return wire.AppendVarint(wire.AppendBytes(nil, 1, nil), 2, id), nil + } + routes, err := s.miniContent.ListMiniContentRoutes(ctx) + if err != nil { + return nil, err + } + available := false + now := s.now().UnixMilli() + for _, route := range routes { + if route.ContentType != 14 || now < route.Start || now > route.End { + continue + } + for _, story := range s.miniDesign.Groups[route.ContentID] { + if story == id { + available = true + } + } + } + if !available { + return nil, errors.New("eventactions: daily story event inactive") + } + var rewards []gamedata.Reward + if reward.Count > 0 { + rewards = append(rewards, reward) + } + bundle, err := s.economy.Apply(ctx, identity, nil, rewards) + if err != nil { + return nil, err + } + s.state.Claims[ck] = true + return wire.AppendVarint(wire.AppendBytes(nil, 1, bundle), 2, id), nil +} + +func (s *Service) cafeteriaReward(ctx command.Context, req []byte, identity string) ([]byte, error) { + group, id := val(req, 2), val(req, 3) + row, ok := s.row("CafeteriaEventTable", 5, 6, group, id) + if !ok || row.V(13) == 0 || row.V(11) == 0 { + return nil, errors.New("eventactions: cafeteria interaction absent") + } + var uid uint64 + for _, v := range s.registry.List() { + if v.Type == 25 && s.active(v) && (v.SubID == group || v.SubID == 0 && v.ID == group) { + uid = v.UID + break + } + } + if uid == 0 { + return nil, errors.New("eventactions: cafeteria event inactive") + } + if len(s.design.Tables["CafeteriaDefaultTable"]) != 1 { + return nil, errors.New("eventactions: cafeteria default missing") + } + defaults := s.design.Tables["CafeteriaDefaultTable"][0] + cap := defaults.V(8) + if cap == 0 || s.state.CafeteriaCurrency >= cap { + return nil, errors.New("eventactions: cafeteria daily currency limit") + } + term := defaults.V(3) + if row.V(4) != 1 { + term = defaults.V(28) + } + receiptKey := key(uid, group, id) + now := s.now().UnixMilli() + if last := s.state.CafeteriaLast[receiptKey]; last > 0 && now-last < int64(term)*1000 { + return nil, errors.New("eventactions: cafeteria interaction cooldown") + } + count := min(row.V(11), cap-s.state.CafeteriaCurrency) + rewards := []gamedata.Reward{{Type: row.V(13), ID: row.V(12), Count: count}} + bundle, err := s.economy.Apply(ctx, identity, nil, rewards) + if err != nil { + return nil, err + } + s.state.CafeteriaCurrency += count + s.state.CafeteriaLast[receiptKey] = now + out := wire.AppendBytes(nil, 1, bundle) + out = wire.AppendVarint(out, 2, s.state.CafeteriaCurrency) + return out, nil +} + +// Tactics uses the client's custom battle stage fields 8/9 and has fixed +// tutorial decks. Character health and ordinary pack rewards do not apply. +func (s *Service) HandlesBattle(mode uint64) bool { return mode == 29 } +func (s *Service) EnterBattle(ctx command.Context, req []byte, receipt string) ([]byte, error) { + + digest := battleDigest(req) + if r, ok := s.state.Receipts[ctx.SessionID+":enter:"+receipt]; ok { + if r.Digest != digest { + return nil, errors.New("eventactions: changed battle enter replay") + } + return r.Reply, nil + } + before, _ := json.Marshal(s.state) + if val(req, 5) != 29 { + return nil, errors.New("eventactions: wrong tactics battle mode") + } + group, stage, deck := val(req, 8), val(req, 9), val(req, 4) + row, ok := s.row("TacticsBingoTable", 4, 5, group, stage) + if !ok || row.V(2) != deck { + return nil, errors.New("eventactions: invalid tactics stage/deck") + } + var uid uint64 + for _, v := range s.registry.List() { + if v.Type != 20 || !s.active(v) { + continue + } + g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if ok && g.V(1) == group { + uid = v.UID + break + } + } + if uid == 0 { + return nil, errors.New("eventactions: tactics event inactive") + } + if _, err := s.tacticsBlueParty(row.V(1)); err != nil { + return nil, err + } + // The client sends BattleEnter before the new scene's DeckSave. Its + // battle reset clears the prior stage's virtual party; do the same here. + s.state.Deck = nil + s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = uid, stage, deck + s.state.Receipts[ctx.SessionID+":enter:"+receipt] = receiptRecord(nil, digest) + if e := s.save(ctx); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + return nil, nil +} + +func (s *Service) CompleteBattle(ctx command.Context, req []byte, receipt string) ([]byte, error) { + + if r, ok := s.state.Receipts[ctx.SessionID+":battle:"+receipt]; ok { + if r.Digest != battleDigest(req) { + return nil, errors.New("eventactions: changed battle completion replay") + } + return r.Reply, nil + } + before, _ := json.Marshal(s.state) + uid, stage := s.state.BattleUID, s.state.BattleStage + if uid == 0 || stage == 0 { + return nil, errors.New("eventactions: tactics battle not entered") + } + if _, e := s.resolve(uid, 20); e != nil { + return nil, e + } + if val(req, 2) == 1 && !contains(s.state.Tactics[uid], stage) { + priorLines := s.tacticsLines(uid) + s.state.Tactics[uid] = append(s.state.Tactics[uid], stage) + if s.progress != nil { + if e := s.progress(ctx, 346, stage, 1); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + lines := s.tacticsLines(uid) + if lines > priorLines { + if e := s.progress(ctx, 347, 0, lines-priorLines); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + } + v, _ := s.registry.Resolve(uid) + g, _ := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + n := uint64(5) - g.V(4) + if uint64(len(s.state.Tactics[uid])) == n*n { + if e := s.progress(ctx, 348, 0, 1); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + } + } + } + var out []byte + for _, id := range s.state.Tactics[uid] { + out = wire.AppendVarint(out, 29, id) + } + s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = 0, 0, 0 + s.state.Receipts[ctx.SessionID+":battle:"+receipt] = receiptRecord(out, battleDigest(req)) + if e := s.save(ctx); e != nil { + _ = json.Unmarshal(before, &s.state) + return nil, e + } + return out, nil +} diff --git a/go/internal/server/domain/events/actions/service.go b/go/internal/server/domain/events/actions/service.go new file mode 100644 index 0000000..8b6b1c8 --- /dev/null +++ b/go/internal/server/domain/events/actions/service.go @@ -0,0 +1,219 @@ +// Package eventactions owns repeatable event interaction state. It uses the +// injected calendar and shared transactional reward service. +package eventactions + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + + "time" +) + +type Economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} +type receipt struct { + Digest string + Reply []byte +} +type spawn struct { + UID, Group, ID uint64 + Start int64 + Caught map[string]bool +} +type vote struct{ Round, Candidate, Normal, Additional uint64 } +type snapshot struct { + Version string + BattleUID, BattleStage, BattleDeck uint64 + Claims map[string]bool + Favorites map[uint64]bool + Votes map[string]*vote + NormalVoted map[string]bool + VoteRewards map[string]bool + Spawns map[uint64]*spawn + DailyNormal, DailySpecial uint64 + CafeteriaCurrency uint64 + CafeteriaLast map[string]int64 + Day string + Tactics map[uint64][]uint64 + Deck []byte + Receipts map[string]receipt +} +type Service struct { + store stateio.Store + design *gamedata.EventActionsDesign + registry events.Resolver + economy Economy + state snapshot + now func() time.Time + + friendship func(uint64) uint64 + chargeInfo func(ctx command.Context) ([]byte, error) + progress func(ctx command.Context, _ uint64, _ uint64, _ uint64) error + voteTotals func(uint64, uint64) (map[uint64]uint64, error) + miniContent MiniContentResolver + miniDesign *gamedata.MiniContentDesign +} + +func Open(ctx command.Context, store stateio.Store, d *gamedata.EventActionsDesign, r events.Resolver, e Economy) (*Service, error) { + if store == nil || d == nil || r == nil || e == nil { + return nil, errors.New("eventactions: missing dependency") + } + s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now} + b, err := store.Load(ctx.State, "eventactions") + if err != nil { + return nil, err + } + if b != nil { + if err = stateio.RequireExactJSONObject(b, "Version", "BattleUID", "BattleStage", "BattleDeck", "Claims", "Favorites", "Votes", "NormalVoted", "VoteRewards", "Spawns", "DailyNormal", "DailySpecial", "CafeteriaCurrency", "CafeteriaLast", "Day", "Tactics", "Deck", "Receipts"); err != nil { + return nil, err + } + if err = json.Unmarshal(b, &s.state); err != nil { + return nil, err + } + if s.state.Version != versionconfig.State() || s.state.Claims == nil || s.state.Favorites == nil || s.state.Votes == nil || s.state.NormalVoted == nil || s.state.VoteRewards == nil || s.state.Spawns == nil || s.state.Tactics == nil || s.state.Receipts == nil || s.state.CafeteriaLast == nil { + return nil, errors.New("eventactions: invalid state layout") + } + for _, v := range s.state.Votes { + if v == nil || v.Candidate == 0 || v.Round == 0 { + return nil, errors.New("eventactions: invalid saved vote") + } + } + for _, p := range s.state.Spawns { + if p == nil || p.Caught == nil || p.ID == 0 { + return nil, errors.New("eventactions: invalid saved spawn") + } + } + } else { + s.state.Version = versionconfig.State() + } + s.init() + return s, nil +} +func (s *Service) init() { + if s.state.CafeteriaLast == nil { + s.state.CafeteriaLast = map[string]int64{} + } + if s.state.Claims == nil { + s.state.Claims = map[string]bool{} + } + if s.state.Favorites == nil { + s.state.Favorites = map[uint64]bool{} + } + if s.state.Votes == nil { + s.state.Votes = map[string]*vote{} + } + if s.state.NormalVoted == nil { + s.state.NormalVoted = map[string]bool{} + } + if s.state.VoteRewards == nil { + s.state.VoteRewards = map[string]bool{} + } + if s.state.Spawns == nil { + s.state.Spawns = map[uint64]*spawn{} + } + if s.state.Tactics == nil { + s.state.Tactics = map[uint64][]uint64{} + } + if s.state.Receipts == nil { + s.state.Receipts = map[string]receipt{} + } +} + +func (s *Service) AttachFriendshipLevel(f func(uint64) uint64) { s.friendship = f } +func (s *Service) AttachChargeInfo(f func(ctx command.Context) ([]byte, error)) { s.chargeInfo = f } +func (s *Service) AttachProgress(f func(ctx command.Context, _ uint64, _ uint64, _ uint64) error) { + s.progress = f +} + +// The current server owns one account save. Its real votes are the default +// leaderboard; a future account aggregator can supply broader server totals. +func (s *Service) AttachVoteTotals(f func(uint64, uint64) (map[uint64]uint64, error)) { + s.voteTotals = f +} +func (s *Service) save(ctx command.Context) error { + b, e := json.Marshal(s.state) + if e != nil { + return e + } + return s.store.Save(ctx.State, "eventactions", b) +} + +func key(v ...uint64) string { + r := []string{} + for _, n := range v { + r = append(r, fmt.Sprint(n)) + } + return strings.Join(r, "/") +} +func (s *Service) active(v events.Schedule) bool { + return s.now().UnixMilli() >= v.Start && s.now().UnixMilli() < v.End +} +func (s *Service) resolve(uid, t uint64) (events.Schedule, error) { + v, e := s.registry.Resolve(uid) + if e != nil { + return v, e + } + if v.Type != t || !s.active(v) { + return v, errors.New("eventactions: event inactive or wrong type") + } + return v, nil +} +func (s *Service) find(t uint64) (events.Schedule, bool) { + for _, v := range s.registry.List() { + if v.Type == t && s.active(v) { + return v, true + } + } + return events.Schedule{}, false +} +func (s *Service) roll() { + day := s.now().UTC().Format("2006-01-02") + if s.state.Day != day { + s.state.Day = day + s.state.DailyNormal = 0 + s.state.DailySpecial = 0 + s.state.Spawns = map[uint64]*spawn{} + s.state.CafeteriaCurrency = 0 + s.state.CafeteriaLast = map[string]int64{} + s.state.NormalVoted = map[string]bool{} + } +} + +func (s *Service) row(table string, groupField, idField int, g, id uint64) (gamedata.EventActionRow, bool) { + for _, r := range s.design.Tables[table] { + if r.V(groupField) == g && r.V(idField) == id { + return r, true + } + } + return gamedata.EventActionRow{}, false +} + +func (s *Service) RecordTacticsClear(ctx command.Context, uid, stage uint64) error { + + v, e := s.resolve(uid, 20) + if e != nil { + return e + } + group, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) + if !ok { + return errors.New("eventactions: tactics group missing") + } + if _, ok = s.row("TacticsBingoTable", 4, 5, group.V(1), stage); !ok { + return errors.New("eventactions: invalid tactics stage") + } + if slices.Contains(s.state.Tactics[uid], stage) { + return nil + } + s.state.Tactics[uid] = append(s.state.Tactics[uid], stage) + slices.Sort(s.state.Tactics[uid]) + return s.save(ctx) +} diff --git a/go/internal/server/eventactions/spawn_window.go b/go/internal/server/domain/events/actions/spawn_window.go similarity index 95% rename from go/internal/server/eventactions/spawn_window.go rename to go/internal/server/domain/events/actions/spawn_window.go index a2152fb..cb131a1 100644 --- a/go/internal/server/eventactions/spawn_window.go +++ b/go/internal/server/domain/events/actions/spawn_window.go @@ -1,7 +1,7 @@ package eventactions import ( - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" "fmt" "time" ) diff --git a/go/internal/server/domain/events/actions/tactics_deck.go b/go/internal/server/domain/events/actions/tactics_deck.go new file mode 100644 index 0000000..4e69cbf --- /dev/null +++ b/go/internal/server/domain/events/actions/tactics_deck.go @@ -0,0 +1,57 @@ +package eventactions + +import ( + "bd2server/internal/server/design/gamedata" + + "errors" + "sort" +) + +// BattlePlayLibrary gives TacticsBingo the standard 3 by 4 grid and five +// characters. Positions are zero-based; DeckSave sequences start at one. +const tacticsGridSize = 3 * 4 +const tacticsPartySize = 5 + +type tacticsDeckEntry struct { + index, character, costume, position, sequence uint64 +} + +// SetPlayDataTarosTactics builds the first five authored CharGroup rows with +// their CharTable default costumes; player ownership never enters this path. +func (s *Service) tacticsBlueParty(group uint64) (map[uint64]uint64, error) { + var rows []gamedata.EventActionRow + for _, row := range s.design.Tables["CharGroupTable"] { + if group != 0 && row.V(2) == group { + rows = append(rows, row) + } + } + sort.Slice(rows, func(i, j int) bool { return rows[i].V(3) < rows[j].V(3) }) + if len(rows) > tacticsPartySize { + rows = rows[:tacticsPartySize] + } + if len(rows) == 0 { + return nil, errors.New("eventactions: tactics blue party missing") + } + party := map[uint64]uint64{} + for _, row := range rows { + id := row.V(1) + character, ok := s.design.Row("CharTable", 12, id) + if id == 0 || party[id] != 0 || row.V(5) == 0 || !ok || character.V(5) == 0 { + return nil, errors.New("eventactions: invalid tactics blue party design") + } + party[id] = character.V(5) + } + return party, nil +} + +func matchesTacticsParty(entries []tacticsDeckEntry, party map[uint64]uint64) bool { + if len(entries) != len(party) { + return false + } + for _, e := range entries { + if party[e.character] != e.costume { + return false + } + } + return true +} diff --git a/go/internal/server/domain/events/actions/voting.go b/go/internal/server/domain/events/actions/voting.go new file mode 100644 index 0000000..1464da7 --- /dev/null +++ b/go/internal/server/domain/events/actions/voting.go @@ -0,0 +1,46 @@ +package eventactions + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/events" + "slices" + "sort" +) + +func (s *Service) voteEvent() (events.Schedule, gamedata.EventActionRow, bool) { + v, ok := s.find(23) + if !ok { + return v, gamedata.EventActionRow{}, false + } + r, ok := s.design.Row("VotingEventTable", 6, v.ID) + return v, r, ok +} +func (s *Service) rounds(v events.Schedule, r gamedata.EventActionRow) (uint64, []gamedata.EventActionRow) { + var rows []gamedata.EventActionRow + current := uint64(0) + for _, x := range s.design.Tables["VotingRoundTable"] { + if x.V(2) == r.V(14) { + rows = append(rows, x) + } + } + sort.Slice(rows, func(i, j int) bool { return rows[i].V(3) < rows[j].V(3) }) + for _, x := range rows { + if s.now().UnixMilli() >= v.Start+int64(x.V(4))*86400000 { + current = x.V(3) + } + } + return current, rows +} +func (s *Service) candidates(r gamedata.EventActionRow) []uint64 { + var ids []uint64 + for _, x := range s.design.Tables["VotingCandidateTable"] { + if x.V(1) == r.V(10) { + ids = append(ids, x.V(2)) + } + } + slices.Sort(ids) + return ids +} +func contains(ids []uint64, id uint64) bool { + return slices.Contains(ids, id) +} diff --git a/go/internal/server/events/ap_refresh.go b/go/internal/server/domain/events/ap_refresh.go similarity index 58% rename from go/internal/server/events/ap_refresh.go rename to go/internal/server/domain/events/ap_refresh.go index ca479de..af014b9 100644 --- a/go/internal/server/events/ap_refresh.go +++ b/go/internal/server/domain/events/ap_refresh.go @@ -1,10 +1,10 @@ package events import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" "encoding/json" "fmt" "maps" @@ -17,8 +17,7 @@ type economyAPClock struct { } func (e *Economy) AttachAPRefresh(caps map[uint64]uint64, design gamedata.HuntingAPDesign) error { - e.mu.Lock() - defer e.mu.Unlock() + for typ, max := range caps { if (typ != 30 && typ != 32) || max == 0 || max > math.MaxInt32 { return fmt.Errorf("events: invalid free AP refresh") @@ -29,14 +28,14 @@ func (e *Economy) AttachAPRefresh(caps map[uint64]uint64, design gamedata.Huntin e.now = time.Now return nil } -func (e *Economy) refreshAP(s *economySnapshot) error { +func (e *Economy) refreshAP(ctx command.Context, s *economySnapshot) error { if len(e.apCaps) == 0 { return nil } now := e.now().UnixMilli() offset := (e.resetSeconds - 9*3600) * 1000 next := ((now-offset)/86400000+1)*86400000 + offset - b, err := e.store.Load("eventapclock") + b, err := e.store.Load(ctx.State, "eventapclock") if err != nil { return err } @@ -60,27 +59,26 @@ func (e *Economy) refreshAP(s *economySnapshot) error { if err != nil { return err } - if err = e.store.Save("eventapclock", raw); err != nil { + if err = e.store.Save(ctx.State, "eventapclock", raw); err != nil { return err } raw, err = json.Marshal(s) if err != nil { return err } - return e.store.Save("event_economy", raw) + return e.store.Save(ctx.State, "event_economy", raw) } -func (e *Economy) CanApply(costs []gamedata.Reward) error { - e.mu.Lock() - defer e.mu.Unlock() - s, err := e.load() +func (e *Economy) CanApply(ctx command.Context, costs []gamedata.Reward) error { + + s, err := e.load(ctx) if err != nil { return err } - if err = e.refreshAP(&s); err != nil { + if err = e.refreshAP(ctx, &s); err != nil { return err } var wallet, ap []gamedata.Reward - var items []player.Item + var items []assets.Item totals := map[[2]uint64]uint64{} for _, r := range costs { k := [2]uint64{r.Type, r.ID} @@ -101,7 +99,7 @@ func (e *Economy) CanApply(costs []gamedata.Reward) error { return fmt.Errorf("events: insufficient currency") } case inventoryType(r.Type): - selected, err := e.items.SelectMutable(r.Type, r.ID, n) + selected, err := e.items.SelectMutable(ctx, r.Type, r.ID, n) if err != nil { return err } @@ -110,54 +108,19 @@ func (e *Economy) CanApply(costs []gamedata.Reward) error { return fmt.Errorf("events: unsupported cost type %d", r.Type) } } - if err = e.wallet.CanExchange(wallet, nil); err != nil { + if err = e.wallet.CanExchange(ctx, wallet, nil); err != nil { return err } if len(ap) > 0 { if e.hunting == nil { return fmt.Errorf("events: AP unavailable") } - if err = e.hunting.CanExchangeAP(ap, nil); err != nil { + if err = e.hunting.CanExchangeAP(ctx, ap, nil); err != nil { return err } } if len(items) > 0 { - return e.items.CanConsume(items) + return e.items.CanConsume(ctx, items) } return nil } - -func (e *Economy) ChargeInfo() ([]byte, error) { - e.mu.Lock() - defer e.mu.Unlock() - s, err := e.load() - if err != nil { - return nil, err - } - if err = e.refreshAP(&s); err != nil { - return nil, err - } - raw, err := e.store.Load("eventapclock") - if err != nil { - return nil, err - } - if raw == nil { - return nil, nil - } - var clock economyAPClock - if err = json.Unmarshal(raw, &clock); err != nil { - return nil, err - } - var out []byte - for _, typ := range []uint64{30, 32} { - if _, ok := e.apCaps[typ]; !ok { - continue - } - info := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000)) - item := wire.AppendVarint(nil, 3, typ) - item = wire.AppendVarint(item, 4, s.Balances[typ]) - info = wire.AppendBytes(info, 2, item) - out = wire.AppendBytes(out, 1, info) - } - return out, nil -} diff --git a/go/internal/server/domain/events/boxes.go b/go/internal/server/domain/events/boxes.go new file mode 100644 index 0000000..63524f1 --- /dev/null +++ b/go/internal/server/domain/events/boxes.go @@ -0,0 +1,21 @@ +package events + +import ( + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "fmt" +) + +type BoxService struct { + items *assets.Inventory + economy *Economy + store stateio.Store +} +type boxReceipt struct{ Request, Response []byte } + +func OpenBoxes(store stateio.Store, items *assets.Inventory, economy *Economy) (*BoxService, error) { + if store == nil || items == nil || economy == nil { + return nil, fmt.Errorf("events: invalid box runtime") + } + return &BoxService{store: store, items: items, economy: economy}, nil +} diff --git a/go/internal/server/events/buff_rewards.go b/go/internal/server/domain/events/buff_rewards.go similarity index 79% rename from go/internal/server/events/buff_rewards.go rename to go/internal/server/domain/events/buff_rewards.go index 9bbb371..214a39c 100644 --- a/go/internal/server/events/buff_rewards.go +++ b/go/internal/server/domain/events/buff_rewards.go @@ -7,10 +7,10 @@ import ( "fmt" "math" "sort" - "sync" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" ) type buffRewardState struct { @@ -18,22 +18,21 @@ type buffRewardState struct { Receipts map[string]string } type BuffRewards struct { - mu sync.Mutex store stateio.Store design map[uint64]gamedata.PictorialBuffStat } -func OpenBuffRewards(store stateio.Store, design map[uint64]gamedata.PictorialBuffStat) (*BuffRewards, error) { +func OpenBuffRewards(ctx command.Context, store stateio.Store, design map[uint64]gamedata.PictorialBuffStat) (*BuffRewards, error) { if store == nil || len(design) == 0 { return nil, fmt.Errorf("events: buff reward dependencies unavailable") } s := &BuffRewards{store: store, design: design} - _, err := s.load() + _, err := s.load(ctx) return s, err } -func (s *BuffRewards) load() (buffRewardState, error) { +func (s *BuffRewards) load(ctx command.Context) (buffRewardState, error) { v := buffRewardState{Counts: map[uint64]uint64{}, Receipts: map[string]string{}} - b, err := s.store.Load("buff_item_rewards") + b, err := s.store.Load(ctx.State, "buff_item_rewards") if err != nil || b == nil { return v, err } @@ -61,16 +60,15 @@ func (s *BuffRewards) Validate(rewards []gamedata.Reward) error { } return nil } -func (s *BuffRewards) GrantOnce(identity string, rewards []gamedata.Reward) error { +func (s *BuffRewards) GrantOnce(ctx command.Context, identity string, rewards []gamedata.Reward) error { if identity == "" { return fmt.Errorf("events: empty buff reward identity") } if err := s.Validate(rewards); err != nil { return err } - s.mu.Lock() - defer s.mu.Unlock() - v, err := s.load() + + v, err := s.load(ctx) if err != nil { return err } @@ -94,12 +92,11 @@ func (s *BuffRewards) GrantOnce(identity string, rewards []gamedata.Reward) erro if err != nil { return err } - return s.store.Save("buff_item_rewards", raw) + return s.store.Save(ctx.State, "buff_item_rewards", raw) } -func (s *BuffRewards) SnapshotBuffs() ([]gamedata.PictorialBuffStat, error) { - s.mu.Lock() - defer s.mu.Unlock() - v, err := s.load() +func (s *BuffRewards) SnapshotBuffs(ctx command.Context) ([]gamedata.PictorialBuffStat, error) { + + v, err := s.load(ctx) if err != nil { return nil, err } diff --git a/go/internal/server/domain/events/calendar/binary.go b/go/internal/server/domain/events/calendar/binary.go new file mode 100644 index 0000000..bf3f33e --- /dev/null +++ b/go/internal/server/domain/events/calendar/binary.go @@ -0,0 +1,75 @@ +package calendar + +import ( + "fmt" +) + +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) 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) 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) 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 +} + +// MarshalBinary emits format 1's fixed-order record payload and checksum. + +// UnmarshalBinary rejects damaged, unsupported, oversized and trailing data. diff --git a/go/internal/server/calendar/load.go b/go/internal/server/domain/events/calendar/load.go similarity index 61% rename from go/internal/server/calendar/load.go rename to go/internal/server/domain/events/calendar/load.go index 310537f..8a940d5 100644 --- a/go/internal/server/calendar/load.go +++ b/go/internal/server/domain/events/calendar/load.go @@ -1,19 +1,15 @@ package calendar import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gacha" - "bd2server/internal/server/schedule" + "bd2server/internal/server/design/schedule" + "bd2server/internal/server/domain/commerce/gacha" + "bd2server/internal/server/domain/events" "fmt" "math" - "os" - "path/filepath" - "sort" - "strings" "time" ) -func timestamp(raw string) (uint64, error) { +func ParseTimestamp(raw string) (uint64, error) { t, e := time.Parse(time.RFC3339Nano, raw) if e != nil { return 0, fmt.Errorf("invalid RFC3339 timestamp %q", raw) @@ -25,11 +21,11 @@ func timestamp(raw string) (uint64, error) { return uint64(ms), nil } func window(start, end string, equal bool) (uint64, uint64, error) { - a, e := timestamp(start) + a, e := ParseTimestamp(start) if e != nil { return 0, 0, e } - b, e := timestamp(end) + b, e := ParseTimestamp(end) if e != nil { return 0, 0, e } @@ -60,111 +56,6 @@ func convertSeason(s Season) (schedule.Season, error) { return schedule.Season{ID: s.ID, StartMilliseconds: a, EndMilliseconds: b, RankRewardGroupID: s.RankRewardGroupID, Error: s.Error, Return: s.Return}, nil } -// LoadDirectory validates the whole directory before returning any calendar. -// Every calendar file must use the .bd2schedule suffix. -// Directories and symlinks are -// rejected so an accidentally omitted calendar never produces a partial set. -func LoadDirectory(dir, gameVersion, gameDataVersion string) (*Set, error) { - entries, e := os.ReadDir(dir) - if e != nil { - return nil, fmt.Errorf("calendar: read directory: %w", e) - } - set := &Set{GachaSeed: &gacha.ScheduleSeed{ClientVersion: gameVersion}} - seen := map[string]bool{} - claim := func(key string) error { - if seen[key] { - return fmt.Errorf("duplicate calendar identity %s", key) - } - seen[key] = true - return nil - } - files := 0 - for _, entry := range entries { - if !entry.Type().IsRegular() || !strings.HasSuffix(entry.Name(), ".bd2schedule") { - return nil, fmt.Errorf("calendar: unsupported entry %s", entry.Name()) - } - info, e := entry.Info() - if e != nil { - return nil, e - } - if info.Size() > MaxFileSize { - return nil, fmt.Errorf("calendar: %s: file size limit exceeded", entry.Name()) - } - raw, e := os.ReadFile(filepath.Join(dir, entry.Name())) - if e != nil { - return nil, e - } - m, e := UnmarshalBinary(raw) - if e != nil { - return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e) - } - if m.SchemaVersion != 1 || strings.TrimSpace(m.Revision) == "" || m.GameVersion != gameVersion || m.GameDataVersion != gameDataVersion || gameVersion == "" || gameDataVersion == "" { - return nil, fmt.Errorf("calendar: %s: incompatible schema/version or missing revision", entry.Name()) - } - if e = merge(set, m, claim); e != nil { - return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e) - } - set.Revisions = append(set.Revisions, m.Revision) - files++ - } - if files == 0 { - return nil, fmt.Errorf("calendar: empty directory") - } - sort.Slice(set.EventHubs, func(i, j int) bool { return set.EventHubs[i].UID < set.EventHubs[j].UID }) - sort.Slice(set.MiniGameHubs, func(i, j int) bool { return set.MiniGameHubs[i].Slot < set.MiniGameHubs[j].Slot }) - sort.Slice(set.Events, func(i, j int) bool { - a, b := set.Events[i], set.Events[j] - if a.UID != b.UID { - return a.UID < b.UID - } - if a.Type != b.Type { - return a.Type < b.Type - } - if a.ID != b.ID { - return a.ID < b.ID - } - return a.SubID < b.SubID - }) - sortWindows := func(w []gacha.ScheduleWindow) { - sort.Slice(w, func(i, j int) bool { - if w[i].GroupID != w[j].GroupID { - return w[i].GroupID < w[j].GroupID - } - return w[i].StartTime < w[j].StartTime - }) - } - sortWindows(set.GachaSeed.Schedules) - sortWindows(set.GachaSeed.StepUps) - if e = set.GachaSeed.Validate(gameVersion); e != nil { - return nil, e - } - if set.RegularService != nil { - sort.Slice(set.RegularService.Contents, func(i, j int) bool { return set.RegularService.Contents[i].ID < set.RegularService.Contents[j].ID }) - sort.Slice(set.RegularService.Regular, func(i, j int) bool { - return set.RegularService.Regular[i].ContentID < set.RegularService.Regular[j].ContentID - }) - if e = set.RegularService.Validate(); e != nil { - return nil, e - } - } - sort.Slice(set.CashProducts, func(i, j int) bool { - a, b := set.CashProducts[i], set.CashProducts[j] - if a.GroupID != b.GroupID { - return a.GroupID < b.GroupID - } - if a.ProductID != b.ProductID { - return a.ProductID < b.ProductID - } - return a.SaleGroup < b.SaleGroup - }) - if set.MonsterHunt != nil { - sort.Slice(set.MonsterHunt.Seasons, func(i, j int) bool { - return set.MonsterHunt.Seasons[i].Season.ID < set.MonsterHunt.Seasons[j].Season.ID - }) - sort.Slice(set.MonsterHunt.History, func(i, j int) bool { return set.MonsterHunt.History[i].Season < set.MonsterHunt.History[j].Season }) - } - return set, nil -} func merge(s *Set, m Manifest, claim func(string) error) error { for _, v := range m.EventHubs { if e := claim(fmt.Sprintf("hub:%d", v.UID)); e != nil { @@ -180,7 +71,7 @@ func merge(s *Set, m Manifest, claim func(string) error) error { if e != nil { return e } - play, e := timestamp(v.PlayEnd) + play, e := ParseTimestamp(v.PlayEnd) if e != nil { return e } @@ -291,11 +182,11 @@ func merge(s *Set, m Manifest, claim func(string) error) error { if _, e := convertSeason(v.Season); e != nil { return e } - end, e := timestamp(v.CalculateEndAt) + end, e := ParseTimestamp(v.CalculateEndAt) if e != nil { return e } - seasonEnd, _ := timestamp(v.Season.End) + seasonEnd, _ := ParseTimestamp(v.Season.End) if end < seasonEnd { return fmt.Errorf("hunt calculation ends before season") } @@ -338,13 +229,13 @@ func merge(s *Set, m Manifest, claim func(string) error) error { var a, b uint64 var e error if v.Start != "" { - a, e = timestamp(v.Start) + a, e = ParseTimestamp(v.Start) if e != nil { return e } } if v.End != "" { - b, e = timestamp(v.End) + b, e = ParseTimestamp(v.End) if e != nil { return e } diff --git a/go/internal/server/domain/events/calendar/protocol_adapter.go b/go/internal/server/domain/events/calendar/protocol_adapter.go new file mode 100644 index 0000000..5094da6 --- /dev/null +++ b/go/internal/server/domain/events/calendar/protocol_adapter.go @@ -0,0 +1,818 @@ +package calendar + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/design/schedule" + + "bd2server/internal/server/domain/commerce/gacha" + "bd2server/internal/server/domain/events" + "bd2server/internal/server/protocol/wire" + "bytes" + "crypto/sha256" + "encoding/binary" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "unicode/utf8" +) + +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 +} + +// 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 := ParseTimestamp(hub.Start) + if err != nil { + return err + } + playEnd, err := ParseTimestamp(hub.PlayEnd) + if err != nil { + return err + } + end, err := ParseTimestamp(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 +} + +// LoadDirectory validates the whole directory before returning any calendar. +// Every calendar file must use the .bd2schedule suffix. +// Directories and symlinks are +// rejected so an accidentally omitted calendar never produces a partial set. +func LoadDirectory(dir, gameVersion, gameDataVersion string) (*Set, error) { + entries, e := os.ReadDir(dir) + if e != nil { + return nil, fmt.Errorf("calendar: read directory: %w", e) + } + set := &Set{GachaSeed: &gacha.ScheduleSeed{ClientVersion: gameVersion}} + seen := map[string]bool{} + claim := func(key string) error { + if seen[key] { + return fmt.Errorf("duplicate calendar identity %s", key) + } + seen[key] = true + return nil + } + files := 0 + for _, entry := range entries { + if !entry.Type().IsRegular() || !strings.HasSuffix(entry.Name(), ".bd2schedule") { + return nil, fmt.Errorf("calendar: unsupported entry %s", entry.Name()) + } + info, e := entry.Info() + if e != nil { + return nil, e + } + if info.Size() > MaxFileSize { + return nil, fmt.Errorf("calendar: %s: file size limit exceeded", entry.Name()) + } + raw, e := os.ReadFile(filepath.Join(dir, entry.Name())) + if e != nil { + return nil, e + } + m, e := UnmarshalBinary(raw) + if e != nil { + return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e) + } + if m.SchemaVersion != 1 || strings.TrimSpace(m.Revision) == "" || m.GameVersion != gameVersion || m.GameDataVersion != gameDataVersion || gameVersion == "" || gameDataVersion == "" { + return nil, fmt.Errorf("calendar: %s: incompatible schema/version or missing revision", entry.Name()) + } + if e = merge(set, m, claim); e != nil { + return nil, fmt.Errorf("calendar: %s: %w", entry.Name(), e) + } + set.Revisions = append(set.Revisions, m.Revision) + files++ + } + if files == 0 { + return nil, fmt.Errorf("calendar: empty directory") + } + sort.Slice(set.EventHubs, func(i, j int) bool { return set.EventHubs[i].UID < set.EventHubs[j].UID }) + sort.Slice(set.MiniGameHubs, func(i, j int) bool { return set.MiniGameHubs[i].Slot < set.MiniGameHubs[j].Slot }) + sort.Slice(set.Events, func(i, j int) bool { + a, b := set.Events[i], set.Events[j] + if a.UID != b.UID { + return a.UID < b.UID + } + if a.Type != b.Type { + return a.Type < b.Type + } + if a.ID != b.ID { + return a.ID < b.ID + } + return a.SubID < b.SubID + }) + sortWindows := func(w []gacha.ScheduleWindow) { + sort.Slice(w, func(i, j int) bool { + if w[i].GroupID != w[j].GroupID { + return w[i].GroupID < w[j].GroupID + } + return w[i].StartTime < w[j].StartTime + }) + } + sortWindows(set.GachaSeed.Schedules) + sortWindows(set.GachaSeed.StepUps) + if e = set.GachaSeed.Validate(gameVersion); e != nil { + return nil, e + } + if set.RegularService != nil { + sort.Slice(set.RegularService.Contents, func(i, j int) bool { return set.RegularService.Contents[i].ID < set.RegularService.Contents[j].ID }) + sort.Slice(set.RegularService.Regular, func(i, j int) bool { + return set.RegularService.Regular[i].ContentID < set.RegularService.Regular[j].ContentID + }) + if e = set.RegularService.Validate(); e != nil { + return nil, e + } + } + sort.Slice(set.CashProducts, func(i, j int) bool { + a, b := set.CashProducts[i], set.CashProducts[j] + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + if a.ProductID != b.ProductID { + return a.ProductID < b.ProductID + } + return a.SaleGroup < b.SaleGroup + }) + if set.MonsterHunt != nil { + sort.Slice(set.MonsterHunt.Seasons, func(i, j int) bool { + return set.MonsterHunt.Seasons[i].Season.ID < set.MonsterHunt.Seasons[j].Season.ID + }) + sort.Slice(set.MonsterHunt.History, func(i, j int) bool { return set.MonsterHunt.History[i].Season < set.MonsterHunt.History[j].Season }) + } + return set, nil +} + +// 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 +} + +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) 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 (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) 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) +} + +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 +} + +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 +} diff --git a/go/internal/server/calendar/schema.go b/go/internal/server/domain/events/calendar/schema.go similarity index 94% rename from go/internal/server/calendar/schema.go rename to go/internal/server/domain/events/calendar/schema.go index c17451d..7169ca3 100644 --- a/go/internal/server/calendar/schema.go +++ b/go/internal/server/domain/events/calendar/schema.go @@ -2,9 +2,9 @@ package calendar import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gacha" - "bd2server/internal/server/schedule" + "bd2server/internal/server/design/schedule" + "bd2server/internal/server/domain/commerce/gacha" + "bd2server/internal/server/domain/events" ) type Manifest struct { diff --git a/go/internal/server/domain/events/economy.go b/go/internal/server/domain/events/economy.go new file mode 100644 index 0000000..47e8d22 --- /dev/null +++ b/go/internal/server/domain/events/economy.go @@ -0,0 +1,150 @@ +package events + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/json" + "fmt" + "maps" + "math" + + "time" +) + +type HuntingAP interface { + CanExchangeAP(ctx command.Context, costs, rewards []gamedata.Reward) error + ExchangeAPOnce(ctx command.Context, identity string, costs, rewards []gamedata.Reward) error +} + +type rewardResolver interface { + Resolve([]gamedata.BattleReward) ([]gamedata.BattleReward, error) +} +type equipmentRoller interface { + RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) +} + +type economyReceipt struct{ Digest, Definition, Bundle []byte } +type economySnapshot struct { + Balances map[uint64]uint64 `json:"balances"` + Receipts map[string]economyReceipt `json:"receipts"` +} + +// Economy dispatches verified static rewards to their owning domains. It is +// called inside the same account transaction as the gameplay operation. +type Economy struct { + ownedDesign map[uint64]map[uint64]bool + avatarRewards *gamedata.AvatarRewardDesign + buffRewards *BuffRewards + + store stateio.Store + items *assets.Inventory + wallet *assets.Wallet + collection *roster.CollectionStore + equipment *assets.EquipmentInventory + costumes roster.CostumeDesignSource + options equipmentRoller + graph rewardResolver + hunting HuntingAP + prestige map[uint64]uint64 + prestigePortrait func() uint64 + apCaps map[uint64]uint64 + resetSeconds int64 + now func() time.Time + initial map[uint64]uint64 +} + +func NewEconomy(ctx command.Context, store stateio.Store, items *assets.Inventory, wallet *assets.Wallet, collection *roster.CollectionStore, equipment *assets.EquipmentInventory, costumes roster.CostumeDesignSource, options equipmentRoller, graph rewardResolver, initial map[uint64]uint64) (*Economy, error) { + if store == nil || items == nil || wallet == nil || collection == nil || equipment == nil || costumes == nil || options == nil || graph == nil { + return nil, fmt.Errorf("events: invalid economy configuration") + } + e := &Economy{store: store, items: items, wallet: wallet, collection: collection, equipment: equipment, costumes: costumes, options: options, graph: graph, initial: map[uint64]uint64{}} + for t, n := range initial { + if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 { + return nil, fmt.Errorf("events: invalid initial currency %d", t) + } + e.initial[t] = n + } + _, err := e.load(ctx) + return e, err +} + +// AdditionalCurrencyFields maps EElementType to its UserDBInfo field. These +// balances use a gameplay entry, leaving the frozen wallet schema unchanged. +var AdditionalCurrencyFields = map[uint64]int{15: 16, 16: 17, 18: 18, 24: 22, 30: 34, 31: 35, 32: 36, 33: 37, 38: 42, 39: 44, 40: 45, 43: 48, 44: 54, 54: 61, 60: 60, 70: 71} + +func (e *Economy) AttachAvatarRewards(d *gamedata.AvatarRewardDesign) { e.avatarRewards = d } + +func (e *Economy) AttachHuntingAP(h HuntingAP) { e.hunting = h } +func (e *Economy) AdditionalCurrencies(ctx command.Context) (map[int]uint64, error) { + + s, err := e.load(ctx) + if err != nil { + return nil, err + } + if err = e.refreshAP(ctx, &s); err != nil { + return nil, err + } + out := map[int]uint64{} + for t, f := range AdditionalCurrencyFields { + out[f] = s.Balances[t] + } + return out, nil +} +func (e *Economy) load(ctx command.Context) (economySnapshot, error) { + s := economySnapshot{Balances: map[uint64]uint64{}, Receipts: map[string]economyReceipt{}} + maps.Copy(s.Balances, e.initial) + b, err := e.store.Load(ctx.State, "event_economy") + if err != nil || b == nil { + return s, err + } + if err = stateio.RequireExactJSONObject(b, "balances", "receipts"); err != nil { + return s, err + } + if err = json.Unmarshal(b, &s); err != nil { + return s, err + } + if s.Balances == nil || s.Receipts == nil { + return s, fmt.Errorf("events: invalid saved economy") + } + for t, n := range s.Balances { + if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 { + return s, fmt.Errorf("events: invalid saved currency %d", t) + } + } + for id, r := range s.Receipts { + if id == "" || len(r.Digest) != sha256.Size { + return s, fmt.Errorf("events: invalid economy receipt") + } + } + return s, nil +} + +func walletCurrency(t uint64) bool { + switch t { + case 2, 3, 4, 12, 20, 22, 68: + return true + } + return false +} +func extraCurrency(t uint64) bool { _, ok := AdditionalCurrencyFields[t]; return ok } +func apCurrency(t uint64) bool { return t == 21 || t == 23 } +func inventoryType(t uint64) bool { + switch t { + case 5, 7, 8, 9, 13, 14, 17, 19, 25, 26, 27, 29, 34, 45, 46, 47, 49, 50, 61, 69: + return true + } + return false +} + +func (e *Economy) resolveGranted(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) { + if g, ok := e.graph.(interface { + ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error) + }); ok { + return g.ResolveGranted(rewards) + } + return e.graph.Resolve(rewards) +} diff --git a/go/internal/server/eventexchange/service.go b/go/internal/server/domain/events/exchange/protocol_adapter.go similarity index 53% rename from go/internal/server/eventexchange/service.go rename to go/internal/server/domain/events/exchange/protocol_adapter.go index 7c8b1ee..c022abd 100644 --- a/go/internal/server/eventexchange/service.go +++ b/go/internal/server/domain/events/exchange/protocol_adapter.go @@ -1,162 +1,33 @@ package eventexchange import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/wire" "bytes" - "crypto/rand" - "encoding/json" "fmt" "math" - "math/big" "sort" "strconv" - "sync" - "time" ) -type Runtime interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) - ConsumeAndGrant(string, []player.Item, []gamedata.BattleReward) ([]byte, error) -} -type progress struct { - Group, Page, Key, Free uint64 - Reset int64 - Counts map[string]uint64 -} -type receipt struct { - Fingerprint, Response []byte - Code int -} -type snapshot struct { - Version string `json:"version"` - Progress map[string]progress `json:"progress"` - Receipts map[string]receipt `json:"receipts"` -} -type Service struct { - mu sync.Mutex - store stateio.Store - design *gamedata.EventExchangeCatalog - registry events.Resolver - runtime Runtime - now func() time.Time - draw func(uint64) (uint64, error) -} - -func Open(store stateio.Store, design *gamedata.EventExchangeCatalog, registry events.Resolver, runtime Runtime) (*Service, error) { - if store == nil || design == nil || registry == nil || runtime == nil { - return nil, fmt.Errorf("eventexchange: invalid configuration") - } - s := &Service{store: store, design: design, registry: registry, runtime: runtime, now: time.Now} - s.draw = func(n uint64) (uint64, error) { - if n == 0 { - return 0, fmt.Errorf("eventexchange: empty reward pool") - } - v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) - if e != nil { - return 0, e - } - return v.Uint64(), nil - } - _, err := s.load() - return s, err -} -func (s *Service) load() (snapshot, error) { - st := snapshot{versionconfig.State(), map[string]progress{}, map[string]receipt{}} - b, err := s.store.Load("eventexchange") - if err != nil { - return st, err - } - if b == nil { - return st, nil - } - if err = stateio.RequireExactJSONObject(b, "version", "progress", "receipts"); err != nil { - return st, err - } - dec := json.NewDecoder(bytes.NewReader(b)) - dec.DisallowUnknownFields() - if err = dec.Decode(&st); err != nil { - return st, err - } - if st.Version != versionconfig.State() || st.Progress == nil || st.Receipts == nil { - return st, fmt.Errorf("eventexchange: incompatible state") - } - for uid, p := range st.Progress { - n, e := strconv.ParseUint(uid, 10, 64) - g, ok := s.design.Groups[p.Group] - if e != nil || n == 0 || !ok || p.Page < g.StartPage || (!g.Repeat && p.Page > g.EndPage) || p.Counts == nil || p.Free > g.FreeCount { - return st, fmt.Errorf("eventexchange: invalid saved progress") - } - known := map[string]uint64{} - for _, entry := range g.Page(p.Page) { - known[strconv.FormatUint(entry.ID, 10)] = entry.SetCount - } - for id, count := range p.Counts { - limit, ok := known[id] - if !ok || count > limit { - return st, fmt.Errorf("eventexchange: invalid reward count") - } - } - } - return st, nil -} -func (s *Service) save(st snapshot) error { - b, err := json.Marshal(st) - if err != nil { - return err - } - return s.store.Save("eventexchange", b) -} -func (s *Service) active(uid uint64) (events.Schedule, gamedata.EventExchangeGroup, error) { - a, e := s.registry.Resolve(uid) - if e != nil { - return a, gamedata.EventExchangeGroup{}, e - } - g, ok := s.design.Groups[a.ID] - now := s.now().UnixMilli() - if !ok || a.Type != 7 || now < a.Start || now >= a.End { - return a, g, fmt.Errorf("eventexchange: event unavailable") - } - return a, g, nil -} -func (s *Service) init(st *snapshot, a events.Schedule, g gamedata.EventExchangeGroup) progress { - k := strconv.FormatUint(a.UID, 10) - p, ok := st.Progress[k] - if !ok { - p = progress{Group: g.ID, Page: g.StartPage, Free: g.FreeCount, Counts: map[string]uint64{}} - } - if g.FreeType == 2 { - now := s.now() - if p.Reset <= now.UnixMilli() { - p.Free = g.FreeCount - p.Reset = now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).UnixMilli() - } - } - return p -} -func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { - return s.HandleSession(path, req, "local") -} -func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { +func (s *Service) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID code, ok := map[string]int{"/EventExchangeInfo": 183, "/EventExchangeReward": 184, "/EventExchangeNextPageOpen": 185}[path] if !ok { return 0, nil, false, nil } - s.mu.Lock() - defer s.mu.Unlock() + seq, found, err := wire.Varint(req, 1) if err != nil || !found || seq == 0 || seq > math.MaxInt32 { return code, nil, true, fmt.Errorf("eventexchange: invalid sequence") } - st, err := s.load() + st, err := s.load(ctx) if err != nil { return code, nil, true, err } - key := session + ":" + strconv.FormatUint(seq, 10) + key := commandSession + ":" + strconv.FormatUint(seq, 10) fingerprint := append([]byte(path), req...) if r, ok := st.Receipts[key]; ok { if !bytes.Equal(fingerprint, r.Fingerprint) { @@ -180,7 +51,7 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ out = wire.AppendBytes(out, 2, countWire(a.UID, g.ID, entry.ID, p.Counts[strconv.FormatUint(entry.ID, 10)])) } } - if err = s.save(st); err != nil { + if err = s.save(ctx, st); err != nil { return code, nil, true, err } return code, out, true, nil @@ -289,19 +160,19 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ } var bundle []byte if free { - bundle, err = s.runtime.Apply("eventexchange:"+key, nil, rewards) + bundle, err = s.runtime.Apply(ctx, "eventexchange:"+key, nil, rewards) } else { currency := g.Cost.Type == 2 || g.Cost.Type == 3 || g.Cost.Type == 4 || g.Cost.Type == 12 || g.Cost.Type == 20 if currency { cost := g.Cost cost.Count *= count - bundle, err = s.runtime.Apply("eventexchange:"+key, []gamedata.Reward{cost}, rewards) + bundle, err = s.runtime.Apply(ctx, "eventexchange:"+key, []gamedata.Reward{cost}, rewards) } else { rs := make([]gamedata.BattleReward, len(rewards)) for i, r := range rewards { rs[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 } - bundle, err = s.runtime.ConsumeAndGrant("eventexchange:"+key, uses, rs) + bundle, err = s.runtime.ConsumeAndGrant(ctx, "eventexchange:"+key, uses, rs) } } if err != nil { @@ -313,43 +184,12 @@ func (s *Service) HandleSession(path string, req []byte, session string) (int, [ } st.Progress[strconv.FormatUint(uid, 10)] = p st.Receipts[key] = receipt{fingerprint, out, code} - if err = s.save(st); err != nil { + if err = s.save(ctx, st); err != nil { return code, nil, true, err } return code, out, true, nil } -func exhausted(g gamedata.EventExchangeGroup, p progress) bool { - for _, e := range g.Page(p.Page) { - if p.Counts[strconv.FormatUint(e.ID, 10)] < e.SetCount { - return false - } - } - return true -} -func canAdvance(g gamedata.EventExchangeGroup, p progress) bool { - if p.Page >= g.EndPage && !g.Repeat { - return false - } - var keys uint64 - for _, e := range g.Page(p.Page) { - if e.KeyType == 1 { - keys++ - } - } - return exhausted(g, p) || (keys > 0 && p.Key >= keys) -} -func advance(g gamedata.EventExchangeGroup, p *progress) error { - if p.Page >= math.MaxInt32 { - return fmt.Errorf("eventexchange: page overflow") - } - if p.Page >= g.EndPage && !g.Repeat { - return fmt.Errorf("eventexchange: final page") - } - p.Page++ - p.Key = 0 - p.Counts = map[string]uint64{} - return nil -} + func progressWire(uid uint64, p progress) []byte { var out []byte for _, f := range []struct { @@ -362,6 +202,7 @@ func progressWire(uid uint64, p progress) []byte { } return out } + func countWire(uid, group, id, count uint64) []byte { b := wire.AppendVarint(nil, 1, uid) b = wire.AppendVarint(b, 2, group) @@ -371,8 +212,9 @@ func countWire(uid, group, id, count uint64) []byte { } return b } -func parseUses(req []byte) ([]player.Item, error) { - var out []player.Item + +func parseUses(req []byte) ([]assets.Item, error) { + var out []assets.Item seen := map[uint64]bool{} err := wire.Walk(req, func(f wire.Field) error { if f.Number != 4 { @@ -381,7 +223,7 @@ func parseUses(req []byte) ([]player.Item, error) { if f.Type != 2 { return fmt.Errorf("eventexchange: malformed use item") } - var item player.Item + var item assets.Item for n, p := range map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count} { v, _, e := wire.Varint(f.Value, n) if e != nil { diff --git a/go/internal/server/domain/events/exchange/service.go b/go/internal/server/domain/events/exchange/service.go new file mode 100644 index 0000000..25876e5 --- /dev/null +++ b/go/internal/server/domain/events/exchange/service.go @@ -0,0 +1,171 @@ +package eventexchange + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "bytes" + "crypto/rand" + "encoding/json" + "fmt" + "math" + "math/big" + "strconv" + + "time" +) + +type Runtime interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) + ConsumeAndGrant(ctx command.Context, _ string, _ []assets.Item, _ []gamedata.BattleReward) ([]byte, error) +} +type progress struct { + Group, Page, Key, Free uint64 + Reset int64 + Counts map[string]uint64 +} +type receipt struct { + Fingerprint, Response []byte + Code int +} +type snapshot struct { + Version string `json:"version"` + Progress map[string]progress `json:"progress"` + Receipts map[string]receipt `json:"receipts"` +} +type Service struct { + store stateio.Store + design *gamedata.EventExchangeCatalog + registry events.Resolver + runtime Runtime + now func() time.Time + draw func(uint64) (uint64, error) +} + +func Open(ctx command.Context, store stateio.Store, design *gamedata.EventExchangeCatalog, registry events.Resolver, runtime Runtime) (*Service, error) { + if store == nil || design == nil || registry == nil || runtime == nil { + return nil, fmt.Errorf("eventexchange: invalid configuration") + } + s := &Service{store: store, design: design, registry: registry, runtime: runtime, now: time.Now} + s.draw = func(n uint64) (uint64, error) { + if n == 0 { + return 0, fmt.Errorf("eventexchange: empty reward pool") + } + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + } + _, err := s.load(ctx) + return s, err +} +func (s *Service) load(ctx command.Context) (snapshot, error) { + st := snapshot{versionconfig.State(), map[string]progress{}, map[string]receipt{}} + b, err := s.store.Load(ctx.State, "eventexchange") + if err != nil { + return st, err + } + if b == nil { + return st, nil + } + if err = stateio.RequireExactJSONObject(b, "version", "progress", "receipts"); err != nil { + return st, err + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err = dec.Decode(&st); err != nil { + return st, err + } + if st.Version != versionconfig.State() || st.Progress == nil || st.Receipts == nil { + return st, fmt.Errorf("eventexchange: incompatible state") + } + for uid, p := range st.Progress { + n, e := strconv.ParseUint(uid, 10, 64) + g, ok := s.design.Groups[p.Group] + if e != nil || n == 0 || !ok || p.Page < g.StartPage || (!g.Repeat && p.Page > g.EndPage) || p.Counts == nil || p.Free > g.FreeCount { + return st, fmt.Errorf("eventexchange: invalid saved progress") + } + known := map[string]uint64{} + for _, entry := range g.Page(p.Page) { + known[strconv.FormatUint(entry.ID, 10)] = entry.SetCount + } + for id, count := range p.Counts { + limit, ok := known[id] + if !ok || count > limit { + return st, fmt.Errorf("eventexchange: invalid reward count") + } + } + } + return st, nil +} +func (s *Service) save(ctx command.Context, st snapshot) error { + b, err := json.Marshal(st) + if err != nil { + return err + } + return s.store.Save(ctx.State, "eventexchange", b) +} +func (s *Service) active(uid uint64) (events.Schedule, gamedata.EventExchangeGroup, error) { + a, e := s.registry.Resolve(uid) + if e != nil { + return a, gamedata.EventExchangeGroup{}, e + } + g, ok := s.design.Groups[a.ID] + now := s.now().UnixMilli() + if !ok || a.Type != 7 || now < a.Start || now >= a.End { + return a, g, fmt.Errorf("eventexchange: event unavailable") + } + return a, g, nil +} +func (s *Service) init(st *snapshot, a events.Schedule, g gamedata.EventExchangeGroup) progress { + k := strconv.FormatUint(a.UID, 10) + p, ok := st.Progress[k] + if !ok { + p = progress{Group: g.ID, Page: g.StartPage, Free: g.FreeCount, Counts: map[string]uint64{}} + } + if g.FreeType == 2 { + now := s.now() + if p.Reset <= now.UnixMilli() { + p.Free = g.FreeCount + p.Reset = now.UTC().Truncate(24 * time.Hour).Add(24 * time.Hour).UnixMilli() + } + } + return p +} + +func exhausted(g gamedata.EventExchangeGroup, p progress) bool { + for _, e := range g.Page(p.Page) { + if p.Counts[strconv.FormatUint(e.ID, 10)] < e.SetCount { + return false + } + } + return true +} +func canAdvance(g gamedata.EventExchangeGroup, p progress) bool { + if p.Page >= g.EndPage && !g.Repeat { + return false + } + var keys uint64 + for _, e := range g.Page(p.Page) { + if e.KeyType == 1 { + keys++ + } + } + return exhausted(g, p) || (keys > 0 && p.Key >= keys) +} +func advance(g gamedata.EventExchangeGroup, p *progress) error { + if p.Page >= math.MaxInt32 { + return fmt.Errorf("eventexchange: page overflow") + } + if p.Page >= g.EndPage && !g.Repeat { + return fmt.Errorf("eventexchange: final page") + } + p.Page++ + p.Key = 0 + p.Counts = map[string]uint64{} + return nil +} diff --git a/go/internal/server/eventgames/SPEC.md b/go/internal/server/domain/events/games/SPEC.md similarity index 100% rename from go/internal/server/eventgames/SPEC.md rename to go/internal/server/domain/events/games/SPEC.md diff --git a/go/internal/server/domain/events/games/games.go b/go/internal/server/domain/events/games/games.go new file mode 100644 index 0000000..c01f340 --- /dev/null +++ b/go/internal/server/domain/events/games/games.go @@ -0,0 +1,148 @@ +// Package eventgames implements this server's persistent seasonal minigames. +package eventgames + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + "slices" + + "time" +) + +type Resolver interface { + Resolve(uint64) (events.Schedule, error) +} +type Rewards interface { + ConsumeAndGrant(ctx command.Context, _ string, _ []assets.Item, _ []gamedata.BattleReward) ([]byte, error) +} +type GameState struct { + UID, Type, ID, Position, Clear, Free, Reset, Tries, SinceSpecial uint64 + Board, Opened, Lines []uint64 + Special bool +} +type reply struct{ Request, Body []byte } +type snapshot struct { + Version int `json:"version"` + Games map[string]GameState `json:"games"` + Replies map[string]reply `json:"replies"` +} + +// RuleLoader returns shared immutable game rules. +type RuleLoader func(uint64, uint64) (*gamedata.EventGame, error) + +type Service struct { + store stateio.Store + resolver Resolver + rewards Rewards + state snapshot + load func(uint64, uint64) (*gamedata.EventGame, error) + sample func(uint64) (uint64, error) + now func() time.Time +} + +func Open(ctx command.Context, store stateio.Store, load RuleLoader, resolver Resolver, rewards Rewards) (*Service, error) { + if store == nil || load == nil || resolver == nil || rewards == nil { + return nil, fmt.Errorf("eventgames: incomplete runtime") + } + s := &Service{store: store, resolver: resolver, rewards: rewards, now: time.Now, state: snapshot{1, map[string]GameState{}, map[string]reply{}}} + s.load = load + s.sample = func(max uint64) (uint64, error) { + if max == 0 { + return 0, fmt.Errorf("eventgames: empty sample") + } + n, e := rand.Int(rand.Reader, new(big.Int).SetUint64(max)) + if e != nil { + return 0, e + } + return n.Uint64(), nil + } + raw, e := store.Load(ctx.State, "eventgames") + if e != nil { + return nil, e + } + if raw != nil { + if e = stateio.RequireExactJSONObject(raw, "version", "games", "replies"); e != nil { + return nil, e + } + if e = json.Unmarshal(raw, &s.state); e != nil { + return nil, e + } + if s.state.Version != 1 || s.state.Games == nil || s.state.Replies == nil { + return nil, fmt.Errorf("eventgames: incompatible state") + } + } + for key, g := range s.state.Games { + d, e := s.load(g.Type, g.ID) + if e != nil { + return nil, e + } + if e = validateSaved(key, g, d); e != nil { + return nil, e + } + } + return s, nil +} + +func contains(a []uint64, v uint64) bool { + return slices.Contains(a, v) +} + +var routes = map[string]struct { + Code int + Kind uint64 +}{"/MiniGameBoardInfo": {223, 12}, "/MiniGameBoardPlay": {224, 12}, "/MiniGameBingoInfo": {256, 13}, "/MiniGameBingoPlay": {257, 13}, "/MiniPuzzleInfo": {343, 17}, "/MiniPuzzleOpen": {344, 17}, "/MiniPuzzleAllOpen": {345, 17}, "/MiniPuzzleRenew": {346, 17}, "/MiniGameRouletteInfo": {415, 19}, "/MiniGameRouletteDraw": {416, 19}} + +func (s *Service) initialize(g *GameState, d *gamedata.EventGame) error { + if d.Type != 13 && d.Type != 17 { + return nil + } + count := g.Clear + if d.Type == 17 && count == 0 { + count = 1 + g.Clear = 1 + } + var cells []gamedata.EventGameReward + max := uint64(0) + for _, r := range d.Cells { + if r.Count > max { + max = r.Count + } + } + if count > max { + count = max + } + for _, r := range d.Cells { + if r.Count == count { + cells = append(cells, r) + } + } + if len(cells) == 0 { + return fmt.Errorf("eventgames: board rewards missing") + } + g.Board = nil + g.Opened = nil + g.Lines = nil + for _, r := range cells { + g.Board = append(g.Board, r.ID) + } + if d.Type == 13 { + for i := len(g.Board) - 1; i > 0; i-- { + j, e := s.sample(uint64(i + 1)) + if e != nil { + return e + } + if j >= uint64(i+1) { + return fmt.Errorf("eventgames: sampler out of range") + } + g.Board[i], g.Board[j] = g.Board[j], g.Board[i] + } + } + return nil +} diff --git a/go/internal/server/domain/events/games/play.go b/go/internal/server/domain/events/games/play.go new file mode 100644 index 0000000..b33debc --- /dev/null +++ b/go/internal/server/domain/events/games/play.go @@ -0,0 +1,44 @@ +package eventgames + +import ( + "bd2server/internal/server/design/gamedata" +) + +func rewardCount(d *gamedata.EventGame, count uint64) uint64 { + max := uint64(0) + for _, r := range d.Cells { + if r.Count > max { + max = r.Count + } + } + if count > max { + return max + } + return count +} +func completeLine(g *GameState, n, typ, index uint64) bool { + if n == 0 { + return false + } + for i := range n { + var pos uint64 + switch typ { + case 1: + pos = index*n + i + case 2: + pos = i*n + index + case 0: + if index == 0 { + pos = i*n + i + } else { + pos = i*n + (n - 1 - i) + } + default: + return false + } + if pos >= uint64(len(g.Board)) || !contains(g.Opened, pos) { + return false + } + } + return true +} diff --git a/go/internal/server/eventgames/play.go b/go/internal/server/domain/events/games/protocol_adapter.go similarity index 50% rename from go/internal/server/eventgames/play.go rename to go/internal/server/domain/events/games/protocol_adapter.go index b1037b8..ced384d 100644 --- a/go/internal/server/eventgames/play.go +++ b/go/internal/server/domain/events/games/protocol_adapter.go @@ -1,10 +1,17 @@ package eventgames import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/wire" + "bytes" + "encoding/binary" + "encoding/json" "fmt" "math" + "strconv" + "strings" ) func (s *Service) play(path string, req []byte, g *GameState, d *gamedata.EventGame) ([]byte, uint64, map[int][]gamedata.BattleReward, error) { @@ -254,41 +261,243 @@ func (s *Service) play(path string, req []byte, g *GameState, d *gamedata.EventG } return body, cost, rewards, nil } -func rewardCount(d *gamedata.EventGame, count uint64) uint64 { - max := uint64(0) - for _, r := range d.Cells { - if r.Count > max { - max = r.Count + +func nums(b []byte, n int) ([]uint64, error) { + var out []uint64 + e := wire.Walk(b, func(f wire.Field) error { + if f.Number != n { + return nil } - } - if count > max { - return max - } - return count -} -func completeLine(g *GameState, n, typ, index uint64) bool { - if n == 0 { - return false - } - for i := range n { - var pos uint64 - switch typ { - case 1: - pos = index*n + i - case 2: - pos = i*n + index - case 0: - if index == 0 { - pos = i*n + i - } else { - pos = i*n + (n - 1 - i) + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + out = append(out, v) + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + for p := f.Value; len(p) > 0; { + v, k := binary.Uvarint(p) + if k <= 0 { + return wire.ErrMalformed } - default: - return false + out = append(out, v) + p = p[k:] } - if pos >= uint64(len(g.Board)) || !contains(g.Opened, pos) { - return false + return nil + }) + return out, e +} + +func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } + +func (s *Service) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + r, ok := routes[path] + if !ok { + return 0, nil, false, nil + } + + fail := func(e error) (int, []byte, bool, error) { return r.Code, nil, true, e } + if e := wire.Walk(req, func(f wire.Field) error { + if f.Type == 0 && f.Number != 3 { + v, _ := binary.Uvarint(f.Value) + if v > math.MaxInt32 { + return wire.ErrMalformed + } + } + return nil + }); e != nil { + return fail(e) + } + seq := scalar(req, 1) + if seq == 0 || seq > math.MaxInt32 { + return fail(fmt.Errorf("eventgames: invalid sequence")) + } + key := fmt.Sprintf("%s:%s:%d", commandSession, path, seq) + if v, ok := s.state.Replies[key]; ok { + if !bytes.Equal(v.Request, req) { + return fail(fmt.Errorf("eventgames: changed request retry")) + } + return r.Code, v.Body, true, nil + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + uids, e := nums(req, 2) + if e != nil || len(uids) == 0 && !strings.HasSuffix(path, "Info") { + return fail(fmt.Errorf("eventgames: missing schedule")) + } + if len(uids) == 0 { + return r.Code, nil, true, nil + } + if !strings.HasSuffix(path, "Info") && len(uids) != 1 { + return fail(fmt.Errorf("eventgames: operation requires one schedule")) + } + var out []byte + seen := map[uint64]bool{} + for _, uid := range uids { + if uid == 0 || seen[uid] { + return fail(fmt.Errorf("eventgames: invalid duplicate schedule")) + } + seen[uid] = true + c, e := s.resolver.Resolve(uid) + if e != nil { + return fail(e) + } + if c.UID != uid || c.Type != r.Kind { + return fail(fmt.Errorf("eventgames: schedule/game mismatch")) + } + d, e := s.load(c.Type, c.ID) + if e != nil { + return fail(e) + } + k := strconv.FormatUint(uid, 10) + g, exists := next.Games[k] + if !exists { + g = GameState{UID: uid, Type: c.Type, ID: c.ID, Free: d.Free} + if e = s.initialize(&g, d); e != nil { + return fail(e) + } + } + if g.Type != c.Type || g.ID != c.ID { + return fail(fmt.Errorf("eventgames: saved game identity changed")) + } + now := uint64(s.now().UnixMilli()) + if c.Type == 19 && g.Reset <= now { + g.Free = d.Free + g.Reset = (now/86400000 + 1) * 86400000 + } + if strings.HasSuffix(path, "Info") { + out = wire.AppendBytes(out, 1, stateWire(g, d)) + next.Games[k] = g + continue + } + if int64(now) < c.Start || int64(now) >= c.End { + return fail(fmt.Errorf("eventgames: event is closed")) + } + before := stateWire(g, d) + body, cost, rewards, e := s.play(path, req, &g, d) + if e != nil { + return fail(e) + } + consumeField := 3 + if path == "/MiniPuzzleOpen" { + consumeField = 4 + } + if path == "/MiniGameRouletteDraw" { + consumeField = 5 + } + items, e := consume(req, consumeField, d, cost) + if e != nil { + return fail(e) + } + bundles := map[int][]byte{} + fields := []int{3, 4, 5, 6, 7} + if c.Type == 19 { + fields = []int{2, 3} + } + for _, field := range fields { + rr := rewards[field] + if len(rr) == 0 { + continue + } + consumed := []assets.Item(nil) + if len(items) > 0 { + consumed = items + items = nil + } + b, e := s.rewards.ConsumeAndGrant(ctx, fmt.Sprintf("eventgames:%s:%d", key, field), consumed, rr) + if e != nil { + return fail(e) + } + bundles[field] = b + } + if len(items) > 0 { + if _, e = s.rewards.ConsumeAndGrant(ctx, "eventgames:"+key+":cost", items, nil); e != nil { + return fail(e) + } + } + switch c.Type { + case 12: + body = wire.AppendBytes(body, 1, stateWire(g, d)) + case 13, 17: + body = wire.AppendBytes(body, 1, before) + body = wire.AppendBytes(body, 2, stateWire(g, d)) + case 19: + body = wire.AppendBytes(body, 1, stateWire(g, d)) + } + for _, field := range fields { + if b := bundles[field]; len(b) > 0 { + body = wire.AppendBytes(body, field, b) + } + } + out = body + next.Games[k] = g + } + next.Replies[key] = reply{append([]byte(nil), req...), out} + payload, e := json.Marshal(next) + if e != nil { + return fail(e) + } + if e = s.store.Save(ctx.State, "eventgames", payload); e != nil { + return fail(e) + } + s.state = next + return r.Code, out, true, nil +} + +func consume(req []byte, n int, d *gamedata.EventGame, count uint64) ([]assets.Item, error) { + var out []assets.Item + total := uint64(0) + seen := map[uint64]bool{} + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + id, typ, qty, index := scalar(f.Value, 2), scalar(f.Value, 3), scalar(f.Value, 4), scalar(f.Value, 1) + if count == 0 || id != d.CostID || typ != d.CostType || qty == 0 || index == 0 || seen[index] || qty > math.MaxInt32 || total > math.MaxUint64-qty { + return fmt.Errorf("eventgames: invalid submitted cost") + } + seen[index] = true + total += qty + out = append(out, assets.Item{InvenIndex: index, ID: id, Type: typ, Count: qty}) + return nil + }) + if e != nil { + return nil, e + } + if d.Cost > 0 && count > math.MaxUint64/d.Cost || total != count*d.Cost { + return nil, fmt.Errorf("eventgames: cost does not match design") + } + return out, nil +} + +func stateWire(g GameState, d *gamedata.EventGame) []byte { + b := wire.AppendVarint(nil, 1, g.UID) + switch g.Type { + case 12: + b = wire.AppendVarint(b, 2, d.ScaffoldGroup) + b = wire.AppendVarint(b, 3, d.Cells[g.Position].ID) + b = wire.AppendVarint(b, 4, g.Clear) + case 13, 17: + b = wire.AppendVarint(b, 2, g.Clear) + for _, v := range g.Board { + b = wire.AppendVarint(b, 3, v) + } + for _, v := range g.Opened { + b = wire.AppendVarint(b, 4, v) + } + case 19: + b = wire.AppendVarint(b, 2, g.Free) + b = wire.AppendVarint(b, 3, g.Reset) + if g.Special { + b = wire.AppendVarint(b, 4, 1) } + b = wire.AppendVarint(b, 5, g.Tries) } - return true + return b } diff --git a/go/internal/server/eventgames/validation.go b/go/internal/server/domain/events/games/validation.go similarity index 98% rename from go/internal/server/eventgames/validation.go rename to go/internal/server/domain/events/games/validation.go index fa94b84..64e77e0 100644 --- a/go/internal/server/eventgames/validation.go +++ b/go/internal/server/domain/events/games/validation.go @@ -1,7 +1,7 @@ package eventgames import ( - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" "fmt" "strconv" ) diff --git a/go/internal/server/events/native_costs.go b/go/internal/server/domain/events/native_costs.go similarity index 78% rename from go/internal/server/events/native_costs.go rename to go/internal/server/domain/events/native_costs.go index 0e6135c..c020162 100644 --- a/go/internal/server/events/native_costs.go +++ b/go/internal/server/domain/events/native_costs.go @@ -4,12 +4,13 @@ import ( "fmt" "math" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" ) // Native shops' FreeJewelry prices use total diamonds: free first, then paid. // Cash conversion policy and explicitly paid-only prices keep their own rules. -func (e *Economy) NativePurchaseCosts(cost gamedata.Reward) ([]gamedata.Reward, error) { +func (e *Economy) NativePurchaseCosts(ctx command.Context, cost gamedata.Reward) ([]gamedata.Reward, error) { if cost.Count == 0 || cost.Count > math.MaxInt32 { return nil, fmt.Errorf("events: invalid native purchase cost") } @@ -19,7 +20,7 @@ func (e *Economy) NativePurchaseCosts(cost gamedata.Reward) ([]gamedata.Reward, if cost.ID != 0 { return nil, fmt.Errorf("events: diamond cost has an item ID") } - balance := e.wallet.Snapshot() + balance := e.wallet.Snapshot(ctx) free := min(cost.Count, balance.FreeJewelry) paid := cost.Count - free if paid > balance.Jewelry { diff --git a/go/internal/server/domain/events/owned_items.go b/go/internal/server/domain/events/owned_items.go new file mode 100644 index 0000000..30d8bda --- /dev/null +++ b/go/internal/server/domain/events/owned_items.go @@ -0,0 +1,3 @@ +package events + +func (e *Economy) AttachOwnedItemDesign(d map[uint64]map[uint64]bool) { e.ownedDesign = d } diff --git a/go/internal/server/eventplay/SPEC.md b/go/internal/server/domain/events/play/SPEC.md similarity index 100% rename from go/internal/server/eventplay/SPEC.md rename to go/internal/server/domain/events/play/SPEC.md diff --git a/go/internal/server/domain/events/play/battle.go b/go/internal/server/domain/events/play/battle.go new file mode 100644 index 0000000..aa50bb0 --- /dev/null +++ b/go/internal/server/domain/events/play/battle.go @@ -0,0 +1,12 @@ +package eventplay + +import ( + "bd2server/internal/server/design/gamedata" +) + +func (s *Service) HandlesBattle(mode uint64) bool { return mode == 17 } + +func (s *Service) AttachBattleChallenges(d gamedata.EventBattleChallenges) { + + s.battleChallenges = d +} diff --git a/go/internal/server/eventplay/field_binding.go b/go/internal/server/domain/events/play/field_binding.go similarity index 94% rename from go/internal/server/eventplay/field_binding.go rename to go/internal/server/domain/events/play/field_binding.go index 52a1f23..9d07d34 100644 --- a/go/internal/server/eventplay/field_binding.go +++ b/go/internal/server/domain/events/play/field_binding.go @@ -1,7 +1,7 @@ package eventplay import ( - "bd2server/internal/server/versionconfig" + "bd2server/internal/server/platform/versionconfig" "bytes" "encoding/json" "fmt" @@ -37,8 +37,7 @@ func (s *Service) AttachFieldBindingsFile(path string) error { if seed.Version != versionconfig.Current().GameVersion { return fmt.Errorf("eventplay: field binding version mismatch") } - s.mu.Lock() - defer s.mu.Unlock() + seen := map[int]bool{} for _, b := range seed.Bindings { pack, ok := s.design.FieldPacks[b.PackID] diff --git a/go/internal/server/domain/events/play/mini_event_hubs.go b/go/internal/server/domain/events/play/mini_event_hubs.go new file mode 100644 index 0000000..d5f4eab --- /dev/null +++ b/go/internal/server/domain/events/play/mini_event_hubs.go @@ -0,0 +1,33 @@ +package eventplay + +import ( + "bd2server/internal/server/design/gamedata" +) + +// miniHubSchedule is a project calendar identity; HubID is distinct from the +// pack ID used by EventPack schedules. +type miniHubBinding struct { + Slot, ContentType uint64 + UIDs []uint64 +} + +type miniHubSchedule struct { + UID, HubID uint64 + Start, PlayEnd, End int64 + Bindings []miniHubBinding +} + +// Hub content and EventType are separate client enums. MiniGame schedules +// are design identities; calendar SubID is zero for the supported slot routes. +func (s *Service) miniSlotSchedule(contentType, id uint64) (uint64, uint64, bool) { + eventType, known := gamedata.MiniHubEventType(contentType) + if !known { + return 0, 0, false + } + if contentType == 6 { + if _, err := s.design.Row("PackEventMiniGameTable", 8, id); err != nil { + return 0, 0, false + } + } + return eventType, 0, true +} diff --git a/go/internal/server/domain/events/play/play.go b/go/internal/server/domain/events/play/play.go new file mode 100644 index 0000000..5308b22 --- /dev/null +++ b/go/internal/server/domain/events/play/play.go @@ -0,0 +1,123 @@ +// Package eventplay owns event stories and client-simulated minigame runs. +package eventplay + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + "bd2server/internal/server/protocol/staticdata" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + "math" + "strings" + + "time" +) + +type Rewards interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} +type Run struct { + UID, Game, Stage, Mode, Started, Score, HP uint64 + Family, Session string + Objects, Skills []uint64 + Coin, Exp, Char, MapGroup uint64 + SkillLevels map[uint64]uint64 + MaxHP, Rerolls, Level, SkillCredits uint64 + Offers []uint64 + LevelExp uint64 + Killed, Picked, Available map[uint64]uint64 +} +type record struct { + UID, Game, Stage, Mode, Best, Paid, Date uint64 + Family string + Clears map[string]bool + PendingBundle []byte +} +type reply struct{ Request, Body []byte } +type snapshot struct { + Version int `json:"version"` + Records map[string]record `json:"records"` + Runs map[string]Run `json:"runs"` + Replies map[string]reply `json:"replies"` + Stories map[string]bool `json:"stories"` + Upgrades map[string]uint64 `json:"upgrades"` +} + +// FieldRuleLoader returns shared immutable field game rules. +type FieldRuleLoader func(uint64) (*gamedata.EventField, error) + +type Service struct { + fieldRules FieldRuleLoader + battleChallenges gamedata.EventBattleChallenges + + store stateio.Store + registry events.Resolver + economy Rewards + design *gamedata.EventPlayCatalog + state snapshot + now func() time.Time + rooms RoomRuntime + onProgress func(command.Context, uint64, uint64, uint64) error + hubCalendars *readonly.Seed + fieldBindings []FieldBinding +} + +func Open(ctx command.Context, store stateio.Store, design *gamedata.EventPlayCatalog, fieldRules FieldRuleLoader, registry events.Resolver, economy Rewards) (*Service, error) { + if store == nil || design == nil || fieldRules == nil || registry == nil || economy == nil { + return nil, fmt.Errorf("eventplay: incomplete configuration") + } + s := &Service{store: store, registry: registry, economy: economy, design: design, fieldRules: fieldRules, now: time.Now, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}} + raw, e := store.Load(ctx.State, "eventplay") + if e != nil { + return nil, e + } + if raw != nil { + if e = stateio.RequireExactJSONObject(raw, "version", "records", "runs", "replies", "stories", "upgrades"); e != nil { + return nil, e + } + if e = json.Unmarshal(raw, &s.state); e != nil { + return nil, e + } + if s.state.Version != 1 || s.state.Records == nil || s.state.Runs == nil || s.state.Replies == nil || s.state.Stories == nil || s.state.Upgrades == nil { + return nil, fmt.Errorf("eventplay: incompatible save") + } + } + for _, r := range s.state.Records { + if r.UID == 0 || r.Game == 0 || r.Best > math.MaxInt32 || r.Clears == nil { + return nil, fmt.Errorf("eventplay: invalid saved record") + } + if _, e = s.design.Row("PackEventMiniGameTable", 8, r.Game); e != nil { + return nil, e + } + } + return s, nil +} + +// AttachHubCalendars installs project-maintained hub layouts, including the +// separate play/end windows and slots referencing multiple domain identities. +func (s *Service) AttachHubCalendars(seed *readonly.Seed) { + s.hubCalendars = seed +} + +func (s *Service) grant(ctx command.Context, identity string, rewards []gamedata.BattleReward) ([]byte, error) { + rs := make([]gamedata.Reward, 0, len(rewards)) + for _, r := range rewards { + rs = append(rs, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + } + return s.economy.Apply(ctx, identity, nil, rs) +} + +func family(path string) string { + p := strings.TrimPrefix(path, "/MiniGame") + for _, f := range []string{"Hopscotch", "Survival", "Defense", "Rhythm", "Sichuan", "Action", "Field", "Run"} { + if strings.HasPrefix(p, f) { + return f + } + } + return "" +} +func recKey(uid, stage, mode uint64, f string) string { + return fmt.Sprintf("%d:%s:%d:%d", uid, f, stage, mode) +} diff --git a/go/internal/server/domain/events/play/progress.go b/go/internal/server/domain/events/play/progress.go new file mode 100644 index 0000000..3dae2b8 --- /dev/null +++ b/go/internal/server/domain/events/play/progress.go @@ -0,0 +1,7 @@ +package eventplay + +import "bd2server/internal/server/domain/command" + +func (s *Service) AttachProgress(fn func(ctx command.Context, condition, sub, count uint64) error) { + s.onProgress = fn +} diff --git a/go/internal/server/domain/events/play/protocol_adapter.go b/go/internal/server/domain/events/play/protocol_adapter.go new file mode 100644 index 0000000..e89df66 --- /dev/null +++ b/go/internal/server/domain/events/play/protocol_adapter.go @@ -0,0 +1,1948 @@ +package eventplay + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + "bd2server/internal/server/protocol/wire" + "bytes" + "encoding/binary" + "encoding/json" + "fmt" + "math" + "slices" + "sort" + "strconv" + "strings" +) + +func (s *Service) upgrade(ctx command.Context, path string, req []byte, uid uint64, next *snapshot, key string) ([]byte, error) { + prefix := fmt.Sprintf("%d:", uid) + var costs, rewards []gamedata.Reward + if path == "/MiniGameSurvivalCharUpgrade" { + id, target := num(req, 2), num(req, 3) + k := prefix + strconv.FormatUint(id, 10) + if target == 0 || target != next.Upgrades[k]+1 { + return nil, fmt.Errorf("eventplay: upgrade level is not next") + } + var row []byte + for _, r := range s.design.Rows("FieldMiniGameUpgradeTable", 1, id) { + if num(r, 2) == target { + row = r + break + } + } + if row == nil { + return nil, fmt.Errorf("eventplay: upgrade design missing") + } + costs = []gamedata.Reward{{Type: 43, Count: num(row, 3)}} + if _, e := s.economy.Apply(ctx, "eventplay:"+key, costs, nil); e != nil { + return nil, e + } + next.Upgrades[k] = target + return nil, nil + } + for _, r := range s.design.Tables["FieldMiniGameUpgradeTable"] { + id, level := num(r, 1), num(r, 2) + if next.Upgrades[prefix+strconv.FormatUint(id, 10)] >= level { + rewards = append(rewards, gamedata.Reward{Type: 43, Count: num(r, 3)}) + } + } + bundle, e := s.economy.Apply(ctx, "eventplay:"+key, nil, rewards) + if e != nil { + return nil, e + } + for k := range next.Upgrades { + if len(k) >= len(prefix) && k[:len(prefix)] == prefix { + delete(next.Upgrades, k) + } + } + return bundle, nil +} + +func (s *Service) survivalProgress(req []byte, a *Run) (uint64, uint64, error) { + char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char) + if e != nil { + return 0, 0, e + } + var mapRow []byte + for _, r := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { + if num(r, 3) == a.Stage { + mapRow = r + } + } + if mapRow == nil { + return 0, 0, fmt.Errorf("eventplay: survival map missing") + } + monsters := s.design.Rows("FieldMiniGameMonsterTable", 2, num(mapRow, 4)) + if a.Killed == nil { + a.Killed = map[uint64]uint64{} + } + if a.Picked == nil { + a.Picked = map[uint64]uint64{} + } + if a.Available == nil { + a.Available = map[uint64]uint64{} + } + elapsed := uint64(0) + if uint64(s.now().UnixMilli()) > a.Started { + elapsed = (uint64(s.now().UnixMilli()) - a.Started) / 1000 + } + e = wire.Walk(req, func(f wire.Field) error { + if f.Number != 6 { + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + id, count := num(f.Value, 1), num(f.Value, 2) + if count == 0 { + return fmt.Errorf("eventplay: empty kill count") + } + var row []byte + for _, r := range monsters { + if num(r, 5) == id { + row = r + break + } + } + if row == nil { + return fmt.Errorf("eventplay: monster outside survival map") + } + max := num(row, 12) + repeats := num(row, 11) + start := num(row, 16) + interval := num(row, 13) + if elapsed < start { + return fmt.Errorf("eventplay: monster not spawned yet") + } + waves := uint64(1) + if interval > 0 { + waves += (elapsed - start) / interval + } + if repeats > 0 && waves > repeats { + waves = repeats + } + if max == 0 || a.Killed[id] > max*waves || count > max*waves-a.Killed[id] { + return fmt.Errorf("eventplay: kill count exceeds designed spawns") + } + a.Killed[id] += count + box := num(row, 6) + if box != 0 { + items, err := s.boxItems(box) + if err != nil { + return err + } + for _, item := range items { + a.Available[item] += count + } + } + return nil + }) + if e != nil { + return 0, 0, e + } + exp, coin := uint64(0), uint64(0) + healing := uint64(0) + e = wire.Walk(req, func(f wire.Field) error { + if f.Number != 5 { + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + id, count := num(f.Value, 1), num(f.Value, 2) + if count == 0 || count > a.Available[id] { + return fmt.Errorf("eventplay: item count exceeds committed drops") + } + row, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, id) + if e != nil { + return e + } + v := num(row, 4) + if count > math.MaxInt32 || v > math.MaxInt32/count { + return fmt.Errorf("eventplay: item value overflow") + } + a.Available[id] -= count + a.Picked[id] += count + switch num(row, 3) { + case 0: + healing += v * count + case 3: + exp += uint64(math.Floor(float64(v*count) * doubleField(char, 8, 1))) + case 4: + coin += uint64(math.Floor(float64(v*count) * doubleField(char, 10, 1))) + case 5: + a.SkillCredits += count + } + return nil + }) + if e != nil { + return 0, 0, e + } + boxes, e := list(req, 7) + if e != nil { + return 0, 0, e + } + for _, id := range boxes { + if _, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id); e != nil { + return 0, 0, e + } + } + hp := num(req, 2) + if hp > a.MaxHP || hp > a.HP+healing { + return 0, 0, fmt.Errorf("eventplay: invalid survival HP") + } + a.HP = hp + a.Exp += exp + a.Coin += coin + a.LevelExp += exp + if a.Exp > math.MaxInt32 || a.Coin > math.MaxInt32 { + return 0, 0, fmt.Errorf("eventplay: survival progress exceeds protocol range") + } + growth := num(char, 2) + for { + var current, next []byte + for _, r := range s.design.Rows("FieldMiniGameCharLevelTable", 1, growth) { + if num(r, 2) == a.Level { + current = r + } + if num(r, 2) == a.Level+1 { + next = r + } + } + if current == nil || next == nil || num(current, 3) == 0 || a.LevelExp < num(current, 3) { + break + } + a.LevelExp -= num(current, 3) + a.Level++ + a.SkillCredits++ + } + return exp, coin, nil +} + +func doubleField(b []byte, n int, def float64) float64 { + v := def + _ = wire.Walk(b, func(f wire.Field) error { + if f.Number == n && f.Type == 1 { + v = math.Float64frombits(binary.LittleEndian.Uint64(f.Value)) + } + return nil + }) + return v +} + +func (s *Service) boxItems(id uint64) ([]uint64, error) { + r, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id) + if e != nil { + return nil, e + } + values, e := list(r, 3) + if e != nil { + return nil, e + } + return values, nil +} + +func (s *Service) survivalOffers(a *Run) ([]byte, error) { + if a.SkillCredits == 0 { + return nil, fmt.Errorf("eventplay: no earned skill selection") + } + if len(a.Offers) > 0 { + if a.Rerolls == 0 { + return nil, fmt.Errorf("eventplay: no remaining rerolls") + } + a.Rerolls-- + } + a.Offers = nil + rows := append([][]byte(nil), s.design.Tables["FieldMiniGameSkillGroupTable"]...) + sort.Slice(rows, func(i, j int) bool { return num(rows[i], 1) < num(rows[j], 1) }) + for _, r := range rows { + id := num(r, 1) + if a.SkillLevels[id] < num(r, 2) { + a.Offers = append(a.Offers, id) + if len(a.Offers) == 3 { + break + } + } + } + out := wire.AppendVarint(nil, 1, a.Rerolls) + for _, id := range a.Offers { + out = wire.AppendVarint(out, 2, id) + } + return out, nil +} + +func summaryWire(st *snapshot, record bool) []byte { + keys := []string{} + for k, v := range st.Records { + if v.Best > 0 { + keys = append(keys, k) + } + } + sort.Strings(keys) + var out []byte + for _, k := range keys { + v := st.Records[k] + field := map[string]int{"Survival": 1, "Sichuan": 2, "Action": 3, "Rhythm": 5, "Hopscotch": 6}[v.Family] + if field == 0 { + continue + } + var b []byte + switch v.Family { + case "Survival": + b = wire.AppendVarint(nil, 2, v.Best) + case "Sichuan": + b = wire.AppendDouble(nil, 2, float64(v.Best)) + case "Action": + b = wire.AppendVarint(nil, 2, v.Best) + b = wire.AppendVarint(b, 3, v.Stage) + case "Rhythm": + b = wire.AppendVarint(nil, 2, v.Best) + b = wire.AppendVarint(b, 3, v.Stage) + case "Hopscotch": + b = wire.AppendVarint(nil, 1, v.Stage) + b = wire.AppendVarint(b, 3, v.Best) + } + if record { + switch v.Family { + case "Survival": + b = wire.AppendVarint(nil, 1, v.Best) + b = wire.AppendDouble(b, 2, 100) + case "Sichuan": + b = wire.AppendDouble(nil, 1, float64(v.Best)) + b = wire.AppendDouble(b, 2, 100) + case "Action": + b = wire.AppendVarint(nil, 1, v.Stage) + b = wire.AppendVarint(b, 2, v.Best) + b = wire.AppendDouble(b, 3, 100) + case "Rhythm": + b = wire.AppendVarint(nil, 1, v.Best) + b = wire.AppendVarint(b, 2, v.Stage) + b = wire.AppendDouble(b, 3, 100) + case "Hopscotch": + b = wire.AppendVarint(nil, 1, v.Stage) + b = wire.AppendVarint(b, 2, v.Best) + b = wire.AppendDouble(b, 4, 100) + } + } + out = wire.AppendBytes(out, field, b) + } + return out +} + +func (s *Service) endScore(f string, req []byte, a Run) (uint64, error) { + var score uint64 + switch f { + case "Run": + score = num(req, 3) + case "Field": + score = a.Score + case "Action": + score = num(req, 7) + case "Rhythm": + score = num(req, 4) + r, e := s.design.Row("RhythmGameMusicTable", 5, a.Stage) + if e != nil { + return 0, e + } + maxField := 8 + if a.Mode == 1 { //nolint:staticcheck // QF1003 + maxField = 2 + } else if a.Mode == 2 { + maxField = 1 + } + if score > num(r, maxField) { + return 0, fmt.Errorf("eventplay: rhythm score exceeds static maximum") + } + judgments := uint64(0) + err := wire.Walk(req, func(f wire.Field) error { + if f.Number == 3 { + if f.Type != 2 { + return wire.ErrMalformed + } + typ, count := num(f.Value, 1), num(f.Value, 2) + if typ > 10 || count > math.MaxInt32 { + return fmt.Errorf("eventplay: invalid note judgment") + } + judgments += count + } + return nil + }) + if err != nil { + return 0, err + } + noteField := 10 + if a.Mode > 0 { + noteField = 4 + } + if judgments > num(r, noteField) { + return 0, fmt.Errorf("eventplay: judgment count exceeds designed notes") + } + case "Sichuan": + score = num(req, 4) + case "Hopscotch": + score = num(req, 2) + if score > 10000 { + return 0, fmt.Errorf("eventplay: captured area exceeds 100 percent") + } + case "Survival": + score = num(req, 3) + d := s.design.Tables["FieldMiniGameSurvivalTable"][0] + if score > num(d, 24) { + return 0, fmt.Errorf("eventplay: survival time exceeds design") + } + case "Defense": + score = num(req, 3) + } + if score > math.MaxInt32 { + return 0, fmt.Errorf("eventplay: submitted score exceeds protocol range") + } + return score, nil +} + +func (s *Service) scoreRewards(game, score uint64) (uint64, []gamedata.BattleReward, error) { + d, e := s.design.Row("PackEventMiniGameTable", 8, game) + if e != nil { + return 0, nil, e + } + return s.design.ScoreRewards(num(d, 10), score) +} + +func (s *Service) progress(ctx command.Context, path string, req []byte, rk string, next *snapshot, key string) ([]byte, error) { + a, ok := next.Runs[rk] + if !ok { + return nil, fmt.Errorf("eventplay: no active run") + } + var out []byte + switch path { + case "/MiniGameFieldScore": + d, e := s.fieldRules(a.Game) + if e != nil { + return nil, e + } + ids, e := list(req, 3) + if e != nil || len(ids) == 0 { + return nil, fmt.Errorf("eventplay: missing field objects") + } + for _, id := range ids { + v, ok := d.Objects[id] + if !ok || has(a.Objects, id) { + return nil, fmt.Errorf("eventplay: object invalid or already scored") + } + a.Objects = append(a.Objects, id) + if v.Type == 1 { //nolint:staticcheck // QF1003 + a.Score += v.Point + } else if v.Type == 2 { + a.HP += v.Point + } + } + out = wire.AppendVarint(nil, 1, a.Score) + out = wire.AppendVarint(out, 2, a.HP) + case "/MiniGameSurvivalPlay": + exp, coin, e := s.survivalProgress(req, &a) + if e != nil { + return nil, e + } + if s.onProgress != nil { + total := uint64(0) + for id, count := range a.Killed { + previous := next.Runs[rk].Killed[id] + if count > previous { + total += count - previous + } + } + if total > 0 { + if e = s.onProgress(ctx, 319, a.Game, total); e != nil { + return nil, e + } + } + } + out = wire.AppendVarint(nil, 1, coin) + out = wire.AppendVarint(out, 2, exp) + case "/MiniGameSurvivalSkillSelectList": + var e error + out, e = s.survivalOffers(&a) + if e != nil { + return nil, e + } + case "/MiniGameSurvivalSkillUp": + id := num(req, 2) + if a.SkillCredits == 0 || !has(a.Offers, id) { + return nil, fmt.Errorf("eventplay: skill not earned or offered") + } + group, err := s.design.Row("FieldMiniGameSkillGroupTable", 1, id) + if err != nil { + return nil, fmt.Errorf("eventplay: invalid skill") + } + if item := num(req, 3); item != 0 { + if _, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, item); e != nil { + return nil, e + } + } + if a.SkillLevels == nil { + a.SkillLevels = map[uint64]uint64{} + } + level := a.SkillLevels[id] + 1 + if level > num(group, 2) { + return nil, fmt.Errorf("eventplay: survival skill at maximum") + } + capacity := uint64(0) + for _, r := range s.design.Tables["FieldMiniGameSurvivalTable"] { + if num(r, 21) > capacity { + capacity = num(r, 21) + } + } + if !has(a.Skills, id) && uint64(len(a.SkillLevels)) >= capacity { + return nil, fmt.Errorf("eventplay: survival skill capacity reached") + } + a.SkillLevels[id] = level + a.SkillCredits-- + a.Offers = nil + if level == num(group, 2) && s.onProgress != nil { + if e := s.onProgress(ctx, 318, a.Game, 1); e != nil { + return nil, e + } + if e := s.onProgress(ctx, 321, id, 1); e != nil { + return nil, e + } + } + if !has(a.Skills, id) { + a.Skills = append(a.Skills, id) + } + v := wire.AppendVarint(nil, 1, id) + v = wire.AppendVarint(v, 2, level) + out = wire.AppendBytes(nil, 1, v) + default: + if strings.Contains(path, "QuickReward") || strings.Contains(path, "Reward") { + return nil, fmt.Errorf("eventplay: reward requires a completed unpaid score record") + } + return nil, fmt.Errorf("eventplay: unsupported run transition") + } + next.Runs[rk] = a + _ = key + return out, nil +} + +// LockRoomRun installs the stage selected by native room matching before the +// client's HTTP Start. It does not award rewards or trust HTTP stage changes. +func (s *Service) LockRoomRun(ctx command.Context, session, family, guid string, uid, group, stage, monster uint64) error { + + if family != "Defense" && family != "Action" { + return fmt.Errorf("eventplay: invalid room family") + } + c, e := s.calendar(uid, family) + if e != nil { + return e + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + next.Runs[session+":"+family+":native"] = Run{UID: uid, Game: c.ID, Stage: stage, Mode: group, Char: monster, Family: family, Session: guid} + raw, _ = json.Marshal(next) + if e = s.store.Save(ctx.State, "eventplay", raw); e != nil { + return e + } + s.state = next + return nil +} + +func specializedInfo(st *snapshot, uid uint64, f string) []byte { + var out []byte + if f == "Action" || f == "Survival" || f == "Defense" { + out = wire.AppendVarint(out, 1, uid) + } + for _, r := range st.Records { + if r.UID != uid || r.Family != f { + continue + } + switch f { + case "Sichuan": + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 6, r.Best) + out = wire.AppendBytes(out, 1, v) + case "Rhythm": + v := wire.AppendVarint(nil, 1, r.Stage) + v = wire.AppendVarint(v, 2, r.Mode) + v = wire.AppendVarint(v, 3, r.Best) + out = wire.AppendBytes(out, 1, v) + case "Action": + v := wire.AppendVarint(nil, 1, r.Stage) + v = wire.AppendVarint(v, 2, r.Best) + out = wire.AppendBytes(out, 2, v) + case "Hopscotch": + v := wire.AppendVarint(nil, 1, r.Stage) + if r.Best > 0 { + v = wire.AppendVarint(v, 2, 1) + } + out = wire.AppendBytes(out, 2, v) + case "Survival": + out = wire.AppendVarint(out, 4, r.Best) + for stage, clear := range r.Clears { + if !clear { + continue + } + var group, id uint64 + if _, e := fmt.Sscanf(stage, "map:%d:%d", &group, &id); e == nil { + v := wire.AppendVarint(nil, 1, group) + v = wire.AppendVarint(v, 2, id) + out = wire.AppendBytes(out, 8, v) + } + } + } + } + if f == "Survival" { + prefix := fmt.Sprintf("%d:", uid) + for key, level := range st.Upgrades { + if !strings.HasPrefix(key, prefix) { + continue + } + var id uint64 + _, _ = fmt.Sscanf(key[len(prefix):], "%d", &id) + v := wire.AppendVarint(nil, 1, id) + v = wire.AppendVarint(v, 2, level) + out = wire.AppendBytes(out, 10, v) + } + } + return out +} + +func rankingWire(st *snapshot, uid uint64, f string, record bool) []byte { + keys := []string{} + for k, r := range st.Records { + if r.UID == uid && r.Family == f && r.Best > 0 { + keys = append(keys, k) + } + } + sort.Strings(keys) + var out []byte + for _, k := range keys { + r := st.Records[k] + var b []byte + switch f { + case "Survival": + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Best) + b = wire.AppendDouble(b, 2, 100) + } + case "Hopscotch": + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Stage) + b = wire.AppendVarint(b, 2, r.Best) + b = wire.AppendDouble(b, 4, 100) + } + case "Action": + b = wire.AppendVarint(nil, 3, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Stage) + b = wire.AppendVarint(b, 2, r.Best) + b = wire.AppendDouble(b, 3, 100) + } + case "Sichuan": + b = wire.AppendVarint(nil, 7, 1) + b = wire.AppendVarint(b, 8, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Best) + b = wire.AppendDouble(b, 2, 100) + } + case "Rhythm": + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + if record { + b = wire.AppendVarint(nil, 1, r.Best) + b = wire.AppendVarint(b, 2, r.Stage) + b = wire.AppendDouble(b, 3, 100) + } + default: + b = wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 4, r.Best) + } + out = wire.AppendBytes(out, 1, b) + } + return out +} + +func (s *Service) claimScore(ctx command.Context, path string, uid uint64, f string, next *snapshot, key string) ([]byte, error) { + for k, r := range next.Records { + if r.UID != uid || r.Family != f || r.Best == 0 { + continue + } + point, rewards, e := s.scoreRewards(r.Game, r.Best) + if e != nil { + return nil, e + } + day := uint64(s.now().UnixMilli()) / 86400000 + if r.Date != day { + r.Paid = 0 + } + if point <= r.Paid { + return nil, fmt.Errorf("eventplay: daily best reward already paid") + } + _, old, e := s.scoreRewards(r.Game, r.Paid) + if e != nil { + return nil, e + } + bundle, e := s.grant(ctx, "eventplay:"+key, rewardDifference(old, rewards)) + if e != nil { + return nil, e + } + r.Paid = point + r.Date = day + next.Records[k] = r + if strings.HasSuffix(path, "QuickReward") { + return wire.AppendBytes(nil, 1, bundle), nil + } + out := wire.AppendVarint(nil, 1, point) + return wire.AppendBytes(out, 2, bundle), nil + } + return nil, fmt.Errorf("eventplay: no completed score to reward") +} + +func (s *Service) AssociatedMissionGroup(c events.Schedule) (uint64, error) { + if c.Type != 11 { + return 0, nil + } + game, e := s.design.Row("PackEventMiniGameTable", 8, c.ID) + if e != nil { + return 0, e + } + switch num(game, 3) { + case 5: + row, e := s.design.Row("FieldMiniGameSurvivalTable", 9, num(game, 4)) + if e != nil { + return 0, e + } + return num(row, 8), nil + case 7: + if len(s.design.Tables["MGDDefaultTable"]) > 0 { + return num(s.design.Tables["MGDDefaultTable"][0], 5), nil + } + case 8: + if len(s.design.Tables["ActionGameDefaultTable"]) > 0 { + return num(s.design.Tables["ActionGameDefaultTable"][0], 8), nil + } + case 12: + if len(s.design.Tables["HopscotchDefaultTable"]) > 0 { + return num(s.design.Tables["HopscotchDefaultTable"][0], 20), nil + } + } + return 0, nil +} + +func num(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } + +func list(b []byte, n int) ([]uint64, error) { + var out []uint64 + e := wire.Walk(b, func(f wire.Field) error { + if f.Number != n { + return nil + } + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + out = append(out, v) + return nil + } + if f.Type != 2 { + return wire.ErrMalformed + } + for p := f.Value; len(p) > 0; { + v, k := binary.Uvarint(p) + if k <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + p = p[k:] + } + return nil + }) + return out, e +} + +func (s *Service) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + code, ok := codes[path] + if !ok { + return 0, nil, false, nil + } + + fail := func(e error) (int, []byte, bool, error) { return code, nil, true, e } + seq := num(req, 1) + if seq == 0 || seq > math.MaxInt32 { + return fail(fmt.Errorf("eventplay: invalid sequence")) + } + if e := wire.Walk(req, func(f wire.Field) error { + if f.Type == 0 { + v, _ := binary.Uvarint(f.Value) + if v > math.MaxInt64 { + return wire.ErrMalformed + } + } + return nil + }); e != nil { + return fail(e) + } + // Public hub calendars are derived afresh, never stored as account replies. + if path == "/EventHubInfo" || path == "/MiniEventHubInfo" || path == "/MiniGameHubInfo" { + if s.hubCalendars != nil && path == "/MiniGameHubInfo" { + return s.hubCalendars.Handle(ctx, path, req) + } + if s.hubCalendars != nil && path == "/EventHubInfo" { + out, err := s.publicEventHubs(ctx, req) + return code, out, true, err + } + if path == "/MiniEventHubInfo" { + out, err := s.miniEventHubs(ctx, req) + return code, out, true, err + } + return code, nil, true, nil + } + key := fmt.Sprintf("%s:%s:%d", commandSession, path, seq) + if r, ok := s.state.Replies[key]; ok { + if !bytes.Equal(r.Request, req) { + return fail(fmt.Errorf("eventplay: changed request retry")) + } + return code, r.Body, true, nil + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + var out []byte + var e error + switch path { + case "/MiniGameUserRecordInfo", "/MiniGameRanking": + out = summaryWire(&next, path == "/MiniGameUserRecordInfo") + case "/MiniGameRelayServerInfo": + return fail(fmt.Errorf("eventplay: native relay channels are not configured")) + + case "/PackEventStoryInfo", "/PackEventBattleInfo": + uids, err := list(req, 2) + if err != nil { + return fail(err) + } + for _, uid := range uids { + c, err := s.registry.Resolve(uid) + if err != nil { + return fail(err) + } + if path == "/PackEventStoryInfo" { + for _, r := range s.design.Rows("PackEventStoryTable", 1, c.ID) { + id := num(r, 2) + if next.Stories[fmt.Sprintf("%d:%d:%d", uid, c.ID, id)] { + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 2, c.ID) + v = wire.AppendVarint(v, 3, id) + out = wire.AppendBytes(out, 1, v) + } + } + } else { + for _, r := range s.design.Rows("PackEventBattleTable", 4, c.ID) { + id := num(r, 5) + if !next.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, c.ID, id)] { + continue + } + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 2, c.ID) + v = wire.AppendVarint(v, 3, id) + for _, idx := range s.battleChallengeIndexesLocked(uid, c.ID, id) { + v = wire.AppendVarint(v, 4, idx) + } + out = wire.AppendBytes(out, 1, v) + } + } + } + case "/PackEventStoryClear", "/PackEventStoryReplayClear": + uid, group, id := num(req, 2), num(req, 3), num(req, 4) + if path == "/PackEventStoryReplayClear" { + group, id = num(req, 2), num(req, 3) + uid = 0 + } + if uid != 0 { + c, err := s.registry.Resolve(uid) + if err != nil { + return fail(err) + } + if c.ID != group || s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End { + return fail(fmt.Errorf("eventplay: story schedule unavailable")) + } + } + r, err := s.design.Story(group, id) + if err != nil { + return fail(err) + } + storyKey := fmt.Sprintf("%d:%d:%d", uid, group, id) + if !next.Stories[storyKey] { + rs, err := gamedata.EventPlayRewards(r, 11, 10, 9) + if err != nil { + return fail(err) + } + out, e = s.grant(ctx, "eventplay:story:"+storyKey, rs) + if e != nil { + return fail(e) + } + out = wire.AppendBytes(nil, 1, out) + next.Stories[storyKey] = true + } + if path == "/PackEventStoryClear" { + v := wire.AppendVarint(nil, 1, uid) + v = wire.AppendVarint(v, 2, group) + v = wire.AppendVarint(v, 3, id) + out = wire.AppendBytes(out, 2, v) + } + default: + out, e = s.game(ctx, path, req, commandSession, &next, key) + } + if e != nil { + return fail(e) + } + next.Replies[key] = reply{append([]byte(nil), req...), out} + b, e := json.Marshal(next) + if e != nil { + return fail(e) + } + if e = s.store.Save(ctx.State, "eventplay", b); e != nil { + return fail(e) + } + s.state = next + return code, out, true, nil +} + +func recordWire(r record) []byte { + b := wire.AppendVarint(nil, 1, r.UID) + b = wire.AppendVarint(b, 2, r.Paid) + b = wire.AppendVarint(b, 3, r.Best) + if r.Best > 0 && r.Paid < r.Best { + b = wire.AppendVarint(b, 4, 1) + } + return b +} + +func (s *Service) calendar(uid uint64, f string) (events.Schedule, error) { + if uid == 0 { + for _, c := range s.registry.List() { + if c.Type == 11 { + d, e := s.design.Row("PackEventMiniGameTable", 8, c.ID) + if e != nil { + continue + } + kind := num(d, 3) + expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f] + if f == "Run" && (kind == 2 || kind == 4) { + expected = kind + } + if kind != expected { + continue + } + uid = c.UID + break + } + } + } + c, e := s.registry.Resolve(uid) + if e != nil { + return c, e + } + if c.Type != 11 { + return c, fmt.Errorf("eventplay: minigame schedule mismatch") + } + if _, e = s.design.Row("PackEventMiniGameTable", 8, c.ID); e != nil { + return c, e + } + d, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID) + kind := num(d, 3) + expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f] + if f == "Run" && (kind == 2 || kind == 4) { + expected = kind + } + if kind != expected { + return c, fmt.Errorf("eventplay: configured minigame family mismatch") + } + return c, nil +} + +func (s *Service) game(ctx command.Context, path string, req []byte, session string, next *snapshot, key string) ([]byte, error) { + f := family(path) + if f == "" { + return nil, fmt.Errorf("eventplay: unknown family") + } + start := strings.HasSuffix(path, "Start") + end := strings.HasSuffix(path, "End") + runKey := session + ":" + f + uid := num(req, 2) + if end || path == "/MiniGameFieldScore" || path == "/MiniGameSurvivalPlay" || strings.Contains(path, "Skill") { + if a, ok := next.Runs[runKey]; ok { + uid = a.UID + } else { + return nil, fmt.Errorf("eventplay: no active run") + } + } + c, e := s.calendar(uid, f) + if e != nil { + return nil, e + } + uid = c.UID + stage, mode := num(req, 3), num(req, 4) + if f == "Rhythm" { + mode = num(req, 5) + } + if f == "Survival" { + stage = num(req, 5) + } + if f == "Sichuan" { + stage = num(req, 4) + } + rk := recKey(uid, stage, mode, f) + r := next.Records[rk] + if r.Clears == nil { + r = record{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Family: f, Clears: map[string]bool{}} + } + if path == "/MiniGameSurvivalCharUpgrade" || path == "/MiniGameSurvivalCharUpgradeReset" { + return s.upgrade(ctx, path, req, uid, next, key) + } + if path == "/MiniGameDefenseMatching" { + matching := num(req, 3) + if matching > 2 { + return nil, fmt.Errorf("eventplay: unknown matching mode") + } + next.Runs[runKey+":matching"] = Run{UID: uid, Game: c.ID, Family: f, Session: session} + return nil, nil + } + if start { + if s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End { + return nil, fmt.Errorf("eventplay: season closed") + } + if _, active := next.Runs[runKey]; active { + return nil, fmt.Errorf("eventplay: run already active") + } + if e = s.validateStage(f, stage, mode); e != nil { + return nil, e + } + a := Run{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Started: uint64(s.now().UnixMilli()), Family: f, Session: session, HP: 100} + if f == "Action" { + game, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID) + group := num(game, 4) + rows := s.design.Rows("ActionGameStageTable", 3, group) + if len(rows) == 0 { + return nil, fmt.Errorf("eventplay: action stage group missing") + } + a.Stage = num(rows[0], 4) + a.Mode = group + a.Char = num(rows[0], 1) + } + if f == "Action" || f == "Defense" { + if locked, ok := next.Runs[runKey+":native"]; ok { + a.Stage, a.Mode, a.Char = locked.Stage, locked.Mode, locked.Char + a.Session = locked.Session + if s.rooms != nil { + if e = s.rooms.ValidateRoom(session, a.Session, uid); e != nil { + return nil, e + } + } + } + } + var startReply []byte + if f == "Field" { + d, e := s.fieldRules(c.ID) + if e != nil { + return nil, e + } + a.HP = d.HP + ids := make([]uint64, 0, len(d.Objects)) + for id := range d.Objects { + ids = append(ids, id) + } + slices.Sort(ids) + for _, id := range ids { + startReply = wire.AppendVarint(startReply, 1, id) + } + } + if f == "Survival" { + a.Char = num(req, 3) + a.MapGroup = num(req, 4) + char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char) + if e != nil { + return nil, e + } + a.HP = num(char, 20) + a.MaxHP = a.HP + a.Rerolls = num(char, 19) + a.Level = 1 + a.SkillCredits = 0 + a.Killed = map[uint64]uint64{} + a.Picked = map[uint64]uint64{} + a.Available = map[uint64]uint64{} + skillGroup := num(char, 3) + skills := s.design.Rows("FieldMiniGameSkillTable", 7, skillGroup) + if len(skills) == 0 { + return nil, fmt.Errorf("eventplay: character starting skill missing") + } + a.Skills = []uint64{num(skills[0], 8)} + a.SkillLevels = map[uint64]uint64{skillGroup: 1} + found := false + for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { + if num(m, 3) == a.Stage { + found = true + } + } + if !found { + return nil, fmt.Errorf("eventplay: survival map missing") + } + } + next.Runs[runKey] = a + if s.onProgress != nil { + if e = s.onProgress(ctx, 343, c.ID, 1); e != nil { + return nil, e + } + if e = s.onProgress(ctx, 315, c.ID, 1); e != nil { + return nil, e + } + } + next.Records[rk] = r + if f == "Survival" { + v := wire.AppendVarint(nil, 1, a.Skills[0]) + v = wire.AppendVarint(v, 2, 1) + out := wire.AppendBytes(nil, 1, v) + return wire.AppendVarint(out, 2, seqSeed(a.Started)), nil + } + return startReply, nil + } + if end { + a := next.Runs[runKey] + if a.UID != uid { + return nil, fmt.Errorf("eventplay: run schedule changed") + } + if f == "Action" && (num(req, 3) != a.Mode || num(req, 4) != a.Stage || num(req, 6) != a.Char) { + return nil, fmt.Errorf("eventplay: action submitted stage/monster differs from configured start") + } + if f == "Action" || f == "Defense" { + guid, _, err := wire.Bytes(req, 2) + if err != nil { + return nil, err + } + if a.Session != session && !bytes.Equal(guid, []byte(a.Session)) { + return nil, fmt.Errorf("eventplay: native room guid mismatch") + } + if s.rooms != nil { + if e = s.rooms.ValidateRoom(session, string(guid), uid); e != nil { + return nil, e + } + } + } + score, e := s.endScore(f, req, a) + if e != nil { + return nil, e + } + if s.onProgress != nil { + switch f { + case "Survival": + for condition, count := range map[uint64]uint64{316: a.Level, 317: score} { + if e = s.onProgress(ctx, condition, c.ID, count); e != nil { + return nil, e + } + } + if num(req, 2) == 0 { + if e = s.onProgress(ctx, 320, a.Stage, 1); e != nil { + return nil, e + } + } + case "Defense": + if e = s.onProgress(ctx, 322, c.ID, score); e != nil { + return nil, e + } + case "Action": + if e = s.onProgress(ctx, 323, a.Char, 1); e != nil { + return nil, e + } + for condition, field := range map[uint64]int{326: 9, 327: 10, 329: 12, 330: 13, 333: 14, 334: 15, 335: 16, 336: 17} { + if count := num(req, field); count > 0 { + if e = s.onProgress(ctx, condition, a.Char, count); e != nil { + return nil, e + } + } + } + } + } + rk = recKey(uid, a.Stage, a.Mode, f) + r = next.Records[rk] + if r.Clears == nil { + r.Clears = map[string]bool{} + } + if score > r.Best { + r.Best = score + } + var out []byte + point, rewards, e := s.scoreRewards(c.ID, score) + if e != nil { + return nil, e + } + if f == "Defense" { + for _, row := range s.design.Tables["MGDRewardTable"] { + id, wave := num(row, 1), num(row, 6) + ck := fmt.Sprintf("wave:%d", id) + if wave <= score && !r.Clears[ck] { + rr, err := gamedata.EventPlayRewards(row, 5, 3, 2) + if err != nil { + return nil, err + } + b, err := s.grant(ctx, "eventplay:"+key+":"+ck, rr) + if err != nil { + return nil, err + } + out = wire.AppendBytes(out, 1, b) + out = wire.AppendVarint(out, 2, id) + r.Clears[ck] = true + } + } + } + if f == "Survival" { + if num(req, 5) > a.Coin || num(req, 6) > a.Exp { + return nil, fmt.Errorf("eventplay: end coin/exp exceeds committed play progress") + } + out = wire.AppendVarint(out, 2, a.Coin) + out = wire.AppendVarint(out, 3, a.Exp) + if num(req, 2) == 0 { + r.Clears[fmt.Sprintf("map:%d:%d", a.MapGroup, a.Stage)] = true + for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { + if num(m, 3) != a.Stage { + continue + } + rr, err := gamedata.EventPlayRewards(m, 8, 7, 6) + if err != nil { + return nil, err + } + if len(rr) > 0 { + b, err := s.grant(ctx, fmt.Sprintf("eventplay:map:%d:%d:%d", uid, a.MapGroup, a.Stage), rr) + if err != nil { + return nil, err + } + out = wire.AppendBytes(out, 1, b) + } + } + } + if a.Coin > 0 { + b, err := s.grant(ctx, "eventplay:"+key+":coin", []gamedata.BattleReward{{Type: 43, Count: a.Coin}}) + if err != nil { + return nil, err + } + out = wire.AppendBytes(out, 1, b) + } + } + day := uint64(s.now().UnixMilli()) / 86400000 + if r.Date != day { + r.Paid = 0 + } + if point > r.Paid { + _, previous, e := s.scoreRewards(c.ID, r.Paid) + if e != nil { + return nil, e + } + bundle, e := s.grant(ctx, "eventplay:"+key, rewardDifference(previous, rewards)) + if e != nil { + return nil, e + } + field := 1 + if f == "Run" { //nolint:staticcheck // QF1003 + field = 3 + } else if f == "Field" { + field = 5 + } else if f == "Action" { + field = 3 + } else if f == "Hopscotch" { + field = 4 + } + if f == "Rhythm" { + r.PendingBundle = append(r.PendingBundle, bundle...) + } else { + out = wire.AppendBytes(out, field, bundle) + } + r.Paid = point + r.Date = day + } + switch f { + case "Run": + out = wire.AppendVarint(out, 1, r.Best) + out = wire.AppendVarint(out, 2, r.Paid) + case "Field": + out = wire.AppendVarint(out, 1, uid) + out = wire.AppendVarint(out, 2, score) + out = wire.AppendVarint(out, 3, r.Best) + out = wire.AppendVarint(out, 4, r.Paid) + case "Sichuan": + out = wire.AppendDouble(out, 4, float64(r.Best)) + case "Rhythm": + v := wire.AppendVarint(nil, 1, a.Stage) + v = wire.AppendVarint(v, 2, a.Mode) + v = wire.AppendVarint(v, 3, r.Best) + v = wire.AppendVarint(v, 4, num(req, 2)) + v = wire.AppendVarint(v, 5, num(req, 6)) + out = wire.AppendBytes(out, 1, v) + case "Action": + out = wire.AppendVarint(out, 1, r.Best) + case "Hopscotch": + v := wire.AppendVarint(nil, 1, a.Stage) + if score > 0 { + v = wire.AppendVarint(v, 2, 1) + } + out = wire.AppendBytes(out, 2, v) + } + next.Records[rk] = r + delete(next.Runs, runKey) + if s.rooms != nil && (f == "Action" || f == "Defense") { + if e = s.rooms.CompleteRoom(session, a.Session, score); e != nil { + return nil, e + } + } + return out, nil + } + if strings.Contains(path, "Ranking") || strings.Contains(path, "RecordInfo") { + return rankingWire(next, uid, f, strings.Contains(path, "RecordInfo")), nil + } + if strings.HasSuffix(path, "Info") { + var out []byte + if f == "Rhythm" { + for k, v := range next.Records { + if v.UID == uid && v.Family == f && len(v.PendingBundle) > 0 { + out = wire.AppendBytes(out, 2, v.PendingBundle) + v.PendingBundle = nil + next.Records[k] = v + } + } + } + if f != "Run" && f != "Field" { + if _, exists := next.Records[rk]; !exists { + next.Records[rk] = r + } + return append(out, specializedInfo(next, uid, f)...), nil + } + for _, v := range next.Records { + if v.UID == uid && v.Family == f { + out = wire.AppendBytes(out, 1, recordWire(v)) + } + } + if len(out) == 0 { + next.Records[rk] = r + out = wire.AppendBytes(out, 1, recordWire(r)) + } + return out, nil + } + if strings.Contains(path, "QuickReward") || path == "/MiniGameFieldReward" { + return s.claimScore(ctx, path, uid, f, next, key) + } + return s.progress(ctx, path, req, runKey, next, key) +} + +// ListMiniContentRoutes exposes the same validated hub-scoped calendar used +// for presentation, including routes whose play period has already ended. +func (s *Service) ListMiniContentRoutes(ctx command.Context) ([]gamedata.MiniContentRoute, error) { + body, err := s.miniEventHubs(ctx, wire.AppendVarint(nil, 1, 1)) + if err != nil { + return nil, err + } + var routes []gamedata.MiniContentRoute + seen := map[uint64]bool{} + err = wire.Walk(body, func(hub wire.Field) error { + if hub.Number != 1 || hub.Type != 2 { + return nil + } + return wire.Walk(hub.Value, func(slot wire.Field) error { + if slot.Number != 6 || slot.Type != 2 { + return nil + } + typ := num(slot.Value, 2) + if typ != 13 && typ != 14 { + return nil + } + uid := num(slot.Value, 4) + if seen[uid] { + return fmt.Errorf("eventplay: ambiguous mini content UID %d", uid) + } + seen[uid] = true + routes = append(routes, gamedata.MiniContentRoute{UID: uid, ContentType: typ, ContentID: num(slot.Value, 3), Start: int64(num(slot.Value, 5)), End: int64(num(slot.Value, 6))}) + return nil + }) + }) + if err != nil { + return nil, err + } + sort.Slice(routes, func(i, j int) bool { return routes[i].UID < routes[j].UID }) + return routes, nil +} + +// ResolveMiniContentUID resolves hub-scoped story/quiz identities without +// manufacturing EventScheduleInfo rows. Callers enforce the returned window. +func (s *Service) ResolveMiniContentUID(ctx command.Context, uid uint64) (contentType, contentID uint64, start, end int64, err error) { + if uid == 0 { + return 0, 0, 0, 0, fmt.Errorf("eventplay: missing mini content UID") + } + body, err := s.miniEventHubs(ctx, wire.AppendVarint(nil, 1, 1)) + if err != nil { + return 0, 0, 0, 0, err + } + found := false + err = wire.Walk(body, func(hub wire.Field) error { + if hub.Number != 1 || hub.Type != 2 { + return nil + } + return wire.Walk(hub.Value, func(slot wire.Field) error { + if slot.Number != 6 || slot.Type != 2 || num(slot.Value, 4) != uid { + return nil + } + typ := num(slot.Value, 2) + if typ != 13 && typ != 14 { + return nil + } + if found { + return fmt.Errorf("eventplay: ambiguous mini content UID %d", uid) + } + found = true + contentType, contentID = typ, num(slot.Value, 3) + start, end = int64(num(slot.Value, 5)), int64(num(slot.Value, 6)) + return nil + }) + }) + if err != nil { + return 0, 0, 0, 0, err + } + if !found { + return 0, 0, 0, 0, fmt.Errorf("eventplay: mini content UID %d unavailable", uid) + } + return contentType, contentID, start, end, nil +} + +func (s *Service) miniEventHubs(ctx command.Context, req []byte) ([]byte, error) { + var hubs []miniHubSchedule + if s.hubCalendars == nil { + return nil, nil + } + _, body, handled, err := s.hubCalendars.Handle(ctx, "/EventHubInfo", req) + if err != nil { + return nil, err + } + if handled { + err = wire.Walk(body, func(f wire.Field) error { + if f.Number != 1 || f.Type != 2 { + return nil + } + hub := miniHubSchedule{UID: num(f.Value, 1), HubID: num(f.Value, 2), Start: int64(num(f.Value, 3)), PlayEnd: int64(num(f.Value, 4)), End: int64(num(f.Value, 5))} + if err := wire.Walk(f.Value, func(setting wire.Field) error { + if setting.Number != 6 || setting.Type != 2 { + return nil + } + uids, err := list(setting.Value, 3) + if err != nil { + return err + } + hub.Bindings = append(hub.Bindings, miniHubBinding{num(setting.Value, 1), num(setting.Value, 2), uids}) + return nil + }); err != nil { + return err + } + hubs = append(hubs, hub) + return nil + }) + if err != nil { + return nil, err + } + } + sort.Slice(hubs, func(i, j int) bool { return hubs[i].UID < hubs[j].UID }) + rows := s.registry.List() + var out []byte + for _, hub := range hubs { + table, err := s.design.Row("PackEventHubTable", 14, hub.HubID) + if err != nil || num(table, 13) != 1 { + continue + } + v := wire.AppendVarint(nil, 1, hub.UID) + v = wire.AppendVarint(v, 2, hub.HubID) + v = wire.AppendVarint(v, 3, uint64(hub.Start)) + v = wire.AppendVarint(v, 4, uint64(hub.PlayEnd)) + v = wire.AppendVarint(v, 5, uint64(hub.End)) + slots := s.design.Rows("PackEventListTable", 6, hub.HubID) + sort.Slice(slots, func(i, j int) bool { return num(slots[i], 10) < num(slots[j], 10) }) + for _, binding := range hub.Bindings { + found := false + for _, slot := range slots { + if binding.Slot == num(slot, 11) { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("eventplay: mini hub %d static slot index %d missing", hub.UID, binding.Slot) + } + } + for _, slot := range slots { + contentType, contentID := num(slot, 9), num(slot, 7) + bound := map[uint64]bool{} + for _, binding := range hub.Bindings { + if binding.Slot == num(slot, 11) { + if binding.ContentType != contentType { + return nil, fmt.Errorf("eventplay: mini hub %d slot %d content type mismatch", hub.UID, num(slot, 10)) + } + for _, uid := range binding.UIDs { + bound[uid] = true + } + } + } + if len(bound) == 0 { + continue + } + // Mini hub stories and NPC quizzes have hub-scoped UIDs, rather + // than entries in EventScheduleInfo. Their explicit binding and + // static EndDateType supply the calendar; never invent a global + // event type for these separate client protocols. + if contentType == 13 || contentType == 14 { + if len(bound) != 1 || bound[0] { + return nil, fmt.Errorf("eventplay: mini hub %d slot %d requires one nonzero hub-scoped UID", hub.UID, num(slot, 10)) + } + var uid uint64 + for id := range bound { + uid = id + } + for _, row := range rows { + if row.UID == uid { + return nil, fmt.Errorf("eventplay: mini hub %d slot %d hub-scoped UID collides with global schedule", hub.UID, num(slot, 10)) + } + } + end := hub.End + if num(slot, 4) == 0 { + end = hub.PlayEnd + } + if end <= hub.Start { + continue + } + b := wire.AppendVarint(nil, 1, num(slot, 10)) + b = wire.AppendVarint(b, 2, contentType) + b = wire.AppendVarint(b, 3, contentID) + b = wire.AppendVarint(b, 4, uid) + b = wire.AppendVarint(b, 5, uint64(hub.Start)) + b = wire.AppendVarint(b, 6, uint64(end)) + v = wire.AppendBytes(v, 6, b) + continue + } + eventType, subID, known := s.miniSlotSchedule(contentType, contentID) + if !known { + return nil, fmt.Errorf("eventplay: mini hub %d slot %d content type %d unsupported", hub.UID, num(slot, 10), contentType) + } + for uid := range bound { + found := false + for _, row := range rows { + if row.UID == uid && row.Type == eventType && row.ID == contentID && row.SubID == subID { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("eventplay: mini hub %d slot %d schedule %d identity mismatch", hub.UID, num(slot, 10), uid) + } + } + var matches []events.Schedule + for _, row := range rows { + if bound[row.UID] && row.Type == eventType && row.ID == contentID && row.SubID == subID && row.UID != 0 && row.Start < hub.End && hub.Start < row.End { + matches = append(matches, row) + } + } + if len(matches) == 0 { + continue + } + if len(matches) > 1 { + return nil, fmt.Errorf("eventplay: mini hub %d slot %d has ambiguous schedules", hub.UID, num(slot, 10)) + } + child := matches[0] + start, end := child.Start, child.End + if start < hub.Start { + start = hub.Start + } + if end > hub.End { + end = hub.End + } + // Project policy follows static EndDateType: play content closes at + // PlayEnd; exchange content can remain through the declared End. + if num(slot, 4) == 0 && end > hub.PlayEnd { + end = hub.PlayEnd + } + if end <= start { + continue + } + b := wire.AppendVarint(nil, 1, num(slot, 10)) + b = wire.AppendVarint(b, 2, contentType) + b = wire.AppendVarint(b, 3, contentID) + b = wire.AppendVarint(b, 4, child.UID) + b = wire.AppendVarint(b, 5, uint64(start)) + b = wire.AppendVarint(b, 6, uint64(end)) + v = wire.AppendBytes(v, 6, b) + } + out = wire.AppendBytes(out, 1, v) + } + return out, nil +} + +// Public event packs retain their declared wire settings and both end windows. +// Mini hub prefabs are served exclusively by MiniEventHubInfo. +func (s *Service) publicEventHubs(ctx command.Context, req []byte) ([]byte, error) { + _, body, _, err := s.hubCalendars.Handle(ctx, "/EventHubInfo", req) + if err != nil { + return nil, err + } + var out []byte + err = wire.Walk(body, func(f wire.Field) error { + if f.Number != 1 || f.Type != 2 { + return nil + } + row, err := s.design.Row("PackEventHubTable", 14, num(f.Value, 2)) + if err != nil { + return nil + } + if num(row, 13) == 0 { + out = wire.AppendBytes(out, 1, f.Value) + } + return nil + }) + return out, err +} + +// ListEventFieldPacks authorizes hidden fields through installed pack rules +// and the live project calendar. A hub field and a minigame field are separate +// routes: some public hubs have no PackEventMiniGameTable row at all. +func (s *Service) ListEventFieldPacks(ctx command.Context) ([]gamedata.EventFieldPack, error) { + + return s.eventFieldPacks(ctx) +} + +func (s *Service) ResolveEventFieldPack(ctx command.Context, id int) (gamedata.EventFieldPack, bool, error) { + + packs, err := s.eventFieldPacks(ctx) + if err != nil { + return gamedata.EventFieldPack{}, false, err + } + for _, p := range packs { + if p.ID == id { + return p, true, nil + } + } + return gamedata.EventFieldPack{}, false, nil +} + +func (s *Service) eventFieldPacks(ctx command.Context) ([]gamedata.EventFieldPack, error) { + now := s.now().UnixMilli() + result := map[int]gamedata.EventFieldPack{} + // Bound minigames use the hub play window even when their global schedule + // has a longer archive window. Zero means a known, currently closed binding. + boundEnds := map[uint64]int64{} + add := func(id int, uid, game, hub, mapID, point uint64, end int64) error { + base, ok := s.design.FieldPacks[id] + if !ok { + return nil + } + if len(base.MapIDs) == 0 { + return fmt.Errorf("eventplay: hidden pack %d has no installed map", id) + } + if mapID != 0 { + found := false + for _, m := range base.MapIDs { + if uint64(m) == mapID { + found = true + } + } + if !found { + return fmt.Errorf("eventplay: pack %d calendar map %d mismatch", id, mapID) + } + } + if old, exists := result[id]; exists && old.End >= end { + return nil + } + base.MapIDs = append([]int(nil), base.MapIDs...) + base.BuyRewards = append([]gamedata.Reward(nil), base.BuyRewards...) + base.ScheduleUID, base.GameID, base.HubID, base.End = uid, game, hub, end + base.InitialMapID, base.PointPositionID = mapID, point + if base.InitialMapID == 0 { + base.InitialMapID = uint64(base.MapIDs[0]) + } + result[id] = base + return nil + } + if s.hubCalendars != nil { + _, body, handled, err := s.hubCalendars.Handle(ctx, "/EventHubInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil { + return nil, err + } + if handled { + err = wire.Walk(body, func(f wire.Field) error { + if f.Number != 1 || f.Type != 2 { + return nil + } + start, end := int64(num(f.Value, 3)), int64(num(f.Value, 4)) + if err := wire.Walk(f.Value, func(setting wire.Field) error { + if setting.Number != 6 || setting.Type != 2 || num(setting.Value, 2) != 6 { + return nil + } + uids, err := list(setting.Value, 3) + if err != nil { + return err + } + for _, uid := range uids { + if _, exists := boundEnds[uid]; !exists { + boundEnds[uid] = 0 + } + if now >= start && now < end && boundEnds[uid] < end { + boundEnds[uid] = end + } + } + return nil + }); err != nil { + return err + } + if now < start || now >= end { + return nil + } + hubID := num(f.Value, 2) + hub, e := s.design.Row("PackEventHubTable", 14, hubID) + if e != nil { + return e + } + return add(int(num(hub, 20)), num(f.Value, 1), 0, hubID, 0, 0, end) + }) + if err != nil { + return nil, err + } + } + } + for _, c := range s.registry.List() { + for _, binding := range s.fieldBindings { + if c.Type == binding.EventType && now >= c.Start && now < c.End { + if err := add(binding.PackID, c.UID, c.ID, 0, 0, 0, c.End); err != nil { + return nil, err + } + p := result[binding.PackID] + p.ContentOpenType = binding.ContentOpenType + result[binding.PackID] = p + } + } + if c.Type != 11 || now < c.Start || now >= c.End { + continue + } + end := c.End + if hubEnd, bound := boundEnds[c.UID]; bound { + if hubEnd == 0 { + continue + } + if hubEnd < end { + end = hubEnd + } + } + game, err := s.design.Row("PackEventMiniGameTable", 8, c.ID) + if err != nil { + return nil, err + } + if err = add(int(num(game, 12)), c.UID, c.ID, 0, num(game, 9), num(game, 13), end); err != nil { + return nil, err + } + } + var packs []gamedata.EventFieldPack + for _, p := range result { + packs = append(packs, p) + } + sort.Slice(packs, func(i, j int) bool { return packs[i].ID < packs[j].ID }) + return packs, nil +} + +// FieldObjectEventPeriod follows EventLostCoinInfo: the exact public calendar +// must be active, and the installed event must explicitly contain this pack. +func (s *Service) FieldObjectEventPeriod(pack int) (string, int64, error) { + + now := s.now().UnixMilli() + period := "" + var end int64 + for _, schedule := range s.registry.List() { + if schedule.Type != 14 || now < schedule.Start || now >= schedule.End { + continue + } + row, err := s.design.Row("EventLostCoinTable", 4, schedule.ID) + if err != nil { + return "", 0, err + } + packs, err := list(row, 1) + if err != nil { + return "", 0, err + } + matches := false + for _, id := range packs { + matches = matches || id == uint64(pack) + } + if !matches { + continue + } + if period != "" { + return "", 0, fmt.Errorf("eventplay: ambiguous active lost coin calendar for pack %d", pack) + } + period = fmt.Sprintf("event:%d:%d:%d:%d", schedule.UID, schedule.ID, schedule.Start, schedule.End) + end = schedule.End + } + if period == "" { + return "", 0, fmt.Errorf("eventplay: no active lost coin calendar for pack %d", pack) + } + return period, end, nil +} + +func (s *Service) EnterBattle(ctx command.Context, req []byte, receipt string) ([]byte, error) { + + if num(req, 5) != 17 || receipt == "" { + return nil, fmt.Errorf("eventplay: invalid event battle") + } + group, id, deck := num(req, 8), num(req, 9), num(req, 4) + var row []byte + for _, r := range s.design.Rows("PackEventBattleTable", 4, group) { + if num(r, 5) == id && num(r, 1) == deck { + row = r + break + } + } + if row == nil { + return nil, fmt.Errorf("eventplay: event battle deck/stage mismatch") + } + var uid uint64 + for _, c := range s.registry.List() { + if c.ID == group && (c.Type == 9 || c.Type == 8) && s.now().UnixMilli() >= c.Start && s.now().UnixMilli() < c.End { + uid = c.UID + break + } + } + if uid == 0 { + return nil, fmt.Errorf("eventplay: event battle schedule unavailable") + } + previous := uint64(0) + for _, r := range s.design.Rows("PackEventBattleTable", 4, group) { + stage := num(r, 5) + if stage < id && stage > previous { + previous = stage + } + } + if previous != 0 && !s.state.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, group, previous)] { + return nil, fmt.Errorf("eventplay: previous event battle not cleared") + } + if validator, ok := s.economy.(interface { + CanApply(ctx command.Context, _ []gamedata.Reward) error + }); ok { + cost := num(row, 3) + if cost > 0 { + if e := validator.CanApply(ctx, []gamedata.Reward{{Type: 30, Count: cost}}); e != nil { + return nil, e + } + } + } + payload, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(payload, &next) + next.Runs["battle:"+receipt] = Run{UID: uid, Game: group, Stage: id, Mode: 17, Started: uint64(s.now().UnixMilli()), Family: "Battle", Session: strings.Split(receipt, ":")[0]} + payload, _ = json.Marshal(next) + if e := s.store.Save(ctx.State, "eventplay", payload); e != nil { + return nil, e + } + s.state = next + return nil, nil +} + +func (s *Service) CompleteBattle(ctx command.Context, req []byte, receipt string) ([]byte, error) { + + key := "eventbattle:" + receipt + if r, ok := s.state.Replies[key]; ok { + if !bytes.Equal(r.Request, req) { + return nil, fmt.Errorf("eventplay: changed battle retry") + } + return r.Body, nil + } + a, ok := s.state.Runs["battle:"+receipt] + if !ok { + return nil, fmt.Errorf("eventplay: no entered event battle") + } + var row []byte + for _, r := range s.design.Rows("PackEventBattleTable", 4, a.Game) { + if num(r, 5) == a.Stage { + row = r + break + } + } + if row == nil { + return nil, fmt.Errorf("eventplay: saved battle design missing") + } + raw, _ := json.Marshal(s.state) + var next snapshot + _ = json.Unmarshal(raw, &next) + var out []byte + if num(req, 2) == 1 { + rs, e := gamedata.EventPlayRewards(row, 9, 10, 8) + if e != nil { + return nil, e + } + definitions := s.battleChallenges[num(row, 1)] + claims, e := gamedata.VerifySubmittedChallenges(req, definitions) + if e != nil { + return nil, e + } + for _, index := range claims { + marker := fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, index) + if !next.Stories[marker] { + rs = append(rs, definitions[index].Reward) + next.Stories[marker] = true + } + } + rewards := []gamedata.Reward{} + for _, r := range rs { + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + } + cost := num(row, 3) + var costs []gamedata.Reward + if cost > 0 { + costs = []gamedata.Reward{{Type: 30, Count: cost}} + } + bundle, e := s.economy.Apply(ctx, key, costs, rewards) + if e != nil { + return nil, e + } + out = wire.AppendBytes(out, 5, bundle) + info := wire.AppendVarint(nil, 1, a.UID) + info = wire.AppendVarint(info, 2, a.Game) + info = wire.AppendVarint(info, 3, a.Stage) + for i := range definitions { + if next.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, i)] { + info = wire.AppendVarint(info, 4, uint64(i)) + } + } + out = wire.AppendBytes(out, 16, info) + next.Stories[fmt.Sprintf("battle:%d:%d:%d", a.UID, a.Game, a.Stage)] = true + } + delete(next.Runs, "battle:"+receipt) + next.Replies[key] = reply{append([]byte(nil), req...), out} + raw, _ = json.Marshal(next) + if e := s.store.Save(ctx.State, "eventplay", raw); e != nil { + return nil, e + } + s.state = next + return out, nil +} + +func (s *Service) BattleChallengeIndexes(uid, group, stage uint64) []uint64 { + + return s.battleChallengeIndexesLocked(uid, group, stage) +} + +func (s *Service) battleChallengeIndexesLocked(uid, group, stage uint64) []uint64 { + var deck uint64 + for _, row := range s.design.Rows("PackEventBattleTable", 4, group) { + if num(row, 5) == stage { + deck = num(row, 1) + break + } + } + var out []uint64 + for i := range s.battleChallenges[deck] { + if s.state.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", uid, group, stage, i)] { + out = append(out, uint64(i)) + } + } + return out +} diff --git a/go/internal/server/domain/events/play/room.go b/go/internal/server/domain/events/play/room.go new file mode 100644 index 0000000..1c45f43 --- /dev/null +++ b/go/internal/server/domain/events/play/room.go @@ -0,0 +1,11 @@ +package eventplay + +// RoomRuntime is implemented by the native matching/relay service. HTTP +// gameplay settles only runs whose native room is authenticated. + +type RoomRuntime interface { + ValidateRoom(session, guid string, uid uint64) error + CompleteRoom(session, guid string, score uint64) error +} + +func (s *Service) AttachRooms(r RoomRuntime) { s.rooms = r } diff --git a/go/internal/server/eventplay/routes.go b/go/internal/server/domain/events/play/routes.go similarity index 100% rename from go/internal/server/eventplay/routes.go rename to go/internal/server/domain/events/play/routes.go diff --git a/go/internal/server/domain/events/play/runtime.go b/go/internal/server/domain/events/play/runtime.go new file mode 100644 index 0000000..6e319a3 --- /dev/null +++ b/go/internal/server/domain/events/play/runtime.go @@ -0,0 +1,40 @@ +package eventplay + +import ( + "fmt" + "slices" +) + +func (s *Service) validateStage(f string, id, mode uint64) error { + switch f { + case "Rhythm": + r, e := s.design.Row("RhythmGameMusicTable", 5, id) + if e != nil { + return e + } + if mode > 2 { + return fmt.Errorf("eventplay: unknown rhythm mode") + } + _ = r + case "Hopscotch": + _, e := s.design.Row("HopscotchStageTable", 6, id) + return e + case "Sichuan": + _, e := s.design.Row("SichuanStageTable", 2, id) + return e + case "Survival": + if len(s.design.Tables["FieldMiniGameSurvivalTable"]) == 0 { + return fmt.Errorf("eventplay: survival design missing") + } + case "Action": + if len(s.design.Tables["ActionGameDefaultTable"]) == 0 { + return fmt.Errorf("eventplay: action design missing") + } + } + return nil +} + +func has(a []uint64, id uint64) bool { + return slices.Contains(a, id) +} +func seqSeed(n uint64) uint64 { return n & 2147483647 } diff --git a/go/internal/server/domain/events/play/upgrade.go b/go/internal/server/domain/events/play/upgrade.go new file mode 100644 index 0000000..919e960 --- /dev/null +++ b/go/internal/server/domain/events/play/upgrade.go @@ -0,0 +1,21 @@ +package eventplay + +import ( + "bd2server/internal/server/design/gamedata" +) + +func rewardDifference(old, next []gamedata.BattleReward) []gamedata.BattleReward { + paid := map[[2]uint64]uint64{} + for _, r := range old { + paid[[2]uint64{r.Type, r.ID}] += r.Count + } + var out []gamedata.BattleReward + for _, r := range next { + n := paid[[2]uint64{r.Type, r.ID}] + if r.Count > n { + r.Count -= n + out = append(out, r) + } + } + return out +} diff --git a/go/internal/server/domain/events/protocol_adapter.go b/go/internal/server/domain/events/protocol_adapter.go new file mode 100644 index 0000000..c363e9f --- /dev/null +++ b/go/internal/server/domain/events/protocol_adapter.go @@ -0,0 +1,581 @@ +package events + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/protocol/wire" + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" +) + +func (h SkinHandler) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + + if code, response, handled, err := h.Economy.OwnedItemInfo(ctx, path, request); handled || err != nil { + return code, response, handled, err + } + return h.Economy.Handle(ctx, path, request) +} + +func (e *Economy) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + if path != "/PrestigeSkinSet" { + return e.PrestigeSkinInfo(ctx, path, req) + } + + if err := wire.Walk(req, func(f wire.Field) error { + if (f.Number == 1 && f.Type != 0) || (f.Number == 2 && f.Type != 2) { + return fmt.Errorf("events: invalid prestige request field") + } + return nil + }); err != nil { + return 426, nil, true, err + } + seq, _, err := wire.Varint(req, 1) + info, found, err2 := wire.Bytes(req, 2) + if err != nil || err2 != nil || !found || seq == 0 || seq > math.MaxInt32 || commandSession == "" { + return 426, nil, true, fmt.Errorf("events: invalid prestige set request") + } + if err := wire.Walk(info, func(f wire.Field) error { + if f.Number >= 1 && f.Number <= 4 && f.Type != 0 { + return fmt.Errorf("events: invalid prestige info field") + } + return nil + }); err != nil { + return 426, nil, true, err + } + costume, _, err := wire.Varint(info, 1) + design, _, err2 := wire.Varint(info, 2) + setting, _, err3 := wire.Varint(info, 3) + timeValue, _, err4 := wire.Varint(info, 4) + if err != nil || err2 != nil || err3 != nil || err4 != nil || costume == 0 || costume > math.MaxInt32 || design == 0 || design > math.MaxInt32 || setting > 1 || timeValue > math.MaxInt64 || e.prestige[design] != costume { + return 426, nil, true, fmt.Errorf("events: invalid prestige skin") + } + s, err := e.loadPrestigeSkins(ctx) + if err != nil { + return 426, nil, true, err + } + key := commandSession + ":" + strconv.FormatUint(seq, 10) + if receipt, ok := s.Receipts[key]; ok { + if !bytes.Equal(receipt.Request, req) { + return 426, nil, true, fmt.Errorf("events: prestige sequence conflict") + } + return 426, append([]byte(nil), receipt.Response...), true, nil + } + owned := false + for _, item := range e.items.All(ctx) { + if item.Type == 45 && item.ID == design && item.Count > 0 { + owned = true + break + } + } + if !owned { + return 426, nil, true, fmt.Errorf("events: prestige skin not owned") + } + if setting == 1 { + s.Selections[costume] = design + } else if s.Selections[costume] == design { + delete(s.Selections, costume) + } + portrait := costume + if e.prestigePortrait != nil { + portrait = e.prestigePortrait() + } + out := wire.AppendVarint(nil, 1, portrait) + out = wire.AppendVarint(out, 2, s.Selections[portrait]) + s.Receipts[key] = prestigeSkinReceipt{Request: append([]byte(nil), req...), Response: append([]byte(nil), out...)} + raw, err := json.Marshal(s) + if err == nil { + err = e.store.Save(ctx.State, "prestige_skin_sets", raw) + } + if err != nil { + return 426, nil, true, err + } + return 426, out, true, nil +} + +func (e *Economy) PrestigeSkinInfo(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + if path != "/PrestigeSkinInfo" { + return 0, nil, false, nil + } + seq, _, err := wire.Varint(req, 1) + if err != nil || seq == 0 || seq > math.MaxInt32 { + return 425, nil, true, fmt.Errorf("events: missing sequence") + } + + s, err := e.loadPrestigeSkins(ctx) + if err != nil { + return 425, nil, true, err + } + var out []byte + seen := map[uint64]bool{} + for _, item := range e.items.All(ctx) { + if item.Type != 45 || item.Count == 0 || seen[item.ID] { + continue + } + costume, ok := e.prestige[item.ID] + if !ok { + return 425, nil, true, fmt.Errorf("events: owned skin missing design") + } + seen[item.ID] = true + b := wire.AppendVarint(nil, 1, costume) + b = wire.AppendVarint(b, 2, item.ID) + if s.Selections[costume] == item.ID { + b = wire.AppendVarint(b, 3, 1) + } + b = wire.AppendVarint(b, 4, item.TimeValue) + out = wire.AppendBytes(out, 1, b) + } + return 425, out, true, nil +} + +func (r *Registry) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + if path != "/EventScheduleInfo" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(req, 1) + if err != nil || !found || seq == 0 { + return 163, nil, true, fmt.Errorf("events: invalid sequence") + } + var out []byte + for _, s := range r.List() { + b := wire.AppendVarint(nil, 1, s.UID) + b = wire.AppendVarint(b, 2, s.Type) + b = wire.AppendVarint(b, 3, s.ID) + if s.SubID != 0 { + b = wire.AppendVarint(b, 4, s.SubID) + } + b = wire.AppendVarint(b, 5, uint64(s.Start)) + b = wire.AppendVarint(b, 6, uint64(s.End)) + if now := r.now().UnixMilli(); now >= s.Start && now < s.End { + b = wire.AppendVarint(b, 7, 1) + } + out = wire.AppendBytes(out, 1, b) + } + return 163, out, true, nil +} + +// ID card items are loaded through ItemInfo; there is no IdCardInfo ownership +// endpoint. AvatarInfo separately includes its owned ItemDBInfo list. +func (e *Economy) OwnedItemInfo(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + if path != "/AvatarInfo" { + return 0, nil, false, nil + } + seq, _, err := wire.Varint(req, 1) + if err != nil || seq == 0 { + return 467, nil, true, fmt.Errorf("events: missing sequence") + } + var out []byte + for _, item := range e.items.All(ctx) { + if item.Type == 49 || item.Type == 50 || item.Type == 61 { + out = wire.AppendBytes(out, 2, assets.ItemWire(item)) + } + } + return 467, out, true, nil +} + +func (e *Economy) Apply(ctx command.Context, identity string, costs, rewards []gamedata.Reward) ([]byte, error) { + definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) + d := sha256.Sum256(definition) + + s, err := e.load(ctx) + if err != nil { + + return nil, err + } + if r, ok := s.Receipts[identity]; ok { + + if !bytes.Equal(r.Definition, d[:]) { + return nil, fmt.Errorf("events: reward identity reused with different definition") + } + return append([]byte(nil), r.Bundle...), nil + } + + var selected []assets.Item + // Merge repeated definitions before selecting stacks so the same quantity + // cannot be selected twice from a single inventory entry. + totals := map[[2]uint64]uint64{} + for _, r := range costs { + k := [2]uint64{r.Type, r.ID} + if r.Count == 0 || r.Count > math.MaxInt64 || totals[k] > math.MaxInt64-r.Count { + return nil, fmt.Errorf("events: invalid cost") + } + totals[k] += r.Count + } + keys := make([][2]uint64, 0, len(totals)) + for k := range totals { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i][0] != keys[j][0] { + return keys[i][0] < keys[j][0] + } + return keys[i][1] < keys[j][1] + }) + for _, k := range keys { + n := totals[k] + if walletCurrency(k[0]) || extraCurrency(k[0]) || apCurrency(k[0]) { + if k[1] != 0 { + return nil, fmt.Errorf("events: currency has item id") + } + selected = append(selected, assets.Item{Type: k[0], Count: n}) + continue + } + items, err := e.items.SelectMutable(ctx, k[0], k[1], n) + if err != nil { + return nil, err + } + selected = append(selected, items...) + } + var rs []gamedata.BattleReward + for _, r := range rewards { + rs = append(rs, gamedata.BattleReward(r)) + } + return e.consumeAndGrant(ctx, identity, selected, rs, d[:], false) +} + +func (e *Economy) ConsumeAndGrant(ctx command.Context, identity string, consumed []assets.Item, rewards []gamedata.BattleReward) ([]byte, error) { + return e.consumeAndGrant(ctx, identity, consumed, rewards, nil, false) +} + +func (e *Economy) consumeAndGrant(ctx command.Context, identity string, consumed []assets.Item, rewards []gamedata.BattleReward, definition []byte, openBox bool) ([]byte, error) { + + if identity == "" { + return nil, fmt.Errorf("events: empty reward identity") + } + s, err := e.load(ctx) + if err != nil { + return nil, err + } + raw, err := json.Marshal(struct { + Costs []assets.Item + Rewards []gamedata.BattleReward + }{consumed, rewards}) + if err != nil { + return nil, err + } + digest := sha256.Sum256(raw) + if receipt, ok := s.Receipts[identity]; ok { + if !bytes.Equal(receipt.Digest, digest[:]) { + return nil, fmt.Errorf("events: reward identity reused with different contents") + } + return append([]byte(nil), receipt.Bundle...), nil + } + if err = e.refreshAP(ctx, &s); err != nil { + return nil, err + } + var itemCosts []assets.Item + var walletCosts, apCosts []gamedata.Reward + for _, c := range consumed { + if c.Count == 0 || c.Count > math.MaxInt32 { + return nil, fmt.Errorf("events: invalid consumption count") + } + r := gamedata.Reward{Type: c.Type, ID: c.ID, Count: c.Count} + switch { + case walletCurrency(c.Type): + if c.ID != 0 || c.InvenIndex != 0 { + return nil, fmt.Errorf("events: invalid currency cost") + } + walletCosts = append(walletCosts, r) + case apCurrency(c.Type): + if c.ID != 0 || c.InvenIndex != 0 { + return nil, fmt.Errorf("events: invalid hunting AP cost") + } + apCosts = append(apCosts, r) + case extraCurrency(c.Type): + if c.ID != 0 || c.InvenIndex != 0 || s.Balances[c.Type] < c.Count { + return nil, fmt.Errorf("events: insufficient currency %d", c.Type) + } + s.Balances[c.Type] -= c.Count + default: + if !inventoryType(c.Type) { + return nil, fmt.Errorf("events: unsupported consumed item type %d", c.Type) + } + itemCosts = append(itemCosts, c) + } + } + if len(itemCosts) > 0 { + if err = e.items.CanConsume(ctx, itemCosts); err != nil { + return nil, err + } + } + var expanded []gamedata.BattleReward + if openBox { + expanded, err = e.graph.Resolve(rewards) + } else { + expanded, err = e.resolveGranted(rewards) + } + if err != nil { + return nil, err + } + expanded, err = e.avatarRewards.Expand(expanded) + if err != nil { + return nil, err + } + var walletRewards, apRewards, buffItems []gamedata.Reward + var itemRewards []gamedata.BattleReward + var costumes []uint64 + var equips []assets.Equipment + var bundle []byte + for _, r := range expanded { + if r.Count == 0 || r.Count > math.MaxInt32 { + return nil, fmt.Errorf("events: invalid reward count") + } + switch { + case walletCurrency(r.Type): + if r.ID != 0 { + return nil, fmt.Errorf("events: currency reward has item id") + } + walletRewards = append(walletRewards, gamedata.Reward(r)) + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(assets.Item{Type: r.Type, Count: r.Count})) + case apCurrency(r.Type): + if r.ID != 0 { + return nil, fmt.Errorf("events: invalid AP reward") + } + apRewards = append(apRewards, gamedata.Reward(r)) + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(assets.Item{Type: r.Type, Count: r.Count})) + case extraCurrency(r.Type): + if r.ID != 0 || s.Balances[r.Type] > math.MaxInt32-r.Count { + return nil, fmt.Errorf("events: currency overflow") + } + s.Balances[r.Type] += r.Count + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(assets.Item{Type: r.Type, Count: r.Count})) + case r.Type == 63: + if e.buffRewards == nil { + return nil, fmt.Errorf("events: buff reward runtime unavailable") + } + buffItems = append(buffItems, gamedata.Reward(r)) + bundle = wire.AppendBytes(bundle, 6, assets.ItemWire(assets.Item{Type: r.Type, ID: r.ID, Count: r.Count})) + case r.Type == 11: + if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 { + return nil, fmt.Errorf("events: unknown or excessive costume reward %d", r.ID) + } + for n := uint64(0); n < r.Count; n++ { + costumes = append(costumes, r.ID) + } + case r.Type == 10: + if r.Count > 1000 { + return nil, fmt.Errorf("events: excessive equipment reward") + } + for n := uint64(0); n < r.Count; n++ { + main, sub, private, rollErr := e.options.RollOptions(r.ID) + if rollErr != nil { + return nil, rollErr + } + entry := assets.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}} + for _, o := range main { + entry.MainOption = append(entry.MainOption, assets.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + } + for _, o := range sub { + entry.SubOption = append(entry.SubOption, assets.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + } + if private != nil { + entry.PrivateOption = &assets.EquipmentOption{GroupID: private.GroupID, ID: private.ID} + } + equips = append(equips, entry) + } + case inventoryType(r.Type): + if r.Type == 47 || r.Type == 69 { + if !e.ownedDesign[r.Type][r.ID] { + return nil, fmt.Errorf("events: unknown owned item design %d:%d", r.Type, r.ID) + } + } + if r.Type == 49 || r.Type == 50 || r.Type == 61 { + valid := e.avatarRewards != nil && e.avatarRewards.Items[r.Type][r.ID] + if !valid && !e.ownedDesign[r.Type][r.ID] { + return nil, fmt.Errorf("events: unknown avatar member %d:%d", r.Type, r.ID) + } + } + if r.Type == 45 { + if _, ok := e.prestige[r.ID]; !ok { + return nil, fmt.Errorf("events: unknown prestige skin %d", r.ID) + } + } + if r.ID == 0 { + return nil, fmt.Errorf("events: zero inventory reward id") + } + itemRewards = append(itemRewards, r) + default: + return nil, fmt.Errorf("events: unsupported reward type %d", r.Type) + } + } + if len(buffItems) > 0 { + if err = e.buffRewards.Validate(buffItems); err != nil { + return nil, err + } + } + if err = e.wallet.CanExchange(ctx, walletCosts, walletRewards); err != nil { + return nil, err + } + if len(apCosts)+len(apRewards) > 0 { + if e.hunting == nil { + return nil, fmt.Errorf("events: hunting AP runtime unavailable") + } + if err = e.hunting.CanExchangeAP(ctx, apCosts, apRewards); err != nil { + return nil, err + } + } + // Validation and random generation finish before the first account write. + if len(itemCosts) > 0 { + if err = e.items.Consume(ctx, itemCosts); err != nil { + return nil, err + } + } + if err = e.wallet.ExchangeOnce(ctx, identity+":wallet", walletCosts, walletRewards); err != nil { + return nil, err + } + if len(apCosts)+len(apRewards) > 0 { + if err = e.hunting.ExchangeAPOnce(ctx, identity+":ap", apCosts, apRewards); err != nil { + return nil, err + } + } + if len(buffItems) > 0 { + if err = e.buffRewards.GrantOnce(ctx, identity+":buff-items", buffItems); err != nil { + return nil, err + } + } + items, err := e.items.GrantOnce(ctx, identity+":items", itemRewards) + if err != nil { + return nil, err + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + bundle = wire.AppendBytes(bundle, 6, assets.ItemWire(assets.Item{Type: item.Type, ID: item.ID, Count: item.Count})) + } + if len(costumes) > 0 { + grant, err := e.collection.GrantCostumes(ctx, identity+":costumes", costumes, e.costumes) + if err != nil { + return nil, err + } + var overflow uint64 + for _, x := range grant.Exchanges { + if x.ExchangeItemType != 20 || x.ExchangeCount > math.MaxInt64-overflow { + return nil, fmt.Errorf("events: invalid costume exchange") + } + overflow += x.ExchangeCount + } + if overflow > 0 { + if _, err = e.wallet.GrantMileageOnce(ctx, identity+":overflow", overflow); err != nil { + return nil, err + } + } + bundle = append(bundle, roster.CollectionRewardBundle(e.collection, grant)...) + } + for i, entry := range equips { + saved, err := e.equipment.GrantGeneratedOnce(ctx, identity+":equipment:"+strconv.Itoa(i), entry) + if err != nil { + return nil, err + } + bundle = wire.AppendBytes(bundle, 4, assets.EquipmentWire(saved)) + bundle = wire.AppendBytes(bundle, 6, assets.ItemWire(assets.Item{Type: 10, ID: saved.ID, Count: 1})) + } + s.Receipts[identity] = economyReceipt{Digest: digest[:], Definition: definition, Bundle: bundle} + data, err := json.Marshal(s) + if err != nil { + return nil, err + } + if err = e.store.Save(ctx.State, "event_economy", data); err != nil { + return nil, err + } + return bundle, nil +} + +func (e *Economy) OpenBox(ctx command.Context, identity string, box assets.Item) ([]byte, error) { + return e.consumeAndGrant(ctx, identity, []assets.Item{box}, []gamedata.BattleReward{{Type: 9, ID: box.ID, Count: box.Count}}, nil, true) +} + +func (s *BoxService) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + commandSession := ctx.SessionID + if path != "/UseRandomBox" { + return 0, nil, false, nil + } + + seq, _, e := wire.Varint(req, 1) + index, _, e2 := wire.Varint(req, 2) + count, _, e3 := wire.Varint(req, 3) + if e != nil || e2 != nil || e3 != nil || seq == 0 || index == 0 || count == 0 || count > 1000 || seq > math.MaxInt32 || index > math.MaxInt64 { + return 143, nil, true, fmt.Errorf("events: invalid box request") + } + key := commandSession + ":" + strconv.FormatUint(seq, 10) + receipts := map[string]boxReceipt{} + raw, e := s.store.Load(ctx.State, "eventboxes") + if e != nil { + return 143, nil, true, e + } + if raw != nil { + if e = json.Unmarshal(raw, &receipts); e != nil { + return 143, nil, true, e + } + } + if r, ok := receipts[key]; ok { + if !bytes.Equal(req, r.Request) { + return 143, nil, true, fmt.Errorf("events: box sequence conflict") + } + return 143, r.Response, true, nil + } + var box assets.Item + for _, item := range s.items.All(ctx) { + if item.InvenIndex == index { + box = item + break + } + } + if box.Type != 9 || box.Count < count { + return 143, nil, true, fmt.Errorf("events: box unavailable") + } + box.Count = count + bundle, e := s.economy.OpenBox(ctx, "usebox:"+key, box) + if e != nil { + return 143, nil, true, e + } + out := wire.AppendBytes(nil, 1, bundle) + receipts[key] = boxReceipt{append([]byte(nil), req...), out} + raw, e = json.Marshal(receipts) + if e != nil { + return 143, nil, true, e + } + if e = s.store.Save(ctx.State, "eventboxes", raw); e != nil { + return 143, nil, true, e + } + return 143, out, true, nil +} + +func (e *Economy) ChargeInfo(ctx command.Context) ([]byte, error) { + + s, err := e.load(ctx) + if err != nil { + return nil, err + } + if err = e.refreshAP(ctx, &s); err != nil { + return nil, err + } + raw, err := e.store.Load(ctx.State, "eventapclock") + if err != nil { + return nil, err + } + if raw == nil { + return nil, nil + } + var clock economyAPClock + if err = json.Unmarshal(raw, &clock); err != nil { + return nil, err + } + var out []byte + for _, typ := range []uint64{30, 32} { + if _, ok := e.apCaps[typ]; !ok { + continue + } + info := wire.AppendVarint(nil, 1, uint64(clock.Next-86400000)) + item := wire.AppendVarint(nil, 3, typ) + item = wire.AppendVarint(item, 4, s.Balances[typ]) + info = wire.AppendBytes(info, 2, item) + out = wire.AppendBytes(out, 1, info) + } + return out, nil +} diff --git a/go/internal/server/events/registry.go b/go/internal/server/domain/events/registry.go similarity index 63% rename from go/internal/server/events/registry.go rename to go/internal/server/domain/events/registry.go index bfc2aef..92a3b7c 100644 --- a/go/internal/server/events/registry.go +++ b/go/internal/server/domain/events/registry.go @@ -3,10 +3,8 @@ package events import ( "fmt" - "sync" - "time" - "bd2server/internal/server/wire" + "time" ) type Schedule struct { @@ -21,7 +19,6 @@ type Resolver interface { } type Registry struct { - mu sync.RWMutex rows []Schedule now func() time.Time } @@ -42,21 +39,18 @@ func (r *Registry) Replace(rows []Schedule) error { } seen[identity] = true } - r.mu.Lock() - defer r.mu.Unlock() + r.rows = append([]Schedule(nil), rows...) return nil } func (r *Registry) List() []Schedule { - r.mu.RLock() - defer r.mu.RUnlock() + return append([]Schedule(nil), r.rows...) } func (r *Registry) Resolve(uid uint64) (Schedule, error) { - r.mu.RLock() - defer r.mu.RUnlock() + var result Schedule found := false for _, s := range r.rows { @@ -72,29 +66,3 @@ func (r *Registry) Resolve(uid uint64) (Schedule, error) { } return Schedule{}, fmt.Errorf("events: unknown schedule %d", uid) } - -func (r *Registry) Handle(path string, req []byte) (int, []byte, bool, error) { - if path != "/EventScheduleInfo" { - return 0, nil, false, nil - } - seq, found, err := wire.Varint(req, 1) - if err != nil || !found || seq == 0 { - return 163, nil, true, fmt.Errorf("events: invalid sequence") - } - var out []byte - for _, s := range r.List() { - b := wire.AppendVarint(nil, 1, s.UID) - b = wire.AppendVarint(b, 2, s.Type) - b = wire.AppendVarint(b, 3, s.ID) - if s.SubID != 0 { - b = wire.AppendVarint(b, 4, s.SubID) - } - b = wire.AppendVarint(b, 5, uint64(s.Start)) - b = wire.AppendVarint(b, 6, uint64(s.End)) - if now := r.now().UnixMilli(); now >= s.Start && now < s.End { - b = wire.AppendVarint(b, 7, 1) - } - out = wire.AppendBytes(out, 1, b) - } - return 163, out, true, nil -} diff --git a/go/internal/server/domain/events/skin_handler.go b/go/internal/server/domain/events/skin_handler.go new file mode 100644 index 0000000..6a62402 --- /dev/null +++ b/go/internal/server/domain/events/skin_handler.go @@ -0,0 +1,4 @@ +package events + +// SkinHandler restores skin ownership through the client's dedicated query. +type SkinHandler struct{ Economy *Economy } diff --git a/go/internal/server/domain/events/skins.go b/go/internal/server/domain/events/skins.go new file mode 100644 index 0000000..dce7f91 --- /dev/null +++ b/go/internal/server/domain/events/skins.go @@ -0,0 +1,47 @@ +package events + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" +) + +func (e *Economy) AttachPrestigeSkins(design map[uint64]uint64) { e.prestige = design } +func (e *Economy) AttachPrestigePortrait(portrait func() uint64) { e.prestigePortrait = portrait } + +type prestigeSkinReceipt struct{ Request, Response []byte } +type prestigeSkinState struct { + Selections map[uint64]uint64 `json:"selections"` + Receipts map[string]prestigeSkinReceipt `json:"receipts"` +} + +func (e *Economy) loadPrestigeSkins(ctx command.Context) (prestigeSkinState, error) { + s := prestigeSkinState{Selections: map[uint64]uint64{}, Receipts: map[string]prestigeSkinReceipt{}} + raw, err := e.store.Load(ctx.State, "prestige_skin_sets") + if err != nil || raw == nil { + return s, err + } + if err = stateio.RequireExactJSONObject(raw, "selections", "receipts"); err != nil { + return s, err + } + if err = json.Unmarshal(raw, &s); err != nil { + return s, err + } + if s.Selections == nil || s.Receipts == nil { + return s, fmt.Errorf("events: invalid saved prestige skins") + } + for costume, design := range s.Selections { + if costume == 0 || design == 0 || e.prestige[design] != costume { + return s, fmt.Errorf("events: invalid saved prestige selection") + } + } + return s, nil +} + +// PrestigeSkinSelections returns a detached projection for costume responses. +func (e *Economy) PrestigeSkinSelections(ctx command.Context) (map[uint64]uint64, error) { + + s, err := e.loadPrestigeSkins(ctx) + return s.Selections, err +} diff --git a/go/internal/server/domain/events/tasks/inventory_projection.go b/go/internal/server/domain/events/tasks/inventory_projection.go new file mode 100644 index 0000000..e6c61b2 --- /dev/null +++ b/go/internal/server/domain/events/tasks/inventory_projection.go @@ -0,0 +1,63 @@ +package eventtasks + +import ( + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "fmt" +) + +type InventoryEquipment struct{ Level uint64 } +type InventoryCostume struct{ ID, Level uint64 } +type InventorySnapshot struct { + Items map[[2]uint64]uint64 + Equipment map[uint64]InventoryEquipment + Costumes map[uint64]InventoryCostume +} +type InventoryProvider interface { + InventorySnapshot(ctx command.Context) (InventorySnapshot, error) +} +type ItemSource interface { + All(ctx command.Context) []assets.Item +} +type EquipmentSource interface { + All(ctx command.Context) []assets.Equipment +} +type CostumeSource interface{ Costumes() []roster.Costume } +type InventoryProjection struct { + Items ItemSource + Equipment EquipmentSource + Costumes CostumeSource + StateVersion func() uint64 +} + +func (p *InventoryProjection) ObservationVersion() string { + if p.StateVersion == nil { + return "" + } + var costumes uint64 + if source, ok := p.Costumes.(interface{ ObservationVersion() uint64 }); ok { + costumes = source.ObservationVersion() + } + return fmt.Sprintf("%d/%d", p.StateVersion(), costumes) +} + +func (p *InventoryProjection) InventorySnapshot(ctx command.Context) (InventorySnapshot, error) { + s := InventorySnapshot{Items: map[[2]uint64]uint64{}, Equipment: map[uint64]InventoryEquipment{}, Costumes: map[uint64]InventoryCostume{}} + if p.Items != nil { + for _, v := range p.Items.All(ctx) { + s.Items[[2]uint64{v.Type, v.ID}] += v.Count + } + } + if p.Equipment != nil { + for _, v := range p.Equipment.All(ctx) { + s.Equipment[v.InvenIndex] = InventoryEquipment{Level: v.Level} + } + } + if p.Costumes != nil { + for _, v := range p.Costumes.Costumes() { + s.Costumes[v.InvenIndex] = InventoryCostume{ID: v.ID, Level: v.Level} + } + } + return s, nil +} diff --git a/go/internal/server/domain/events/tasks/notify_changes.go b/go/internal/server/domain/events/tasks/notify_changes.go new file mode 100644 index 0000000..95caa3f --- /dev/null +++ b/go/internal/server/domain/events/tasks/notify_changes.go @@ -0,0 +1,65 @@ +package eventtasks + +import "bd2server/internal/server/domain/command" + +import ( + "fmt" + "strings" +) + +// The baseline belongs to the current account transaction, not a client session. +// Reads and retries therefore do not replay historical progress as notifications. +type missionNoticeKey struct { + Schedule string + Event, Group, Task uint64 +} + +// visibleMissionValues is a read-only view. In particular, it must not call +// mission(), which initializes and resets persisted progress as a side effect. +func (s *Service) visibleMissionValues(ctx command.Context) map[missionNoticeKey]uint64 { + schedules := s.taskSchedules() + version := s.inventoryObservationVersion() + if version != "" { + var signature strings.Builder + now := s.now().UTC() + year, week := now.ISOWeek() + fmt.Fprintf(&signature, "%s/%s/%d:%d", version, now.Format("2006-01-02"), year, week) + for _, v := range schedules { + fmt.Fprintf(&signature, "/%s:%d:%d:%d:%t:%d", scheduleKey(v), v.ID, v.Start, v.End, s.active(v), (now.UnixMilli()-v.Start)/86400000) + } + version = signature.String() + if s.visibleMissions != nil && s.visibleVersion == version { + return s.visibleMissions + } + } + out := map[missionNoticeKey]uint64{} + for _, v := range schedules { + if v.Type != 4 || !s.active(v) { + continue + } + group := s.design.MissionGroups[v.ID] + period := "" + if group.Type == 1 { + period = s.day() + } + if group.Type == 2 { + y, w := s.now().UTC().ISOWeek() + period = fmt.Sprintf("%d-%d", y, w) + } + for _, t := range s.design.Missions { + if !s.availableTask(ctx, v, t) { + continue + } + k := missionNoticeKey{scheduleKey(v), v.ID, t.Group, t.ID} + value := uint64(0) + if m := s.state.Missions[k.Schedule+"/"+key(t.ID)]; m != nil && m.Period == period { + value = m.Value + } + out[k] = value + } + } + if version != "" { + s.visibleMissions, s.visibleVersion = out, version + } + return out +} diff --git a/go/internal/server/domain/events/tasks/observer.go b/go/internal/server/domain/events/tasks/observer.go new file mode 100644 index 0000000..ba3b90e --- /dev/null +++ b/go/internal/server/domain/events/tasks/observer.go @@ -0,0 +1,66 @@ +package eventtasks + +import ( + "bd2server/internal/server/domain/command" + "encoding/json" +) + +func (s *Service) BeginLogin(ctx command.Context) { + + s.inventoryReady = false +} + +func (s *Service) inventoryObservationVersion() string { + if provider, ok := s.provider.(interface{ ObservationVersion() string }); ok { + return provider.ObservationVersion() + } + return "" +} +func (s *Service) inventorySnapshot(ctx command.Context) (InventorySnapshot, error) { + return s.provider.InventorySnapshot(ctx) +} +func (s *Service) BeforeDispatch(ctx command.Context, _ string, _ []byte) error { + + s.beforeMissions = s.visibleMissionValues(ctx) + + if s.provider == nil { + return nil + } + version := s.inventoryObservationVersion() + if s.inventoryReady && version != "" && version == s.observationVersion { + return nil + } + s.inventoryReady = false + var e error + s.before, e = s.inventorySnapshot(ctx) + if e == nil { + s.observationVersion = s.inventoryObservationVersion() + s.inventoryReady = true + } + return e +} +func (s *Service) AttachInventoryProvider(p InventoryProvider) { s.provider = p } + +func (s *Service) CompleteSingleTargetEvent(ctx command.Context, condition uint64, unlocked func(command.Context, uint64, uint64) bool) error { + + before, _ := json.Marshal(s.state) + for _, v := range s.taskSchedules() { + if !s.active(v) { + continue + } + for _, t := range s.design.Missions { + if t.Type != condition || t.SubType != 0 || t.Target != 1 || len(t.Params) != 0 || !s.availableTask(ctx, v, t) { + continue + } + if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(ctx, t.UnlockPack, t.UnlockQuest)) { + continue + } + s.mission(v, t.ID).Value = 1 + } + } + if e := s.save(ctx); e != nil { + _ = json.Unmarshal(before, &s.state) + return e + } + return nil +} diff --git a/go/internal/server/eventtasks/service.go b/go/internal/server/domain/events/tasks/protocol_adapter.go similarity index 53% rename from go/internal/server/eventtasks/service.go rename to go/internal/server/domain/events/tasks/protocol_adapter.go index efb3a94..b158156 100644 --- a/go/internal/server/eventtasks/service.go +++ b/go/internal/server/domain/events/tasks/protocol_adapter.go @@ -1,15 +1,9 @@ -// Package eventtasks owns attendance, scheduled missions and pass progress. -// Calendar policy is supplied by events.Resolver; requests never create dates -// or manufacture progress. The parent request transaction includes all saves. package eventtasks import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "bd2server/internal/server/world" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" "crypto/sha256" "encoding/hex" "encoding/json" @@ -18,342 +12,10 @@ import ( "slices" "sort" "strconv" - "sync" "time" ) -type Economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} - -// AttendanceMailIssuer delivers attendance attachments inside the parent -// account transaction. Its durable identity prevents duplicate mail on replay. -type AttendanceMailIssuer interface { - IssueAttachmentsOnce(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error -} - -type attendance struct { - Group, Count uint64 - LastDay string - Obtained map[string]bool - History map[string]bool -} -type mission struct { - Value uint64 - Claimed bool - Period string -} -type pass struct { - Exp uint64 - Premium bool - Claimed map[string]bool -} -type receipt struct { - Digest string - Code int - Reply []byte -} -type snapshot struct { - Version string - NewbieStep uint64 - Attendance map[string]*attendance - Missions map[string]*mission - Passes map[string]*pass - LoginDays map[string]int64 - Receipts map[string]receipt -} -type Service struct { - provider world.GameplayAchievementProvider - before world.GameplayAchievementSnapshot - beforeMissions map[missionNoticeKey]uint64 - mu sync.Mutex - store stateio.Store - design *gamedata.EventTasksDesign - registry events.Resolver - economy Economy - state snapshot - now func() time.Time - session string - unlocked func(uint64, uint64) bool - authorizeCash func(uint64, uint64) bool - attendancePremium func(uint64) bool - attendanceMail AttendanceMailIssuer - associated func(events.Schedule) uint64 -} - -func Open(store stateio.Store, d *gamedata.EventTasksDesign, r events.Resolver, e Economy) (*Service, error) { - if store == nil || d == nil || r == nil || e == nil { - return nil, errors.New("eventtasks: missing dependency") - } - s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now} - b, err := store.Load("eventtasks") - if err != nil { - return nil, err - } - if b != nil { - if err = stateio.RequireExactJSONObject(b, "Version", "NewbieStep", "Attendance", "Missions", "Passes", "LoginDays", "Receipts"); err != nil { - return nil, err - } - if err = json.Unmarshal(b, &s.state); err != nil { - return nil, err - } - if s.state.Version != versionconfig.State() || s.state.Attendance == nil || s.state.Missions == nil || s.state.Passes == nil || s.state.LoginDays == nil || s.state.Receipts == nil { - return nil, errors.New("eventtasks: invalid state layout") - } - for _, a := range s.state.Attendance { - if a == nil || a.Obtained == nil || a.History == nil { - return nil, errors.New("eventtasks: invalid attendance") - } - } - for _, m := range s.state.Missions { - if m == nil { - return nil, errors.New("eventtasks: invalid mission") - } - } - for _, p := range s.state.Passes { - if p == nil || p.Claimed == nil { - return nil, errors.New("eventtasks: invalid pass") - } - } - } else { - s.state.Version = versionconfig.State() - } - s.init() - return s, nil -} -func (s *Service) init() { - if s.state.Attendance == nil { - s.state.Attendance = map[string]*attendance{} - } - if s.state.Missions == nil { - s.state.Missions = map[string]*mission{} - } - if s.state.Passes == nil { - s.state.Passes = map[string]*pass{} - } - if s.state.LoginDays == nil { - s.state.LoginDays = map[string]int64{} - } - if s.state.Receipts == nil { - s.state.Receipts = map[string]receipt{} - } -} -func (s *Service) SetSession(v string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = v } -func (s *Service) AttachUnlockResolver(f func(uint64, uint64) bool) { s.unlocked = f } -func (s *Service) AttachCashAuthorization(f func(uint64, uint64) bool) { s.authorizeCash = f } -func (s *Service) AttachAttendancePremium(f func(uint64) bool) { s.attendancePremium = f } -func (s *Service) AttachAssociatedMissionGroup(f func(events.Schedule) uint64) { s.associated = f } - -func (s *Service) AttachAttendanceMail(issuer AttendanceMailIssuer) { s.attendanceMail = issuer } - -func (s *Service) mailAttendance(identity string, rewards []gamedata.Reward) error { - if s.attendanceMail == nil { - return errors.New("eventtasks: attendance mailbox unavailable") - } - return s.attendanceMail.IssueAttachmentsOnce(identity, "Attendance Rewards", "Your attendance rewards are ready. Please claim the attachments in your mailbox.", rewards, s.now()) -} - -func (s *Service) SetNewbieStep(step uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - if step > s.state.NewbieStep { - s.state.NewbieStep = step - } - return s.save() -} -func (s *Service) NewbieStep() uint64 { s.mu.Lock(); defer s.mu.Unlock(); return s.state.NewbieStep } -func (s *Service) save() error { - b, e := json.Marshal(s.state) - if e != nil { - return e - } - return s.store.Save("eventtasks", b) -} -func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } -func key(v ...uint64) string { - var k string - for i, n := range v { - if i > 0 { - k += "/" //nolint:modernize // stringsbuilder - } - k += strconv.FormatUint(n, 10) - } - return k -} - -// scheduleKey preserves the public UID where it is present. UID zero is a -// protocol default shared by permanent groups, so its local identity also -// includes the event type and design group. -func scheduleKey(v events.Schedule) string { - if v.UID != 0 { - return key(v.UID) - } - return key(0, v.Type, v.ID, v.SubID) -} -func (s *Service) active(v events.Schedule) bool { - n := s.now().UnixMilli() - return n >= v.Start && n < v.End -} -func (s *Service) find(t, id uint64) (events.Schedule, error) { - rows := s.registry.List() - if t == 4 { - rows = s.taskSchedules() - } - for _, v := range rows { - if v.Type == t && v.ID == id && s.active(v) { - return v, nil - } - } - return events.Schedule{}, fmt.Errorf("eventtasks: event %d/%d inactive", t, id) -} - -func (s *Service) taskSchedules() []events.Schedule { - rows := s.registry.List() - var out []events.Schedule - seen := map[uint64]bool{} - for _, v := range rows { - if v.Type == 4 { - out = append(out, v) - if s.active(v) { - seen[v.ID] = true - } - } - } - for _, v := range rows { - if v.Type == 5 && s.active(v) { - g := s.design.Passes[v.ID].MissionGroup - if g > 0 && !seen[g] { - v.Type = 4 - v.ID = g - out = append(out, v) - seen[g] = true - } - } - } - if s.associated != nil { - for _, v := range rows { - if !s.active(v) { - continue - } - g := s.associated(v) - if g > 0 && !seen[g] { - v.Type = 4 - v.ID = g - out = append(out, v) - seen[g] = true - } - } - } - return out -} -func (s *Service) resolve(uid uint64) (events.Schedule, error) { - v, e := s.registry.Resolve(uid) - if e != nil { - return v, e - } - if !s.active(v) { - return v, errors.New("eventtasks: event expired") - } - return v, nil -} -func (s *Service) day() string { return s.now().UTC().Format("2006-01-02") } -func (s *Service) attendance(v events.Schedule) *attendance { - k := scheduleKey(v) - a := s.state.Attendance[k] - if a == nil { - g := s.design.Attendance[v.ID].Group - var first uint64 - for _, x := range s.design.AttendanceGroups { - if x.Group == g && (first == 0 || x.ID < first) { - first = x.ID - } - } - if first > 0 { - g = first - } - a = &attendance{Group: g, Obtained: map[string]bool{}, History: map[string]bool{}} - s.state.Attendance[k] = a - } - return a -} - -// attendanceRewards is shared by automatic and explicit claims. Obtained is -// the current cycle ledger; History is the client-visible historical ledger. -func (s *Service) attendanceRewards(v events.Schedule, a *attendance, group, id uint64) []gamedata.Reward { - if a.Obtained[key(group, id)] { - return nil - } - if v.Type == 0 { - if group != a.Group { - return nil - } - for _, r := range s.design.AttendanceRewards[group] { - if r.ID != id || r.Day > a.Count { - continue - } - var rewards []gamedata.Reward - if r.Basic.Count > 0 { - rewards = append(rewards, r.Basic) - } - if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ticket) && r.Premium.Count > 0 { - rewards = append(rewards, r.Premium) - } - return rewards - } - } else if v.Type == 1 && group == v.ID && id > 0 { - box, ok := s.design.LimitRewards[[2]uint64{group, id}] - date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02") - _, logged := s.state.LoginDays[date] - if ok && box > 0 && logged && date == s.day() { - return []gamedata.Reward{{Type: 9, ID: box, Count: 1}} - } - } - return nil -} -func (s *Service) pass(v events.Schedule) *pass { - k := scheduleKey(v) - p := s.state.Passes[k] - if p == nil { - p = &pass{Claimed: map[string]bool{}} - s.state.Passes[k] = p - } - return p -} -func (s *Service) mission(v events.Schedule, id uint64) *mission { - k := scheduleKey(v) + "/" + key(id) - m := s.state.Missions[k] - if m == nil { - m = &mission{} - s.state.Missions[k] = m - } - g := s.design.MissionGroups[v.ID] - period := "" - if g.Type == 1 { - period = s.day() - } - if g.Type == 2 { - y, w := s.now().UTC().ISOWeek() - period = fmt.Sprintf("%d-%d", y, w) - } - if m.Period != period { - *m = mission{Period: period} - } - return m -} -func (s *Service) availableTask(v events.Schedule, t gamedata.EventTask) bool { - g := s.design.MissionGroups[v.ID] - for i, n := range g.Groups { - if n == t.Group { - if g.Type == 3 && int64(i) > (s.now().UnixMilli()-v.Start)/86400000 { - return false - } - return t.UnlockPack == 0 && t.UnlockQuest == 0 || s.unlocked != nil && s.unlocked(t.UnlockPack, t.UnlockQuest) - } - } - return false -} - -func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { +func (s *Service) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { codes := map[string]int{"/Attendance": 0, "/AttendanceInfo": 0, "/EventRewardHistory": 0, "/EventReward": 151, "/EventMissionInfo": 127, "/MissionClear": 120, "/MissionUpdate": 119, "/PassInfo": 124, "/PassReward": 126, "/PassBuy": 129, "/LoginEvent": 0} code, found := codes[path] if !found { @@ -374,8 +36,7 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { return 0, nil, false, nil } } - s.mu.Lock() - defer s.mu.Unlock() + seq, present, err := wire.Varint(req, 1) if err != nil || !present || seq == 0 { return code, nil, true, errors.New("eventtasks: invalid sequence") @@ -385,7 +46,7 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { } digest := sha256.Sum256(append([]byte(path), req...)) ds := hex.EncodeToString(digest[:]) - rk := s.session + ":" + path + ":" + key(seq) + rk := ctx.SessionID + ":" + path + ":" + key(seq) if r, ok := s.state.Receipts[rk]; ok { if r.Digest != ds { return code, nil, true, errors.New("eventtasks: changed replay request") @@ -393,14 +54,14 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { return r.Code, append([]byte(nil), r.Reply...), true, nil } before, _ := json.Marshal(s.state) - out, err := s.handle(path, req, rk) + out, err := s.handle(ctx, path, req, rk) if err != nil { s.state = snapshot{} _ = json.Unmarshal(before, &s.state) return code, nil, true, err } s.state.Receipts[rk] = receipt{ds, code, out} - if err = s.save(); err != nil { + if err = s.save(ctx); err != nil { s.state = snapshot{} _ = json.Unmarshal(before, &s.state) return code, nil, true, err @@ -408,7 +69,9 @@ func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { return code, out, true, nil } -func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) { +func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } + +func (s *Service) handle(ctx command.Context, path string, b []byte, identity string) ([]byte, error) { switch path { case "/Attendance", "/LoginEvent": today := s.day() @@ -501,7 +164,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } slices.Sort(ids) for _, id := range ids { - rewards := s.attendanceRewards(v, a, group, id) + rewards := s.attendanceRewards(ctx, v, a, group, id) if len(rewards) == 0 { continue } @@ -515,7 +178,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } } if len(granted) > 0 { - if err := s.mailAttendance(identity, granted); err != nil { + if err := s.mailAttendance(ctx, identity, granted); err != nil { return nil, err } } @@ -574,11 +237,11 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) if a.Obtained[ck] { return nil, errors.New("eventtasks: attendance already received") } - rewards := s.attendanceRewards(v, a, g, id) + rewards := s.attendanceRewards(ctx, v, a, g, id) if len(rewards) == 0 { return nil, errors.New("eventtasks: attendance day unavailable") } - if err := s.mailAttendance(identity, rewards); err != nil { + if err := s.mailAttendance(ctx, identity, rewards); err != nil { return nil, err } a.Obtained[ck] = true @@ -591,7 +254,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) continue } for _, t := range s.design.Missions { - if !s.availableTask(v, t) { + if !s.availableTask(ctx, v, t) { continue } m := s.mission(v, t.ID) @@ -617,7 +280,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) return e } t, ok := s.design.Missions[id] - if !ok || t.Group != scalar(f.Value, 1) || !s.availableTask(v, t) { + if !ok || t.Group != scalar(f.Value, 1) || !s.availableTask(ctx, v, t) { return errors.New("eventtasks: invalid mission update") } if value > s.mission(v, id).Value { @@ -637,7 +300,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) var done []uint64 var exp uint64 for _, t := range s.design.Missions { - if t.Group != g || !s.availableTask(v, t) || !all && t.ID != id { + if t.Group != g || !s.availableTask(ctx, v, t) || !all && t.ID != id { continue } m := s.mission(v, t.ID) @@ -650,7 +313,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) if len(done) == 0 { return nil, errors.New("eventtasks: no completed unclaimed mission") } - bundle, e := s.economy.Apply(identity, nil, rewards) + bundle, e := s.economy.Apply(ctx, identity, nil, rewards) if e != nil { return nil, e } @@ -660,12 +323,12 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) // Completion-of-mission conditions are derived from durable claims, // rather than a client-supplied total. for _, t := range s.design.Missions { - if !s.availableTask(v, t) || (t.Type != 1001 && t.Type != 1002) { + if !s.availableTask(ctx, v, t) || (t.Type != 1001 && t.Type != 1002) { continue } var completed uint64 for _, other := range s.design.Missions { - if !s.availableTask(v, other) || !s.mission(v, other.ID).Claimed { + if !s.availableTask(ctx, v, other) || !s.mission(v, other.ID).Claimed { continue } match := other.Group == t.Group @@ -776,7 +439,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } var bundle []byte if len(claims) > 0 { - bundle, e = s.economy.Apply(identity, nil, rewards) + bundle, e = s.economy.Apply(ctx, identity, nil, rewards) if e != nil { return nil, fmt.Errorf("eventtasks: pass reward grant failed pass=%d level=%d reward_type=%d all=%t: %w", v.ID, id, rt, all, e) } @@ -799,7 +462,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) complete = false } else { for _, t := range s.design.Missions { - if s.availableTask(mv, t) && !s.mission(mv, t.ID).Claimed { + if s.availableTask(ctx, mv, t) && !s.mission(mv, t.ID).Claimed { complete = false } } @@ -853,7 +516,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } // CashShopBuy charges paid diamonds and grants the pass ticket first. // Activation consumes that purchase entitlement and the design ticket. - if chosen.CashID != 0 && (s.authorizeCash == nil || !s.authorizeCash(v.ID, typ)) { + if chosen.CashID != 0 && (s.authorizeCash == nil || !s.authorizeCash(ctx, v.ID, typ)) { return nil, fmt.Errorf("eventtasks: cash entitlement required pass=%d type=%d sku=%d/%d/%d", v.ID, typ, chosen.CashGroup, chosen.CashID, chosen.CashSales) } var costs []gamedata.Reward @@ -865,7 +528,7 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) } costs = append(costs, cost) } - if _, e = s.economy.Apply(identity, costs, chosen.Rewards); e != nil { + if _, e = s.economy.Apply(ctx, identity, costs, chosen.Rewards); e != nil { return nil, fmt.Errorf("eventtasks: pass payment failed pass=%d type=%d: %w", v.ID, typ, e) } if chosen.LevelsGranted > 0 { @@ -881,73 +544,8 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) return nil, errors.New("eventtasks: unsupported operation") } -// RecordEvent is called by authoritative gameplay. It returns no rewards and -// caps every matching task at its design threshold. -func (s *Service) RecordEvent(condition, sub, count uint64, unlocked func(uint64, uint64) bool) error { - s.mu.Lock() - defer s.mu.Unlock() - if !s.recordEventLocked(condition, sub, count, unlocked) { - return nil - } - return s.save() -} +func (s *Service) Notify(ctx command.Context) ([]byte, error) { -// recordEventLocked reports every persistent mutation, including initializing -// a matching task or rolling its period. Irrelevant and already capped events -// leave the account snapshot untouched. -func (s *Service) recordEventLocked(condition, sub, count uint64, unlocked func(uint64, uint64) bool) bool { - changed := false - if count == 0 { - return false - } - for _, v := range s.taskSchedules() { - if v.Type != 4 || !s.active(v) { - continue - } - for _, t := range s.design.Missions { - if t.Type != condition || t.SubType != 0 && t.SubType != sub || condition == 349 && t.SubType != sub || !s.availableTask(v, t) { - continue - } - if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) { - continue - } - match := len(t.Params) == 0 - for _, n := range t.Params { - if n == sub { - match = true - } - } - if !match { - continue - } - previous := s.state.Missions[scheduleKey(v)+"/"+key(t.ID)] - var prior mission - if previous != nil { - prior = *previous - } - m := s.mission(v, t.ID) - if previous == nil || prior != *m { - changed = true - } - if m.Claimed { - continue - } - if m.Value >= t.Target { - continue - } - if count >= t.Target-m.Value { - m.Value = t.Target - } else { - m.Value += count - } - changed = true - } - } - return changed -} -func (s *Service) Notify() ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() var out []byte for _, v := range s.taskSchedules() { if v.Type != 4 || !s.active(v) { @@ -955,7 +553,7 @@ func (s *Service) Notify() ([]byte, error) { } groups := map[uint64][]byte{} for _, t := range s.design.Missions { - if !s.availableTask(v, t) { + if !s.availableTask(ctx, v, t) { continue } m := s.mission(v, t.ID) @@ -979,17 +577,147 @@ func (s *Service) Notify() ([]byte, error) { return out, nil } -// RecordLogin shares the same daily marker as Attendance, preventing repeated -// reconnects from inflating event login tasks. Main should invoke this once -// through the regular mission observer before serving attendance queries. -func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error { - s.mu.Lock() - marker := "mission-login:" + s.day() - if _, ok := s.state.LoginDays[marker]; ok { - s.mu.Unlock() - return nil +func (s *Service) AfterDispatch(ctx command.Context, path string, request, response []byte) ([]byte, error) { + if s.provider != nil { + version := s.inventoryObservationVersion() + if s.inventoryReady && version != "" && version == s.observationVersion { + return s.notifyMissionChanges(ctx) + } + s.inventoryReady = false + after, e := s.inventorySnapshot(ctx) + if e != nil { + return nil, e + } + rk := fmt.Sprintf("observer:%s:%s:%d", ctx.SessionID, path, scalar(request, 1)) + + _, seen := s.state.Receipts[rk] + + if !seen { + type delta struct{ condition, sub, count uint64 } + var deltas []delta + for kind, old := range s.before.Items { + current := after.Items[kind] + if current < old { + condition := uint64(12) + if kind[1] == 0 { + condition = 11 + } + deltas = append(deltas, delta{condition, kind[0], old - current}) + } + } + for kind, current := range after.Items { + old := s.before.Items[kind] + if current > old { + deltas = append(deltas, delta{32, kind[0], current - old}) + } + } + for idx, current := range after.Equipment { + old, ok := s.before.Equipment[idx] + if ok && current.Level > old.Level { + deltas = append(deltas, delta{14, 0, current.Level - old.Level}) + } + } + for idx, current := range after.Costumes { + old, ok := s.before.Costumes[idx] + if ok && current.Level > old.Level { + deltas = append(deltas, delta{104, current.ID, current.Level - old.Level}) + } + } + if len(deltas) > 0 { + + before, e := json.Marshal(s.state) + if e != nil { + + return nil, e + } + changed := false + for _, d := range deltas { + changed = s.recordEventLocked(ctx, d.condition, d.sub, d.count, s.unlocked) || changed + } + if changed { + s.state.Receipts[rk] = receipt{Digest: "observer"} + e = s.save(ctx) + } + if e != nil { + s.state = snapshot{} + _ = json.Unmarshal(before, &s.state) + } + + if e != nil { + return nil, e + } + } + } + s.before = after + s.observationVersion = s.inventoryObservationVersion() + s.inventoryReady = true } - s.state.LoginDays[marker] = s.now().UnixMilli() - s.mu.Unlock() - return s.RecordEvent(0, 0, 1, unlocked) + return s.notifyMissionChanges(ctx) +} + +func (s *Service) notifyMissionChanges(ctx command.Context) ([]byte, error) { + + if s.beforeMissions == nil { + return nil, nil + } + current := s.visibleMissionValues(ctx) + var keys []missionNoticeKey + for k, value := range current { + if value != s.beforeMissions[k] { + keys = append(keys, k) + } + } + sort.Slice(keys, func(i, j int) bool { + a, b := keys[i], keys[j] + if a.Event != b.Event { + return a.Event < b.Event + } + if a.Group != b.Group { + return a.Group < b.Group + } + if a.Task != b.Task { + return a.Task < b.Task + } + return a.Schedule < b.Schedule + }) + var out, groupMap, rows []byte + var eventID, groupID uint64 + flushGroup := func() { + if len(rows) == 0 { + return + } + entry := wire.AppendVarint(nil, 1, groupID) + entry = wire.AppendBytes(entry, 2, rows) + groupMap = wire.AppendBytes(groupMap, 1, entry) + rows = nil + } + flushEvent := func() { + flushGroup() + if len(groupMap) == 0 { + return + } + entry := wire.AppendVarint(nil, 1, eventID) + entry = wire.AppendBytes(entry, 2, groupMap) + out = wire.AppendBytes(out, 4, entry) + groupMap = nil + } + for _, k := range keys { + if k.Event != eventID { + flushEvent() + eventID = k.Event + groupID = k.Group + } + if k.Group != groupID { + flushGroup() + groupID = k.Group + } + entry := wire.AppendVarint(nil, 1, k.Task) + entry = wire.AppendVarint(entry, 2, current[k]) + rows = wire.AppendBytes(rows, 1, entry) + } + flushEvent() + // Consume only this request's baseline; the next BeforeDispatch takes a fresh + // snapshot even when a transaction rolls back or the session changes. + s.beforeMissions = nil + return out, nil } diff --git a/go/internal/server/domain/events/tasks/service.go b/go/internal/server/domain/events/tasks/service.go new file mode 100644 index 0000000..ee9055d --- /dev/null +++ b/go/internal/server/domain/events/tasks/service.go @@ -0,0 +1,435 @@ +// Package eventtasks owns attendance, scheduled missions and pass progress. +// Calendar policy is supplied by events.Resolver; requests never create dates +// or manufacture progress. The parent request transaction includes all saves. +package eventtasks + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/events" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "strconv" + + "time" +) + +type Economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} + +// AttendanceMailIssuer delivers attendance attachments inside the parent +// account transaction. Its durable identity prevents duplicate mail on replay. +type AttendanceMailIssuer interface { + IssueAttachmentsOnce(ctx command.Context, identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error +} + +type attendance struct { + Group, Count uint64 + LastDay string + Obtained map[string]bool + History map[string]bool +} +type mission struct { + Value uint64 + Claimed bool + Period string +} +type pass struct { + Exp uint64 + Premium bool + Claimed map[string]bool +} +type receipt struct { + Digest string + Code int + Reply []byte +} +type snapshot struct { + Version string + NewbieStep uint64 + Attendance map[string]*attendance + Missions map[string]*mission + Passes map[string]*pass + LoginDays map[string]int64 + Receipts map[string]receipt +} +type Service struct { + provider InventoryProvider + before InventorySnapshot + observationVersion string + inventoryReady bool + beforeMissions map[missionNoticeKey]uint64 + visibleMissions map[missionNoticeKey]uint64 + visibleVersion string + + store stateio.Store + design *gamedata.EventTasksDesign + registry events.Resolver + economy Economy + state snapshot + now func() time.Time + + unlocked func(command.Context, uint64, uint64) bool + authorizeCash func(command.Context, uint64, uint64) bool + attendancePremium func(command.Context, uint64) bool + attendanceMail AttendanceMailIssuer + associated func(events.Schedule) uint64 +} + +func Open(ctx command.Context, store stateio.Store, d *gamedata.EventTasksDesign, r events.Resolver, e Economy) (*Service, error) { + if store == nil || d == nil || r == nil || e == nil { + return nil, errors.New("eventtasks: missing dependency") + } + s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now} + b, err := store.Load(ctx.State, "eventtasks") + if err != nil { + return nil, err + } + if b != nil { + if err = stateio.RequireExactJSONObject(b, "Version", "NewbieStep", "Attendance", "Missions", "Passes", "LoginDays", "Receipts"); err != nil { + return nil, err + } + if err = json.Unmarshal(b, &s.state); err != nil { + return nil, err + } + if s.state.Version != versionconfig.State() || s.state.Attendance == nil || s.state.Missions == nil || s.state.Passes == nil || s.state.LoginDays == nil || s.state.Receipts == nil { + return nil, errors.New("eventtasks: invalid state layout") + } + for _, a := range s.state.Attendance { + if a == nil || a.Obtained == nil || a.History == nil { + return nil, errors.New("eventtasks: invalid attendance") + } + } + for _, m := range s.state.Missions { + if m == nil { + return nil, errors.New("eventtasks: invalid mission") + } + } + for _, p := range s.state.Passes { + if p == nil || p.Claimed == nil { + return nil, errors.New("eventtasks: invalid pass") + } + } + } else { + s.state.Version = versionconfig.State() + } + s.init() + return s, nil +} +func (s *Service) init() { + if s.state.Attendance == nil { + s.state.Attendance = map[string]*attendance{} + } + if s.state.Missions == nil { + s.state.Missions = map[string]*mission{} + } + if s.state.Passes == nil { + s.state.Passes = map[string]*pass{} + } + if s.state.LoginDays == nil { + s.state.LoginDays = map[string]int64{} + } + if s.state.Receipts == nil { + s.state.Receipts = map[string]receipt{} + } +} + +func (s *Service) AttachUnlockResolver(f func(command.Context, uint64, uint64) bool) { s.unlocked = f } +func (s *Service) AttachCashAuthorization(f func(command.Context, uint64, uint64) bool) { + s.authorizeCash = f +} +func (s *Service) AttachAttendancePremium(f func(command.Context, uint64) bool) { + s.attendancePremium = f +} +func (s *Service) AttachAssociatedMissionGroup(f func(events.Schedule) uint64) { s.associated = f } + +func (s *Service) AttachAttendanceMail(issuer AttendanceMailIssuer) { s.attendanceMail = issuer } + +func (s *Service) mailAttendance(ctx command.Context, identity string, rewards []gamedata.Reward) error { + if s.attendanceMail == nil { + return errors.New("eventtasks: attendance mailbox unavailable") + } + return s.attendanceMail.IssueAttachmentsOnce(ctx, identity, "Attendance Rewards", "Your attendance rewards are ready. Please claim the attachments in your mailbox.", rewards, s.now()) +} + +func (s *Service) SetNewbieStep(ctx command.Context, step uint64) error { + + if step > s.state.NewbieStep { + s.state.NewbieStep = step + } + return s.save(ctx) +} +func (s *Service) NewbieStep() uint64 { return s.state.NewbieStep } +func (s *Service) save(ctx command.Context) error { + b, e := json.Marshal(s.state) + if e != nil { + return e + } + return s.store.Save(ctx.State, "eventtasks", b) +} + +func key(v ...uint64) string { + var k string + for i, n := range v { + if i > 0 { + k += "/" //nolint:modernize // stringsbuilder + } + k += strconv.FormatUint(n, 10) + } + return k +} + +// scheduleKey preserves the public UID where it is present. UID zero is a +// protocol default shared by permanent groups, so its local identity also +// includes the event type and design group. +func scheduleKey(v events.Schedule) string { + if v.UID != 0 { + return key(v.UID) + } + return key(0, v.Type, v.ID, v.SubID) +} +func (s *Service) active(v events.Schedule) bool { + n := s.now().UnixMilli() + return n >= v.Start && n < v.End +} +func (s *Service) find(t, id uint64) (events.Schedule, error) { + rows := s.registry.List() + if t == 4 { + rows = s.taskSchedules() + } + for _, v := range rows { + if v.Type == t && v.ID == id && s.active(v) { + return v, nil + } + } + return events.Schedule{}, fmt.Errorf("eventtasks: event %d/%d inactive", t, id) +} + +func (s *Service) taskSchedules() []events.Schedule { + rows := s.registry.List() + var out []events.Schedule + seen := map[uint64]bool{} + for _, v := range rows { + if v.Type == 4 { + out = append(out, v) + if s.active(v) { + seen[v.ID] = true + } + } + } + for _, v := range rows { + if v.Type == 5 && s.active(v) { + g := s.design.Passes[v.ID].MissionGroup + if g > 0 && !seen[g] { + v.Type = 4 + v.ID = g + out = append(out, v) + seen[g] = true + } + } + } + if s.associated != nil { + for _, v := range rows { + if !s.active(v) { + continue + } + g := s.associated(v) + if g > 0 && !seen[g] { + v.Type = 4 + v.ID = g + out = append(out, v) + seen[g] = true + } + } + } + return out +} +func (s *Service) resolve(uid uint64) (events.Schedule, error) { + v, e := s.registry.Resolve(uid) + if e != nil { + return v, e + } + if !s.active(v) { + return v, errors.New("eventtasks: event expired") + } + return v, nil +} +func (s *Service) day() string { return s.now().UTC().Format("2006-01-02") } +func (s *Service) attendance(v events.Schedule) *attendance { + k := scheduleKey(v) + a := s.state.Attendance[k] + if a == nil { + g := s.design.Attendance[v.ID].Group + var first uint64 + for _, x := range s.design.AttendanceGroups { + if x.Group == g && (first == 0 || x.ID < first) { + first = x.ID + } + } + if first > 0 { + g = first + } + a = &attendance{Group: g, Obtained: map[string]bool{}, History: map[string]bool{}} + s.state.Attendance[k] = a + } + return a +} + +// attendanceRewards is shared by automatic and explicit claims. Obtained is +// the current cycle ledger; History is the client-visible historical ledger. +func (s *Service) attendanceRewards(ctx command.Context, v events.Schedule, a *attendance, group, id uint64) []gamedata.Reward { + if a.Obtained[key(group, id)] { + return nil + } + if v.Type == 0 { + if group != a.Group { + return nil + } + for _, r := range s.design.AttendanceRewards[group] { + if r.ID != id || r.Day > a.Count { + continue + } + var rewards []gamedata.Reward + if r.Basic.Count > 0 { + rewards = append(rewards, r.Basic) + } + if ticket := s.design.Attendance[v.ID].Ticket; ticket != 0 && s.attendancePremium != nil && s.attendancePremium(ctx, ticket) && r.Premium.Count > 0 { + rewards = append(rewards, r.Premium) + } + return rewards + } + } else if v.Type == 1 && group == v.ID && id > 0 { + box, ok := s.design.LimitRewards[[2]uint64{group, id}] + date := time.UnixMilli(v.Start).UTC().AddDate(0, 0, int(id)-1).Format("2006-01-02") + _, logged := s.state.LoginDays[date] + if ok && box > 0 && logged && date == s.day() { + return []gamedata.Reward{{Type: 9, ID: box, Count: 1}} + } + } + return nil +} +func (s *Service) pass(v events.Schedule) *pass { + k := scheduleKey(v) + p := s.state.Passes[k] + if p == nil { + p = &pass{Claimed: map[string]bool{}} + s.state.Passes[k] = p + } + return p +} +func (s *Service) mission(v events.Schedule, id uint64) *mission { + k := scheduleKey(v) + "/" + key(id) + m := s.state.Missions[k] + if m == nil { + m = &mission{} + s.state.Missions[k] = m + } + g := s.design.MissionGroups[v.ID] + period := "" + if g.Type == 1 { + period = s.day() + } + if g.Type == 2 { + y, w := s.now().UTC().ISOWeek() + period = fmt.Sprintf("%d-%d", y, w) + } + if m.Period != period { + *m = mission{Period: period} + } + return m +} +func (s *Service) availableTask(ctx command.Context, v events.Schedule, t gamedata.EventTask) bool { + g := s.design.MissionGroups[v.ID] + for i, n := range g.Groups { + if n == t.Group { + if g.Type == 3 && int64(i) > (s.now().UnixMilli()-v.Start)/86400000 { + return false + } + return t.UnlockPack == 0 && t.UnlockQuest == 0 || s.unlocked != nil && s.unlocked(ctx, t.UnlockPack, t.UnlockQuest) + } + } + return false +} + +// RecordEvent is called by authoritative gameplay. It returns no rewards and +// caps every matching task at its design threshold. +func (s *Service) RecordEvent(ctx command.Context, condition, sub, count uint64, unlocked func(command.Context, uint64, uint64) bool) error { + + if !s.recordEventLocked(ctx, condition, sub, count, unlocked) { + return nil + } + return s.save(ctx) +} + +// recordEventLocked reports every persistent mutation, including initializing +// a matching task or rolling its period. Irrelevant and already capped events +// leave the account snapshot untouched. +func (s *Service) recordEventLocked(ctx command.Context, condition, sub, count uint64, unlocked func(command.Context, uint64, uint64) bool) bool { + changed := false + if count == 0 { + return false + } + for _, v := range s.taskSchedules() { + if v.Type != 4 || !s.active(v) { + continue + } + for _, t := range s.design.Missions { + if t.Type != condition || t.SubType != 0 && t.SubType != sub || condition == 349 && t.SubType != sub || !s.availableTask(ctx, v, t) { + continue + } + if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(ctx, t.UnlockPack, t.UnlockQuest)) { + continue + } + match := len(t.Params) == 0 + for _, n := range t.Params { + if n == sub { + match = true + } + } + if !match { + continue + } + previous := s.state.Missions[scheduleKey(v)+"/"+key(t.ID)] + var prior mission + if previous != nil { + prior = *previous + } + m := s.mission(v, t.ID) + if previous == nil || prior != *m { + changed = true + } + if m.Claimed { + continue + } + if m.Value >= t.Target { + continue + } + if count >= t.Target-m.Value { + m.Value = t.Target + } else { + m.Value += count + } + changed = true + } + } + return changed +} + +// RecordLogin shares the same daily marker as Attendance, preventing repeated +// reconnects from inflating event login tasks. Main should invoke this once +// through the regular mission observer before serving attendance queries. +func (s *Service) RecordLogin(ctx command.Context, unlocked func(command.Context, uint64, uint64) bool) error { + + marker := "mission-login:" + s.day() + if _, ok := s.state.LoginDays[marker]; ok { + + return nil + } + s.state.LoginDays[marker] = s.now().UnixMilli() + + return s.RecordEvent(ctx, 0, 0, 1, unlocked) +} diff --git a/go/internal/server/domain/identity/registration_test.go b/go/internal/server/domain/identity/registration_test.go new file mode 100644 index 0000000..588b658 --- /dev/null +++ b/go/internal/server/domain/identity/registration_test.go @@ -0,0 +1,129 @@ +package identity_test + +import ( + "bd2server/internal/server/domain/identity" + identitystore "bd2server/internal/server/storage/identity" + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +// Registration authority must distinguish players even when clients use the +// same device flow. Admin authority belongs to the retained first identity. +func TestPlayerIdentityAndAdministrativeAuthorityRemainSeparate(t *testing.T) { + path := filepath.Join(t.TempDir(), "identity.db") + open := func() *identitystore.Store { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i + 1) + } + s, e := identitystore.Open(path, key) + if e != nil { + t.Fatal(e) + } + return s + } + store := open() + service, e := identity.New(identity.Config{Providers: map[string]string{"discord": "client"}, DeviceTTL: time.Minute, AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour}, store) + if e != nil { + t.Fatal(e) + } + login := func(subject string) identity.TokenSet { + t.Helper() + device, e := service.CreateDevice("discord", "127.0.0.1") + if e != nil { + t.Fatal(e) + } + auth, e := service.Start("discord", device.ID, device.StartTicket) + if e != nil { + t.Fatal(e) + } + if e = service.CompleteDevice(auth.ID, "discord", identity.ProviderIdentity{Issuer: "https://discord.com", Subject: subject}); e != nil { + t.Fatal(e) + } + result, e := service.Poll(device.ID, device.Secret) + if e != nil { + t.Fatal(e) + } + if _, e = service.Poll(device.ID, device.Secret); e == nil { + t.Fatal("device credentials delivered twice") + } + return result.Tokens + } + a, b := login("111"), login("222") + aID, e := service.ValidateAccess(a.AccessToken) + if e != nil { + t.Fatal(e) + } + bID, e := service.ValidateAccess(b.AccessToken) + if e != nil { + t.Fatal(e) + } + if aID == bID || aID == "" || bID == "" { + t.Fatal("distinct provider subjects share one asset owner") + } + profileA, e := store.GameIdentity(context.Background(), aID) + if e != nil { + t.Fatal(e) + } + profileB, e := store.GameIdentity(context.Background(), bID) + if e != nil { + t.Fatal(e) + } + if profileA.OwnerIndex <= 0 || profileB.OwnerIndex <= 0 || profileA.OwnerIndex == profileB.OwnerIndex || profileA.UserID == profileB.UserID { + t.Fatal("different asset owners share client-visible identity") + } + owner, e := store.OwnerAccountID(context.Background()) + if e != nil || owner != aID { + t.Fatalf("original owner not retained: %s %v", owner, e) + } + if _, e = service.AuthorizeAdministrator(a.AccessToken); e != nil { + t.Fatal("original owner lost authority", e) + } + if _, e = service.AuthorizeAdministrator(b.AccessToken); !errors.Is(e, identity.ErrUnauthorized) { + t.Fatal("new player gained administrative authority", e) + } + refreshed, e := service.Refresh(a.RefreshToken, "attempt-owner-00001") + if e != nil { + t.Fatal(e) + } + retried, e := service.Refresh(a.RefreshToken, "attempt-owner-00001") + if e != nil || refreshed.AccessToken != retried.AccessToken || refreshed.RefreshToken != retried.RefreshToken { + t.Fatal("network retry issued different credentials") + } + if _, e = service.Refresh(a.RefreshToken, "attempt-owner-00002"); e == nil { + t.Fatal("used refresh token accepted as new operation") + } + if _, e = service.ValidateAccess(refreshed.AccessToken); e == nil { + t.Fatal("replayed owner's family remained valid") + } + if actual, e := service.ValidateAccess(b.AccessToken); e != nil || actual != bID { + t.Fatal("owner replay revoked unrelated player", e) + } + if e = store.Close(); e != nil { + t.Fatal(e) + } + store = open() + defer func() { _ = store.Close() }() + service, e = identity.New(identity.Config{Providers: map[string]string{"discord": "client"}, DeviceTTL: time.Minute, AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour}, store) + if e != nil { + t.Fatal(e) + } + if actual, e := service.ValidateAccess(b.AccessToken); e != nil || actual != bID { + t.Fatal("restart mixed player credentials", e) + } + retainedA, e := store.GameIdentity(context.Background(), aID) + if e != nil || retainedA != profileA { + t.Fatal("restart changed original player public identity", e) + } + retainedB, e := store.GameIdentity(context.Background(), bID) + if e != nil || retainedB != profileB { + t.Fatal("restart changed other player public identity", e) + } + if owner, e = store.OwnerAccountID(context.Background()); e != nil || owner != aID { + t.Fatal("restart reassigned administrative owner") + } +} diff --git a/go/internal/server/domain/identity/repository.go b/go/internal/server/domain/identity/repository.go new file mode 100644 index 0000000..e0f887d --- /dev/null +++ b/go/internal/server/domain/identity/repository.go @@ -0,0 +1,104 @@ +package identity + +import "time" + +type ProviderIdentity struct{ Issuer, Subject string } +type Config struct { + Providers map[string]string + AccessTTL, RefreshTTL, DeviceTTL time.Duration +} +type TokenSet struct { + Provider, AccessToken, RefreshToken string + AccessExpiresIn, RefreshExpiresIn int64 +} +type StoredRefreshResult struct { + Provider, AccessToken, RefreshToken string + AccessExpiresAt, RefreshExpiresAt int64 +} + +func (r StoredRefreshResult) Tokens(now int64) TokenSet { + return TokenSet{Provider: r.Provider, AccessToken: r.AccessToken, RefreshToken: r.RefreshToken, AccessExpiresIn: max(r.AccessExpiresAt-now, 0), RefreshExpiresIn: max(r.RefreshExpiresAt-now, 0)} +} + +type CreatedDevice struct{ ID, Secret, StartTicket string } +type Authorization struct{ ID, State, Verifier, Nonce string } +type StartRecord struct { + Matches bool + Provider, Status string + Expires int64 +} +type AuthorizationRecord struct { + ID, Provider, Status string + Expires int64 + VerifierCipher, NonceCipher []byte +} +type AccountRecord struct{ ID, Status string } +type DeviceRecord struct { + Matches bool + Status, ErrorCode string + Expires int64 + ResultCipher []byte +} +type RefreshRecord struct { + FamilyID, AccountID, Provider, AccountStatus string + TokenExpires, FamilyExpires int64 + Used, Revoked bool +} +type RefreshAttempt struct { + Matches bool + Result StoredRefreshResult +} +type AccessRecord struct { + FamilyID, AccountID, AccountStatus string + Expires int64 + Revoked bool +} +type DeviceInsert struct { + ID, ClientIP, Secret, StartTicket, Provider string + Created, Expires int64 +} +type FamilyInsert struct { + ID, AccountID, Provider string + Created, Expires int64 +} + +type Repository interface { + Now() time.Time + Begin() (Transaction, error) + StartRecord(id, ticket string) (StartRecord, error) + StartAuthorization(id, state, verifier, nonce string) (bool, error) + CancelAuthorization(provider, state string, now int64) (bool, error) + AuthorizationRecord(state string) (AuthorizationRecord, error) + AuthorizationSecrets(AuthorizationRecord) (verifier, nonce []byte, err error) + FailAuthorization(id string) error + DecodeDeviceResult(id string, cipher []byte) (TokenSet, error) + AccessToken(token string) (AccessRecord, error) + AdministratorAccountID() (string, error) +} + +type Transaction interface { + CleanupExpired(now int64) error + PendingDevices(clientIP string, now int64) (int, error) + InsertDevice(DeviceInsert) error + Identity(ProviderIdentity) (AccountRecord, bool, error) + AccountCount() (int, error) + SetAdministratorAccount(accountID string) error + RejectDevice(id, provider, code string) (bool, error) + InsertAccount(accountID, provider string, identity ProviderIdentity, now int64) error + TouchIdentity(identity ProviderIdentity, now int64) error + InsertFamily(FamilyInsert) error + CompleteDevice(id, provider string, result TokenSet) (bool, error) + PollDevice(id, secret string) (DeviceRecord, error) + ConsumeDevice(id string) (bool, error) + RefreshToken(token string) (RefreshRecord, error) + RefreshAttempt(familyID, attemptID, requestToken string) (RefreshAttempt, bool, error) + ConsumeRefreshToken(token string, now int64) (bool, error) + RevokeFamily(id string, now int64) (bool, error) + DeleteFamilyAccess(id string) error + InsertAccess(token, familyID, accountID string, created, expires int64) error + InsertRefresh(token, familyID string, created, expires int64) error + InsertRefreshAttempt(familyID, attemptID, requestToken string, result StoredRefreshResult, created, expires int64) error + AccessToken(token string) (AccessRecord, error) + Commit() error + Rollback() error +} diff --git a/go/internal/server/domain/identity/service.go b/go/internal/server/domain/identity/service.go new file mode 100644 index 0000000..2b77e95 --- /dev/null +++ b/go/internal/server/domain/identity/service.go @@ -0,0 +1,429 @@ +package identity + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "time" +) + +type FailureKind uint8 + +const ( + Unavailable FailureKind = iota + Invalid + Forbidden + Expired + Conflict + Unauthorized + TooManyPending +) + +type Failure struct { + Kind FailureKind + Message string + RefreshInvalid bool +} + +func (e *Failure) Error() string { return e.Message } +func failure(kind FailureKind, message string) error { return &Failure{Kind: kind, Message: message} } + +var ( + ErrUnauthorized = errors.New("auth: unauthorized") + ErrConsumed = errors.New("auth: consumed") + ErrNotAllowed = errors.New("auth: identity is not allowed") +) + +type Service struct { + config Config + store Repository +} + +func New(config Config, store Repository) (*Service, error) { + if store == nil { + return nil, errors.New("auth: missing identity repository") + } + return &Service{config: config, store: store}, nil +} +func (s *Service) Now() time.Time { return s.store.Now() } +func randomToken(size int) (string, error) { + b := make([]byte, size) + if _, e := rand.Read(b); e != nil { + return "", e + } + return base64.RawURLEncoding.EncodeToString(b), nil +} +func (s *Service) CreateDevice(provider, clientIP string) (CreatedDevice, error) { + if _, ok := s.config.Providers[provider]; !ok { + return CreatedDevice{}, failure(Invalid, "provider is not enabled") + } + id, e := randomToken(18) + if e != nil { + return CreatedDevice{}, e + } + secret, e := randomToken(32) + if e != nil { + return CreatedDevice{}, e + } + ticket, e := randomToken(32) + if e != nil { + return CreatedDevice{}, e + } + now := s.Now() + tx, e := s.store.Begin() + if e != nil { + return CreatedDevice{}, e + } + defer func() { _ = tx.Rollback() }() + if e = tx.CleanupExpired(now.Unix()); e != nil { + return CreatedDevice{}, e + } + pending, e := tx.PendingDevices(clientIP, now.Unix()) + if e != nil { + return CreatedDevice{}, e + } + if pending >= 5 { + return CreatedDevice{}, failure(TooManyPending, "too many pending login transactions") + } + if e = tx.InsertDevice(DeviceInsert{ID: id, ClientIP: clientIP, Secret: secret, StartTicket: ticket, Provider: provider, Created: now.Unix(), Expires: now.Add(s.config.DeviceTTL).Unix()}); e != nil { + return CreatedDevice{}, e + } + if e = tx.Commit(); e != nil { + return CreatedDevice{}, e + } + return CreatedDevice{ID: id, Secret: secret, StartTicket: ticket}, nil +} +func (s *Service) Start(provider, id, ticket string) (Authorization, error) { + row, e := s.store.StartRecord(id, ticket) + if e != nil || !row.Matches || row.Provider != provider || row.Status != "created" { + return Authorization{}, failure(Forbidden, "invalid login transaction") + } + if s.Now().Unix() >= row.Expires { + return Authorization{}, failure(Expired, "login transaction expired") + } + state, e := randomToken(32) + if e != nil { + return Authorization{}, e + } + verifier, e := randomToken(32) + if e != nil { + return Authorization{}, e + } + nonce, e := randomToken(24) + if e != nil { + return Authorization{}, e + } + updated, e := s.store.StartAuthorization(id, state, verifier, nonce) + if e != nil { + return Authorization{}, failure(Conflict, "could not start authorization") + } + if !updated { + return Authorization{}, failure(Conflict, "could not start authorization") + } + return Authorization{ID: id, State: state, Verifier: verifier, Nonce: nonce}, nil +} +func (s *Service) CancelAuthorization(provider, state string) error { + updated, e := s.store.CancelAuthorization(provider, state, s.Now().Unix()) + if e != nil { + return failure(Unavailable, "authorization state unavailable") + } + if !updated { + return failure(Forbidden, "invalid or expired authorization state") + } + return nil +} +func (s *Service) Authorization(provider, state string) (Authorization, error) { + row, e := s.store.AuthorizationRecord(state) + if e != nil || row.Provider != provider || row.Status != "authorizing" || s.Now().Unix() >= row.Expires { + return Authorization{}, failure(Forbidden, "invalid or expired authorization state") + } + verifier, nonce, e := s.store.AuthorizationSecrets(row) + if e != nil { + return Authorization{}, failure(Unavailable, "authorization state unavailable") + } + defer clear(verifier) + defer clear(nonce) + return Authorization{ID: row.ID, State: state, Verifier: string(verifier), Nonce: string(nonce)}, nil +} +func (s *Service) RejectProvider(id string) { _ = s.store.FailAuthorization(id) } +func ValidateProviderIdentity(provider string, id ProviderIdentity) error { + switch provider { + case "discord": + if id.Issuer != "https://discord.com" || len(id.Subject) == 0 || len(id.Subject) > 32 { + return errors.New("auth: invalid Discord identity") + } + for _, c := range id.Subject { + if c < '0' || c > '9' { + return errors.New("auth: invalid Discord identity") + } + } + case "google": + if id.Issuer != "https://accounts.google.com" || len(id.Subject) == 0 || len(id.Subject) > 255 { + return errors.New("auth: invalid Google identity") + } + default: + return errors.New("auth: unsupported identity provider") + } + return nil +} +func (s *Service) ValidateGoogleClaims(issuer, audience, subject, nonce, expectedNonce string, expires int64) bool { + validIssuer := issuer == "https://accounts.google.com" || issuer == "accounts.google.com" + return validIssuer && audience == s.config.Providers["google"] && subject != "" && nonce == expectedNonce && s.Now().Unix() < expires +} +func (s *Service) CompleteDevice(deviceID, provider string, id ProviderIdentity) error { + if e := ValidateProviderIdentity(provider, id); e != nil { + return e + } + now := s.Now() + tx, e := s.store.Begin() + if e != nil { + return e + } + defer func() { _ = tx.Rollback() }() + account, found, e := tx.Identity(id) + if e != nil { + return e + } + reject := func() error { + updated, e := tx.RejectDevice(deviceID, provider, "not_allowed") + if e != nil { + return e + } + if !updated { + return ErrConsumed + } + if e = tx.Commit(); e != nil { + return e + } + return ErrNotAllowed + } + if !found { + count, e := tx.AccountCount() + if e != nil { + return e + } + account.ID, e = randomToken(18) + if e != nil { + return e + } + if e = tx.InsertAccount(account.ID, provider, id, now.Unix()); e != nil { + return e + } + if count == 0 { + if e = tx.SetAdministratorAccount(account.ID); e != nil { + return e + } + } + account.Status = "active" + } else if e = tx.TouchIdentity(id, now.Unix()); e != nil { + return e + } + if account.Status != "active" { + return reject() + } + family, e := randomToken(18) + if e != nil { + return e + } + access, e := randomToken(32) + if e != nil { + return e + } + refresh, e := randomToken(32) + if e != nil { + return e + } + if e = tx.InsertFamily(FamilyInsert{ID: family, AccountID: account.ID, Provider: provider, Created: now.Unix(), Expires: now.Add(s.config.RefreshTTL).Unix()}); e != nil { + return e + } + if e = tx.InsertAccess(access, family, account.ID, now.Unix(), now.Add(s.config.AccessTTL).Unix()); e != nil { + return e + } + if e = tx.InsertRefresh(refresh, family, now.Unix(), now.Add(s.config.RefreshTTL).Unix()); e != nil { + return e + } + tokens := TokenSet{Provider: provider, AccessToken: access, RefreshToken: refresh, AccessExpiresIn: int64(s.config.AccessTTL.Seconds()), RefreshExpiresIn: int64(s.config.RefreshTTL.Seconds())} + updated, e := tx.CompleteDevice(deviceID, provider, tokens) + if e != nil { + return e + } + if !updated { + return ErrConsumed + } + return tx.Commit() +} + +type PollResult struct { + Status, ErrorCode string + Tokens TokenSet +} + +func (s *Service) Poll(id, secret string) (PollResult, error) { + tx, e := s.store.Begin() + if e != nil { + return PollResult{}, failure(Unavailable, "login result unavailable") + } + defer func() { _ = tx.Rollback() }() + row, e := tx.PollDevice(id, secret) + if e != nil || !row.Matches { + return PollResult{}, failure(Forbidden, "invalid device transaction") + } + if s.Now().Unix() >= row.Expires { + return PollResult{}, failure(Expired, "device transaction expired") + } + switch row.Status { + case "created", "authorizing": + return PollResult{Status: "pending"}, nil + case "failed": + return PollResult{Status: "failed", ErrorCode: row.ErrorCode}, nil + case "complete": + tokens, e := s.store.DecodeDeviceResult(id, row.ResultCipher) + if e != nil { + return PollResult{}, failure(Unavailable, "login result unavailable") + } + updated, e := tx.ConsumeDevice(id) + if e != nil || !updated { + return PollResult{}, failure(Expired, "login result already consumed") + } + if e = tx.Commit(); e != nil { + return PollResult{}, failure(Unavailable, "login result unavailable") + } + return PollResult{Status: "complete", Tokens: tokens}, nil + default: + return PollResult{}, failure(Expired, "device transaction consumed") + } +} +func ValidRefreshAttemptID(v string) bool { + if len(v) < 16 || len(v) > 128 { + return false + } + for _, c := range v { + valid := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' + if !valid { + return false + } + } + return true +} +func invalidRefresh(kind FailureKind, message string) error { + return &Failure{Kind: kind, Message: message, RefreshInvalid: true} +} +func (s *Service) Refresh(token, attemptID string) (TokenSet, error) { + if token == "" || !ValidRefreshAttemptID(attemptID) { + return TokenSet{}, failure(Invalid, "refresh_token and valid attempt_id required") + } + now := s.Now() + tx, e := s.store.Begin() + if e != nil { + return TokenSet{}, e + } + defer func() { _ = tx.Rollback() }() + if e = tx.CleanupExpired(now.Unix()); e != nil { + return TokenSet{}, e + } + row, e := tx.RefreshToken(token) + if e != nil || row.Revoked || row.AccountStatus != "active" || now.Unix() >= row.FamilyExpires { + return TokenSet{}, invalidRefresh(Unauthorized, "refresh token invalid") + } + saved, found, e := tx.RefreshAttempt(row.FamilyID, attemptID, token) + if e != nil { + return TokenSet{}, e + } + if found { + if !saved.Matches { + return TokenSet{}, invalidRefresh(Conflict, "refresh attempt_id already belongs to another request") + } + if saved.Result.Provider == "" || saved.Result.AccessToken == "" || saved.Result.RefreshToken == "" { + return TokenSet{}, failure(Unavailable, "refresh unavailable") + } + return saved.Result.Tokens(now.Unix()), nil + } + if now.Unix() >= row.TokenExpires { + return TokenSet{}, invalidRefresh(Unauthorized, "refresh token expired") + } + if row.Used { + if _, e = tx.RevokeFamily(row.FamilyID, now.Unix()); e != nil { + return TokenSet{}, e + } + if e = tx.Commit(); e != nil { + return TokenSet{}, e + } + return TokenSet{}, invalidRefresh(Unauthorized, "refresh token replayed") + } + access, e := randomToken(32) + if e != nil { + return TokenSet{}, e + } + refresh, e := randomToken(32) + if e != nil { + return TokenSet{}, e + } + updated, e := tx.ConsumeRefreshToken(token, now.Unix()) + if e != nil { + return TokenSet{}, e + } + if !updated { + return TokenSet{}, invalidRefresh(Unauthorized, "refresh token invalid") + } + if e = tx.DeleteFamilyAccess(row.FamilyID); e != nil { + return TokenSet{}, e + } + accessExpiry := now.Add(s.config.AccessTTL).Unix() + refreshExpiry := min(row.FamilyExpires, now.Add(s.config.RefreshTTL).Unix()) + if e = tx.InsertAccess(access, row.FamilyID, row.AccountID, now.Unix(), accessExpiry); e != nil { + return TokenSet{}, e + } + if e = tx.InsertRefresh(refresh, row.FamilyID, now.Unix(), refreshExpiry); e != nil { + return TokenSet{}, e + } + result := StoredRefreshResult{Provider: row.Provider, AccessToken: access, RefreshToken: refresh, AccessExpiresAt: accessExpiry, RefreshExpiresAt: refreshExpiry} + if e = tx.InsertRefreshAttempt(row.FamilyID, attemptID, token, result, now.Unix(), refreshExpiry); e != nil { + return TokenSet{}, e + } + if e = tx.Commit(); e != nil { + return TokenSet{}, e + } + return result.Tokens(now.Unix()), nil +} +func (s *Service) Revoke(token string) error { + now := s.Now().Unix() + tx, e := s.store.Begin() + if e != nil { + return e + } + defer func() { _ = tx.Rollback() }() + row, e := tx.AccessToken(token) + if e != nil || row.AccountStatus != "active" || row.Revoked || now >= row.Expires { + return failure(Unauthorized, "access token invalid") + } + updated, e := tx.RevokeFamily(row.FamilyID, now) + if e != nil { + return e + } + if !updated { + return failure(Unauthorized, "access token invalid") + } + return tx.Commit() +} +func (s *Service) ValidateAccess(token string) (string, error) { + if token == "" { + return "", ErrUnauthorized + } + row, e := s.store.AccessToken(token) + if e != nil || row.AccountStatus != "active" || row.Revoked || s.Now().Unix() >= row.Expires { + return "", ErrUnauthorized + } + return row.AccountID, nil +} + +func (s *Service) AuthorizeAdministrator(token string) (string, error) { + accountID, err := s.ValidateAccess(token) + if err != nil { + return "", err + } + owner, err := s.store.AdministratorAccountID() + if err != nil || owner == "" || owner != accountID { + return "", ErrUnauthorized + } + return accountID, nil +} diff --git a/go/internal/server/domain/inventory/character_source.go b/go/internal/server/domain/inventory/character_source.go new file mode 100644 index 0000000..0abf5b2 --- /dev/null +++ b/go/internal/server/domain/inventory/character_source.go @@ -0,0 +1,16 @@ +package inventory + +import "bd2server/internal/server/domain/command" + +type EquipmentCharacter struct { + InvenIndex uint64 + ID uint64 + TalentLevel uint64 + TalentExp uint64 + Response []byte +} +type EquipmentCharacterSource interface { + EquipmentCharacters() []EquipmentCharacter + EquipmentCharacter(ctx command.Context, _ uint64) (EquipmentCharacter, bool) + AddEquipmentTalentExperience(ctx command.Context, _ uint64, _ uint64, _ uint64) (EquipmentCharacter, error) +} diff --git a/go/internal/server/player/commerce_items.go b/go/internal/server/domain/inventory/commerce_items.go similarity index 85% rename from go/internal/server/player/commerce_items.go rename to go/internal/server/domain/inventory/commerce_items.go index 4b52131..b99cd3d 100644 --- a/go/internal/server/player/commerce_items.go +++ b/go/internal/server/domain/inventory/commerce_items.go @@ -1,18 +1,18 @@ -package player +package inventory import ( + "bd2server/internal/server/domain/command" "fmt" "math" ) // GrantCommerceOnce preserves the exact item expiry supplied by the commerce // entitlement policy. It shares the existing atomic inventory layout. -func (s *Inventory) GrantCommerceOnce(identity string, rewards []Item) ([]Item, error) { +func (s *Inventory) GrantCommerceOnce(ctx command.Context, identity string, rewards []Item) ([]Item, error) { if identity == "" { return nil, fmt.Errorf("player: missing commerce reward identity") } - s.mu.Lock() - defer s.mu.Unlock() + if s.owned.Granted[identity] { var out []Item for _, idx := range s.owned.GrantItems[identity] { @@ -55,17 +55,16 @@ func (s *Inventory) GrantCommerceOnce(identity string, rewards []Item) ([]Item, out = append(out, r) } next.Granted[identity] = true - if err := s.commitOwned(next); err != nil { + if err := s.commitOwned(ctx, next); err != nil { return nil, err } s.owned = next return out, nil } func (s *Inventory) ContentTicketExpiry(id uint64) uint64 { - s.mu.Lock() - defer s.mu.Unlock() + var expiry uint64 - for _, items := range [][]Item{s.starter.Items, s.owned.Items} { + for _, items := range [][]Item{s.starter, s.owned.Items} { for _, i := range items { if i.Type == 19 && i.ID == id && i.Count > 0 && i.ExpiryTime > expiry { expiry = i.ExpiryTime diff --git a/go/internal/server/domain/inventory/content_open.go b/go/internal/server/domain/inventory/content_open.go new file mode 100644 index 0000000..cfa164c --- /dev/null +++ b/go/internal/server/domain/inventory/content_open.go @@ -0,0 +1,29 @@ +package inventory + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/storage/stateio" +) + +const contentOpenPacketCode = 622 + +type contentOpenReceipt struct { + Digest string `json:"digest"` + Body []byte `json:"body"` +} + +type contentOpenState struct { + Receipts map[string]contentOpenReceipt `json:"receipts"` +} + +// ContentOpenService grants the completion ticket for the client's sole +// implemented ContentOpen type (1). The prerequisite ticket is never spent. +// Handle must run in the account request transaction: inventory and receipt +// writes commit together, and account recovery reconstructs Inventory after +// a dirty rollback. Receipts are reloaded for every request. +type ContentOpenService struct { + design *gamedata.ContentOpeningDesign + inventory *Inventory + store stateio.Store + squadLevel func() (uint64, error) +} diff --git a/go/internal/server/player/entry_ledger.go b/go/internal/server/domain/inventory/entry_ledger.go similarity index 63% rename from go/internal/server/player/entry_ledger.go rename to go/internal/server/domain/inventory/entry_ledger.go index cb23aba..4e2e34c 100644 --- a/go/internal/server/player/entry_ledger.go +++ b/go/internal/server/domain/inventory/entry_ledger.go @@ -1,14 +1,15 @@ -package player +package inventory import ( "encoding/json" "fmt" - "bd2server/internal/server/stateio" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" ) -func loadBoolEntries(store stateio.AtomicEntryStore, domain, bucket string) (map[string]bool, error) { - entries, err := store.ListEntries(domain, bucket) +func loadBoolEntries(ctx command.Context, store stateio.ScopedEntryStore, domain, bucket string) (map[string]bool, error) { + entries, err := store.ListEntries(ctx.State, domain, bucket) if err != nil { return nil, err } @@ -22,8 +23,8 @@ func loadBoolEntries(store stateio.AtomicEntryStore, domain, bucket string) (map return result, nil } -func loadUintEntries(store stateio.AtomicEntryStore, domain, bucket string) (map[string]uint64, error) { - entries, err := store.ListEntries(domain, bucket) +func loadUintEntries(ctx command.Context, store stateio.ScopedEntryStore, domain, bucket string) (map[string]uint64, error) { + entries, err := store.ListEntries(ctx.State, domain, bucket) if err != nil { return nil, err } diff --git a/go/internal/server/domain/inventory/equipment.go b/go/internal/server/domain/inventory/equipment.go new file mode 100644 index 0000000..7bd53c9 --- /dev/null +++ b/go/internal/server/domain/inventory/equipment.go @@ -0,0 +1,927 @@ +package inventory + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "maps" + "math" + "reflect" + "sort" + "strconv" + "strings" + + "unicode/utf8" +) + +// Equipment is one server-owned equipment instance. The immutable definition +// (name, icon, slot and base stats) remains in the client's local GameData. +type Equipment struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level"` + UseChar uint64 `json:"use_char,omitempty"` + KeepFlag uint64 `json:"keep_flag,omitempty"` + LockFlag uint64 `json:"lock_flag,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + Mark string `json:"mark,omitempty"` + MainOption []EquipmentOption `json:"main_option,omitempty"` + SubOption []EquipmentOption `json:"sub_option,omitempty"` + PrivateOption *EquipmentOption `json:"private_option,omitempty"` + Rank []uint64 `json:"rank,omitempty"` + UpgradeAttempts uint64 `json:"upgrade_attempts"` +} + +// EquipmentOption is the exact EquipmentOptionTable composite key used by an +// equipment instance. The client resolves the displayed stat curve locally. +type EquipmentOption struct { + GroupID uint64 `json:"group_id"` + ID uint64 `json:"id"` +} + +type equipmentSnapshot struct { + Version string `json:"version"` + NextIndex uint64 `json:"next_index"` + Equipment []Equipment `json:"-"` + Granted map[string]uint64 `json:"-"` +} + +// EquipmentInventory persists non-stackable equipment independently from +// ItemDBInfo inventory because the wire protocols are different types. +type EquipmentInventory struct { + store stateio.ScopedEntryStore + owned equipmentSnapshot + persisted equipmentSnapshot + corePresent bool + characters EquipmentCharacterSource + slots map[uint64]uint64 + upgrade *gamedata.EquipmentUpgradeDesign + craft *gamedata.EquipmentCraftDesign + smelting *gamedata.EquipmentSmeltingDesign + optionReroll *gamedata.EquipmentOptionRerollDesign + statDesign *gamedata.EquipmentStatDesign + wallet *Wallet + inventory *Inventory + + smeltCache map[string]smeltingReply + pendingReroll *equipmentOptionRerollPending + presets map[equipmentPresetKey]equipmentPreset +} + +type smeltingReply struct { + code int + body []byte +} + +type equipmentOptionRerollPending struct { + Equipment Equipment `json:"equipment"` +} + +func (s *EquipmentInventory) AttachUpgrade(ctx command.Context, design *gamedata.EquipmentUpgradeDesign, wallet *Wallet, inventory *Inventory) error { + if design == nil || wallet == nil || inventory == nil { + return errors.New("player: incomplete equipment upgrade configuration") + } + + s.upgrade, s.wallet, s.inventory = design, wallet, inventory + return nil +} + +func (s *EquipmentInventory) AttachCraft(ctx command.Context, design *gamedata.EquipmentCraftDesign) error { + if design == nil { + return errors.New("player: incomplete equipment crafting configuration") + } + + s.craft = design + return nil +} + +func (s *EquipmentInventory) AttachSmelting(ctx command.Context, design *gamedata.EquipmentSmeltingDesign, wallet *Wallet, inventory *Inventory) error { + if design == nil || wallet == nil || inventory == nil { + return errors.New("player: incomplete equipment smelting configuration") + } + + s.smelting, s.wallet, s.inventory = design, wallet, inventory + return nil +} + +func (s *EquipmentInventory) AttachOptionReroll(ctx command.Context, design *gamedata.EquipmentOptionRerollDesign, wallet *Wallet, inventory *Inventory) error { + if design == nil || wallet == nil || inventory == nil { + return errors.New("player: incomplete equipment option reroll configuration") + } + + s.optionReroll, s.wallet, s.inventory = design, wallet, inventory + if s.pendingReroll != nil { + if err := s.validatePendingRerollLocked(s.pendingReroll); err != nil { + return err + } + } + return nil +} + +// BeginLogin resets the in-memory request replay cache. A repeated protobuf +// sequence in one login must return the first refinement result without a +// second roll or charge. +func (s *EquipmentInventory) BeginLogin(ctx command.Context) { + id := ctx.SessionID + + if id == "" { + return + } + s.smeltCache = make(map[string]smeltingReply) +} + +func (s *EquipmentInventory) AttachSlots(ctx command.Context, slots map[uint64]uint64) error { + if len(slots) == 0 { + return errors.New("player: empty equipment slot design") + } + + s.slots = make(map[uint64]uint64, len(slots)) + for id, slot := range slots { + if id == 0 || slot > 4 { + return errors.New("player: invalid equipment slot design") + } + s.slots[id] = slot + } + return nil +} + +func (s *EquipmentInventory) AttachCharacters(ctx command.Context, characters EquipmentCharacterSource) error { + if characters == nil { + return errors.New("player: nil character store") + } + known := make(map[uint64]bool) + for _, character := range characters.EquipmentCharacters() { + known[character.InvenIndex] = true + } + + for key := range s.presets { + if !known[key.CharacterIndex] { + return fmt.Errorf("player: equipment preset references unknown character %d", key.CharacterIndex) + } + } + s.characters = characters + return nil +} + +func OpenEquipmentInventory(ctx command.Context, store stateio.Store) (*EquipmentInventory, error) { + entries, ok := store.(stateio.ScopedEntryStore) + if !ok { + return nil, errors.New("player: nil equipment store") + } + s := &EquipmentInventory{store: entries, smeltCache: make(map[string]smeltingReply), presets: make(map[equipmentPresetKey]equipmentPreset), owned: equipmentSnapshot{ + Version: versionconfig.State(), NextIndex: 910000001, Granted: map[string]uint64{}, + }} + data, err := store.Load(ctx.State, "equipment") + if err != nil { + return nil, err + } + if data != nil { + s.corePresent = true + if err := stateio.RequireExactJSONObject(data, "version", "next_index"); err != nil { + return nil, fmt.Errorf("player: incompatible equipment layout: %w", err) + } + var shape map[string]json.RawMessage + if err := json.Unmarshal(data, &shape); err != nil { + return nil, fmt.Errorf("player: decode equipment shape: %w", err) + } + for _, name := range []string{"equipment", "granted"} { + if _, exists := shape[name]; exists { + return nil, fmt.Errorf("player: equipment %s must use entries", name) + } + } + if err := json.Unmarshal(data, &s.owned); err != nil { + return nil, fmt.Errorf("player: decode equipment: %w", err) + } + } else if err := stateio.RequireNoEntries(entries, ctx.State, "equipment", "equipment", "granted", "reroll_pending", "presets"); err != nil { + return nil, fmt.Errorf("player: invalid equipment storage: %w", err) + } + if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 910000001 { + return nil, errors.New("player: invalid saved equipment") + } + s.owned.Granted, err = loadUintEntries(ctx, entries, "equipment", "granted") + if err != nil { + return nil, err + } + rawEquipment, err := entries.ListEntries(ctx.State, "equipment", "equipment") + if err != nil { + return nil, err + } + for key, value := range rawEquipment { + var entry Equipment + index, parseErr := strconv.ParseUint(key, 10, 64) + var shape map[string]json.RawMessage + if parseErr != nil || json.Unmarshal(value, &shape) != nil || json.Unmarshal(value, &entry) != nil || entry.InvenIndex != index { + return nil, fmt.Errorf("player: invalid equipment entry %q", key) + } + if _, present := shape["upgrade_attempts"]; !present { + return nil, errors.New("player: equipment save requires upgrade_attempts; migrate the development save") + } + s.owned.Equipment = append(s.owned.Equipment, entry) + } + sort.Slice(s.owned.Equipment, func(i, j int) bool { return s.owned.Equipment[i].InvenIndex < s.owned.Equipment[j].InvenIndex }) + for _, entry := range s.owned.Equipment { + if len(entry.Rank) != 3 { + return nil, fmt.Errorf("player: equipment %d requires exactly three rank slots, found %d; repair the development save before starting", entry.InvenIndex, len(entry.Rank)) + } + } + pendingEntries, err := entries.ListEntries(ctx.State, "equipment", "reroll_pending") + if err != nil { + return nil, err + } + if len(pendingEntries) > 1 { + return nil, errors.New("player: multiple equipment option reroll candidates") + } + if raw, ok := pendingEntries["current"]; ok { + if err := stateio.RequireExactJSONObject(raw, "equipment"); err != nil { + return nil, fmt.Errorf("player: incompatible equipment option reroll candidate: %w", err) + } + var pending equipmentOptionRerollPending + if err := json.Unmarshal(raw, &pending); err != nil { + return nil, fmt.Errorf("player: decode equipment option reroll candidate: %w", err) + } + s.pendingReroll = &pending + if err := s.validatePendingRerollLocked(s.pendingReroll); err != nil { + return nil, err + } + } else if len(pendingEntries) != 0 { + return nil, errors.New("player: invalid equipment option reroll candidate key") + } + if err := s.loadEquipmentPresets(ctx, entries); err != nil { + return nil, err + } + s.persisted = cloneEquipmentSnapshot(s.owned) + return s, nil +} + +func (s *EquipmentInventory) EnsurePersisted(ctx command.Context) error { + + data, err := s.store.Load(ctx.State, "equipment") + if err != nil { + return err + } + if data != nil { + return nil + } + return s.commitLocked(ctx, cloneEquipmentSnapshot(s.owned), "initial account generation") +} + +// GrantOnce returns the same instance on a retry, allowing QuestClear response +// retries without duplicating ownership. +func (s *EquipmentInventory) GrantOnce(ctx command.Context, identity string, equipmentID uint64) (Equipment, error) { + if identity == "" || equipmentID == 0 { + return Equipment{}, errors.New("player: invalid equipment grant") + } + + if index := s.owned.Granted[identity]; index != 0 { + for _, current := range s.owned.Equipment { + if current.InvenIndex == index { + return current, nil + } + } + return Equipment{}, errors.New("player: equipment grant index is missing") + } + return s.grantLocked(ctx, identity, Equipment{ID: equipmentID}) +} + +// GrantGeneratedOnce saves an independently generated gacha instance. Retry +// calls return the original rolls rather than creating a second copy. +func (s *EquipmentInventory) GrantGeneratedOnce(ctx command.Context, identity string, entry Equipment) (Equipment, error) { + if identity == "" || entry.ID == 0 || len(entry.Rank) != 3 { + return Equipment{}, errors.New("player: invalid generated equipment") + } + + return s.grantLocked(ctx, identity, entry) +} + +func (s *EquipmentInventory) grantLocked(ctx command.Context, identity string, entry Equipment) (Equipment, error) { + if index := s.owned.Granted[identity]; index != 0 { + for _, current := range s.owned.Equipment { + if current.InvenIndex == index { + return current, nil + } + } + return Equipment{}, errors.New("player: equipment grant index is missing") + } + if len(entry.Rank) == 0 { + // Rank is a fixed three-slot client field (unlock thresholds +3/+6/+9). + // Even an unenhanced item must have three explicit zero values. + entry.Rank = []uint64{0, 0, 0} + } + if len(entry.Rank) != 3 { + return Equipment{}, errors.New("player: equipment requires exactly three rank slots") + } + entry.InvenIndex = s.owned.NextIndex + next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex + 1, + Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted)+1)} + next.Equipment = append(next.Equipment, entry) + maps.Copy(next.Granted, s.owned.Granted) + next.Granted[identity] = entry.InvenIndex + if err := s.commitLocked(ctx, next, "grant"); err != nil { + return Equipment{}, err + } + return entry, nil +} + +const ( + equipUpgradeSuccess = iota + equipUpgradeFail + equipUpgradeStopMaxLevel + equipUpgradeStopSuccess + equipUpgradeStopNotEnough + equipUpgradeStopGoldLimit + equipUpgradeStopTargetLevel + equipUpgradeStopMaxTryCount +) + +func (s *EquipmentInventory) smeltingCacheKey(ctx command.Context, kind string, seq uint64) string { + return kind + ":" + ctx.SessionID + ":seq:" + strconv.FormatUint(seq, 10) +} + +func (s *EquipmentInventory) smeltingEquipmentLocked(index uint64) (int, Equipment, error) { + if s.smelting == nil || s.wallet == nil || s.inventory == nil { + return -1, Equipment{}, errors.New("player: equipment smelting unavailable") + } + position := s.equipmentPositionLocked(index) + if position < 0 { + return -1, Equipment{}, fmt.Errorf("player: unknown equipment %d", index) + } + entry := s.owned.Equipment[position] + design, ok := s.smelting.Equipment[entry.ID] + if !ok || entry.Level != design.MaxLevel || len(entry.Rank) != 3 { + return -1, Equipment{}, fmt.Errorf("player: equipment %d is not ready for smelting", index) + } + if _, err := s.smelting.Score(entry.ID, entry.Rank); err != nil { + return -1, Equipment{}, fmt.Errorf("player: equipment %d has invalid smelting rank: %w", index, err) + } + return position, entry, nil +} + +func validateSmeltingMaterials(costs []gamedata.PromotionCost, materials []Item) (gold, mileageMaterial uint64, err error) { + want := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + want[[2]uint64{cost.Type, cost.ID}] += cost.Count + } + got := make(map[[2]uint64]uint64, len(materials)) + for _, item := range materials { + got[[2]uint64{item.Type, item.ID}] += item.Count + if item.Type == 4 { + gold += item.Count + } else { + mileageMaterial += item.Count + } + } + if len(got) != len(want) { + return 0, 0, errors.New("player: equipment smelting material kinds mismatch") + } + for key, count := range want { + if got[key] != count { + return 0, 0, fmt.Errorf("player: equipment smelting material %d/%d=%d want=%d", key[0], key[1], got[key], count) + } + } + return gold, mileageMaterial, nil +} + +func (s *EquipmentInventory) selectSmeltingCosts(ctx command.Context, costs []gamedata.PromotionCost, attempts uint64) ([]Item, uint64, error) { + var result []Item + var gold uint64 + for _, cost := range costs { + if attempts != 0 && cost.Count > ^uint64(0)/attempts { + return nil, 0, errors.New("player: equipment smelting cost overflow") + } + count := cost.Count * attempts + switch cost.Type { + case 4: + if cost.ID != 0 || gold != 0 || !s.wallet.CanSpendGold(count) { + return nil, 0, errors.New("player: insufficient equipment smelting gold") + } + gold = count + result = append(result, Item{Type: 4, Count: count}) + case 8: + items, err := s.inventory.SelectMutable(ctx, cost.Type, cost.ID, count) + if err != nil { + return nil, 0, err + } + result = append(result, items...) + default: + return nil, 0, fmt.Errorf("player: unsupported equipment smelting cost type %d", cost.Type) + } + } + return result, gold, nil +} + +// commitSmeltingLocked keeps refinement's three typed snapshots synchronized +// in memory. Caller holds equipment.mu; this method takes the remaining locks +// in wallet -> inventory order, calculates every candidate before writing, +// then publishes all three only after their saves succeed. The surrounding +// request transaction supplies durable all-or-none recovery across the writes. +func (s *EquipmentInventory) commitSmeltingLocked(ctx command.Context, + nextEquipment equipmentSnapshot, + consumed []Item, + gold, mileageMaterial uint64, + identity, operation string, +) (Currency, uint64, error) { + if identity == "" || mileageMaterial == 0 || s.wallet == nil || s.inventory == nil { + return Currency{}, 0, errors.New("player: invalid transactional equipment smelting") + } + + nextWallet := cloneWallet(s.wallet.state) + if nextWallet.Spent[identity] { + return Currency{}, 0, errors.New("player: equipment smelting request was already committed") + } + if nextWallet.Gold < gold { + return Currency{}, 0, errors.New("player: insufficient gold for equipment smelting") + } + nextWallet.Gold -= gold + nextWallet.Spent[identity] = true + threshold, rewardCount := s.smelting.Mileage.UseCount, s.smelting.Mileage.RewardCount + if threshold == 0 || rewardCount == 0 || nextWallet.EquipMileageExchangeGage >= threshold || + math.MaxUint64-nextWallet.EquipMileageExchangeGage < mileageMaterial { + return Currency{}, 0, errors.New("player: invalid equipment smelting gauge") + } + total := nextWallet.EquipMileageExchangeGage + mileageMaterial + exchanges := total / threshold + earned := exchanges * rewardCount + if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-nextWallet.EquipMileage < earned { + return Currency{}, 0, errors.New("player: equipment mileage overflow") + } + nextWallet.EquipMileageExchangeGage = total % threshold + nextWallet.EquipMileage += earned + + nextItems := cloneOwnedSnapshot(s.inventory.owned) + for _, want := range consumed { + if err := consumeOwnedItem(&nextItems, want); err != nil { + return Currency{}, 0, err + } + } + + walletData, err := json.Marshal(nextWallet) + if err != nil { + return Currency{}, 0, err + } + if err := s.store.SaveWithEntries(ctx.State, "wallet", walletData, entry("spent", identity, []byte("true"))); err != nil { + return Currency{}, 0, fmt.Errorf("player: persist equipment %s wallet: %w", operation, err) + } + if err := s.inventory.commitOwned(ctx, nextItems); err != nil { + return Currency{}, 0, fmt.Errorf("player: persist equipment %s items: %w", operation, err) + } + if err := s.persistEquipment(ctx, nextEquipment, operation); err != nil { + return Currency{}, 0, err + } + s.owned = nextEquipment + s.persisted = cloneEquipmentSnapshot(nextEquipment) + s.wallet.state = nextWallet + s.inventory.owned = nextItems + return nextWallet.Currency, earned, nil +} + +func consumeOwnedItem(next *ownedSnapshot, want Item) error { + if next == nil || want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 { + return errors.New("player: invalid item consumption") + } + for i, current := range next.Items { + if current.InvenIndex != want.InvenIndex { + continue + } + if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count { + return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex) + } + current.Count -= want.Count + if current.Count == 0 { + next.Items = append(next.Items[:i], next.Items[i+1:]...) + } else { + next.Items[i] = current + } + return nil + } + return fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex) +} + +func validateOptionRerollMaterials(costs []gamedata.PromotionCost, materials []Item, conversion *gamedata.EquipmentOptionRerollConversion) (uint64, []Item, error) { + if len(costs) == 0 || len(materials) == 0 { + return 0, nil, errors.New("player: EquipOptionReRoll has no material") + } + expected := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + key := [2]uint64{cost.Type, cost.ID} + if cost.Type == 0 || cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || math.MaxUint64-expected[key] < cost.Count { + return 0, nil, errors.New("player: invalid equipment option reroll cost") + } + expected[key] += cost.Count + } + actual := make(map[[2]uint64]uint64, len(materials)) + consumed := make([]Item, 0, len(materials)) + for _, material := range materials { + key := [2]uint64{material.Type, material.ID} + if math.MaxUint64-actual[key] < material.Count { + return 0, nil, errors.New("player: EquipOptionReRoll material count overflows") + } + actual[key] += material.Count + if material.Type != 4 { + consumed = append(consumed, material) + } + } + for key, want := range expected { + if conversion != nil && key == ([2]uint64{conversion.TargetType, conversion.TargetID}) && + expected[[2]uint64{conversion.SourceType, conversion.SourceID}] == 0 { + targetKey := key + sourceKey := [2]uint64{conversion.SourceType, conversion.SourceID} + targetCount := actual[targetKey] + if targetCount > want || conversion.Ratio == 0 || want-targetCount > math.MaxUint64/conversion.Ratio || actual[sourceKey] != (want-targetCount)*conversion.Ratio { + return 0, nil, fmt.Errorf("player: EquipOptionReRoll converted material %d/%d does not match cost", key[0], key[1]) + } + delete(actual, targetKey) + delete(actual, sourceKey) + continue + } + if actual[key] != want { + return 0, nil, fmt.Errorf("player: EquipOptionReRoll material %d/%d=%d want=%d", key[0], key[1], actual[key], want) + } + delete(actual, key) + } + if len(actual) != 0 { + return 0, nil, errors.New("player: EquipOptionReRoll material kinds mismatch") + } + return expected[[2]uint64{4, 0}], consumed, nil +} + +func (s *EquipmentInventory) commitOptionRerollLocked(ctx command.Context, pending *equipmentOptionRerollPending, consumed []Item, gold uint64, identity string) error { + if pending == nil || identity == "" || s.wallet == nil || s.inventory == nil { + return errors.New("player: invalid transactional equipment option reroll") + } + + nextWallet := cloneWallet(s.wallet.state) + if nextWallet.Spent[identity] { + return errors.New("player: equipment option reroll request was already committed") + } + if nextWallet.Gold < gold { + return errors.New("player: insufficient gold for equipment option reroll") + } + nextWallet.Gold -= gold + nextWallet.Spent[identity] = true + nextItems := cloneOwnedSnapshot(s.inventory.owned) + for _, want := range consumed { + if err := consumeOwnedItem(&nextItems, want); err != nil { + return err + } + } + walletData, err := json.Marshal(nextWallet) + if err != nil { + return err + } + if err := s.store.SaveWithEntries(ctx.State, "wallet", walletData, entry("spent", identity, []byte("true"))); err != nil { + return fmt.Errorf("player: persist equipment option reroll wallet: %w", err) + } + if err := s.inventory.commitOwned(ctx, nextItems); err != nil { + return fmt.Errorf("player: persist equipment option reroll items: %w", err) + } + if err := s.persistEquipmentState(ctx, s.owned, pending, true, "option reroll"); err != nil { + return err + } + s.wallet.state = nextWallet + s.inventory.owned = nextItems + s.pendingReroll = &equipmentOptionRerollPending{Equipment: cloneEquipment(pending.Equipment)} + return nil +} + +func upgradeLackItems(costs []gamedata.PromotionCost) []Item { + items := make([]Item, 0, len(costs)) + for _, cost := range costs { + items = append(items, Item{ID: cost.ID, Type: cost.Type, Count: cost.Count}) + } + return items +} + +func (s *EquipmentInventory) equipmentPositionLocked(index uint64) int { + for i := range s.owned.Equipment { + if s.owned.Equipment[i].InvenIndex == index { + return i + } + } + return -1 +} + +func (s *EquipmentInventory) selectUpgradeCosts(ctx command.Context, costs []gamedata.PromotionCost) ([]Item, uint64, error) { + var selected []Item + var gold uint64 + for _, cost := range costs { + switch cost.Type { + case 4: + if cost.ID != 0 || cost.Count == 0 || gold != 0 { + return nil, 0, errors.New("player: invalid equipment upgrade gold cost") + } + gold = cost.Count + selected = append(selected, Item{Type: 4, Count: cost.Count}) + case 8: + items, err := s.inventory.SelectMutable(ctx, cost.Type, cost.ID, cost.Count) + if err != nil { + return nil, 0, err + } + selected = append(selected, items...) + default: + return nil, 0, fmt.Errorf("player: unsupported equipment upgrade cost type %d", cost.Type) + } + } + return selected, gold, nil +} + +func (s *EquipmentInventory) attemptUpgradeLocked(ctx command.Context, index uint64, materials []Item) (Equipment, bool, uint64, []Item, error) { + if s.upgrade == nil || s.wallet == nil { + return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade unavailable") + } + position := s.equipmentPositionLocked(index) + if position < 0 { + return Equipment{}, false, 0, nil, fmt.Errorf("player: unknown equipment %d", index) + } + current := s.owned.Equipment[position] + level, _, err := s.upgrade.Level(current.ID, current.Level) + if err != nil { + return Equipment{}, false, 0, nil, err + } + want := make(map[[2]uint64]uint64, len(level.Costs)) + for _, cost := range level.Costs { + want[[2]uint64{cost.Type, cost.ID}] += cost.Count + } + got := make(map[[2]uint64]uint64) + var gold uint64 + var items []Item + for _, material := range materials { + got[[2]uint64{material.Type, material.ID}] += material.Count + if material.Type == 4 { + if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { + return Equipment{}, false, 0, nil, errors.New("player: invalid equipment upgrade currency") + } + gold = material.Count + } else { + items = append(items, material) + } + } + if len(got) != len(want) { + return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material kinds mismatch") + } + for key, count := range want { + if got[key] != count { + return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material %d/%d=%d want=%d", key[0], key[1], got[key], count) + } + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return Equipment{}, false, 0, nil, errors.New("player: insufficient gold for equipment upgrade") + } + if len(items) != 0 { + if s.inventory == nil { + return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade inventory unavailable") + } + if err := s.inventory.CanConsume(ctx, items); err != nil { + return Equipment{}, false, 0, nil, err + } + } + // Draw before any cross-store write: RNG failure must never charge the + // player. Ordinary enhancement also unlocks an official grade at the + // +3/+6/+9 pivots; smelting can later change those grades, but is separate. + success, err := s.upgrade.Roll(level.SuccessRatio) + if err != nil { + return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment upgrade: %w", err) + } + var rankSlot, rankValue uint64 + if success { + nextLevel := current.Level + 1 + switch nextLevel { + case 3: + rankSlot = 1 + case 6: + rankSlot = 2 + case 9: + rankSlot = 3 + } + if rankSlot != 0 { + if len(current.Rank) != 3 || current.Rank[rankSlot-1] != 0 { + return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment %d invalid rank state at +%d", index, nextLevel) + } + rankValue, err = s.upgrade.RollRank(current.ID, rankSlot) + if err != nil { + return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment rank: %w", err) + } + } + } + attempt := current.UpgradeAttempts + 1 + identity := "equip-upgrade:" + strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(attempt, 10) + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(ctx, identity, gold); err != nil { + return Equipment{}, false, 0, nil, fmt.Errorf("player: spend equipment upgrade gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(ctx, items); err != nil { + return Equipment{}, false, 0, nil, fmt.Errorf("player: consume equipment upgrade items: %w", err) + } + } + next := cloneEquipmentSnapshot(s.owned) + next.Equipment[position].UpgradeAttempts = attempt + if success { + next.Equipment[position].Level++ + if rankSlot != 0 { + next.Equipment[position].Rank[rankSlot-1] = rankValue + } + } + if err := s.commitLocked(ctx, next, "upgrade"); err != nil { + return Equipment{}, false, 0, nil, err + } + return next.Equipment[position], success, gold, materials, nil +} + +func (s *EquipmentInventory) equippedCharacter(ctx command.Context, entry Equipment) (EquipmentCharacter, bool) { + if entry.UseChar == 0 || s.characters == nil { + return EquipmentCharacter{}, false + } + return s.characters.EquipmentCharacter(ctx, entry.UseChar) +} + +// EquipmentInfo.MakeStringCustomMarkData emits either "~|" +// (icon 1..15) or a text mark followed by "|". Text input is capped +// by the 2-byte MAXIMUM_CUSTOMMARK_TEXT_SIZE in CustomSettingPopupUI; a +// single character is prefixed with '_' so it cannot be confused with an ID. +func validEquipmentMark(raw []byte) bool { + if len(raw) == 0 || len(raw) > 8 || !utf8.Valid(raw) { + return false + } + parts := strings.Split(string(raw), "|") + if len(parts) != 2 { + return false + } + color, err := strconv.Atoi(parts[1]) + if err != nil || color < 0 || color > 5 { + return false + } + left := parts[0] + if suffix, found := strings.CutPrefix(left, "~"); found { + icon, err := strconv.Atoi(suffix) + return err == nil && icon >= 1 && icon <= 15 + } + if text, found := strings.CutPrefix(left, "_"); found { + return utf8.RuneCountInString(text) == 1 && len([]byte(text)) <= 2 + } + return len(left) >= 1 && len([]byte(left)) <= 2 +} + +func cloneEquipmentSnapshot(current equipmentSnapshot) equipmentSnapshot { + next := equipmentSnapshot{Version: current.Version, NextIndex: current.NextIndex, + Equipment: append([]Equipment(nil), current.Equipment...), Granted: make(map[string]uint64, len(current.Granted))} + for i := range next.Equipment { + next.Equipment[i] = cloneEquipment(next.Equipment[i]) + } + maps.Copy(next.Granted, current.Granted) + return next +} + +func cloneEquipment(current Equipment) Equipment { + next := current + next.MainOption = append([]EquipmentOption(nil), current.MainOption...) + next.SubOption = append([]EquipmentOption(nil), current.SubOption...) + next.Rank = append([]uint64(nil), current.Rank...) + if current.PrivateOption != nil { + option := *current.PrivateOption + next.PrivateOption = &option + } + return next +} + +func (s *EquipmentInventory) validatePendingRerollLocked(pending *equipmentOptionRerollPending) error { + if pending == nil || pending.Equipment.InvenIndex == 0 || pending.Equipment.ID == 0 { + return errors.New("player: invalid equipment option reroll candidate") + } + position := s.equipmentPositionLocked(pending.Equipment.InvenIndex) + if position < 0 { + return fmt.Errorf("player: option reroll candidate references unknown equipment %d", pending.Equipment.InvenIndex) + } + current := s.owned.Equipment[position] + if len(pending.Equipment.MainOption) != len(current.MainOption) || len(pending.Equipment.SubOption) != len(current.SubOption) { + return errors.New("player: option reroll candidate option counts do not match equipment") + } + for i, option := range pending.Equipment.MainOption { + if option.GroupID != current.MainOption[i].GroupID || option.ID == 0 { + return fmt.Errorf("player: option reroll candidate main option %d is invalid", i) + } + } + for i, option := range pending.Equipment.SubOption { + if option.GroupID != current.SubOption[i].GroupID || option.ID == 0 { + return fmt.Errorf("player: option reroll candidate sub option %d is invalid", i) + } + } + if s.optionReroll != nil { + definition, ok := s.optionReroll.Lookup(current.ID) + if !ok || len(definition.MainGroups) != len(pending.Equipment.MainOption) || len(definition.SubGroups) != len(pending.Equipment.SubOption) { + return errors.New("player: option reroll candidate has no matching GameData design") + } + for i, option := range pending.Equipment.MainOption { + if option.GroupID != definition.MainGroups[i] || !optionChoiceExists(s.optionReroll.Groups[option.GroupID], option.ID) { + return fmt.Errorf("player: option reroll candidate main option %d is not in GameData", i) + } + } + for i, option := range pending.Equipment.SubOption { + if option.GroupID != definition.SubGroups[i] || !optionChoiceExists(s.optionReroll.Groups[option.GroupID], option.ID) { + return fmt.Errorf("player: option reroll candidate sub option %d is not in GameData", i) + } + } + } + invariant := cloneEquipment(pending.Equipment) + invariant.MainOption = append([]EquipmentOption(nil), current.MainOption...) + invariant.SubOption = append([]EquipmentOption(nil), current.SubOption...) + if !reflect.DeepEqual(invariant, current) { + return errors.New("player: option reroll candidate modifies immutable equipment state") + } + return nil +} + +func optionChoiceExists(group gamedata.OptionGroup, id uint64) bool { + for _, choice := range group.Choices { + if choice.ID == id { + return true + } + } + return false +} + +func (s *EquipmentInventory) commitLocked(ctx command.Context, next equipmentSnapshot, operation string) error { + if err := s.persistEquipment(ctx, next, operation); err != nil { + return err + } + s.owned = next + s.persisted = cloneEquipmentSnapshot(next) + return nil +} + +func (s *EquipmentInventory) persistEquipment(ctx command.Context, next equipmentSnapshot, operation string) error { + return s.persistEquipmentState(ctx, next, nil, false, operation) +} + +func (s *EquipmentInventory) persistEquipmentState(ctx command.Context, next equipmentSnapshot, pending *equipmentOptionRerollPending, pendingDirty bool, operation string) error { + data, err := json.Marshal(next) + if err != nil { + return err + } + changes := make([]stateio.EntryMutation, 0) + for key, value := range next.Granted { + if value != 0 && s.persisted.Granted[key] != value { + changes = append(changes, stateio.EntryMutation{Bucket: "granted", Key: key, Payload: []byte(strconv.FormatUint(value, 10))}) + } + } + before := make(map[uint64]Equipment, len(s.persisted.Equipment)) + for _, item := range s.persisted.Equipment { + before[item.InvenIndex] = item + } + for _, item := range next.Equipment { + old, exists := before[item.InvenIndex] + if !exists || !reflect.DeepEqual(old, item) { + payload, err := json.Marshal(item) + if err != nil { + return err + } + changes = append(changes, stateio.EntryMutation{Bucket: "equipment", Key: strconv.FormatUint(item.InvenIndex, 10), Payload: payload}) + } + delete(before, item.InvenIndex) + } + for index := range before { + changes = append(changes, stateio.EntryMutation{Bucket: "equipment", Key: strconv.FormatUint(index, 10), Delete: true}) + } + if pendingDirty { + change := stateio.EntryMutation{Bucket: "reroll_pending", Key: "current", Delete: pending == nil} + if pending != nil { + change.Payload, err = json.Marshal(pending) + if err != nil { + return err + } + } + changes = append(changes, change) + } + if s.corePresent && next.Version == s.persisted.Version && next.NextIndex == s.persisted.NextIndex { + data = nil + } + if err := s.store.SaveWithEntries(ctx.State, "equipment", data, changes); err != nil { + return fmt.Errorf("player: persist equipment %s: %w", operation, err) + } + s.corePresent = true + return nil +} + +func (s *EquipmentInventory) All(ctx command.Context) []Equipment { + + return append([]Equipment(nil), s.owned.Equipment...) +} + +// Granted returns a prior idempotent grant without creating an item. +func (s *EquipmentInventory) Granted(identity string) (Equipment, bool) { + + index := s.owned.Granted[identity] + if index == 0 { + return Equipment{}, false + } + for _, entry := range s.owned.Equipment { + if entry.InvenIndex == index { + return entry, true + } + } + return Equipment{}, false +} diff --git a/go/internal/server/domain/inventory/equipment_batch_preset.go b/go/internal/server/domain/inventory/equipment_batch_preset.go new file mode 100644 index 0000000..a4f335e --- /dev/null +++ b/go/internal/server/domain/inventory/equipment_batch_preset.go @@ -0,0 +1,114 @@ +package inventory + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +const equipmentSlotCount = 5 + +type equipmentBatchUse struct { + CharacterIndex uint64 + Equipment []uint64 +} + +type equipmentPresetKey struct { + CharacterIndex uint64 + Slot uint64 +} + +type equipmentPresetItem struct { + Type uint64 `json:"type"` + EquipmentIndex uint64 `json:"equipment_index"` +} + +type equipmentPreset struct { + CharacterIndex uint64 `json:"character_index"` + Slot uint64 `json:"slot"` + Name string `json:"name"` + ResourceID uint64 `json:"resource_id"` + ResourceColor uint64 `json:"resource_color"` + Items []equipmentPresetItem `json:"items"` +} + +func (s *EquipmentInventory) loadEquipmentPresets(ctx command.Context, entries stateio.ScopedEntryStore) error { + raw, err := entries.ListEntries(ctx.State, "equipment", "presets") + if err != nil { + return err + } + for key, payload := range raw { + parsed, err := parseEquipmentPresetKey(key) + if err != nil { + return err + } + if err := stateio.RequireExactJSONObject(payload, "character_index", "slot", "name", "resource_id", "resource_color", "items"); err != nil { + return fmt.Errorf("player: incompatible equipment preset %q: %w", key, err) + } + var preset equipmentPreset + if err := json.Unmarshal(payload, &preset); err != nil || preset.CharacterIndex != parsed.CharacterIndex || preset.Slot != parsed.Slot { + return fmt.Errorf("player: invalid equipment preset %q", key) + } + if err := validateEquipmentPresetShape(preset); err != nil { + return fmt.Errorf("player: invalid equipment preset %q: %w", key, err) + } + s.presets[parsed] = preset + } + return nil +} + +func parseEquipmentPresetKey(value string) (equipmentPresetKey, error) { + left, right, found := strings.Cut(value, ":") + if !found { + return equipmentPresetKey{}, fmt.Errorf("player: invalid equipment preset key %q", value) + } + character, characterErr := strconv.ParseUint(left, 10, 64) + slot, slotErr := strconv.ParseUint(right, 10, 64) + if characterErr != nil || slotErr != nil || character == 0 || slot < 1 || slot > 5 || value != equipmentPresetStorageKey(equipmentPresetKey{CharacterIndex: character, Slot: slot}) { + return equipmentPresetKey{}, fmt.Errorf("player: invalid equipment preset key %q", value) + } + return equipmentPresetKey{CharacterIndex: character, Slot: slot}, nil +} + +func equipmentPresetStorageKey(key equipmentPresetKey) string { + return strconv.FormatUint(key.CharacterIndex, 10) + ":" + strconv.FormatUint(key.Slot, 10) +} + +func validateEquipmentPresetShape(preset equipmentPreset) error { + if preset.CharacterIndex == 0 || preset.Slot < 1 || preset.Slot > 5 || !utf8.ValidString(preset.Name) || + utf8.RuneCountInString(preset.Name) < 1 || utf8.RuneCountInString(preset.Name) > 16 || + preset.ResourceID < 1 || preset.ResourceID > 21 || preset.ResourceColor > 5 || len(preset.Items) != equipmentSlotCount { + return errors.New("invalid fields") + } + seen := make(map[uint64]bool, equipmentSlotCount) + for _, item := range preset.Items { + if item.Type >= equipmentSlotCount || seen[item.Type] { + return errors.New("invalid equipment slots") + } + seen[item.Type] = true + } + return nil +} + +func (s *EquipmentInventory) persistEquipmentPreset(ctx command.Context, preset equipmentPreset, operation string) error { + payload, err := json.Marshal(preset) + if err != nil { + return err + } + key := equipmentPresetKey{CharacterIndex: preset.CharacterIndex, Slot: preset.Slot} + change := stateio.EntryMutation{Bucket: "presets", Key: equipmentPresetStorageKey(key), Payload: payload} + if err := s.store.SaveWithEntries(ctx.State, "equipment", nil, []stateio.EntryMutation{change}); err != nil { + return fmt.Errorf("player: persist equipment preset %s: %w", operation, err) + } + return nil +} + +func cloneEquipmentPreset(preset equipmentPreset) equipmentPreset { + preset.Items = append([]equipmentPresetItem(nil), preset.Items...) + return preset +} diff --git a/go/internal/server/domain/inventory/equipment_craft.go b/go/internal/server/domain/inventory/equipment_craft.go new file mode 100644 index 0000000..e4e9d5a --- /dev/null +++ b/go/internal/server/domain/inventory/equipment_craft.go @@ -0,0 +1,316 @@ +package inventory + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "errors" + "fmt" + "math" + "sort" + "strconv" +) + +const equipmentBatchLimit = 100 + +type equipmentAutoBreakResult struct { + Equipment []Equipment + Result uint64 + Attempts uint64 + Consumed []Item + Lack []Item + Gold uint64 + Granted []Item +} + +func (s *EquipmentInventory) prepareEquipmentMaking(ctx command.Context, characterIndex, recipeID, count uint64, materials []Item) ([]Equipment, uint64, uint64, uint64, error) { + if s.craft == nil || s.inventory == nil || s.characters == nil || s.wallet == nil { + return nil, 0, 0, 0, errors.New("player: equipment making unavailable") + } + recipe, ok := s.craft.Recipe(recipeID) + if !ok { + return nil, 0, 0, 0, fmt.Errorf("player: unknown equipment making recipe %d", recipeID) + } + character, ok := s.characters.EquipmentCharacter(ctx, characterIndex) + if !ok { + return nil, 0, 0, 0, fmt.Errorf("player: unknown equipment making character %d", characterIndex) + } + gain, catalyst, maximum, err := s.craft.Talent(character.ID, character.TalentLevel, recipe.TalentLevel, count, character.TalentExp) + if err != nil { + return nil, 0, 0, 0, err + } + if catalyst != 0 && !s.wallet.CanSpendCatalyst(catalyst) { + return nil, 0, 0, 0, errors.New("player: insufficient catalyst for equipment making") + } + if err := ValidateMakingMaterials(recipe.Costs, count, materials); err != nil { + return nil, 0, 0, 0, err + } + if err := s.inventory.CanConsume(ctx, materials); err != nil { + return nil, 0, 0, 0, err + } + if recipe.ResultCount != 0 && count > math.MaxUint64/recipe.ResultCount { + return nil, 0, 0, 0, errors.New("player: equipment making result count overflow") + } + resultCount := count * recipe.ResultCount + generated := make([]Equipment, 0, resultCount) + for range resultCount { + rolled, err := s.craft.Generate(recipeID) + if err != nil { + return nil, 0, 0, 0, fmt.Errorf("player: roll equipment making result: %w", err) + } + entry := Equipment{ID: rolled.Design.ID, Rank: []uint64{0, 0, 0}} + for _, option := range rolled.Main { + entry.MainOption = append(entry.MainOption, EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + } + for _, option := range rolled.Sub { + entry.SubOption = append(entry.SubOption, EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + } + if rolled.Private != nil { + entry.PrivateOption = &EquipmentOption{GroupID: rolled.Private.GroupID, ID: rolled.Private.ID} + } + generated = append(generated, entry) + } + return generated, gain, catalyst, maximum, nil +} + +func ValidateMakingMaterials(costs []gamedata.PromotionCost, count uint64, materials []Item) error { + want := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + if cost.Count == 0 || count > math.MaxUint64/cost.Count { + return errors.New("player: equipment making material cost overflow") + } + key := [2]uint64{cost.Type, cost.ID} + value := cost.Count * count + if math.MaxUint64-want[key] < value { + return errors.New("player: equipment making material cost overflow") + } + want[key] += value + } + got := make(map[[2]uint64]uint64, len(materials)) + for _, item := range materials { + key := [2]uint64{item.Type, item.ID} + if math.MaxUint64-got[key] < item.Count { + return errors.New("player: equipment making material count overflow") + } + got[key] += item.Count + } + if len(got) != len(want) { + return errors.New("player: equipment making material kinds mismatch") + } + for key, count := range want { + if got[key] != count { + return fmt.Errorf("player: equipment making material %d/%d=%d want=%d", key[0], key[1], got[key], count) + } + } + return nil +} + +func (s *EquipmentInventory) grantGeneratedBatch(ctx command.Context, identityPrefix string, entries []Equipment, trackReplay bool) ([]Equipment, error) { + if len(entries) == 0 || (trackReplay && identityPrefix == "") { + return nil, errors.New("player: invalid generated equipment batch") + } + + if trackReplay { + existing := make([]Equipment, 0, len(entries)) + found := 0 + for i := range entries { + index := s.owned.Granted[identityPrefix+":"+strconv.Itoa(i)] + if index == 0 { + continue + } + found++ + position := s.equipmentPositionLocked(index) + if position < 0 { + return nil, errors.New("player: generated equipment replay index is missing") + } + existing = append(existing, cloneEquipment(s.owned.Equipment[position])) + } + if found != 0 { + if found != len(entries) { + return nil, errors.New("player: partial generated equipment replay batch") + } + return existing, nil + } + } + next := cloneEquipmentSnapshot(s.owned) + created := make([]Equipment, 0, len(entries)) + for i, source := range entries { + entry := cloneEquipment(source) + if entry.ID == 0 || len(entry.Rank) != 3 { + return nil, errors.New("player: invalid generated equipment in batch") + } + entry.InvenIndex = next.NextIndex + next.NextIndex++ + next.Equipment = append(next.Equipment, entry) + created = append(created, cloneEquipment(entry)) + if trackReplay { + next.Granted[identityPrefix+":"+strconv.Itoa(i)] = entry.InvenIndex + } + } + if err := s.commitLocked(ctx, next, "generated equipment batch"); err != nil { + return nil, err + } + return created, nil +} + +func (s *EquipmentInventory) runUpgradeToBreak(ctx command.Context, indices []uint64, target uint64, rewardIdentity string) (equipmentAutoBreakResult, error) { + + if s.upgrade == nil || s.wallet == nil || s.inventory == nil { + + return equipmentAutoBreakResult{}, errors.New("player: automatic equipment upgrade and break unavailable") + } + for _, index := range indices { + position := s.equipmentPositionLocked(index) + if position < 0 { + + return equipmentAutoBreakResult{}, fmt.Errorf("player: unknown equipment %d", index) + } + entry := s.owned.Equipment[position] + if entry.UseChar != 0 || entry.LockFlag != 0 || !s.upgrade.CanBreak(entry.ID) { + + return equipmentAutoBreakResult{}, fmt.Errorf("player: equipment %d cannot be automatically broken", index) + } + } + result := equipmentAutoBreakResult{Result: equipUpgradeStopTargetLevel} + stopUpgrades := false + for _, index := range indices { + position := s.equipmentPositionLocked(index) + entry := s.owned.Equipment[position] + maximum := s.upgrade.MaxLevel[entry.ID] + goal := min(target, maximum) + for !stopUpgrades && entry.Level < goal { + level, _, err := s.upgrade.Level(entry.ID, entry.Level) + if err != nil { + + return equipmentAutoBreakResult{}, err + } + materials, gold, err := s.selectUpgradeCosts(ctx, level.Costs) + if err != nil || (gold != 0 && !s.wallet.CanSpendGold(gold)) { + result.Result = equipUpgradeStopNotEnough + result.Lack = upgradeLackItems(level.Costs) + stopUpgrades = true + break + } + updated, _, spent, consumed, err := s.attemptUpgradeLocked(ctx, index, materials) + if err != nil { + + return equipmentAutoBreakResult{}, err + } + result.Attempts++ + result.Gold += spent + for _, item := range consumed { + if item.Type != 4 { + result.Consumed = append(result.Consumed, item) + } + } + entry = updated + if result.Attempts > 300000 { + + return equipmentAutoBreakResult{}, errors.New("player: automatic equipment upgrade exceeded safety limit") + } + } + position = s.equipmentPositionLocked(index) + result.Equipment = append(result.Equipment, cloneEquipment(s.owned.Equipment[position])) + } + rewards := make(map[[2]uint64]uint64) + positions := make([]int, 0, len(indices)) + for _, entry := range result.Equipment { + breakRewards, err := s.upgrade.BreakRewards(entry.ID, entry.Level) + if err != nil { + + return equipmentAutoBreakResult{}, err + } + if err := addBattleRewards(rewards, breakRewards); err != nil { + + return equipmentAutoBreakResult{}, err + } + positions = append(positions, s.equipmentPositionLocked(entry.InvenIndex)) + } + sort.Sort(sort.Reverse(sort.IntSlice(positions))) + next := cloneEquipmentSnapshot(s.owned) + for _, position := range positions { + next.Equipment = append(next.Equipment[:position], next.Equipment[position+1:]...) + } + if err := s.commitLocked(ctx, next, "automatic upgrade and break"); err != nil { + + return equipmentAutoBreakResult{}, err + } + + granted, err := s.inventory.GrantOnce(ctx, rewardIdentity, aggregateBattleRewards(rewards)) + if err != nil { + return equipmentAutoBreakResult{}, fmt.Errorf("player: grant automatic equipment break rewards: %w", err) + } + if granted == nil { + granted = s.inventory.GrantedItems(rewardIdentity) + } + result.Granted = granted + result.Consumed = aggregateItemCounts(result.Consumed) + result.Lack = aggregateItemCounts(result.Lack) + return result, nil +} + +func addBattleRewards(total map[[2]uint64]uint64, rewards []gamedata.BattleReward) error { + for _, reward := range rewards { + key := [2]uint64{reward.Type, reward.ID} + if reward.Count == 0 || math.MaxUint64-total[key] < reward.Count { + return errors.New("player: equipment break reward overflow") + } + total[key] += reward.Count + } + return nil +} + +func aggregateBattleRewards(values map[[2]uint64]uint64) []gamedata.BattleReward { + keys := make([][2]uint64, 0, len(values)) + for key, count := range values { + if count != 0 { + keys = append(keys, key) + } + } + sort.Slice(keys, func(i, j int) bool { + if keys[i][0] != keys[j][0] { + return keys[i][0] < keys[j][0] + } + return keys[i][1] < keys[j][1] + }) + out := make([]gamedata.BattleReward, 0, len(keys)) + for _, key := range keys { + out = append(out, gamedata.BattleReward{Type: key[0], ID: key[1], Count: values[key]}) + } + return out +} + +func aggregateItemCounts(items []Item) []Item { + values := make(map[[2]uint64]uint64) + for _, item := range items { + values[[2]uint64{item.Type, item.ID}] += item.Count + } + keys := make([][2]uint64, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i][0] != keys[j][0] { + return keys[i][0] < keys[j][0] + } + return keys[i][1] < keys[j][1] + }) + out := make([]Item, 0, len(keys)) + for _, key := range keys { + out = append(out, Item{Type: key[0], ID: key[1], Count: values[key]}) + } + return out +} + +func (s *EquipmentInventory) cachedEquipmentReply(key string) (smeltingReply, bool) { + + reply, ok := s.smeltCache[key] + if ok { + reply.body = append([]byte(nil), reply.body...) + } + return reply, ok +} + +func (s *EquipmentInventory) cacheEquipmentReply(key string, code int, body []byte) { + + s.smeltCache[key] = smeltingReply{code: code, body: append([]byte(nil), body...)} +} diff --git a/go/internal/server/domain/inventory/equipment_stats.go b/go/internal/server/domain/inventory/equipment_stats.go new file mode 100644 index 0000000..06615eb --- /dev/null +++ b/go/internal/server/domain/inventory/equipment_stats.go @@ -0,0 +1,16 @@ +package inventory + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "errors" +) + +func (s *EquipmentInventory) AttachStatDesign(ctx command.Context, design *gamedata.EquipmentStatDesign) error { + if design == nil { + return errors.New("player: nil equipment stat design") + } + + s.statDesign = design + return nil +} diff --git a/go/internal/server/player/inventory_slots.go b/go/internal/server/domain/inventory/inventory_slots.go similarity index 57% rename from go/internal/server/player/inventory_slots.go rename to go/internal/server/domain/inventory/inventory_slots.go index 68a8b3e..8c79d26 100644 --- a/go/internal/server/player/inventory_slots.go +++ b/go/internal/server/domain/inventory/inventory_slots.go @@ -1,6 +1,10 @@ -package player +package inventory import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" "bytes" "encoding/json" "errors" @@ -8,12 +12,6 @@ import ( "io" "math" "os" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" ) const developmentSettingsVersion = 1 @@ -47,25 +45,24 @@ type inventorySlotReply struct { // changes only the two backpack values advertised at login; purchased values // remain durable and become visible again when the switch is disabled. type InventorySlots struct { - mu sync.Mutex - store stateio.AtomicEntryStore + store stateio.ScopedEntryStore design *gamedata.InventorySlotDesign wallet *Wallet state inventorySlotSnapshot settingsPath string - sessionID string - replies map[string]inventorySlotReply + + replies map[string]inventorySlotReply } -func OpenInventorySlots(store stateio.Store, design *gamedata.InventorySlotDesign, initial InventorySlotCounts, wallet *Wallet) (*InventorySlots, error) { - entries, ok := store.(stateio.AtomicEntryStore) +func OpenInventorySlots(ctx command.Context, store stateio.Store, design *gamedata.InventorySlotDesign, initial InventorySlotCounts, wallet *Wallet) (*InventorySlots, error) { + entries, ok := store.(stateio.ScopedEntryStore) if !ok || design == nil || wallet == nil { return nil, errors.New("player: incomplete inventory slot configuration") } s := &InventorySlots{store: entries, design: design, wallet: wallet, state: inventorySlotSnapshot{ Version: versionconfig.State(), InventorySlotCounts: initial, }, replies: map[string]inventorySlotReply{}} - data, found, err := entries.LoadEntry("items", "slots", "capacity") + data, found, err := entries.LoadEntry(ctx.State, "items", "slots", "capacity") if err != nil { return nil, err } @@ -95,34 +92,30 @@ func (s *InventorySlots) validateCounts(counts InventorySlotCounts) error { } func (s *InventorySlots) AttachDevelopmentSettings(path string) { - s.mu.Lock() - defer s.mu.Unlock() + s.settingsPath = path } -func (s *InventorySlots) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - if id == "" || id == s.sessionID { +func (s *InventorySlots) BeginLogin(ctx command.Context) { + id := ctx.SessionID + + if id == "" { return } - s.sessionID = id s.replies = map[string]inventorySlotReply{} } -func (s *InventorySlots) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - _, found, err := s.store.LoadEntry("items", "slots", "capacity") +func (s *InventorySlots) EnsurePersisted(ctx command.Context) error { + + _, found, err := s.store.LoadEntry(ctx.State, "items", "slots", "capacity") if err != nil || found { return err } - return s.persistLocked(s.state) + return s.persistLocked(ctx, s.state) } -func (s *InventorySlots) InventorySlotCounts() (InventorySlotCounts, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *InventorySlots) InventorySlotCounts(ctx command.Context) (InventorySlotCounts, error) { + counts := s.state.InventorySlotCounts unlimited, err := loadUnlimitedInventory(s.settingsPath) if err != nil { @@ -135,8 +128,8 @@ func (s *InventorySlots) InventorySlotCounts() (InventorySlotCounts, error) { return counts, nil } -func (s *InventorySlots) UserInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error) { - counts, err := s.InventorySlotCounts() +func (s *InventorySlots) UserInventorySlots(ctx command.Context) (items, storage, equipment, equipmentStorage uint64, err error) { + counts, err := s.InventorySlotCounts(ctx) if err != nil { return 0, 0, 0, 0, err } @@ -169,76 +162,6 @@ func loadUnlimitedInventory(path string) (bool, error) { return settings.Inventory.Unlimited, nil } -func (s *InventorySlots) Handle(path string, request []byte) (int, []byte, bool, error) { - var code int - var current *uint64 - var rule gamedata.InventorySlotRule - switch path { - case "/InvenAddSlot": - code, current, rule = 24, &s.state.Items, s.design.Items - case "/StorageAddSlot": - code, current, rule = 25, &s.state.Storage, s.design.Storage - case "/EquipAddSlot": - code, current, rule = 39, &s.state.Equipment, s.design.Equipment - case "/EquipStorageAddSlot": - code, current, rule = 82, &s.state.EquipmentStorage, s.design.EquipmentStorage - default: - return 0, nil, false, nil - } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 0, nil, true, errors.New("player: inventory slot request missing sequence") - } - count, found, err := wire.Varint(request, 2) - if err != nil || !found || count == 0 { - return 0, nil, true, errors.New("player: inventory slot request invalid count") - } - s.mu.Lock() - defer s.mu.Unlock() - key := fmt.Sprintf("%s:%d", path, seq) - if prior, ok := s.replies[key]; ok { - return prior.code, append([]byte(nil), prior.body...), true, nil - } - if *current > rule.Maximum || count > rule.Maximum-*current { - return 0, nil, true, errors.New("player: inventory slot expansion exceeds GameData maximum") - } - cost, err := inventorySlotPrice(rule, *current, count) - if err != nil { - return 0, nil, true, err - } - identity := fmt.Sprintf("inventory-slot:%s:%s:%d", path, s.sessionID, seq) - var chargeErr error - switch rule.PriceType { - case 2: - _, chargeErr = s.wallet.SpendJewelryOnce(identity, cost) - case 3: - _, chargeErr = s.wallet.SpendFreeJewelryOnce(identity, cost) - case 4: - _, chargeErr = s.wallet.SpendGoldOnce(identity, cost) - default: - chargeErr = errors.New("unsupported slot currency") - } - if err := chargeErr; err != nil { - return 0, nil, true, fmt.Errorf("player: inventory slot price: %w", err) - } - next := s.state - switch path { - case "/InvenAddSlot": - next.Items += count - case "/StorageAddSlot": - next.Storage += count - case "/EquipAddSlot": - next.Equipment += count - case "/EquipStorageAddSlot": - next.EquipmentStorage += count - } - if err := s.persistLocked(next); err != nil { - return 0, nil, true, err - } - s.replies[key] = inventorySlotReply{code: code} - return code, nil, true, nil -} - func inventorySlotPrice(rule gamedata.InventorySlotRule, current, count uint64) (uint64, error) { if (rule.PriceType != 2 && rule.PriceType != 3 && rule.PriceType != 4) || count == 0 || current < rule.Default || current > rule.Maximum || count > rule.Maximum-current { return 0, errors.New("player: invalid inventory slot price request") @@ -259,12 +182,12 @@ func inventorySlotPrice(rule gamedata.InventorySlotRule, current, count uint64) return total, nil } -func (s *InventorySlots) persistLocked(next inventorySlotSnapshot) error { +func (s *InventorySlots) persistLocked(ctx command.Context, next inventorySlotSnapshot) error { data, err := json.Marshal(next) if err != nil { return err } - if err := s.store.SaveWithEntries("items", nil, []stateio.EntryMutation{{Bucket: "slots", Key: "capacity", Payload: data}}); err != nil { + if err := s.store.SaveWithEntries(ctx.State, "items", nil, []stateio.EntryMutation{{Bucket: "slots", Key: "capacity", Payload: data}}); err != nil { return fmt.Errorf("player: persist inventory slots: %w", err) } s.state = next diff --git a/go/internal/server/domain/inventory/item.go b/go/internal/server/domain/inventory/item.go new file mode 100644 index 0000000..81bf310 --- /dev/null +++ b/go/internal/server/domain/inventory/item.go @@ -0,0 +1,19 @@ +package inventory + +type Item struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Type uint64 `json:"type"` + Count uint64 `json:"count"` + KeepFlag uint64 `json:"keep_flag,omitempty"` + TimeValue uint64 `json:"time_value,omitempty"` + ExpiryTime uint64 `json:"expiry_time,omitempty"` + Pictorialbook *ItemPictorial `json:"pictorialbook,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + UseCount uint64 `json:"use_count,omitempty"` +} + +type ItemPictorial struct { + ID uint64 `json:"id"` + GroupID uint64 `json:"group_id"` +} diff --git a/go/internal/server/domain/inventory/item_stacks.go b/go/internal/server/domain/inventory/item_stacks.go new file mode 100644 index 0000000..225c5a4 --- /dev/null +++ b/go/internal/server/domain/inventory/item_stacks.go @@ -0,0 +1,91 @@ +package inventory + +import ( + "bd2server/internal/server/design/gamedata" + "errors" + "math" +) + +func (s *Inventory) AttachItemStacks(design *gamedata.ItemStackDesign) error { + if design == nil || len(design.Limits) == 0 { + return errors.New("inventory: missing item stack design") + } + s.itemStacks = design + return nil +} + +// Reward ItemDBInfo.Count is a delta: CommonPacket.AddItemInfo adds it to an +// existing instance. Inventory state holds totals; replies hold only this grant. +func (s *Inventory) addItems(next *ownedSnapshot, reward Item) ([]Item, error) { + if reward.ID == 0 || reward.Type == 0 || reward.Count == 0 || reward.Count > math.MaxInt32 { + return nil, errors.New("inventory: invalid item reward") + } + limit := uint64(math.MaxInt32) + stackable := reward.Type == 5 || reward.Type == 8 || reward.Type == 9 || reward.Type == 14 + if stackable { + if s.itemStacks == nil { + return nil, errors.New("inventory: item stack design not attached") + } + var found bool + limit, found = s.itemStacks.Limits[[2]uint64{reward.Type, reward.ID}] + if !found || limit == 0 || limit > math.MaxInt32 { + return nil, errors.New("inventory: item stack limit unavailable") + } + } + remaining := reward.Count + var deltas []Item + if stackable { + for i, current := range next.Items { + if current.Type != reward.Type || current.ID != reward.ID || current.ExpiryTime != reward.ExpiryTime || + current.KeepFlag != reward.KeepFlag || current.UseCount != reward.UseCount || current.Count >= limit || current.Pictorialbook != nil { + continue + } + count := min(limit-current.Count, remaining) + next.Items[i].Count += count + delta := current + delta.Count = count + delta.SortID = reward.SortID + deltas = append(deltas, delta) + remaining -= count + if remaining == 0 { + return deltas, nil + } + } + } + for remaining > 0 { + if next.NextIndex == math.MaxUint64 { + return nil, errors.New("inventory: item index exhausted") + } + item := reward + item.InvenIndex = next.NextIndex + item.Count = min(remaining, limit) + next.NextIndex++ + next.Items = append(next.Items, item) + deltas = append(deltas, item) + remaining -= item.Count + } + return deltas, nil +} + +func mergeRewardDeltas(items []Item) []Item { + indices := make(map[uint64]int, len(items)) + var result []Item + for _, item := range items { + if at, found := indices[item.InvenIndex]; found { + result[at].Count += item.Count + } else { + indices[item.InvenIndex] = len(result) + result = append(result, item) + } + } + return result +} + +func recordItemGrant(next *ownedSnapshot, identity string, items []Item) []Item { + items = mergeRewardDeltas(items) + next.GrantRewards[identity] = append([]Item(nil), items...) + for _, item := range items { + next.GrantItems[identity] = append(next.GrantItems[identity], item.InvenIndex) + } + return items +} diff --git a/go/internal/server/player/owned.go b/go/internal/server/domain/inventory/owned.go similarity index 58% rename from go/internal/server/player/owned.go rename to go/internal/server/domain/inventory/owned.go index e6c7920..3e80bc4 100644 --- a/go/internal/server/player/owned.go +++ b/go/internal/server/domain/inventory/owned.go @@ -1,6 +1,10 @@ -package player +package inventory import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" "encoding/json" "errors" "fmt" @@ -8,42 +12,38 @@ import ( "reflect" "sort" "strconv" - "sync" - "time" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" + "time" ) // Inventory holds player-owned rewards separately from the immutable starter // seed. GrantOnce uses a battle identity to prevent double-credit on retries. type Inventory struct { - mu sync.Mutex - store stateio.AtomicEntryStore - starter *Starter + store stateio.ScopedEntryStore + starter []Item randomBoxes *gamedata.RandomBoxDesign + itemStacks *gamedata.ItemStackDesign owned ownedSnapshot persisted ownedSnapshot corePresent bool } type ownedSnapshot struct { - Version string `json:"version"` - NextIndex uint64 `json:"next_index"` - Items []Item `json:"-"` - Granted map[string]bool `json:"-"` - GrantItems map[string][]uint64 `json:"-"` + Version string `json:"version"` + NextIndex uint64 `json:"next_index"` + Items []Item `json:"-"` + Granted map[string]bool `json:"-"` + GrantItems map[string][]uint64 `json:"-"` + GrantRewards map[string][]Item `json:"-"` } -func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) { - entries, ok := store.(stateio.AtomicEntryStore) - if starter == nil || starter.Validate() != nil || !ok { +func OpenInventory(ctx command.Context, store stateio.Store, starter []Item) (*Inventory, error) { + entries, ok := store.(stateio.ScopedEntryStore) + if !ok { return nil, errors.New("player: invalid inventory configuration") } - s := &Inventory{store: entries, starter: starter, owned: ownedSnapshot{Version: versionconfig.State(), NextIndex: 900000001, Granted: map[string]bool{}, GrantItems: map[string][]uint64{}}} - data, err := store.Load("items") + s := &Inventory{store: entries, starter: append([]Item(nil), starter...), owned: ownedSnapshot{Version: versionconfig.State(), NextIndex: 900000001, Granted: map[string]bool{}, GrantItems: map[string][]uint64{}, GrantRewards: map[string][]Item{}}} + data, err := store.Load(ctx.State, "items") if err != nil { return nil, err } @@ -64,17 +64,17 @@ func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) { if err := json.Unmarshal(data, &s.owned); err != nil { return nil, fmt.Errorf("player: decode inventory: %w", err) } - } else if err := stateio.RequireNoEntries(entries, "items", "items", "granted", "grant_items"); err != nil { + } else if err := stateio.RequireNoEntries(entries, ctx.State, "items", "items", "granted", "grant_items", "grant_rewards"); err != nil { return nil, fmt.Errorf("player: invalid inventory storage: %w", err) } if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 900000001 { return nil, errors.New("player: invalid saved inventory") } - s.owned.Granted, err = loadBoolEntries(entries, "items", "granted") + s.owned.Granted, err = loadBoolEntries(ctx, entries, "items", "granted") if err != nil { return nil, err } - rawGrantItems, err := entries.ListEntries("items", "grant_items") + rawGrantItems, err := entries.ListEntries(ctx.State, "items", "grant_items") if err != nil { return nil, err } @@ -86,7 +86,18 @@ func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) { } s.owned.GrantItems[key] = indices } - rawItems, err := entries.ListEntries("items", "items") + rawRewards, err := entries.ListEntries(ctx.State, "items", "grant_rewards") + if err != nil { + return nil, err + } + for key, value := range rawRewards { + var rewards []Item + if key == "" || !s.owned.Granted[key] || json.Unmarshal(value, &rewards) != nil { + return nil, fmt.Errorf("inventory: invalid grant reward entry %q", key) + } + s.owned.GrantRewards[key] = rewards + } + rawItems, err := entries.ListEntries(ctx.State, "items", "items") if err != nil { return nil, err } @@ -103,116 +114,34 @@ func OpenInventory(store stateio.Store, starter *Starter) (*Inventory, error) { return s, nil } -func (s *Inventory) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - data, err := s.store.Load("items") +func (s *Inventory) EnsurePersisted(ctx command.Context) error { + + data, err := s.store.Load(ctx.State, "items") if err != nil { return err } if data != nil { return nil } - return s.commitOwned(cloneOwnedSnapshot(s.owned)) -} - -func (s *Inventory) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/ItemInfo" && path != "/UseRandomBox" { - return 0, nil, false, nil - } - if path == "/UseRandomBox" { - return s.useRandomBox(request) - } - s.mu.Lock() - defer s.mu.Unlock() - items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items)) - items = append(items, s.starter.Items...) - items = append(items, s.owned.Items...) - return (&Starter{Version: versionconfig.State(), Items: items}).Handle(path, request) + return s.commitOwned(ctx, cloneOwnedSnapshot(s.owned)) } // AttachRandomBoxes installs the version-validated deterministic RandomBox // definitions. It is supplied at process startup from real GameData rather // than accepting a client-supplied reward. -func (s *Inventory) AttachRandomBoxes(design *gamedata.RandomBoxDesign) error { +func (s *Inventory) AttachRandomBoxes(ctx command.Context, design *gamedata.RandomBoxDesign) error { if s == nil || design == nil { return errors.New("player: nil random box design") } - s.mu.Lock() - defer s.mu.Unlock() + s.randomBoxes = design return nil } -func (s *Inventory) useRandomBox(request []byte) (int, []byte, bool, error) { - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 { - return 0, nil, true, errors.New("player: UseRandomBox invalid sequence") - } - index, present, err := wire.Varint(request, 2) - if err != nil || !present || index == 0 { - return 0, nil, true, errors.New("player: UseRandomBox invalid inventory index") - } - count, present, err := wire.Varint(request, 3) - if err != nil || !present || count == 0 || count > uint64(^uint32(0)>>1) { - return 0, nil, true, errors.New("player: UseRandomBox invalid use count") - } +func (s *Inventory) All(ctx command.Context) []Item { - s.mu.Lock() - defer s.mu.Unlock() - if s.randomBoxes == nil { - return 0, nil, true, errors.New("player: UseRandomBox design unavailable") - } - boxAt := -1 - for i, item := range s.owned.Items { - if item.InvenIndex == index { - boxAt = i - break - } - } - if boxAt < 0 { - return 0, nil, true, fmt.Errorf("player: UseRandomBox unknown inventory index %d", index) - } - box := s.owned.Items[boxAt] - if box.Type != 9 || box.Count < count { - return 0, nil, true, errors.New("player: UseRandomBox item or count mismatch") - } - rewards, err := s.randomBoxes.Open(box.ID, count) - if err != nil { - return 0, nil, true, err - } - next := cloneOwnedSnapshot(s.owned) - if box.Count == count { - next.Items = append(next.Items[:boxAt], next.Items[boxAt+1:]...) - } else { - next.Items[boxAt].Count -= count - } - granted := make([]Item, 0, len(rewards)) - for _, reward := range rewards { - if reward.Type == 0 || reward.ID == 0 || reward.Count == 0 { - return 0, nil, true, errors.New("player: UseRandomBox invalid GameData reward") - } - item := Item{InvenIndex: next.NextIndex, ID: reward.ID, Type: reward.Type, Count: reward.Count, TimeValue: uint64(time.Now().UnixMilli())} - next.NextIndex++ - next.Items = append(next.Items, item) - granted = append(granted, item) - } - if err := s.commitOwned(next); err != nil { - return 0, nil, true, err - } - s.owned = next - var bundle []byte - for _, item := range granted { - bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) - } - return 143, wire.AppendBytes(nil, 1, bundle), true, nil -} - -func (s *Inventory) All() []Item { - s.mu.Lock() - defer s.mu.Unlock() - items := make([]Item, 0, len(s.starter.Items)+len(s.owned.Items)) - items = append(items, s.starter.Items...) + items := make([]Item, 0, len(s.starter)+len(s.owned.Items)) + items = append(items, s.starter...) items = append(items, s.owned.Items...) return items } @@ -220,12 +149,11 @@ func (s *Inventory) All() []Item { // SelectMutable returns concrete owned stacks for a server-calculated cost. // Starter seed items are immutable and deliberately excluded, matching // Consume. Results preserve inventory order and split the final stack exactly. -func (s *Inventory) SelectMutable(itemType, id, count uint64) ([]Item, error) { +func (s *Inventory) SelectMutable(ctx command.Context, itemType, id, count uint64) ([]Item, error) { if itemType == 0 || id == 0 || count == 0 { return nil, errors.New("player: invalid mutable item selection") } - s.mu.Lock() - defer s.mu.Unlock() + remaining := count var selected []Item for _, item := range s.owned.Items { @@ -244,12 +172,11 @@ func (s *Inventory) SelectMutable(itemType, id, count uint64) ([]Item, error) { return nil, fmt.Errorf("player: insufficient mutable item %d/%d: have %d want %d", itemType, id, count-remaining, count) } -func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) ([]Item, error) { +func (s *Inventory) GrantOnce(ctx command.Context, identity string, rewards []gamedata.BattleReward) ([]Item, error) { if identity == "" { return nil, errors.New("player: missing reward identity") } - s.mu.Lock() - defer s.mu.Unlock() + if s.owned.Granted[identity] { return nil, nil } @@ -258,7 +185,7 @@ func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) // Skin rewards represent ownership; returning an existing skin would also // replay the client's acquisition UI. Include the immutable starter seed. ownedSkins := make(map[uint64]bool) - for _, items := range [][]Item{s.starter.Items, next.Items} { + for _, items := range [][]Item{s.starter, next.Items} { for _, item := range items { if item.Type == 45 && item.Count > 0 { ownedSkins[item.ID] = true @@ -275,14 +202,15 @@ func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) } ownedSkins[r.ID] = true } - item := Item{InvenIndex: next.NextIndex, ID: r.ID, Type: r.Type, Count: r.Count, TimeValue: uint64(time.Now().UnixMilli())} - next.NextIndex++ - next.Items = append(next.Items, item) - newItems = append(newItems, item) - next.GrantItems[identity] = append(next.GrantItems[identity], item.InvenIndex) + items, err := s.addItems(&next, Item{ID: r.ID, Type: r.Type, Count: r.Count, TimeValue: uint64(time.Now().UnixMilli())}) + if err != nil { + return nil, err + } + newItems = append(newItems, items...) } + newItems = recordItemGrant(&next, identity, newItems) next.Granted[identity] = true - if err := s.commitOwned(next); err != nil { + if err := s.commitOwned(ctx, next); err != nil { return nil, err } s.owned = next @@ -291,16 +219,19 @@ func (s *Inventory) GrantOnce(identity string, rewards []gamedata.BattleReward) func cloneOwnedSnapshot(current ownedSnapshot) ownedSnapshot { next := ownedSnapshot{Version: current.Version, NextIndex: current.NextIndex, - Items: append([]Item(nil), current.Items...), Granted: make(map[string]bool, len(current.Granted)+1), GrantItems: make(map[string][]uint64, len(current.GrantItems)+1)} + Items: append([]Item(nil), current.Items...), Granted: make(map[string]bool, len(current.Granted)+1), GrantItems: make(map[string][]uint64, len(current.GrantItems)+1), GrantRewards: make(map[string][]Item, len(current.GrantRewards)+1)} maps.Copy(next.Granted, current.Granted) for k, v := range current.GrantItems { next.GrantItems[k] = append([]uint64(nil), v...) } + for k, v := range current.GrantRewards { + next.GrantRewards[k] = append([]Item(nil), v...) + } return next } -// commitOwned persists a fully validated next snapshot. Caller holds s.mu. -func (s *Inventory) commitOwned(next ownedSnapshot) error { +// commitOwned persists a fully validated next snapshot. +func (s *Inventory) commitOwned(ctx command.Context, next ownedSnapshot) error { data, err := json.Marshal(next) if err != nil { return err @@ -320,6 +251,16 @@ func (s *Inventory) commitOwned(next ownedSnapshot) error { changes = append(changes, stateio.EntryMutation{Bucket: "grant_items", Key: key, Payload: payload}) } } + for key, value := range next.GrantRewards { + previous, found := s.persisted.GrantRewards[key] + if !found || !reflect.DeepEqual(value, previous) { + payload, err := json.Marshal(value) + if err != nil { + return err + } + changes = append(changes, stateio.EntryMutation{Bucket: "grant_rewards", Key: key, Payload: payload}) + } + } before := make(map[uint64]Item, len(s.persisted.Items)) for _, item := range s.persisted.Items { before[item.InvenIndex] = item @@ -341,7 +282,7 @@ func (s *Inventory) commitOwned(next ownedSnapshot) error { if s.corePresent && next.Version == s.persisted.Version && next.NextIndex == s.persisted.NextIndex { data = nil } - if err := s.store.SaveWithEntries("items", data, changes); err != nil { + if err := s.store.SaveWithEntries(ctx.State, "items", data, changes); err != nil { return err } s.corePresent = true @@ -366,8 +307,9 @@ func equalItem(a, b Item) bool { return reflect.DeepEqual(a, b) } // GrantedItems returns the stable instances created by a previous GrantOnce. // Legacy grants made before instance tracking return an empty slice. func (s *Inventory) GrantedItems(identity string) []Item { - s.mu.Lock() - defer s.mu.Unlock() + if rewards, found := s.owned.GrantRewards[identity]; found { + return append([]Item(nil), rewards...) + } indexes := s.owned.GrantItems[identity] result := make([]Item, 0, len(indexes)) for _, index := range indexes { @@ -385,19 +327,17 @@ func (s *Inventory) GrantedItems(identity string) []Item { // granted item stacks were later consumed. Cash-product purchase counts use // this durable marker rather than the current inventory contents. func (s *Inventory) WasGranted(identity string) bool { - s.mu.Lock() - defer s.mu.Unlock() + return s.owned.Granted[identity] } // Consume atomically removes the requested counts from mutable owned items. // Starter seed entries are immutable and are deliberately not accepted here. -func (s *Inventory) CanConsume(requested []Item) error { +func (s *Inventory) CanConsume(ctx command.Context, requested []Item) error { if len(requested) == 0 { return errors.New("player: no items to consume") } - s.mu.Lock() - defer s.mu.Unlock() + remaining := make(map[uint64]Item, len(s.owned.Items)) for _, item := range s.owned.Items { remaining[item.InvenIndex] = item @@ -416,25 +356,19 @@ func (s *Inventory) CanConsume(requested []Item) error { return nil } -func (s *Inventory) Consume(requested []Item) error { - _, err := s.ConsumeAndRefund(requested, nil) +func (s *Inventory) Consume(ctx command.Context, requested []Item) error { + _, err := s.ConsumeAndRefund(ctx, requested, nil) return err } // ConsumeAndRefund changes the spent stack and returned growth resources in a // single inventory save, keeping ItemInfo and RewardInfoBundle consistent. -func (s *Inventory) ConsumeAndRefund(requested []Item, refunds []gamedata.GrowthMaterial) ([]Item, error) { +func (s *Inventory) ConsumeAndRefund(ctx command.Context, requested []Item, refunds []gamedata.GrowthMaterial) ([]Item, error) { if len(requested) == 0 { return nil, errors.New("player: no items to consume") } - s.mu.Lock() - defer s.mu.Unlock() - next := ownedSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, - Items: append([]Item(nil), s.owned.Items...), Granted: make(map[string]bool, len(s.owned.Granted)), GrantItems: make(map[string][]uint64, len(s.owned.GrantItems))} - maps.Copy(next.Granted, s.owned.Granted) - for k, v := range s.owned.GrantItems { - next.GrantItems[k] = append([]uint64(nil), v...) - } + + next := cloneOwnedSnapshot(s.owned) for _, want := range requested { if want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 { return nil, errors.New("player: invalid item consumption") @@ -465,37 +399,17 @@ func (s *Inventory) ConsumeAndRefund(requested []Item, refunds []gamedata.Growth if refund.ID == 0 || refund.Count == 0 { return nil, errors.New("player: invalid growth refund") } - item := Item{InvenIndex: next.NextIndex, ID: refund.ID, Type: 8, Count: refund.Count, TimeValue: uint64(time.Now().UnixMilli())} - next.NextIndex++ - next.Items = append(next.Items, item) - granted = append(granted, item) + items, err := s.addItems(&next, Item{ID: refund.ID, Type: 8, Count: refund.Count, TimeValue: uint64(time.Now().UnixMilli())}) + if err != nil { + return nil, err + } + granted = append(granted, items...) } - if err := s.commitOwned(next); err != nil { + if err := s.commitOwned(ctx, next); err != nil { return nil, err } s.owned = next - return granted, nil + return mergeRewardDeltas(granted), nil } // ItemWire builds ItemDBInfo for RewardDBInfoBundle and ItemInfoResponse. -func ItemWire(item Item) []byte { - var b []byte - for _, f := range []struct { - n int - v uint64 - }{{1, item.InvenIndex}, {2, item.ID}, {3, item.Type}, {4, item.Count}, {5, item.KeepFlag}, {6, item.TimeValue}} { - if f.v != 0 { - b = wire.AppendVarint(b, f.n, f.v) - } - } - if item.ExpiryTime != 0 { - b = wire.AppendVarint(b, 8, item.ExpiryTime) - } - if item.SortID != 0 { - b = wire.AppendVarint(b, 9, item.SortID) - } - if item.UseCount != 0 { - b = wire.AppendVarint(b, 10, item.UseCount) - } - return b -} diff --git a/go/internal/server/domain/inventory/preset_runtime.go b/go/internal/server/domain/inventory/preset_runtime.go new file mode 100644 index 0000000..5f37b78 --- /dev/null +++ b/go/internal/server/domain/inventory/preset_runtime.go @@ -0,0 +1,67 @@ +package inventory + +import ( + "bd2server/internal/server/domain/command" + "errors" + "fmt" +) + +// PresetEquipmentBinding is one character's complete five-slot equipment +// target used by the ordinary party preset service. +type PresetEquipmentBinding struct { + CharacterIndex uint64 + Equipment []uint64 +} + +// ValidatePresetEquipment checks the complete fixed-width target without +// mutating ownership. It lets a cross-domain PresetUse fail before any +// character or deck state has been changed. +func (s *EquipmentInventory) ValidatePresetEquipment(ctx command.Context, bindings []PresetEquipmentBinding) error { + if len(bindings) == 0 { + return errors.New("player: empty preset equipment bindings") + } + seenCharacters := make(map[uint64]bool, len(bindings)) + for _, binding := range bindings { + if binding.CharacterIndex == 0 || seenCharacters[binding.CharacterIndex] || len(binding.Equipment) != equipmentSlotCount { + return errors.New("player: invalid preset equipment binding") + } + seenCharacters[binding.CharacterIndex] = true + if s.characters == nil { + return errors.New("player: preset character store unavailable") + } + if _, found := s.characters.EquipmentCharacter(ctx, binding.CharacterIndex); !found { + return fmt.Errorf("player: preset references unknown character %d", binding.CharacterIndex) + } + } + // CharacterStore.Find may calculate maximum HP, and the production stat + // calculator reads equipped items. It must run before taking this mutex or + // preset validation deadlocks by trying to re-enter inventory.EquipmentInventory. + + if len(s.slots) == 0 { + return errors.New("player: preset equipment slot design unavailable") + } + seenEquipment := make(map[uint64]bool) + for _, binding := range bindings { + for slot, index := range binding.Equipment { + if index == 0 { + continue + } + if seenEquipment[index] { + return fmt.Errorf("player: preset repeats equipment %d", index) + } + seenEquipment[index] = true + position := s.equipmentPositionLocked(index) + if position < 0 { + return fmt.Errorf("player: preset references unknown equipment %d", index) + } + item := s.owned.Equipment[position] + if designedSlot, found := s.slots[item.ID]; !found || designedSlot != uint64(slot) { + return fmt.Errorf("player: preset equipment %d does not belong in slot %d", index, slot) + } + } + } + return nil +} + +// ApplyPresetCostumes changes the currently selected costume for each listed +// character and persists both seeded and collection-owned character records. diff --git a/go/internal/server/player/content_open.go b/go/internal/server/domain/inventory/protocol_capacity_adapter.go similarity index 63% rename from go/internal/server/player/content_open.go rename to go/internal/server/domain/inventory/protocol_capacity_adapter.go index 29e74fc..d4b224c 100644 --- a/go/internal/server/player/content_open.go +++ b/go/internal/server/domain/inventory/protocol_capacity_adapter.go @@ -1,6 +1,10 @@ -package player +package inventory import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" "crypto/sha256" "encoding/binary" "encoding/hex" @@ -10,56 +14,78 @@ import ( "math" "strconv" "strings" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" ) -const contentOpenPacketCode = 622 - -type contentOpenReceipt struct { - Digest string `json:"digest"` - Body []byte `json:"body"` -} - -type contentOpenState struct { - Receipts map[string]contentOpenReceipt `json:"receipts"` -} - -// ContentOpenService grants the completion ticket for the client's sole -// implemented ContentOpen type (1). The prerequisite ticket is never spent. -// Handle must run in the account request transaction: inventory and receipt -// writes commit together, and account recovery reconstructs Inventory after -// a dirty rollback. Receipts are reloaded for every request. -type ContentOpenService struct { - mu sync.Mutex - design *gamedata.ContentOpeningDesign - inventory *Inventory - store stateio.Store - squadLevel func() (uint64, error) - session string -} - -func NewContentOpenService(design *gamedata.ContentOpeningDesign, inventory *Inventory, store stateio.Store, squadLevel func() (uint64, error)) (*ContentOpenService, error) { - if design == nil || inventory == nil || store == nil || squadLevel == nil || design.Prerequisite.TicketID == 0 || design.Completion.TicketID == 0 || design.Completion.TicketID == design.Prerequisite.TicketID { - return nil, errors.New("player: incomplete ContentOpen service") +func (s *InventorySlots) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + var code int + var current *uint64 + var rule gamedata.InventorySlotRule + switch path { + case "/InvenAddSlot": + code, current, rule = 24, &s.state.Items, s.design.Items + case "/StorageAddSlot": + code, current, rule = 25, &s.state.Storage, s.design.Storage + case "/EquipAddSlot": + code, current, rule = 39, &s.state.Equipment, s.design.Equipment + case "/EquipStorageAddSlot": + code, current, rule = 82, &s.state.EquipmentStorage, s.design.EquipmentStorage + default: + return 0, nil, false, nil } - s := &ContentOpenService{design: design, inventory: inventory, store: store, squadLevel: squadLevel} - if _, err := s.loadState(); err != nil { - return nil, err + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, errors.New("player: inventory slot request missing sequence") } - return s, nil + count, found, err := wire.Varint(request, 2) + if err != nil || !found || count == 0 { + return 0, nil, true, errors.New("player: inventory slot request invalid count") + } + + key := fmt.Sprintf("%s:%d", path, seq) + if prior, ok := s.replies[key]; ok { + return prior.code, append([]byte(nil), prior.body...), true, nil + } + if *current > rule.Maximum || count > rule.Maximum-*current { + return 0, nil, true, errors.New("player: inventory slot expansion exceeds GameData maximum") + } + cost, err := inventorySlotPrice(rule, *current, count) + if err != nil { + return 0, nil, true, err + } + identity := fmt.Sprintf("inventory-slot:%s:%s:%d", path, ctx.SessionID, seq) + var chargeErr error + switch rule.PriceType { + case 2: + _, chargeErr = s.wallet.SpendJewelryOnce(ctx, identity, cost) + case 3: + _, chargeErr = s.wallet.SpendFreeJewelryOnce(ctx, identity, cost) + case 4: + _, chargeErr = s.wallet.SpendGoldOnce(ctx, identity, cost) + default: + chargeErr = errors.New("unsupported slot currency") + } + if err := chargeErr; err != nil { + return 0, nil, true, fmt.Errorf("player: inventory slot price: %w", err) + } + next := s.state + switch path { + case "/InvenAddSlot": + next.Items += count + case "/StorageAddSlot": + next.Storage += count + case "/EquipAddSlot": + next.Equipment += count + case "/EquipStorageAddSlot": + next.EquipmentStorage += count + } + if err := s.persistLocked(ctx, next); err != nil { + return 0, nil, true, err + } + s.replies[key] = inventorySlotReply{code: code} + return code, nil, true, nil } -func (s *ContentOpenService) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - s.session = id -} - -func (s *ContentOpenService) Handle(path string, request []byte) (int, []byte, bool, error) { +func (s *ContentOpenService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { if path != "/ContentOpen" { return 0, nil, false, nil } @@ -92,16 +118,15 @@ func (s *ContentOpenService) Handle(path string, request []byte) (int, []byte, b } return 0, nil, true, err } - s.mu.Lock() - defer s.mu.Unlock() - if s.session == "" { + + if ctx.SessionID == "" { return 0, nil, true, errors.New("player: ContentOpen requires authenticated session") } - sessionHash := sha256.Sum256([]byte(s.session)) + sessionHash := sha256.Sum256([]byte(ctx.SessionID)) key := hex.EncodeToString(sessionHash[:]) + ":" + strconv.FormatUint(seq, 10) requestHash := sha256.Sum256(request) digest := hex.EncodeToString(requestHash[:]) - state, err := s.loadState() + state, err := s.loadState(ctx) if err != nil { return 0, nil, true, err } @@ -113,7 +138,7 @@ func (s *ContentOpenService) Handle(path string, request []byte) (int, []byte, b } var prerequisite, complete bool var bundle []byte - for _, item := range s.inventory.All() { + for _, item := range s.inventory.All(ctx) { if item.Type != 19 || item.Count == 0 { continue } @@ -137,7 +162,7 @@ func (s *ContentOpenService) Handle(path string, request []byte) (int, []byte, b if level < max(s.design.Prerequisite.SquadLevel, s.design.Completion.SquadLevel) { return 0, nil, true, errors.New("player: ContentOpen squad level is too low") } - items, err := s.inventory.GrantOnce("content-open:"+strconv.FormatUint(s.design.Completion.TicketID, 10), []gamedata.BattleReward{{Type: 19, ID: s.design.Completion.TicketID, Count: 1}}) + items, err := s.inventory.GrantOnce(ctx, "content-open:"+strconv.FormatUint(s.design.Completion.TicketID, 10), []gamedata.BattleReward{{Type: 19, ID: s.design.Completion.TicketID, Count: 1}}) if err != nil { return 0, nil, true, fmt.Errorf("player: grant ContentOpen ticket: %w", err) } @@ -154,15 +179,26 @@ func (s *ContentOpenService) Handle(path string, request []byte) (int, []byte, b if err != nil { return 0, nil, true, err } - if err := s.store.Save("content_open", data); err != nil { + if err := s.store.Save(ctx.State, "content_open", data); err != nil { return 0, nil, true, fmt.Errorf("player: persist ContentOpen receipt: %w", err) } return contentOpenPacketCode, append([]byte(nil), body...), true, nil } -func (s *ContentOpenService) loadState() (contentOpenState, error) { +func NewContentOpenService(ctx command.Context, design *gamedata.ContentOpeningDesign, inventory *Inventory, store stateio.Store, squadLevel func() (uint64, error)) (*ContentOpenService, error) { + if design == nil || inventory == nil || store == nil || squadLevel == nil || design.Prerequisite.TicketID == 0 || design.Completion.TicketID == 0 || design.Completion.TicketID == design.Prerequisite.TicketID { + return nil, errors.New("player: incomplete ContentOpen service") + } + s := &ContentOpenService{design: design, inventory: inventory, store: store, squadLevel: squadLevel} + if _, err := s.loadState(ctx); err != nil { + return nil, err + } + return s, nil +} + +func (s *ContentOpenService) loadState(ctx command.Context) (contentOpenState, error) { state := contentOpenState{Receipts: make(map[string]contentOpenReceipt)} - data, err := s.store.Load("content_open") + data, err := s.store.Load(ctx.State, "content_open") if err != nil || data == nil { return state, err } diff --git a/go/internal/server/domain/inventory/protocol_equipment_adapter.go b/go/internal/server/domain/inventory/protocol_equipment_adapter.go new file mode 100644 index 0000000..a88fb7f --- /dev/null +++ b/go/internal/server/domain/inventory/protocol_equipment_adapter.go @@ -0,0 +1,1773 @@ +package inventory + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/ownership" + "bd2server/internal/server/protocol/wire" + "encoding/binary" + "errors" + "fmt" + "maps" + "math" + "slices" + "sort" + "strconv" + "strings" + "unicode/utf8" +) + +func EquipmentWire(v Equipment) []byte { + out := ownership.Equipment{InvenIndex: v.InvenIndex, ID: v.ID, Level: v.Level, UseChar: v.UseChar, KeepFlag: v.KeepFlag, LockFlag: v.LockFlag, SortID: v.SortID, Mark: v.Mark, Rank: v.Rank} + for _, o := range v.MainOption { + out.MainOption = append(out.MainOption, ownership.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + } + for _, o := range v.SubOption { + out.SubOption = append(out.SubOption, ownership.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) + } + if v.PrivateOption != nil { + out.PrivateOption = &ownership.EquipmentOption{GroupID: v.PrivateOption.GroupID, ID: v.PrivateOption.ID} + } + return ownership.EncodeEquipment(out) +} + +func equipmentOptionWire(v EquipmentOption) []byte { + return ownership.EncodeEquipmentOption(ownership.EquipmentOption{GroupID: v.GroupID, ID: v.ID}) +} + +func (s *EquipmentInventory) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipBatchUse" && path != "/EquipPresetInfo" && path != "/EquipPresetSave" && path != "/EquipPresetNameChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipSmelting" && path != "/EquipSequenceSmelting" && path != "/EquipOptionReRoll" && path != "/EquipOptionReRollConfirm" && path != "/EquipMainOptChange" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" && path != "/EquipMaking" && path != "/EquipBreak" && path != "/EquipMakingToBreakAuto" && path != "/EquipUpgradeToBreakAuto" { + return 0, nil, false, nil + } + if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path) + } + if path == "/EquipUse" { + return s.use(ctx, request) + } + if path == "/EquipClear" { + return s.clear(ctx, request) + } + if path == "/EquipBatchUse" { + return s.batchUse(ctx, request) + } + if path == "/EquipPresetInfo" { + return s.presetInfo() + } + if path == "/EquipPresetSave" { + return s.presetSave(ctx, request) + } + if path == "/EquipPresetNameChange" { + return s.presetNameChange(ctx, request) + } + if path == "/EquipUpgrade" { + return s.upgradeOnce(ctx, request) + } + if path == "/EquipSequenceUpgrade" { + return s.upgradeSequence(ctx, request) + } + if path == "/EquipMaking" { + return s.makeEquipment(ctx, request) + } + if path == "/EquipBreak" { + return s.breakEquipment(ctx, request) + } + if path == "/EquipMakingToBreakAuto" { + return s.makeToBreakAuto(ctx, request) + } + if path == "/EquipUpgradeToBreakAuto" { + return s.upgradeToBreakAuto(ctx, request) + } + if path == "/EquipSmelting" { + return s.smeltOnce(ctx, request) + } + if path == "/EquipSequenceSmelting" { + return s.smeltSequence(ctx, request) + } + if path == "/EquipOptionReRoll" { + return s.optionRerollRequest(ctx, request) + } + if path == "/EquipOptionReRollConfirm" { + return s.optionRerollConfirm(ctx, request) + } + if path == "/EquipMainOptChange" { + return s.mainOptionChange(ctx, request) + } + if path == "/EquipChange" { + return s.change(ctx, request) + } + if path == "/EquipMarkSet" || path == "/EquipMarkDelete" { + return s.mark(ctx, path, request) + } + if path == "/EquipLock" { + return s.lock(ctx, request) + } + + var response []byte + for _, entry := range s.owned.Equipment { + response = wire.AppendBytes(response, 1, EquipmentWire(entry)) + } + if s.pendingReroll != nil { + response = wire.AppendBytes(response, 2, EquipmentWire(s.pendingReroll.Equipment)) + } + return 34, response, true, nil +} + +// ApplyPresetEquipment applies the same authoritative state transition as +// /EquipBatchUse without fabricating a second wire protocol implementation. +func (s *EquipmentInventory) ApplyPresetEquipment(ctx command.Context, bindings []PresetEquipmentBinding) ([]EquipmentCharacter, error) { + if err := s.ValidatePresetEquipment(ctx, bindings); err != nil { + return nil, err + } + // PresetUse is a server-side atomic operation. Unlike the client-orchestrated + // EquipPreset UI, it must also clear any previous owner of an equipment + // instance in the same batch. + + requested := make(map[uint64]bool, len(bindings)) + desired := make(map[uint64]bool) + for _, binding := range bindings { + requested[binding.CharacterIndex] = true + for _, index := range binding.Equipment { + desired[index] = index != 0 + } + } + additional := make(map[uint64][]uint64) + for _, item := range s.owned.Equipment { + if item.UseChar == 0 || requested[item.UseChar] || !desired[item.InvenIndex] { + continue + } + if additional[item.UseChar] == nil { + additional[item.UseChar] = make([]uint64, equipmentSlotCount) + for _, equipped := range s.owned.Equipment { + if equipped.UseChar == item.UseChar { + if slot, ok := s.slots[equipped.ID]; ok && slot < equipmentSlotCount { + additional[item.UseChar][slot] = equipped.InvenIndex + } + } + } + } + if slot, ok := s.slots[item.ID]; ok && slot < equipmentSlotCount { + additional[item.UseChar][slot] = 0 + } + } + + for character, equipment := range additional { + bindings = append(bindings, PresetEquipmentBinding{CharacterIndex: character, Equipment: equipment}) + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].CharacterIndex < bindings[j].CharacterIndex }) + request := wire.AppendVarint(nil, 1, 1) + for _, binding := range bindings { + entry := wire.AppendVarint(nil, 1, binding.CharacterIndex) + for _, index := range binding.Equipment { + entry = wire.AppendVarint(entry, 2, index) + } + request = wire.AppendBytes(request, 2, entry) + } + _, response, handled, err := s.batchUse(ctx, request) + if err != nil { + return nil, err + } + if !handled { + return nil, errors.New("player: preset equipment batch was not handled") + } + var result []EquipmentCharacter + if err := wire.Walk(response, func(field wire.Field) error { + if field.Number != 1 || field.Type != 2 { + return nil + } + index, found, err := wire.Varint(field.Value, 1) + if err != nil || !found || index == 0 { + return errors.New("player: malformed preset equipment character response") + } + character, found := s.characters.EquipmentCharacter(ctx, index) + if !found { + return fmt.Errorf("player: preset equipment character %d disappeared", index) + } + result = append(result, character) + return nil + }); err != nil { + return nil, err + } + return result, nil +} + +// StatContributions resolves only maximum-health equipment effects. Snapshot +// options under the equipment lock and release it before calculating; callers +// may themselves be the CharacterStore maximum-health callback. +func (s *EquipmentInventory) StatContributions(ctx command.Context, characterIndex uint64) ([]gamedata.StatContribution, error) { + if characterIndex == 0 { + return nil, errors.New("player: invalid equipment stat character") + } + type query struct { + option gamedata.EquipmentOption + sub bool + } + var queries []query + + design := s.statDesign + for _, equipment := range s.owned.Equipment { + if equipment.UseChar != characterIndex { + continue + } + if equipment.Level > math.MaxInt32 { + + return nil, fmt.Errorf("player: invalid equipment %d level", equipment.InvenIndex) + } + var rank [3]int + for i, value := range equipment.Rank { + if i >= 3 || value > 4 { + + return nil, fmt.Errorf("player: invalid equipment %d ranks", equipment.InvenIndex) + } + rank[i] = int(value) + } + add := func(option EquipmentOption, sub bool) { + if option.ID == 1 || option.ID == 2 { + queries = append(queries, query{gamedata.EquipmentOption{GroupID: option.GroupID, ID: option.ID, Level: int(equipment.Level), Rank: rank}, sub}) + } + } + for _, option := range equipment.MainOption { + add(option, false) + } + for _, option := range equipment.SubOption { + add(option, true) + } + if equipment.PrivateOption != nil { + add(*equipment.PrivateOption, false) + } + } + + if len(queries) == 0 { + return nil, nil + } + if design == nil { + return nil, errors.New("player: equipment stat design unavailable") + } + result := make([]gamedata.StatContribution, 0, len(queries)) + for _, query := range queries { + contribution, err := design.HealthContribution(query.option, query.sub) + if err != nil { + return nil, err + } + result = append(result, contribution) + } + return result, nil +} + +func (s *EquipmentInventory) makeEquipment(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + characterIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: EquipMaking missing character") + } + recipeID, found, err := wire.Varint(request, 3) + if err != nil || !found || recipeID == 0 { + return 0, nil, true, errors.New("player: EquipMaking missing recipe") + } + count, found, err := wire.Varint(request, 4) + if err != nil || !found || count == 0 || count > 1000 { + return 0, nil, true, errors.New("player: EquipMaking invalid count") + } + materials, err := DecodeItemRequest(request, 5, "EquipMaking") + if err != nil { + return 0, nil, true, err + } + cacheKey := s.smeltingCacheKey(ctx, "making", seq) + if response, ok := s.cachedEquipmentReply(cacheKey); ok { + return response.code, response.body, true, nil + } + generated, gain, catalyst, maximum, err := s.prepareEquipmentMaking(ctx, characterIndex, recipeID, count, materials) + if err != nil { + return 0, nil, true, err + } + if err := s.inventory.Consume(ctx, materials); err != nil { + return 0, nil, true, fmt.Errorf("player: consume equipment making materials: %w", err) + } + if catalyst != 0 { + identity := "equip-making-catalyst:" + ctx.SessionID + ":" + strconv.FormatUint(seq, 10) + if _, err := s.wallet.SpendCatalystOnce(ctx, identity, catalyst); err != nil { + return 0, nil, true, fmt.Errorf("player: spend equipment making catalyst: %w", err) + } + } + created, err := s.grantGeneratedBatch(ctx, "equip-making:"+ctx.SessionID+":"+strconv.FormatUint(seq, 10), generated, true) + if err != nil { + return 0, nil, true, fmt.Errorf("player: persist made equipment: %w", err) + } + if gain > 0 { + if _, err := s.characters.AddEquipmentTalentExperience(ctx, characterIndex, gain, maximum); err != nil { + return 0, nil, true, err + } + } + var response []byte + for _, entry := range created { + response = wire.AppendBytes(response, 1, EquipmentWire(entry)) + } + if gain != 0 { + response = wire.AppendVarint(response, 2, gain) + } + s.cacheEquipmentReply(cacheKey, 50, response) + return 50, response, true, nil +} + +func (s *EquipmentInventory) breakEquipment(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + indices, err := repeatedEquipmentIndices(request, 2, equipmentBatchLimit) + if err != nil { + return 0, nil, true, err + } + cacheKey := s.smeltingCacheKey(ctx, "break", seq) + if response, ok := s.cachedEquipmentReply(cacheKey); ok { + return response.code, response.body, true, nil + } + + if s.upgrade == nil || s.inventory == nil { + + return 0, nil, true, errors.New("player: equipment break unavailable") + } + rewards := make(map[[2]uint64]uint64) + positions := make([]int, 0, len(indices)) + for _, index := range indices { + position := s.equipmentPositionLocked(index) + if position < 0 { + + return 0, nil, true, fmt.Errorf("player: EquipBreak unknown equipment %d", index) + } + entry := s.owned.Equipment[position] + if entry.UseChar != 0 || entry.LockFlag != 0 || !s.upgrade.CanBreak(entry.ID) { + + return 0, nil, true, fmt.Errorf("player: equipment %d cannot be broken", index) + } + result, err := s.upgrade.BreakRewards(entry.ID, entry.Level) + if err != nil { + + return 0, nil, true, err + } + if err := addBattleRewards(rewards, result); err != nil { + + return 0, nil, true, err + } + positions = append(positions, position) + } + sort.Sort(sort.Reverse(sort.IntSlice(positions))) + next := cloneEquipmentSnapshot(s.owned) + for _, position := range positions { + next.Equipment = append(next.Equipment[:position], next.Equipment[position+1:]...) + } + if err := s.commitLocked(ctx, next, "break"); err != nil { + + return 0, nil, true, err + } + + identity := "equip-break:" + ctx.SessionID + ":" + strconv.FormatUint(seq, 10) + granted, err := s.inventory.GrantOnce(ctx, identity, aggregateBattleRewards(rewards)) + if err != nil { + return 0, nil, true, fmt.Errorf("player: grant equipment break rewards: %w", err) + } + if granted == nil { + granted = s.inventory.GrantedItems(identity) + } + response := wire.AppendBytes(nil, 1, rewardItemBundle(granted)) + s.cacheEquipmentReply(cacheKey, 56, response) + return 56, response, true, nil +} + +func (s *EquipmentInventory) upgradeToBreakAuto(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + indices, err := repeatedEquipmentIndices(request, 2, equipmentBatchLimit) + if err != nil { + return 0, nil, true, err + } + target, _, err := wire.Varint(request, 3) + if err != nil { + return 0, nil, true, errors.New("player: EquipUpgradeToBreakAuto invalid target") + } + cacheKey := s.smeltingCacheKey(ctx, "upgrade-break-auto", seq) + if response, ok := s.cachedEquipmentReply(cacheKey); ok { + return response.code, response.body, true, nil + } + result, err := s.runUpgradeToBreak(ctx, indices, target, "equip-upgrade-break:"+ctx.SessionID+":"+strconv.FormatUint(seq, 10)) + if err != nil { + return 0, nil, true, err + } + response := encodeUpgradeBreakResponse(result, 1, 2, 3, 4, 5, 6, 7) + s.cacheEquipmentReply(cacheKey, 516, response) + return 516, response, true, nil +} + +func (s *EquipmentInventory) makeToBreakAuto(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + characterIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: EquipMakingToBreakAuto missing character") + } + recipeID, found, err := wire.Varint(request, 3) + if err != nil || !found || recipeID == 0 { + return 0, nil, true, errors.New("player: EquipMakingToBreakAuto missing recipe") + } + count, found, err := wire.Varint(request, 4) + if err != nil || !found || count == 0 || count > equipmentBatchLimit { + return 0, nil, true, errors.New("player: EquipMakingToBreakAuto invalid count") + } + materials, err := DecodeItemRequest(request, 5, "EquipMakingToBreakAuto") + if err != nil { + return 0, nil, true, err + } + target, _, err := wire.Varint(request, 6) + if err != nil { + return 0, nil, true, errors.New("player: EquipMakingToBreakAuto invalid target") + } + cacheKey := s.smeltingCacheKey(ctx, "making-break-auto", seq) + if response, ok := s.cachedEquipmentReply(cacheKey); ok { + return response.code, response.body, true, nil + } + generated, gain, catalyst, maximum, err := s.prepareEquipmentMaking(ctx, characterIndex, recipeID, count, materials) + if err != nil { + return 0, nil, true, err + } + if err := s.inventory.Consume(ctx, materials); err != nil { + return 0, nil, true, fmt.Errorf("player: consume equipment making materials: %w", err) + } + if catalyst != 0 { + identity := "equip-making-break-catalyst:" + ctx.SessionID + ":" + strconv.FormatUint(seq, 10) + if _, err := s.wallet.SpendCatalystOnce(ctx, identity, catalyst); err != nil { + return 0, nil, true, fmt.Errorf("player: spend automatic equipment making catalyst: %w", err) + } + } + created, err := s.grantGeneratedBatch(ctx, "", generated, false) + if err != nil { + return 0, nil, true, fmt.Errorf("player: persist auto-break made equipment: %w", err) + } + indices := make([]uint64, 0, len(created)) + for _, entry := range created { + indices = append(indices, entry.InvenIndex) + } + if gain > 0 { + if _, err := s.characters.AddEquipmentTalentExperience(ctx, characterIndex, gain, maximum); err != nil { + return 0, nil, true, err + } + } + result, err := s.runUpgradeToBreak(ctx, indices, target, "equip-making-break-reward:"+ctx.SessionID+":"+strconv.FormatUint(seq, 10)) + if err != nil { + return 0, nil, true, err + } + var response []byte + if gain != 0 { + response = wire.AppendVarint(response, 1, gain) + } + response = append(response, encodeUpgradeBreakResponse(result, 2, 3, 4, 5, 6, 7, 8)...) + s.cacheEquipmentReply(cacheKey, 515, response) + return 515, response, true, nil +} + +func encodeUpgradeBreakResponse(result equipmentAutoBreakResult, equipmentField, resultField, attemptsField, consumedField, lackField, goldField, rewardsField int) []byte { + var response []byte + for _, entry := range result.Equipment { + response = wire.AppendBytes(response, equipmentField, EquipmentWire(entry)) + } + if result.Result != 0 { + response = wire.AppendVarint(response, resultField, result.Result) + } + if result.Attempts != 0 { + response = wire.AppendVarint(response, attemptsField, result.Attempts) + } + for _, item := range result.Consumed { + response = wire.AppendBytes(response, consumedField, ItemWire(item)) + } + for _, item := range result.Lack { + response = wire.AppendBytes(response, lackField, ItemWire(item)) + } + if result.Gold != 0 { + response = wire.AppendVarint(response, goldField, result.Gold) + } + response = wire.AppendBytes(response, rewardsField, rewardItemBundle(result.Granted)) + return response +} + +func repeatedEquipmentIndices(request []byte, number, maximum int) ([]uint64, error) { + var result []uint64 + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != number { + return nil + } + values, err := decodeRepeatedUint64(field) + if err != nil { + return err + } + result = append(result, values...) + return nil + }) + if err != nil || len(result) == 0 || len(result) > maximum { + return nil, errors.New("player: invalid equipment index batch") + } + seen := make(map[uint64]bool, len(result)) + for _, index := range result { + if index == 0 || seen[index] { + return nil, errors.New("player: invalid or duplicate equipment index") + } + seen[index] = true + } + return result, nil +} + +func rewardItemBundle(items []Item) []byte { + var bundle []byte + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) + } + return bundle +} + +func (s *EquipmentInventory) batchUse(ctx command.Context, request []byte) (int, []byte, bool, error) { + entries, err := decodeEquipmentBatchUse(request) + if err != nil { + return 0, nil, true, err + } + if s.characters == nil { + return 0, nil, true, errors.New("player: EquipBatchUse character store unavailable") + } + requestedCharacters := make(map[uint64]bool, len(entries)) + for _, entry := range entries { + if requestedCharacters[entry.CharacterIndex] { + return 0, nil, true, fmt.Errorf("player: EquipBatchUse repeats character %d", entry.CharacterIndex) + } + requestedCharacters[entry.CharacterIndex] = true + if _, found := s.characters.EquipmentCharacter(ctx, entry.CharacterIndex); !found { + return 0, nil, true, fmt.Errorf("player: EquipBatchUse unknown character %d", entry.CharacterIndex) + } + } + + if len(s.slots) == 0 { + + return 0, nil, true, errors.New("player: EquipBatchUse slot design unavailable") + } + desired := make(map[uint64]uint64) + for _, entry := range entries { + for slot, index := range entry.Equipment { + if index == 0 { + continue + } + if desired[index] != 0 { + + return 0, nil, true, fmt.Errorf("player: EquipBatchUse repeats equipment %d", index) + } + position := s.equipmentPositionLocked(index) + if position < 0 { + + return 0, nil, true, fmt.Errorf("player: EquipBatchUse unknown equipment %d", index) + } + item := s.owned.Equipment[position] + if designedSlot, ok := s.slots[item.ID]; !ok || designedSlot != uint64(slot) { + + return 0, nil, true, fmt.Errorf("player: EquipBatchUse equipment %d does not belong in slot %d", index, slot) + } + if item.UseChar != 0 && item.UseChar != entry.CharacterIndex && !requestedCharacters[item.UseChar] { + + return 0, nil, true, fmt.Errorf("player: EquipBatchUse equipment %d is still used by character %d", index, item.UseChar) + } + desired[index] = entry.CharacterIndex + } + } + + next := cloneEquipmentSnapshot(s.owned) + affected := make(map[uint64]bool, len(entries)) + for character := range requestedCharacters { + affected[character] = true + } + for i := range next.Equipment { + item := &next.Equipment[i] + if requestedCharacters[item.UseChar] { + item.UseChar = 0 + } + } + for i := range next.Equipment { + item := &next.Equipment[i] + character := desired[item.InvenIndex] + if character == 0 { + continue + } + if item.UseChar != 0 && item.UseChar != character { + affected[item.UseChar] = true + } + item.UseChar = character + } + if err := s.commitLocked(ctx, next, "batch use"); err != nil { + + return 0, nil, true, err + } + + characterIndices := make([]uint64, 0, len(affected)) + for index := range affected { + characterIndices = append(characterIndices, index) + } + slices.Sort(characterIndices) + var response []byte + for _, index := range characterIndices { + if character, found := s.characters.EquipmentCharacter(ctx, index); found { + response = wire.AppendBytes(response, 1, character.Response) + } + } + return 276, response, true, nil +} + +func decodeEquipmentBatchUse(request []byte) ([]equipmentBatchUse, error) { + var result []equipmentBatchUse + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + if field.Type != 2 { + return errors.New("player: EquipBatchUse invalid batch entry") + } + entry := equipmentBatchUse{} + var characterSeen bool + if err := wire.Walk(field.Value, func(nested wire.Field) error { + switch nested.Number { + case 1: + if characterSeen || nested.Type != 0 { + return errors.New("player: EquipBatchUse invalid character") + } + entry.CharacterIndex, _ = binary.Uvarint(nested.Value) + characterSeen = true + case 2: + values, err := decodeRepeatedUint64(nested) + if err != nil { + return errors.New("player: EquipBatchUse invalid equipment list") + } + entry.Equipment = append(entry.Equipment, values...) + } + return nil + }); err != nil { + return err + } + if !characterSeen || entry.CharacterIndex == 0 || len(entry.Equipment) != equipmentSlotCount { + return errors.New("player: EquipBatchUse requires one character and five equipment slots") + } + result = append(result, entry) + return nil + }) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, errors.New("player: EquipBatchUse has no batch entries") + } + return result, nil +} + +func decodeRepeatedUint64(field wire.Field) ([]uint64, error) { + switch field.Type { + case 0: + value, count := binary.Uvarint(field.Value) + if count <= 0 { + return nil, wire.ErrMalformed + } + return []uint64{value}, nil + case 2: + var result []uint64 + for offset := 0; offset < len(field.Value); { + value, count := binary.Uvarint(field.Value[offset:]) + if count <= 0 { + return nil, wire.ErrMalformed + } + result = append(result, value) + offset += count + } + return result, nil + default: + return nil, wire.ErrMalformed + } +} + +func (s *EquipmentInventory) presetInfo() (int, []byte, bool, error) { + + presets := make([]equipmentPreset, 0, len(s.presets)) + for _, preset := range s.presets { + presets = append(presets, cloneEquipmentPreset(preset)) + } + + sort.Slice(presets, func(i, j int) bool { + if presets[i].CharacterIndex != presets[j].CharacterIndex { + return presets[i].CharacterIndex < presets[j].CharacterIndex + } + return presets[i].Slot < presets[j].Slot + }) + var response []byte + for i := 0; i < len(presets); { + character := presets[i].CharacterIndex + var encoded []byte + encoded = wire.AppendVarint(encoded, 1, character) + for i < len(presets) && presets[i].CharacterIndex == character { + encoded = wire.AppendBytes(encoded, 2, equipmentPresetWire(presets[i])) + i++ + } + response = wire.AppendBytes(response, 1, encoded) + } + return 253, response, true, nil +} + +func equipmentPresetWire(preset equipmentPreset) []byte { + var out []byte + if preset.Name != "" { + out = wire.AppendString(out, 1, preset.Name) + } + out = wire.AppendVarint(out, 2, preset.Slot) + if preset.ResourceID != 0 { + out = wire.AppendVarint(out, 3, preset.ResourceID) + } + if preset.ResourceColor != 0 { + out = wire.AppendVarint(out, 4, preset.ResourceColor) + } + for _, item := range preset.Items { + var encoded []byte + if item.Type != 0 { + encoded = wire.AppendVarint(encoded, 1, item.Type) + } + if item.EquipmentIndex != 0 { + encoded = wire.AppendVarint(encoded, 2, item.EquipmentIndex) + } + out = wire.AppendBytes(out, 5, encoded) + } + return out +} + +func (s *EquipmentInventory) presetSave(ctx command.Context, request []byte) (int, []byte, bool, error) { + preset, err := decodeEquipmentPresetRequest(request, true) + if err != nil { + return 0, nil, true, err + } + if s.characters == nil { + return 0, nil, true, errors.New("player: EquipPresetSave character store unavailable") + } + if _, found := s.characters.EquipmentCharacter(ctx, preset.CharacterIndex); !found { + return 0, nil, true, fmt.Errorf("player: EquipPresetSave unknown character %d", preset.CharacterIndex) + } + + if len(s.slots) == 0 { + return 0, nil, true, errors.New("player: EquipPresetSave slot design unavailable") + } + for _, item := range preset.Items { + if item.EquipmentIndex == 0 { + continue + } + position := s.equipmentPositionLocked(item.EquipmentIndex) + if position < 0 { + return 0, nil, true, fmt.Errorf("player: EquipPresetSave unknown equipment %d", item.EquipmentIndex) + } + equipment := s.owned.Equipment[position] + if s.slots[equipment.ID] != item.Type { + return 0, nil, true, fmt.Errorf("player: EquipPresetSave equipment %d does not match character slot", item.EquipmentIndex) + } + } + if err := s.persistEquipmentPreset(ctx, preset, "save"); err != nil { + return 0, nil, true, err + } + s.presets[equipmentPresetKey{CharacterIndex: preset.CharacterIndex, Slot: preset.Slot}] = cloneEquipmentPreset(preset) + return 254, nil, true, nil +} + +func (s *EquipmentInventory) presetNameChange(ctx command.Context, request []byte) (int, []byte, bool, error) { + metadata, err := decodeEquipmentPresetRequest(request, false) + if err != nil { + return 0, nil, true, err + } + key := equipmentPresetKey{CharacterIndex: metadata.CharacterIndex, Slot: metadata.Slot} + if s.characters == nil { + return 0, nil, true, errors.New("player: EquipPresetNameChange character store unavailable") + } + if _, found := s.characters.EquipmentCharacter(ctx, metadata.CharacterIndex); !found { + return 0, nil, true, fmt.Errorf("player: EquipPresetNameChange unknown character %d", metadata.CharacterIndex) + } + + preset, found := s.presets[key] + if !found { + preset = metadata + } + preset.Name = metadata.Name + preset.ResourceID = metadata.ResourceID + preset.ResourceColor = metadata.ResourceColor + if err := s.persistEquipmentPreset(ctx, preset, "name change"); err != nil { + return 0, nil, true, err + } + s.presets[key] = cloneEquipmentPreset(preset) + return 259, nil, true, nil +} + +func decodeEquipmentPresetRequest(request []byte, withItems bool) (equipmentPreset, error) { + character, found, err := wire.Varint(request, 2) + if err != nil || !found || character == 0 { + return equipmentPreset{}, errors.New("player: equipment preset request missing character") + } + slot, found, err := wire.Varint(request, 3) + if err != nil || !found || slot < 1 || slot > 5 { + return equipmentPreset{}, errors.New("player: equipment preset request invalid slot") + } + nameBytes, _, err := wire.Bytes(request, 4) + if err != nil || !utf8.Valid(nameBytes) { + return equipmentPreset{}, errors.New("player: equipment preset request invalid name") + } + resourceID, _, err := wire.Varint(request, 5) + if err != nil || resourceID < 1 || resourceID > 21 { + return equipmentPreset{}, errors.New("player: equipment preset request invalid resource") + } + color, _, err := wire.Varint(request, 6) + if err != nil || color > 5 { + return equipmentPreset{}, errors.New("player: equipment preset request invalid color") + } + preset := equipmentPreset{CharacterIndex: character, Slot: slot, Name: string(nameBytes), ResourceID: resourceID, ResourceColor: color} + if withItems { + err = wire.Walk(request, func(field wire.Field) error { + if field.Number != 7 { + return nil + } + if field.Type != 2 { + return errors.New("player: EquipPresetSave invalid equipment entry") + } + typeID, _, err := wire.Varint(field.Value, 1) + if err != nil { + return err + } + index, _, err := wire.Varint(field.Value, 2) + if err != nil { + return err + } + preset.Items = append(preset.Items, equipmentPresetItem{Type: typeID, EquipmentIndex: index}) + return nil + }) + if err != nil { + return equipmentPreset{}, err + } + } else { + preset.Items = make([]equipmentPresetItem, equipmentSlotCount) + for i := range preset.Items { + preset.Items[i].Type = uint64(i) + } + } + if err := validateEquipmentPresetShape(preset); err != nil { + return equipmentPreset{}, fmt.Errorf("player: equipment preset request: %w", err) + } + sort.Slice(preset.Items, func(i, j int) bool { return preset.Items[i].Type < preset.Items[j].Type }) + return preset, nil +} + +func (s *EquipmentInventory) optionRerollRequest(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipOptionReRoll missing equipment") + } + mainLocks, err := repeatedBoolField(request, 3, "EquipOptionReRoll main lock") + if err != nil { + return 0, nil, true, err + } + subLocks, err := repeatedBoolField(request, 4, "EquipOptionReRoll sub lock") + if err != nil { + return 0, nil, true, err + } + materials, err := DecodeItemRequest(request, 5, "EquipOptionReRoll") + if err != nil { + return 0, nil, true, err + } + rerollType, _, err := wire.Varint(request, 6) + if err != nil || rerollType > 1 { + return 0, nil, true, errors.New("player: EquipOptionReRoll unsupported reroll type") + } + + cacheKey := s.smeltingCacheKey(ctx, "option-reroll", seq) + if reply, ok := s.smeltCache[cacheKey]; ok { + return reply.code, append([]byte(nil), reply.body...), true, nil + } + if s.optionReroll == nil || s.wallet == nil || s.inventory == nil { + return 0, nil, true, errors.New("player: equipment option reroll unavailable") + } + position := s.equipmentPositionLocked(index) + if position < 0 { + return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll unknown equipment %d", index) + } + current := s.owned.Equipment[position] + base := current + if s.pendingReroll != nil { + if s.pendingReroll.Equipment.InvenIndex != index { + return 0, nil, true, errors.New("player: another equipment option reroll is awaiting confirmation") + } + // A retry from the result screen carries only lock masks. The values + // being locked are therefore the last candidate, not the still-official + // equipment options. Keep chaining candidates until confirm/keep clears + // the pending result. + base = s.pendingReroll.Equipment + } + definition, ok := s.optionReroll.Lookup(current.ID) + if !ok { + return 0, nil, true, fmt.Errorf("player: equipment %d has no option reroll design", current.ID) + } + if len(mainLocks) != len(base.MainOption) || len(mainLocks) != len(definition.MainGroups) || + len(subLocks) != len(base.SubOption) || len(subLocks) != len(definition.SubGroups) { + return 0, nil, true, errors.New("player: EquipOptionReRoll lock arrays do not match equipment options") + } + effectiveMainLocks := append([]bool(nil), mainLocks...) + if rerollType == 1 && len(effectiveMainLocks) != 0 { + // The client's has-another-option mode asks the server to leave the + // first main option alone. It is not a paid lock and remains false in + // the request mask. + effectiveMainLocks[0] = true + } + lockedCount, unlockedRerollable := uint64(0), uint64(0) + for i, locked := range mainLocks { + if base.MainOption[i].GroupID != definition.MainGroups[i] || base.MainOption[i].ID == 0 { + return 0, nil, true, fmt.Errorf("player: equipment %d main option %d does not match GameData", index, i) + } + canReroll := len(s.optionReroll.Groups[definition.MainGroups[i]].Choices) >= 2 + if locked && (i == 0 || !canReroll) { + return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll main option %d cannot be locked", i) + } + if locked { + lockedCount++ + } else if canReroll && !(rerollType == 1 && i == 0) { //nolint:staticcheck // QF1001 + unlockedRerollable++ + } + } + for i, locked := range subLocks { + if base.SubOption[i].GroupID != definition.SubGroups[i] || base.SubOption[i].ID == 0 { + return 0, nil, true, fmt.Errorf("player: equipment %d sub option %d does not match GameData", index, i) + } + canReroll := len(s.optionReroll.Groups[definition.SubGroups[i]].Choices) >= 2 + if locked && !canReroll { + return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll sub option %d cannot be locked", i) + } + if locked { + lockedCount++ + } else if canReroll { + unlockedRerollable++ + } + } + if unlockedRerollable == 0 { + return 0, nil, true, errors.New("player: EquipOptionReRoll must leave a rerollable option unlocked") + } + costs, err := s.optionReroll.Cost(current.ID, lockedCount) + if err != nil { + return 0, nil, true, err + } + gold, consumed, err := validateOptionRerollMaterials(costs, materials, s.optionReroll.Conversion) + if err != nil { + return 0, nil, true, err + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for equipment option reroll") + } + if len(consumed) != 0 { + if err := s.inventory.CanConsume(ctx, consumed); err != nil { + return 0, nil, true, err + } + } + privateLocks := make([]bool, len(definition.PrivateGroups)) + for i := range privateLocks { + privateLocks[i] = true + } + rolled, err := s.optionReroll.RollUnlocked(current.ID, gamedata.EquipmentOptionRerollLocks{Main: effectiveMainLocks, Sub: subLocks, Private: privateLocks}) + if err != nil { + return 0, nil, true, err + } + candidate := cloneEquipment(base) + for i := range candidate.MainOption { + if !effectiveMainLocks[i] { + candidate.MainOption[i] = EquipmentOption{GroupID: rolled.Main[i].GroupID, ID: rolled.Main[i].ID} + } + } + for i := range candidate.SubOption { + if !subLocks[i] { + candidate.SubOption[i] = EquipmentOption{GroupID: rolled.Sub[i].GroupID, ID: rolled.Sub[i].ID} + } + } + pending := &equipmentOptionRerollPending{Equipment: candidate} + if err := s.commitOptionRerollLocked(ctx, pending, consumed, gold, "equip-option-reroll:"+cacheKey); err != nil { + return 0, nil, true, err + } + var response []byte + for _, option := range candidate.MainOption { + response = wire.AppendBytes(response, 1, equipmentOptionWire(option)) + } + for _, option := range candidate.SubOption { + response = wire.AppendBytes(response, 2, equipmentOptionWire(option)) + } + s.smeltCache[cacheKey] = smeltingReply{code: 192, body: append([]byte(nil), response...)} + return 192, response, true, nil +} + +func (s *EquipmentInventory) optionRerollConfirm(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipOptionReRollConfirm missing equipment") + } + confirm, err := optionalBoolField(request, 3, "EquipOptionReRollConfirm confirm") + if err != nil { + return 0, nil, true, err + } + + cacheKey := s.smeltingCacheKey(ctx, "option-reroll-confirm", seq) + if reply, ok := s.smeltCache[cacheKey]; ok { + + return reply.code, append([]byte(nil), reply.body...), true, nil + } + if s.pendingReroll == nil || s.pendingReroll.Equipment.InvenIndex != index { + + return 0, nil, true, fmt.Errorf("player: equipment %d has no option reroll awaiting confirmation", index) + } + position := s.equipmentPositionLocked(index) + if position < 0 { + + return 0, nil, true, fmt.Errorf("player: EquipOptionReRollConfirm unknown equipment %d", index) + } + next := cloneEquipmentSnapshot(s.owned) + if confirm { + next.Equipment[position].MainOption = append([]EquipmentOption(nil), s.pendingReroll.Equipment.MainOption...) + next.Equipment[position].SubOption = append([]EquipmentOption(nil), s.pendingReroll.Equipment.SubOption...) + } + if err := s.persistEquipmentState(ctx, next, nil, true, "option reroll confirm"); err != nil { + + return 0, nil, true, err + } + s.owned = next + s.persisted = cloneEquipmentSnapshot(next) + s.pendingReroll = nil + entry := cloneEquipment(next.Equipment[position]) + + response := wire.AppendBytes(nil, 1, EquipmentWire(entry)) + if character, ok := s.equippedCharacter(ctx, entry); ok { + response = wire.AppendBytes(response, 2, character.Response) + } + + s.smeltCache[cacheKey] = smeltingReply{code: 193, body: append([]byte(nil), response...)} + + return 193, response, true, nil +} + +func (s *EquipmentInventory) mainOptionChange(ctx command.Context, request []byte) (int, []byte, bool, error) { + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipMainOptChange missing equipment") + } + groupID, groupFound, err := wire.Varint(request, 3) + if err != nil || !groupFound || groupID == 0 { + return 0, nil, true, errors.New("player: EquipMainOptChange missing option group") + } + optionID, optionFound, err := wire.Varint(request, 4) + if err != nil || !optionFound || optionID == 0 { + return 0, nil, true, errors.New("player: EquipMainOptChange missing option") + } + + if s.optionReroll == nil { + return 0, nil, true, errors.New("player: equipment option design unavailable") + } + position := s.equipmentPositionLocked(index) + if position < 0 { + return 0, nil, true, fmt.Errorf("player: EquipMainOptChange unknown equipment %d", index) + } + current := s.owned.Equipment[position] + definition, ok := s.optionReroll.Lookup(current.ID) + if !ok || definition.PrivateUniqueCharID == 0 || len(definition.MainGroups) == 0 || len(current.MainOption) == 0 { + return 0, nil, true, fmt.Errorf("player: equipment %d has no changeable main option", current.ID) + } + group, ok := s.optionReroll.Groups[groupID] + if !ok || groupID != definition.MainGroups[0] || current.MainOption[0].GroupID != groupID || len(group.Choices) < 2 || !optionChoiceExists(group, optionID) { + return 0, nil, true, fmt.Errorf("player: equipment %d main option %d/%d is not allowed", current.ID, groupID, optionID) + } + + next := cloneEquipmentSnapshot(s.owned) + next.Equipment[position].MainOption[0] = EquipmentOption{GroupID: groupID, ID: optionID} + pending := s.pendingReroll + pendingDirty := false + if pending != nil && pending.Equipment.InvenIndex == index { + copy := &equipmentOptionRerollPending{Equipment: cloneEquipment(pending.Equipment)} + if len(copy.Equipment.MainOption) == 0 || copy.Equipment.MainOption[0].GroupID != groupID { + return 0, nil, true, errors.New("player: option reroll candidate has no matching main option") + } + copy.Equipment.MainOption[0] = EquipmentOption{GroupID: groupID, ID: optionID} + pending = copy + pendingDirty = true + } + if err := s.persistEquipmentState(ctx, next, pending, pendingDirty, "main option change"); err != nil { + return 0, nil, true, err + } + s.owned = next + s.persisted = cloneEquipmentSnapshot(next) + if pendingDirty { + s.pendingReroll = pending + } + // EquipmentCharacter health reads account equipment through pictorial ownership. + // Release the equipment lock before resolving the equipped character. + + var response []byte + if character, ok := s.equippedCharacter(ctx, current); ok { + response = wire.AppendBytes(response, 1, character.Response) + } + return 537, response, true, nil +} + +func repeatedBoolField(data []byte, number int, name string) ([]bool, error) { + var result []bool + err := wire.Walk(data, func(field wire.Field) error { + if field.Number != number { + return nil + } + switch field.Type { + case 0: + value, count := binary.Uvarint(field.Value) + if count <= 0 || value > 1 { + return fmt.Errorf("player: %s is invalid", name) + } + result = append(result, value == 1) + case 2: + for offset := 0; offset < len(field.Value); { + value, count := binary.Uvarint(field.Value[offset:]) + if count <= 0 || value > 1 { + return fmt.Errorf("player: %s is invalid", name) + } + result = append(result, value == 1) + offset += count + } + default: + return fmt.Errorf("player: %s is invalid", name) + } + return nil + }) + return result, err +} + +func optionalBoolField(data []byte, number int, name string) (bool, error) { + value := false + seen := false + err := wire.Walk(data, func(field wire.Field) error { + if field.Number != number { + return nil + } + if seen || field.Type != 0 { + return fmt.Errorf("player: %s is invalid", name) + } + raw, count := binary.Uvarint(field.Value) + if count <= 0 || raw > 1 { + return fmt.Errorf("player: %s is invalid", name) + } + seen = true + value = raw == 1 + return nil + }) + return value, err +} + +func (s *EquipmentInventory) upgradeOnce(ctx command.Context, request []byte) (int, []byte, bool, error) { + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipUpgrade missing equipment") + } + var materials []Item + err = wire.Walk(request, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return errors.New("player: EquipUpgrade invalid material") + } + var item Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) { + return errors.New("player: EquipUpgrade invalid material") + } + materials = append(materials, item) + return nil + }) + if err != nil { + return 0, nil, true, err + } + if len(materials) == 0 { + return 0, nil, true, errors.New("player: EquipUpgrade has no material") + } + + entry, success, _, _, err := s.attemptUpgradeLocked(ctx, index, materials) + + if err != nil { + return 0, nil, true, err + } + result := uint64(equipUpgradeFail) + if success { + result = equipUpgradeSuccess + } + response := wire.AppendBytes(nil, 1, EquipmentWire(entry)) + if result != 0 { + response = wire.AppendVarint(response, 2, result) + } + if character, ok := s.equippedCharacter(ctx, entry); ok { + response = wire.AppendBytes(response, 3, character.Response) + } + return 37, response, true, nil +} + +func (s *EquipmentInventory) upgradeSequence(ctx command.Context, request []byte) (int, []byte, bool, error) { + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipSequenceUpgrade missing equipment") + } + count, found, err := wire.Varint(request, 3) + if err != nil || !found || count == 0 || count > 100000 { + return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid attempt count") + } + goldLimit, _, err := wire.Varint(request, 5) + if err != nil { + return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid gold limit") + } + target, _, err := wire.Varint(request, 6) + if err != nil { + return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid target") + } + + if s.upgrade == nil || s.wallet == nil { + + return 0, nil, true, errors.New("player: equipment upgrade unavailable") + } + position := s.equipmentPositionLocked(index) + if position < 0 { + + return 0, nil, true, fmt.Errorf("player: EquipSequenceUpgrade unknown equipment %d", index) + } + maximum := s.upgrade.MaxLevel[s.owned.Equipment[position].ID] + if target == 0 || target > maximum { + target = maximum + } + var attempts, usedGold uint64 + result := uint64(equipUpgradeStopMaxTryCount) + var consumed []Item + var lack []Item + for attempts < count { + entry := s.owned.Equipment[position] + if entry.Level >= maximum { + result = equipUpgradeStopMaxLevel + break + } + if entry.Level >= target { + result = equipUpgradeStopTargetLevel + break + } + level, _, designErr := s.upgrade.Level(entry.ID, entry.Level) + if designErr != nil { + + return 0, nil, true, designErr + } + materials, gold, costErr := s.selectUpgradeCosts(ctx, level.Costs) + if costErr != nil { + result = equipUpgradeStopNotEnough + lack = upgradeLackItems(level.Costs) + break + } + if goldLimit != 0 && usedGold+gold > goldLimit { + result = equipUpgradeStopGoldLimit + break + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + result = equipUpgradeStopNotEnough + lack = upgradeLackItems(level.Costs) + break + } + updated, success, spent, actual, attemptErr := s.attemptUpgradeLocked(ctx, index, materials) + if attemptErr != nil { + + return 0, nil, true, attemptErr + } + attempts++ + usedGold += spent + consumed = append(consumed, actual...) + position = s.equipmentPositionLocked(index) + if success && updated.Level >= target { + result = equipUpgradeStopTargetLevel + if updated.Level >= maximum { + result = equipUpgradeStopMaxLevel + } + break + } + } + entry := s.owned.Equipment[position] + + response := wire.AppendBytes(nil, 1, EquipmentWire(entry)) + if character, ok := s.equippedCharacter(ctx, entry); ok { + response = wire.AppendBytes(response, 2, character.Response) + } + response = wire.AppendVarint(response, 3, result) + response = wire.AppendVarint(response, 4, attempts) + for _, item := range consumed { + response = wire.AppendBytes(response, 5, ItemWire(item)) + } + for _, item := range lack { + response = wire.AppendBytes(response, 6, ItemWire(item)) + } + if usedGold != 0 { + response = wire.AppendVarint(response, 7, usedGold) + } + return 176, response, true, nil +} + +func (s *EquipmentInventory) smeltOnce(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipSmelting missing equipment") + } + materials, err := DecodeItemRequest(request, 3, "EquipSmelting") + if err != nil { + return 0, nil, true, err + } + + cacheKey := s.smeltingCacheKey(ctx, "single", seq) + if reply, ok := s.smeltCache[cacheKey]; ok { + return reply.code, append([]byte(nil), reply.body...), true, nil + } + position, current, err := s.smeltingEquipmentLocked(index) + if err != nil { + return 0, nil, true, err + } + costs, err := s.smelting.Cost(current.ID) + if err != nil { + return 0, nil, true, err + } + gold, _, err := validateSmeltingMaterials(costs, materials) + if err != nil { + return 0, nil, true, err + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for equipment smelting") + } + var itemMaterials []Item + var consumedMileageMaterial uint64 + for _, item := range materials { + if item.Type != 4 { + itemMaterials = append(itemMaterials, item) + } + if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID { + consumedMileageMaterial += item.Count + } + } + if len(itemMaterials) != 0 { + if err := s.inventory.CanConsume(ctx, itemMaterials); err != nil { + return 0, nil, true, err + } + } + candidate, err := s.smelting.RollCandidate(current.ID) + if err != nil { + return 0, nil, true, err + } + currentScore, err := s.smelting.Score(current.ID, current.Rank) + if err != nil { + return 0, nil, true, err + } + candidateScore, err := s.smelting.Score(current.ID, candidate) + if err != nil { + return 0, nil, true, err + } + success := candidateScore > currentScore + next := cloneEquipmentSnapshot(s.owned) + if success { + next.Equipment[position].Rank = append([]uint64(nil), candidate...) + } + currency, earned, err := s.commitSmeltingLocked(ctx, next, itemMaterials, gold, consumedMileageMaterial, + "equip-smelting:"+cacheKey, "smelting") + if err != nil { + return 0, nil, true, err + } + current = next.Equipment[position] + response := wire.AppendBytes(nil, 1, EquipmentWire(current)) + + if character, ok := s.equippedCharacter(ctx, current); ok { + response = wire.AppendBytes(response, 2, character.Response) + } + if !success { + response = wire.AppendVarint(response, 3, equipUpgradeFail) + for _, rank := range candidate { + response = wire.AppendVarint(response, 4, rank) + } + } + response = appendSmeltingMileage(response, 5, 6, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned) + + s.smeltCache[cacheKey] = smeltingReply{code: 105, body: append([]byte(nil), response...)} + + return 105, response, true, nil +} + +func (s *EquipmentInventory) smeltSequence(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, _, _ := wire.Varint(request, 1) + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: EquipSequenceSmelting missing equipment") + } + count, found, err := wire.Varint(request, 3) + if err != nil || !found || count == 0 { + return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid attempt count") + } + target, _, err := wire.Varint(request, 4) + if err != nil { + return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid target score") + } + + cacheKey := s.smeltingCacheKey(ctx, "sequence", seq) + if reply, ok := s.smeltCache[cacheKey]; ok { + return reply.code, append([]byte(nil), reply.body...), true, nil + } + position, current, err := s.smeltingEquipmentLocked(index) + if err != nil { + return 0, nil, true, err + } + if count > s.smelting.MaxStreak { + return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting attempt count %d exceeds %d", count, s.smelting.MaxStreak) + } + maximumRanks, err := s.smelting.MaximumRanks(current.ID) + if err != nil { + return 0, nil, true, err + } + maximumScore, err := s.smelting.Score(current.ID, maximumRanks) + if err != nil { + return 0, nil, true, err + } + if target > maximumScore { + return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting target %d exceeds %d", target, maximumScore) + } + costs, err := s.smelting.Cost(current.ID) + if err != nil { + return 0, nil, true, err + } + currentScore, err := s.smelting.Score(current.ID, current.Rank) + if err != nil { + return 0, nil, true, err + } + // The 2.35.10 client splits a requested sequence into packets of at most + // 1000 attempts. Exhausting this packet is not the user's global max-try + // stop: UPGRADE_SUCCESS tells the client to send the next chunk. Terminal + // stop values are reserved for target/max score and insufficient resources. + result := uint64(equipUpgradeSuccess) + var attempts, successes uint64 + if currentScore >= maximumScore { + result = equipUpgradeStopMaxLevel + } else if target != 0 && currentScore >= target { + result = equipUpgradeStopTargetLevel + } + for attempts < count && result == equipUpgradeSuccess { + if _, _, selectErr := s.selectSmeltingCosts(ctx, costs, attempts+1); selectErr != nil { + result = equipUpgradeStopNotEnough + break + } + candidate, rollErr := s.smelting.RollCandidate(current.ID) + if rollErr != nil { + return 0, nil, true, rollErr + } + candidateScore, scoreErr := s.smelting.Score(current.ID, candidate) + if scoreErr != nil { + return 0, nil, true, scoreErr + } + attempts++ + if candidateScore > currentScore { + current.Rank = append([]uint64(nil), candidate...) + currentScore = candidateScore + successes++ + } + if currentScore >= maximumScore { + result = equipUpgradeStopMaxLevel + } else if target != 0 && currentScore >= target { + result = equipUpgradeStopTargetLevel + } + } + var consumed, lack []Item + var gold, mileageMaterial, earned uint64 + currency := s.wallet.Snapshot(ctx) + if attempts != 0 { + consumed, gold, err = s.selectSmeltingCosts(ctx, costs, attempts) + if err != nil { + return 0, nil, true, err + } + for _, item := range consumed { + if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID { + mileageMaterial += item.Count + } + } + var itemMaterials []Item + for _, item := range consumed { + if item.Type != 4 { + itemMaterials = append(itemMaterials, item) + } + } + next := cloneEquipmentSnapshot(s.owned) + next.Equipment[position].Rank = append([]uint64(nil), current.Rank...) + currency, earned, err = s.commitSmeltingLocked(ctx, next, itemMaterials, gold, mileageMaterial, + "equip-sequence-smelting:"+cacheKey, "sequence smelting") + if err != nil { + return 0, nil, true, err + } + current = next.Equipment[position] + } + // NotEnough is used only when the next requested attempt could not be + // funded. Exhausting this packet retains Success so the client can continue + // a sequence whose total requested count exceeds the 1000-attempt chunk. + if result == equipUpgradeStopNotEnough { + lack = upgradeLackItems(costs) + } + response := wire.AppendBytes(nil, 1, EquipmentWire(current)) + + if character, ok := s.equippedCharacter(ctx, current); ok { + response = wire.AppendBytes(response, 2, character.Response) + } + response = wire.AppendVarint(response, 3, result) + response = wire.AppendVarint(response, 4, attempts) + for _, item := range consumed { + response = wire.AppendBytes(response, 5, ItemWire(item)) + } + for _, item := range lack { + response = wire.AppendBytes(response, 6, ItemWire(item)) + } + response = appendSmeltingMileage(response, 7, 8, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned) + if successes != 0 { + response = wire.AppendVarint(response, 9, successes) + } + + s.smeltCache[cacheKey] = smeltingReply{code: 177, body: append([]byte(nil), response...)} + + return 177, response, true, nil +} + +func appendSmeltingMileage(response []byte, gaugeField, rewardField int, gauge uint64, mileage gamedata.EquipmentSmeltingMileage, earned uint64) []byte { + if gauge != 0 { + response = wire.AppendVarint(response, gaugeField, gauge) + } + if earned != 0 { + reward := ItemWire(Item{ID: mileage.RewardID, Type: mileage.RewardType, Count: earned}) + bundle := wire.AppendBytes(nil, 1, reward) + response = wire.AppendBytes(response, rewardField, bundle) + } + return response +} + +func (s *EquipmentInventory) clear(ctx command.Context, request []byte) (int, []byte, bool, error) { + equipmentIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || equipmentIndex == 0 { + return 0, nil, true, errors.New("player: EquipClear missing equipment") + } + characterIndex, found, err := wire.Varint(request, 3) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: EquipClear missing character") + } + if s.characters == nil { + return 0, nil, true, errors.New("player: EquipClear character store unavailable") + } + if _, found := s.characters.EquipmentCharacter(ctx, characterIndex); !found { + return 0, nil, true, fmt.Errorf("player: EquipClear unknown character %d", characterIndex) + } + + next := cloneEquipmentSnapshot(s.owned) + position := -1 + for i := range next.Equipment { + if next.Equipment[i].InvenIndex == equipmentIndex { + position = i + break + } + } + if position < 0 { + + return 0, nil, true, fmt.Errorf("player: EquipClear unknown equipment %d", equipmentIndex) + } + if next.Equipment[position].UseChar != characterIndex { + + return 0, nil, true, fmt.Errorf("player: EquipClear equipment %d is not used by character %d", equipmentIndex, characterIndex) + } + next.Equipment[position].UseChar = 0 + if err := s.commitLocked(ctx, next, "clear"); err != nil { + + return 0, nil, true, err + } + + // Re-query after the equipment mutation so the shared stat calculator + // returns HP with the cleared item excluded. + character, found := s.characters.EquipmentCharacter(ctx, characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: EquipClear character %d disappeared", characterIndex) + } + return 36, wire.AppendBytes(nil, 1, character.Response), true, nil +} + +func (s *EquipmentInventory) lock(ctx command.Context, request []byte) (int, []byte, bool, error) { + equipmentIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || equipmentIndex == 0 { + return 0, nil, true, errors.New("player: EquipLock missing equipment") + } + // LockFlag is int32, but proto3 omits its zero value. A missing field 3 is + // therefore the normal unlock request; present values are restricted to 1. + lockFlag, present, err := wire.Varint(request, 3) + if err != nil || (present && lockFlag != 1) { + return 0, nil, true, errors.New("player: EquipLock invalid lock flag") + } + if !present { + lockFlag = 0 + } + + next := cloneEquipmentSnapshot(s.owned) + position := -1 + for i := range next.Equipment { + if next.Equipment[i].InvenIndex == equipmentIndex { + position = i + break + } + } + if position < 0 { + return 0, nil, true, fmt.Errorf("player: EquipLock unknown equipment %d", equipmentIndex) + } + next.Equipment[position].LockFlag = lockFlag + if err := s.commitLocked(ctx, next, "lock"); err != nil { + return 0, nil, true, err + } + return 38, nil, true, nil +} + +func (s *EquipmentInventory) mark(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + equipmentIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || equipmentIndex == 0 { + return 0, nil, true, fmt.Errorf("player: %s missing equipment", strings.TrimPrefix(path, "/")) + } + mark := "" + packetCode := 397 + if path == "/EquipMarkSet" { + raw, present, fieldErr := wire.Bytes(request, 3) + if fieldErr != nil || !present || !validEquipmentMark(raw) { + return 0, nil, true, errors.New("player: EquipMarkSet invalid mark") + } + mark = string(raw) + packetCode = 396 + } + + next := cloneEquipmentSnapshot(s.owned) + position := -1 + for i := range next.Equipment { + if next.Equipment[i].InvenIndex == equipmentIndex { + position = i + break + } + } + if position < 0 { + return 0, nil, true, fmt.Errorf("player: %s unknown equipment %d", strings.TrimPrefix(path, "/"), equipmentIndex) + } + next.Equipment[position].Mark = mark + if err := s.commitLocked(ctx, next, strings.TrimPrefix(path, "/")); err != nil { + return 0, nil, true, err + } + return packetCode, nil, true, nil +} + +func (s *EquipmentInventory) change(ctx command.Context, request []byte) (int, []byte, bool, error) { + equipmentIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || equipmentIndex == 0 { + return 0, nil, true, errors.New("player: EquipChange missing equipment") + } + characterIndex, found, err := wire.Varint(request, 3) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: EquipChange missing character") + } + if s.characters == nil { + return 0, nil, true, errors.New("player: EquipChange character store unavailable") + } + if _, found := s.characters.EquipmentCharacter(ctx, characterIndex); !found { + return 0, nil, true, fmt.Errorf("player: EquipChange unknown character %d", characterIndex) + } + + if len(s.slots) == 0 { + + return 0, nil, true, errors.New("player: EquipChange slot design unavailable") + } + next := cloneEquipmentSnapshot(s.owned) + position := -1 + for i := range next.Equipment { + if next.Equipment[i].InvenIndex == equipmentIndex { + position = i + break + } + } + if position < 0 { + + return 0, nil, true, fmt.Errorf("player: EquipChange unknown equipment %d", equipmentIndex) + } + selected := next.Equipment[position] + slot, exists := s.slots[selected.ID] + if !exists { + + return 0, nil, true, fmt.Errorf("player: EquipChange equipment design %d not found", selected.ID) + } + if selected.UseChar != 0 && selected.UseChar != characterIndex { + + return 0, nil, true, fmt.Errorf("player: EquipChange equipment %d belongs to another character", equipmentIndex) + } + replaced := false + for i := range next.Equipment { + current := &next.Equipment[i] + if current.InvenIndex == equipmentIndex || current.UseChar != characterIndex { + continue + } + currentSlot, known := s.slots[current.ID] + if !known { + + return 0, nil, true, fmt.Errorf("player: EquipChange equipped design %d not found", current.ID) + } + if currentSlot == slot { + current.UseChar = 0 + replaced = true + } + } + if !replaced { + + return 0, nil, true, fmt.Errorf("player: EquipChange character %d has no equipment in slot %d", characterIndex, slot) + } + next.Equipment[position].UseChar = characterIndex + if err := s.commitLocked(ctx, next, "change"); err != nil { + + return 0, nil, true, err + } + + // Max HP depends on the now-current equipment set, so build CharInfo only + // after the atomic equipment save is visible to the shared stat calculator. + character, found := s.characters.EquipmentCharacter(ctx, characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: EquipChange character %d disappeared", characterIndex) + } + // PacketCodeTypeProto orders EquipChange at 45 (EquipInfo is 34). + return 45, wire.AppendBytes(nil, 1, character.Response), true, nil +} + +func (s *EquipmentInventory) use(ctx command.Context, request []byte) (int, []byte, bool, error) { + equipmentIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || equipmentIndex == 0 { + return 0, nil, true, errors.New("player: EquipUse missing equipment") + } + characterIndex, found, err := wire.Varint(request, 3) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: EquipUse missing character") + } + if s.characters == nil { + return 0, nil, true, errors.New("player: EquipUse character store unavailable") + } + // Find computes account buffs and may query EquipmentInventory.All(). Do + // not hold the equipment mutex while resolving the character's stats. + character, found := s.characters.EquipmentCharacter(ctx, characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: EquipUse unknown character %d", characterIndex) + } + + next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, + Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted))} + maps.Copy(next.Granted, s.owned.Granted) + position := -1 + for i, current := range next.Equipment { + if current.InvenIndex == equipmentIndex { + position = i + break + } + } + if position < 0 { + return 0, nil, true, fmt.Errorf("player: EquipUse unknown equipment %d", equipmentIndex) + } + next.Equipment[position].UseChar = characterIndex + if err := s.commitLocked(ctx, next, "use"); err != nil { + return 0, nil, true, fmt.Errorf("player: persist equipment use: %w", err) + } + return 35, wire.AppendBytes(nil, 1, character.Response), true, nil +} diff --git a/go/internal/server/domain/inventory/protocol_items_adapter.go b/go/internal/server/domain/inventory/protocol_items_adapter.go new file mode 100644 index 0000000..e2ca280 --- /dev/null +++ b/go/internal/server/domain/inventory/protocol_items_adapter.go @@ -0,0 +1,182 @@ +package inventory + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/ownership" + "bd2server/internal/server/protocol/wire" + "encoding/binary" + "errors" + "fmt" + "math" + "slices" + "time" +) + +func ItemWire(v Item) []byte { + return ownership.EncodeItem(ownership.Item{InvenIndex: v.InvenIndex, ID: v.ID, Type: v.Type, Count: v.Count, KeepFlag: v.KeepFlag, TimeValue: v.TimeValue, ExpiryTime: v.ExpiryTime, SortID: v.SortID, UseCount: v.UseCount}) +} + +func (s *RecipeService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/RecipeInfo" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, errInvalidRecipeRequest + } + known := map[uint64]bool{} + for _, id := range s.initial { + known[id] = true + } + for _, item := range s.items.All(ctx) { + // EElementType.CookingRecipe is protocol value 7. Ownership comes from + // seed state or real reward grants, never from every row in CookingTable. + if item.Type != 7 || item.Count == 0 { + continue + } + if !s.design.IDs[item.ID] { + return 0, nil, true, fmt.Errorf("recipes: owned recipe %d is absent from GameData", item.ID) + } + known[item.ID] = true + } + ids := make([]uint64, 0, len(known)) + for id := range known { + ids = append(ids, id) + } + slices.Sort(ids) + var packed []byte + for _, id := range ids { + packed = binary.AppendUvarint(packed, id) + } + if len(packed) == 0 { + return 46, nil, true, nil + } + return 46, wire.AppendBytes(nil, 2, packed), true, nil +} + +func (s *Inventory) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/ItemInfo" && path != "/UseRandomBox" { + return 0, nil, false, nil + } + if path == "/UseRandomBox" { + return s.useRandomBox(ctx, request) + } + + items := make([]Item, 0, len(s.starter)+len(s.owned.Items)) + items = append(items, s.starter...) + items = append(items, s.owned.Items...) + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path) + } + var response []byte + for _, item := range items { + response = wire.AppendBytes(response, 1, ItemWire(item)) + } + return 21, response, true, nil +} + +func decodeVarints(proto []byte, fields map[int]*uint64) error { + return wire.Walk(proto, func(field wire.Field) error { + value, known := fields[field.Number] + if !known { + return fmt.Errorf("player: unsupported starter field %d", field.Number) + } + if field.Type != 0 { + return fmt.Errorf("player: starter field %d wire type %d", field.Number, field.Type) + } + *value, _ = binary.Uvarint(field.Value) + return nil + }) +} + +func (s *Inventory) useRandomBox(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, errors.New("player: UseRandomBox invalid sequence") + } + index, present, err := wire.Varint(request, 2) + if err != nil || !present || index == 0 { + return 0, nil, true, errors.New("player: UseRandomBox invalid inventory index") + } + count, present, err := wire.Varint(request, 3) + if err != nil || !present || count == 0 || count > uint64(^uint32(0)>>1) { + return 0, nil, true, errors.New("player: UseRandomBox invalid use count") + } + + if s.randomBoxes == nil { + return 0, nil, true, errors.New("player: UseRandomBox design unavailable") + } + boxAt := -1 + for i, item := range s.owned.Items { + if item.InvenIndex == index { + boxAt = i + break + } + } + if boxAt < 0 { + return 0, nil, true, fmt.Errorf("player: UseRandomBox unknown inventory index %d", index) + } + box := s.owned.Items[boxAt] + if box.Type != 9 || box.Count < count { + return 0, nil, true, errors.New("player: UseRandomBox item or count mismatch") + } + rewards, err := s.randomBoxes.Open(box.ID, count) + if err != nil { + return 0, nil, true, err + } + next := cloneOwnedSnapshot(s.owned) + if box.Count == count { + next.Items = append(next.Items[:boxAt], next.Items[boxAt+1:]...) + } else { + next.Items[boxAt].Count -= count + } + granted := make([]Item, 0, len(rewards)) + for _, reward := range rewards { + if reward.Type == 0 || reward.ID == 0 || reward.Count == 0 { + return 0, nil, true, errors.New("player: UseRandomBox invalid GameData reward") + } + items, err := s.addItems(&next, Item{ID: reward.ID, Type: reward.Type, Count: reward.Count, TimeValue: uint64(time.Now().UnixMilli())}) + if err != nil { + return 0, nil, true, err + } + granted = append(granted, items...) + } + if err := s.commitOwned(ctx, next); err != nil { + return 0, nil, true, err + } + s.owned = next + var bundle []byte + for _, item := range mergeRewardDeltas(granted) { + bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) + } + return 143, wire.AppendBytes(nil, 1, bundle), true, nil +} + +func DecodeItemRequest(request []byte, number int, operation string) ([]Item, error) { + var result []Item + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != number { + return nil + } + if field.Type != 2 { + return fmt.Errorf("player: %s invalid material", operation) + } + var item Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) { + return fmt.Errorf("player: %s invalid material", operation) + } + result = append(result, item) + return nil + }) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, fmt.Errorf("player: %s has no material", operation) + } + return result, nil +} diff --git a/go/internal/server/domain/inventory/recipes.go b/go/internal/server/domain/inventory/recipes.go new file mode 100644 index 0000000..8eee71f --- /dev/null +++ b/go/internal/server/domain/inventory/recipes.go @@ -0,0 +1,48 @@ +package inventory + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "errors" + "fmt" + "slices" +) + +type RecipeItems interface { + All(ctx command.Context) []Item +} + +type RecipeService struct { + design *gamedata.CookingRecipeDesign + initial []uint64 + items RecipeItems +} + +func (s *RecipeService) Knows(ctx command.Context, id uint64) bool { + if !s.design.IDs[id] { + return false + } + if slices.Contains(s.initial, id) { + return true + } + for _, item := range s.items.All(ctx) { + if item.Type == 7 && item.ID == id && item.Count > 0 { + return true + } + } + return false +} + +func NewRecipeService(design *gamedata.CookingRecipeDesign, initial []uint64, items RecipeItems) (*RecipeService, error) { + if design == nil || len(design.IDs) == 0 || items == nil { + return nil, fmt.Errorf("recipes: missing design or inventory") + } + for _, id := range initial { + if !design.IDs[id] { + return nil, fmt.Errorf("recipes: initial recipe %d is absent from GameData", id) + } + } + return &RecipeService{design: design, initial: append([]uint64(nil), initial...), items: items}, nil +} + +var errInvalidRecipeRequest = errors.New("feature: invalid protobuf request") diff --git a/go/internal/server/player/wallet.go b/go/internal/server/domain/inventory/wallet.go similarity index 72% rename from go/internal/server/player/wallet.go rename to go/internal/server/domain/inventory/wallet.go index 1387c76..cb43be5 100644 --- a/go/internal/server/player/wallet.go +++ b/go/internal/server/domain/inventory/wallet.go @@ -1,4 +1,4 @@ -package player +package inventory import ( "encoding/json" @@ -6,11 +6,11 @@ import ( "fmt" "maps" "math" - "sync" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" ) // Currency uses the UserDBInfo currency fields: type 3 is free jewelry and @@ -35,20 +35,19 @@ type walletSnapshot struct { } type Wallet struct { - mu sync.Mutex - store stateio.AtomicEntryStore + store stateio.ScopedEntryStore state walletSnapshot } -func OpenWallet(store stateio.Store, initial Currency) (*Wallet, error) { - entries, ok := store.(stateio.AtomicEntryStore) +func OpenWallet(ctx command.Context, store stateio.Store, initial Currency) (*Wallet, error) { + entries, ok := store.(stateio.ScopedEntryStore) if !ok { return nil, errors.New("player: wallet store is nil") } s := &Wallet{store: entries, state: walletSnapshot{ Version: versionconfig.State(), Currency: initial, Granted: map[string]bool{}, Spent: map[string]bool{}, }} - b, err := store.Load("wallet") + b, err := store.Load(ctx.State, "wallet") if err != nil { return nil, fmt.Errorf("player: load wallet: %w", err) } @@ -69,66 +68,60 @@ func OpenWallet(store stateio.Store, initial Currency) (*Wallet, error) { if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() { return nil, errors.New("player: malformed wallet state") } - } else if err := stateio.RequireNoEntries(entries, "wallet", "granted", "spent"); err != nil { + } else if err := stateio.RequireNoEntries(entries, ctx.State, "wallet", "granted", "spent"); err != nil { return nil, fmt.Errorf("player: invalid wallet storage: %w", err) } - s.state.Granted, err = loadBoolEntries(entries, "wallet", "granted") + s.state.Granted, err = loadBoolEntries(ctx, entries, "wallet", "granted") if err != nil { return nil, err } - s.state.Spent, err = loadBoolEntries(entries, "wallet", "spent") + s.state.Spent, err = loadBoolEntries(ctx, entries, "wallet", "spent") if err != nil { return nil, err } return s, nil } -func (s *Wallet) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - b, err := s.store.Load("wallet") +func (s *Wallet) EnsurePersisted(ctx command.Context) error { + + b, err := s.store.Load(ctx.State, "wallet") if err != nil { return err } if b != nil { return nil } - return s.commit(cloneWallet(s.state)) + return s.commit(ctx, cloneWallet(s.state)) } -func (s *Wallet) Snapshot() Currency { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Wallet) Snapshot(ctx command.Context) Currency { + return s.state.Currency } func (s *Wallet) WasGranted(identity string) bool { - s.mu.Lock() - defer s.mu.Unlock() + return s.state.Granted[identity] } func (s *Wallet) CanSpendFreeJewelry(amount uint64) bool { - s.mu.Lock() - defer s.mu.Unlock() + return amount > 0 && s.state.FreeJewelry >= amount } func (s *Wallet) CanSpendGold(amount uint64) bool { - s.mu.Lock() - defer s.mu.Unlock() + return amount > 0 && s.state.Gold >= amount } // SpendGoldOnce covers the currency part of a GameData-defined class-up. // Its identity is the old character instance/stage so an interrupted request // cannot charge the same promotion a second time. -func (s *Wallet) SpendGoldOnce(identity string, amount uint64) (Currency, error) { +func (s *Wallet) SpendGoldOnce(ctx command.Context, identity string, amount uint64) (Currency, error) { if identity == "" || amount == 0 { return Currency{}, errors.New("player: invalid gold spend") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.Spent[identity] { return s.state.Currency, nil } @@ -138,32 +131,30 @@ func (s *Wallet) SpendGoldOnce(identity string, amount uint64) (Currency, error) next := cloneWallet(s.state) next.Gold -= amount next.Spent[identity] = true - if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil { + if err := s.commit(ctx, next, entry("spent", identity, []byte("true"))...); err != nil { return Currency{}, err } return next.Currency, nil } func (s *Wallet) WasSpent(identity string) bool { - s.mu.Lock() - defer s.mu.Unlock() + return s.state.Spent[identity] } -func (s *Wallet) SpendFreeJewelryOnce(identity string, amount uint64) (Currency, error) { - return s.spendJewelryOnce(identity, amount, false) +func (s *Wallet) SpendFreeJewelryOnce(ctx command.Context, identity string, amount uint64) (Currency, error) { + return s.spendJewelryOnce(ctx, identity, amount, false) } -func (s *Wallet) SpendJewelryOnce(identity string, amount uint64) (Currency, error) { - return s.spendJewelryOnce(identity, amount, true) +func (s *Wallet) SpendJewelryOnce(ctx command.Context, identity string, amount uint64) (Currency, error) { + return s.spendJewelryOnce(ctx, identity, amount, true) } -func (s *Wallet) spendJewelryOnce(identity string, amount uint64, paid bool) (Currency, error) { +func (s *Wallet) spendJewelryOnce(ctx command.Context, identity string, amount uint64, paid bool) (Currency, error) { if identity == "" || amount == 0 { return Currency{}, errors.New("player: invalid wallet spend") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.Spent[identity] { return s.state.Currency, nil } @@ -180,34 +171,32 @@ func (s *Wallet) spendJewelryOnce(identity string, amount uint64, paid bool) (Cu next.FreeJewelry -= amount } next.Spent[identity] = true - if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil { + if err := s.commit(ctx, next, entry("spent", identity, []byte("true"))...); err != nil { return Currency{}, err } return next.Currency, nil } // Currencies implements account.CurrencyProvider. -func (s *Wallet) Currencies() (gold, freeJewelry, jewelry, mileage uint64) { - c := s.Snapshot() +func (s *Wallet) Currencies(ctx command.Context) (gold, freeJewelry, jewelry, mileage uint64) { + c := s.Snapshot(ctx) return c.Gold, c.FreeJewelry, c.Jewelry, c.Mileage } -func (s *Wallet) HopePowderBalance() uint64 { return s.Snapshot().HopePowder } +func (s *Wallet) HopePowderBalance(ctx command.Context) uint64 { return s.Snapshot(ctx).HopePowder } -func (s *Wallet) CatalystBalance() uint64 { return s.Snapshot().Catalyst } +func (s *Wallet) CatalystBalance(ctx command.Context) uint64 { return s.Snapshot(ctx).Catalyst } func (s *Wallet) CanSpendCatalyst(amount uint64) bool { - s.mu.Lock() - defer s.mu.Unlock() + return amount > 0 && s.state.Catalyst >= amount } -func (s *Wallet) SpendCatalystOnce(identity string, amount uint64) (Currency, error) { +func (s *Wallet) SpendCatalystOnce(ctx command.Context, identity string, amount uint64) (Currency, error) { if identity == "" || amount == 0 { return Currency{}, errors.New("player: invalid catalyst spend") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.Spent[identity] { return s.state.Currency, nil } @@ -217,25 +206,24 @@ func (s *Wallet) SpendCatalystOnce(identity string, amount uint64) (Currency, er next := cloneWallet(s.state) next.Catalyst -= amount next.Spent[identity] = true - if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil { + if err := s.commit(ctx, next, entry("spent", identity, []byte("true"))...); err != nil { return Currency{}, err } return next.Currency, nil } -func (s *Wallet) EquipmentMileageBalances() (mileage, exchangeGage uint64) { - c := s.Snapshot() +func (s *Wallet) EquipmentMileageBalances(ctx command.Context) (mileage, exchangeGage uint64) { + c := s.Snapshot(ctx) return c.EquipMileage, c.EquipMileageExchangeGage } // RecordEquipmentSmelting converts the actually consumed refinement material // into the official residual gauge and type-68 mileage currency. -func (s *Wallet) RecordEquipmentSmelting(materialCount, threshold, rewardCount uint64) (Currency, uint64, error) { +func (s *Wallet) RecordEquipmentSmelting(ctx command.Context, materialCount, threshold, rewardCount uint64) (Currency, uint64, error) { if materialCount == 0 || threshold == 0 || rewardCount == 0 { return Currency{}, 0, errors.New("player: invalid equipment smelting mileage") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.EquipMileageExchangeGage >= threshold || math.MaxUint64-s.state.EquipMileageExchangeGage < materialCount { return Currency{}, 0, errors.New("player: invalid equipment smelting gauge") } @@ -248,7 +236,7 @@ func (s *Wallet) RecordEquipmentSmelting(materialCount, threshold, rewardCount u } next.EquipMileageExchangeGage = total % threshold next.EquipMileage += earned - if err := s.commit(next); err != nil { + if err := s.commit(ctx, next); err != nil { return Currency{}, 0, err } return next.Currency, earned, nil @@ -257,12 +245,11 @@ func (s *Wallet) RecordEquipmentSmelting(materialCount, threshold, rewardCount u // GrantMileageOnce persists the type-20 currency produced when a duplicate // costume is drawn after +5. The gacha grant identity makes recovery after a // collection/wallet split commit safe and idempotent. -func (s *Wallet) GrantMileageOnce(identity string, amount uint64) (Currency, error) { +func (s *Wallet) GrantMileageOnce(ctx command.Context, identity string, amount uint64) (Currency, error) { if identity == "" || amount == 0 { return Currency{}, errors.New("player: invalid mileage grant") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.Granted[identity] { return s.state.Currency, nil } @@ -272,18 +259,17 @@ func (s *Wallet) GrantMileageOnce(identity string, amount uint64) (Currency, err } next.Mileage += amount next.Granted[identity] = true - if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil { + if err := s.commit(ctx, next, entry("granted", identity, []byte("true"))...); err != nil { return Currency{}, err } return next.Currency, nil } -func (s *Wallet) GrantHopePowderOnce(identity string, amount uint64) (Currency, error) { +func (s *Wallet) GrantHopePowderOnce(ctx command.Context, identity string, amount uint64) (Currency, error) { if identity == "" || amount == 0 { return Currency{}, errors.New("player: invalid hope powder grant") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.Granted[identity] { return s.state.Currency, nil } @@ -293,7 +279,7 @@ func (s *Wallet) GrantHopePowderOnce(identity string, amount uint64) (Currency, } next.HopePowder += amount next.Granted[identity] = true - if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil { + if err := s.commit(ctx, next, entry("granted", identity, []byte("true"))...); err != nil { return Currency{}, err } return next.Currency, nil @@ -302,12 +288,11 @@ func (s *Wallet) GrantHopePowderOnce(identity string, amount uint64) (Currency, // GrantQuestOnce applies only currency rewards and is idempotent by the same // quest identity used by the entity stores. Unknown reward types are ignored; // callers dispatch those to their owning inventory domain. -func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Currency, error) { +func (s *Wallet) GrantQuestOnce(ctx command.Context, identity string, rewards []gamedata.Reward) (Currency, error) { if identity == "" { return Currency{}, errors.New("player: missing wallet grant identity") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.Granted[identity] { return s.state.Currency, nil } @@ -342,7 +327,7 @@ func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Cur } } next.Granted[identity] = true - if err := s.commit(next, entry("granted", identity, []byte("true"))...); err != nil { + if err := s.commit(ctx, next, entry("granted", identity, []byte("true"))...); err != nil { return Currency{}, err } return next.Currency, nil @@ -355,12 +340,12 @@ func cloneWallet(in walletSnapshot) walletSnapshot { return out } -func (s *Wallet) commit(next walletSnapshot, changes ...stateio.EntryMutation) error { +func (s *Wallet) commit(ctx command.Context, next walletSnapshot, changes ...stateio.EntryMutation) error { b, err := json.Marshal(next) if err != nil { return err } - if err := s.store.SaveWithEntries("wallet", b, changes); err != nil { + if err := s.store.SaveWithEntries(ctx.State, "wallet", b, changes); err != nil { return fmt.Errorf("player: persist wallet: %w", err) } s.state = next diff --git a/go/internal/server/player/wallet_exchange.go b/go/internal/server/domain/inventory/wallet_exchange.go similarity index 77% rename from go/internal/server/player/wallet_exchange.go rename to go/internal/server/domain/inventory/wallet_exchange.go index 35b01de..c14af2c 100644 --- a/go/internal/server/player/wallet_exchange.go +++ b/go/internal/server/domain/inventory/wallet_exchange.go @@ -1,20 +1,20 @@ -package player +package inventory import ( "fmt" "math" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" ) // ExchangeOnce applies a server-calculated cost and reward in one wallet save. // The caller's account transaction also owns inventory and gameplay receipts. -func (s *Wallet) ExchangeOnce(identity string, costs, rewards []gamedata.Reward) error { +func (s *Wallet) ExchangeOnce(ctx command.Context, identity string, costs, rewards []gamedata.Reward) error { if identity == "" { return fmt.Errorf("player: missing wallet exchange identity") } - s.mu.Lock() - defer s.mu.Unlock() + if s.state.Spent[identity] { return nil } @@ -23,11 +23,11 @@ func (s *Wallet) ExchangeOnce(identity string, costs, rewards []gamedata.Reward) return err } next.Spent[identity] = true - return s.commit(next, entry("spent", identity, []byte("true"))...) + return s.commit(ctx, next, entry("spent", identity, []byte("true"))...) } -func (s *Wallet) CanExchange(costs, rewards []gamedata.Reward) error { - c := s.Snapshot() +func (s *Wallet) CanExchange(ctx command.Context, costs, rewards []gamedata.Reward) error { + c := s.Snapshot(ctx) return exchangeCurrency(&c, costs, rewards) } diff --git a/go/internal/server/mail/attendance.go b/go/internal/server/domain/mail/attendance.go similarity index 84% rename from go/internal/server/mail/attendance.go rename to go/internal/server/domain/mail/attendance.go index 6390001..798f08e 100644 --- a/go/internal/server/mail/attendance.go +++ b/go/internal/server/domain/mail/attendance.go @@ -8,33 +8,32 @@ import ( "strings" "time" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" ) // AttendanceRewardEconomy resolves attachment wrappers only when claimed and // applies every reward to its owning domain under the account transaction. type AttendanceRewardEconomy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) } -func (s *Service) AttachAttendanceRewardEconomy(e AttendanceRewardEconomy) error { +func (s *Service) AttachAttendanceRewardEconomy(ctx command.Context, e AttendanceRewardEconomy) error { if s == nil || e == nil { return errors.New("mail: invalid attendance reward economy") } - s.mu.Lock() - defer s.mu.Unlock() + s.attendanceEconomy = e return nil } // IssueAttachmentsOnce implements the eventtasks attendance issuer. No wallet, // inventory mutation or random reward selection occurs during mail creation. -func (s *Service) IssueAttachmentsOnce(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error { +func (s *Service) IssueAttachmentsOnce(ctx command.Context, identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error { if s == nil { return errors.New("mail: attendance issuer unavailable") } - s.mu.Lock() - defer s.mu.Unlock() + if s.attendanceEconomy == nil { return errors.New("mail: attendance reward economy unavailable") } @@ -52,7 +51,7 @@ func (s *Service) IssueAttachmentsOnce(identity, title, body string, rewards []g } return nil } - return s.enqueueCompensations([]compensation{{identity: key, title: title, body: body, rewards: rewards, sentAt: sentAt}}) + return s.enqueueCompensations(ctx, []compensation{{identity: key, title: title, body: body, rewards: rewards, sentAt: sentAt}}) } func (s *Service) isAttendanceMail(id uint64) bool { diff --git a/go/internal/server/domain/mail/cash.go b/go/internal/server/domain/mail/cash.go new file mode 100644 index 0000000..d08153b --- /dev/null +++ b/go/internal/server/domain/mail/cash.go @@ -0,0 +1,61 @@ +package mail + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "errors" + "fmt" + "maps" + "math" + "reflect" + "strings" + "time" +) + +// Cash attachments are already resolved at purchase, so manual boxes must not +// be expanded again and selected random rewards must not be rolled on claim. +type CashRewardEconomy interface { + ApplyResolved(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} + +func (s *Service) AttachCashRewards(ctx command.Context, e CashRewardEconomy, templates map[uint64]bool) error { + if e == nil || len(templates) == 0 { + return errors.New("mail: missing cash economy or templates") + } + s.cashEconomy = e + s.cashTemplates = make(map[uint64]bool, len(templates)) + maps.Copy(s.cashTemplates, templates) + return nil +} +func (s *Service) IssueCashOnce(ctx command.Context, identity string, template uint64, rewards []gamedata.Reward, sentAt time.Time) error { + + if s.cashEconomy == nil || !s.cashTemplates[template] { + return fmt.Errorf("mail: cash template %d or economy unavailable", template) + } + if identity == "" || strings.TrimSpace(identity) != identity || len(identity) > 450 || sentAt.UnixMilli() <= 0 || sentAt.UnixMilli() >= 253402300799000 { + return errors.New("mail: invalid cash mail identity or time") + } + if err := validateCashAttachments(rewards); err != nil { + return err + } + key := "cash:" + identity + if id, ok := s.issued[key]; ok { + previous := s.dynamic[id] + if !previous.IsCash || previous.TemplateID != template || !reflect.DeepEqual(mailAttachments(previous), rewards) { + return errors.New("mail: cash identity reused with different attachments") + } + return nil + } + return s.enqueueCompensations(ctx, []compensation{{identity: key, templateID: template, isCash: true, rewards: rewards, sentAt: sentAt}}) +} +func validateCashAttachments(rewards []gamedata.Reward) error { + if len(rewards) == 0 { + return errors.New("mail: empty cash attachments") + } + for _, r := range rewards { + if r.Type < 2 || r.Type > 72 || r.Count == 0 || r.Count > math.MaxInt32 || r.ID > math.MaxInt32 { + return fmt.Errorf("mail: invalid cash attachment %+v", r) + } + } + return nil +} diff --git a/go/internal/server/mail/grant_spool.go b/go/internal/server/domain/mail/grant_spool.go similarity index 79% rename from go/internal/server/mail/grant_spool.go rename to go/internal/server/domain/mail/grant_spool.go index 839004f..04b0310 100644 --- a/go/internal/server/mail/grant_spool.go +++ b/go/internal/server/domain/mail/grant_spool.go @@ -13,12 +13,13 @@ import ( "time" "unicode/utf8" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" ) // GrantSpool is a local operator input, independent of the versioned starter // mailbox and player state. Writers atomically replace the whole JSON file. -// Version 1 uses Unix milliseconds; a stable identity is never issued twice, +// Version 2 requires an account recipient and uses Unix milliseconds; a stable identity is never issued twice, // including after the player opens the mail or the server restarts. type GrantSpool struct { Version uint64 `json:"version"` @@ -26,11 +27,12 @@ type GrantSpool struct { } type Grant struct { - Identity string `json:"identity"` - Title string `json:"title"` - Body string `json:"body"` - SentAt int64 `json:"sent_at"` - Rewards []GrantReward `json:"rewards"` + AccountID string `json:"account_id"` + Identity string `json:"identity"` + Title string `json:"title"` + Body string `json:"body"` + SentAt int64 `json:"sent_at"` + Rewards []GrantReward `json:"rewards"` } type GrantReward struct { @@ -67,7 +69,7 @@ func (g compensation) validate() error { // AttachGrantSpoolPath enables reads before each /MailInfo. An absent file is // an empty queue, allowing an operator to create it after server startup. // Existing malformed input is rejected immediately without issuing mail. -func (s *Service) AttachGrantSpoolPath(path string) error { +func (s *Service) AttachGrantSpoolPath(ctx command.Context, path string) error { if s == nil || strings.TrimSpace(path) == "" { return errors.New("mail: invalid grant spool path") } @@ -75,16 +77,18 @@ func (s *Service) AttachGrantSpoolPath(path string) error { if err != nil { return fmt.Errorf("mail: resolve grant spool path: %w", err) } - if _, err := readGrantSpool(abs); err != nil { + if _, err := readGrantSpool(abs, ctx.AccountID); err != nil { return err } - s.mu.Lock() - defer s.mu.Unlock() + s.grantSpoolPath = abs return nil } -func readGrantSpool(path string) ([]compensation, error) { +func readGrantSpool(path, accountID string) ([]compensation, error) { + if strings.TrimSpace(accountID) == "" { + return nil, errors.New("mail: grant spool requires command account identity") + } if path == "" { return nil, nil } @@ -95,7 +99,7 @@ func readGrantSpool(path string) ([]compensation, error) { if err != nil { return nil, fmt.Errorf("mail: read grant spool: %w", err) } - grants, err := decodeGrantSpool(data) + grants, err := decodeGrantSpool(data, accountID) if err != nil { return nil, fmt.Errorf("mail: reject grant spool: %w", err) } @@ -104,7 +108,7 @@ func readGrantSpool(path string) ([]compensation, error) { // Decode the entire file before returning any grants. The exact object reader // rejects missing, unknown, duplicate and null fields at every schema level. -func decodeGrantSpool(data []byte) ([]compensation, error) { +func decodeGrantSpool(data []byte, accountID string) ([]compensation, error) { if !utf8.Valid(data) { return nil, errors.New("invalid UTF-8") } @@ -113,7 +117,7 @@ func decodeGrantSpool(data []byte) ([]compensation, error) { return nil, err } var version uint64 - if err := json.Unmarshal(root["version"], &version); err != nil || version != 1 { + if err := json.Unmarshal(root["version"], &version); err != nil || version != 2 { return nil, errors.New("unsupported grant spool version") } var rawGrants []json.RawMessage @@ -123,7 +127,7 @@ func decodeGrantSpool(data []byte) ([]compensation, error) { seen := make(map[string]bool, len(rawGrants)) grants := make([]compensation, 0, len(rawGrants)) for i, raw := range rawGrants { - fields, err := exactSpoolObject(raw, "identity", "title", "body", "sent_at", "rewards") + fields, err := exactSpoolObject(raw, "account_id", "identity", "title", "body", "sent_at", "rewards") if err != nil { return nil, fmt.Errorf("grant %d: %w", i, err) } @@ -131,10 +135,14 @@ func decodeGrantSpool(data []byte) ([]compensation, error) { if err := json.Unmarshal(raw, &entry); err != nil { return nil, fmt.Errorf("grant %d: %w", i, err) } - if seen[entry.Identity] { + if strings.TrimSpace(entry.AccountID) == "" || strings.TrimSpace(entry.AccountID) != entry.AccountID || utf8.RuneCountInString(entry.AccountID) > 500 { + return nil, fmt.Errorf("grant %d: invalid recipient account", i) + } + recipientIdentity := entry.AccountID + "\x00" + entry.Identity + if seen[recipientIdentity] { return nil, fmt.Errorf("duplicate grant identity %q", entry.Identity) } - seen[entry.Identity] = true + seen[recipientIdentity] = true var rawRewards []json.RawMessage if err := json.Unmarshal(fields["rewards"], &rawRewards); err != nil { return nil, err @@ -149,7 +157,9 @@ func decodeGrantSpool(data []byte) ([]compensation, error) { if err := grant.validate(); err != nil { return nil, fmt.Errorf("grant %d: %w", i, err) } - grants = append(grants, grant) + if entry.AccountID == accountID { + grants = append(grants, grant) + } } return grants, nil } diff --git a/go/internal/server/mail/mail.go b/go/internal/server/domain/mail/mail.go similarity index 52% rename from go/internal/server/mail/mail.go rename to go/internal/server/domain/mail/mail.go index 1cd2f82..a46aa41 100644 --- a/go/internal/server/mail/mail.go +++ b/go/internal/server/domain/mail/mail.go @@ -3,23 +3,21 @@ package mail import ( - "encoding/binary" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" "encoding/json" "errors" "fmt" "os" "path/filepath" "slices" - "sort" "strconv" - "sync" - "time" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" + "time" ) const packetCode = 131 @@ -141,74 +139,6 @@ func (s *Starter) Validate() error { return nil } -func packed(values []uint64) []byte { - var result []byte - for _, value := range values { - result = binary.AppendUvarint(result, value) - } - return result -} - -func (m MailDBInfo) encode() []byte { - result := wire.AppendVarint(nil, 1, m.MailID) - if m.MailType != 0 { - result = wire.AppendVarint(result, 2, m.MailType) - } - if m.TemplateID != 0 { - result = wire.AppendVarint(result, 3, m.TemplateID) - } - if m.Sender != "" { - result = wire.AppendString(result, 4, m.Sender) - } - if m.Title != "" { - result = wire.AppendString(result, 5, m.Title) - } - if m.Body != "" { - result = wire.AppendString(result, 6, m.Body) - } - result = wire.AppendVarint(result, 7, m.ExpiresAt) - if len(m.RewardTypes) != 0 { - result = wire.AppendBytes(result, 8, packed(m.RewardTypes)) - } - if len(m.RewardIDs) != 0 { - result = wire.AppendBytes(result, 9, packed(m.RewardIDs)) - } - if len(m.RewardCounts) != 0 { - result = wire.AppendBytes(result, 10, packed(m.RewardCounts)) - } - if m.IsOpen { - result = wire.AppendVarint(result, 11, 1) - } - if m.OpenTime != 0 { - result = wire.AppendVarint(result, 12, m.OpenTime) - } - result = wire.AppendVarint(result, 13, m.SentAt) - if m.HistoryDeleteTime != 0 { - result = wire.AppendVarint(result, 14, m.HistoryDeleteTime) - } - if m.IsCash { - result = wire.AppendVarint(result, 15, 1) - } - return result -} - -func (s *Starter) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/MailInfo" { - return 0, nil, false, nil - } - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 { - return 0, nil, true, fmt.Errorf("mail: %s invalid sequence", path) - } - var result []byte - for _, entry := range s.Mails { - result = wire.AppendBytes(result, 1, entry.encode()) - } - result = wire.AppendVarint(result, 2, s.MailCount) - result = wire.AppendVarint(result, 3, s.MaxMailID) - return packetCode, result, true, nil -} - func (s *Starter) Write(path string) error { if err := s.Validate(); err != nil { return err @@ -220,18 +150,6 @@ func (s *Starter) Write(path string) error { return os.WriteFile(path, append(data, '\n'), 0o600) } -func unpack(data []byte) ([]uint64, error) { - var result []uint64 - for len(data) != 0 { - value, count := binary.Uvarint(data) - if count <= 0 { - return nil, errors.New("mail: malformed packed values") - } - result, data = append(result, value), data[count:] - } - return result, nil -} - type stateSnapshot struct { Version string `json:"version"` Opened []uint64 `json:"opened"` @@ -247,24 +165,24 @@ type Service struct { cashEconomy CashRewardEconomy cashTemplates map[uint64]bool beforeMailID uint64 - mu sync.Mutex - Starter *Starter - seedPath string - seedStamp fileStamp - grantSpoolPath string - storage stateio.AtomicEntryStore - inventory *player.Inventory - wallet *player.Wallet - collection *player.CollectionStore - costumeDesign player.CostumeDesignSource - state stateSnapshot - dynamic map[uint64]MailDBInfo - issued map[string]uint64 - history map[uint64]MailDBInfo - now func() time.Time + + Starter *Starter + seedPath string + seedStamp fileStamp + grantSpoolPath string + storage stateio.ScopedEntryStore + inventory *assets.Inventory + wallet *assets.Wallet + collection *roster.CollectionStore + costumeDesign roster.CostumeDesignSource + state stateSnapshot + dynamic map[uint64]MailDBInfo + issued map[string]uint64 + history map[uint64]MailDBInfo + now func() time.Time } -func (s *Service) AttachContentTickets(design *gamedata.GachaContentTicketDesign) error { +func (s *Service) AttachContentTickets(ctx command.Context, design *gamedata.GachaContentTicketDesign) error { if design == nil { return errors.New("mail: nil content tickets") } @@ -274,12 +192,11 @@ func (s *Service) AttachContentTickets(design *gamedata.GachaContentTicketDesign // AttachCostumeRewards enables ElementType 11 mail attachments. The catalog is // GameData-backed and the collection ledger makes repeated MailOpen calls safe. -func (s *Service) AttachCostumeRewards(collection *player.CollectionStore, design player.CostumeDesignSource) error { +func (s *Service) AttachCostumeRewards(ctx command.Context, collection *roster.CollectionStore, design roster.CostumeDesignSource) error { if s == nil || collection == nil || design == nil { return errors.New("mail: invalid costume reward configuration") } - s.mu.Lock() - defer s.mu.Unlock() + s.collection, s.costumeDesign = collection, design return nil } @@ -289,8 +206,8 @@ type fileStamp struct { modTime int64 } -func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) { - entries, ok := storage.(stateio.AtomicEntryStore) +func OpenService(ctx command.Context, storage stateio.Store, starter *Starter, inventory *assets.Inventory, wallet *assets.Wallet) (*Service, error) { + entries, ok := storage.(stateio.ScopedEntryStore) if !ok || starter == nil || inventory == nil || wallet == nil { return nil, errors.New("mail: invalid service configuration") } @@ -303,12 +220,12 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve s := &Service{Starter: starter, storage: entries, inventory: inventory, wallet: wallet, state: stateSnapshot{Version: versionconfig.State(), NextDynamicMailID: starter.MaxMailID + 1}, dynamic: map[uint64]MailDBInfo{}, issued: map[string]uint64{}, history: map[uint64]MailDBInfo{}, now: time.Now} - b, err := storage.Load("mail") + b, err := storage.Load(ctx.State, "mail") if err != nil { return nil, fmt.Errorf("mail: load state: %w", err) } if b == nil { - if err := stateio.RequireNoEntries(entries, "mail", "dynamic", "issued", "history"); err != nil { + if err := stateio.RequireNoEntries(entries, ctx.State, "mail", "dynamic", "issued", "history"); err != nil { return nil, fmt.Errorf("mail: invalid storage: %w", err) } return s, nil @@ -319,7 +236,7 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() || s.state.NextDynamicMailID == 0 { return nil, errors.New("mail: malformed state") } - rawDynamic, err := entries.ListEntries("mail", "dynamic") + rawDynamic, err := entries.ListEntries(ctx.State, "mail", "dynamic") if err != nil { return nil, err } @@ -331,7 +248,7 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve } s.dynamic[id] = entry } - rawIssued, err := entries.ListEntries("mail", "issued") + rawIssued, err := entries.ListEntries(ctx.State, "mail", "issued") if err != nil { return nil, err } @@ -355,7 +272,7 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve return nil, fmt.Errorf("mail: invalid persisted starter prestige-skin gift: %w", err) } } - rawHistory, err := entries.ListEntries("mail", "history") + rawHistory, err := entries.ListEntries(ctx.State, "mail", "history") if err != nil { return nil, err } @@ -392,17 +309,16 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve return s, nil } -func (s *Service) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - b, err := s.storage.Load("mail") +func (s *Service) EnsurePersisted(ctx command.Context) error { + + b, err := s.storage.Load(ctx.State, "mail") if err != nil { return err } if b != nil { return nil } - return s.persist(s.state) + return s.persist(ctx, s.state) } // AttachSeedPath enables development-time hot reloading of an atomically @@ -410,7 +326,7 @@ func (s *Service) EnsurePersisted() error { // endpoint: the client continues to call only the normal /MailInfo API. // A changed file is validated before replacing the in-memory starter. A bad // replacement leaves the last known-good starter intact and fails that request. -func (s *Service) AttachSeedPath(path string) error { +func (s *Service) AttachSeedPath(ctx command.Context, path string) error { if s == nil || path == "" { return errors.New("mail: invalid seed watch path") } @@ -418,50 +334,11 @@ func (s *Service) AttachSeedPath(path string) error { if err != nil { return err } - s.mu.Lock() - defer s.mu.Unlock() + s.seedPath, s.seedStamp = filepath.Clean(path), stamp return nil } -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/MailInfo" && path != "/MailOpen" && path != "/MailHistoryInfo" && path != "/CashMailInfo" { - return 0, nil, false, nil - } - if s == nil || s.Starter == nil || s.inventory == nil || s.wallet == nil { - return 0, nil, true, errors.New("mail: unavailable service") - } - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 { - return 0, nil, true, fmt.Errorf("mail: %s invalid sequence", path) - } - s.mu.Lock() - defer s.mu.Unlock() - if path == "/MailInfo" { - grants, err := readGrantSpool(s.grantSpoolPath) - if err != nil { - return 0, nil, true, err - } - if err := s.reloadSeedIfChanged(); err != nil { - return 0, nil, true, err - } - if err := s.enqueueCompensations(grants); err != nil { - return 0, nil, true, err - } - return packetCode, s.info(), true, nil - } - if path == "/CashMailInfo" { - response, err := s.cashInfo(request) - return 140, response, true, err - } - if path == "/MailHistoryInfo" { - response, err := s.historyInfo(request) - return historyPacketCode, response, true, err - } - response, err := s.open(request) - return openPacketCode, response, true, err -} - func seedFileStamp(path string) (fileStamp, error) { info, err := os.Stat(path) if err != nil { @@ -474,7 +351,7 @@ func seedFileStamp(path string) (fileStamp, error) { } // reloadSeedIfChanged is called with s.mu held. -func (s *Service) reloadSeedIfChanged() error { +func (s *Service) reloadSeedIfChanged(ctx command.Context) error { if s.seedPath == "" { return nil } @@ -501,7 +378,7 @@ func (s *Service) reloadSeedIfChanged() error { updated := s.state updated.Opened = append([]uint64(nil), s.state.Opened...) updated.NextDynamicMailID = next.MaxMailID + 1 - if err := s.persist(updated); err != nil { + if err := s.persist(ctx, updated); err != nil { return fmt.Errorf("mail: persist watched seed mail range: %w", err) } } @@ -509,333 +386,7 @@ func (s *Service) reloadSeedIfChanged() error { return nil } -func (s *Service) info() []byte { - var result []byte - remaining := uint64(0) - for _, entry := range s.Starter.Mails { - if entry.IsCash || containsID(s.state.Opened, entry.MailID) { - continue - } - result = wire.AppendBytes(result, 1, entry.encode()) - remaining++ - } - dynamicIDs := make([]uint64, 0, len(s.dynamic)) - for id := range s.dynamic { - dynamicIDs = append(dynamicIDs, id) - } - slices.Sort(dynamicIDs) - for _, id := range dynamicIDs { - if s.dynamic[id].IsCash || containsID(s.state.Opened, id) { - continue - } - result = wire.AppendBytes(result, 1, s.dynamic[id].encode()) - remaining++ - } - // The official total includes one server-side sentinel row. - result = wire.AppendVarint(result, 2, remaining+1) - maxMailID := s.Starter.MaxMailID - if s.state.NextDynamicMailID > maxMailID+1 { - maxMailID = s.state.NextDynamicMailID - 1 - } - result = wire.AppendVarint(result, 3, maxMailID) - return result -} - -func (s *Service) open(request []byte) ([]byte, error) { - ids, err := requestMailIDs(request) - if err != nil || len(ids) == 0 { - return nil, fmt.Errorf("mail: invalid open request: %w", err) - } - selected := make([]MailDBInfo, 0, len(ids)) - seen := make(map[uint64]bool, len(ids)) - for _, id := range ids { - if id == 0 || seen[id] { - return nil, errors.New("mail: duplicate or zero mail ID") - } - seen[id] = true - found := false - for _, entry := range s.Starter.Mails { - if entry.MailID == id { - selected = append(selected, entry) - found = true - break - } - } - if !found { - if entry, exists := s.dynamic[id]; exists { - selected = append(selected, entry) - found = true - } - } - if !found { - return nil, fmt.Errorf("mail: unknown mail %d", id) - } - } - // Validate the entire attendance/cash batch before any reward domain writes. - for _, entry := range selected { - if entry.IsCash { - if s.cashEconomy == nil { - return nil, errors.New("mail: cash reward economy unavailable") - } - if err := validateCashAttachments(mailAttachments(entry)); err != nil { - return nil, err - } - } - if s.isAttendanceMail(entry.MailID) { - if s.attendanceEconomy == nil { - return nil, errors.New("mail: attendance reward economy unavailable") - } - if !containsID(s.state.Opened, entry.MailID) && uint64(s.now().UnixMilli()) >= entry.ExpiresAt { - return nil, errors.New("mail: attendance mail expired") - } - if err := s.validateAttendanceAttachments(mailAttachments(entry)); err != nil { - return nil, err - } - } - } - - var bundle []byte - next := stateSnapshot{Version: s.state.Version, Opened: append([]uint64(nil), s.state.Opened...), NextDynamicMailID: s.state.NextDynamicMailID} - openedAt := s.now().UTC() - changes := make([]stateio.EntryMutation, 0, len(selected)) - newHistory := make([]MailDBInfo, 0, len(selected)) - for _, entry := range selected { - identity := fmt.Sprintf("mail:%d", entry.MailID) - if entry.IsCash { - granted, err := s.cashEconomy.ApplyResolved(identity+":cash", nil, mailAttachments(entry)) - if err != nil { - return nil, err - } - bundle = append(bundle, granted...) - } else if s.isAttendanceMail(entry.MailID) { - granted, err := s.attendanceEconomy.Apply(identity+":attendance", nil, mailAttachments(entry)) - if err != nil { - return nil, err - } - bundle = append(bundle, granted...) - } else { - rewards := make([]gamedata.Reward, len(entry.RewardTypes)) - var items []gamedata.BattleReward - var costumeIDs []uint64 - for i := range entry.RewardTypes { - reward := gamedata.Reward{Type: entry.RewardTypes[i], ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]} - rewards[i] = reward - switch { - case currencyRewardTypes[reward.Type]: - currency := wire.AppendVarint(nil, 3, reward.Type) - currency = wire.AppendVarint(currency, 4, reward.Count) - bundle = wire.AppendBytes(bundle, 1, currency) - case reward.Type == 11: - if s.collection == nil || s.costumeDesign == nil || reward.ID == 0 || reward.Count == 0 || reward.Count > 6 { - return nil, errors.New("mail: costume reward service unavailable or reward invalid") - } - for copy := uint64(0); copy < reward.Count; copy++ { - costumeIDs = append(costumeIDs, reward.ID) - } - case reward.Type == 28: - // DataManager recognizes this type, but RewardDBInfoBundle - // carries MyRoomTrophyDBInfo in a separate field. Encoding it as - // ItemDBInfo would make the local state and client model disagree. - return nil, errors.New("mail: my-room trophy rewards require MyRoomTrophyDBInfo") - default: - if !s.supportedItemDBInfoReward(reward) { - return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type) - } - if reward.ID == 0 || reward.Count == 0 { - return nil, errors.New("mail: invalid item reward") - } - items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 - } - } - if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { - return nil, err - } - granted, err := s.inventory.GrantOnce(identity+":items", items) - if err != nil { - return nil, err - } - if len(granted) == 0 { - granted = s.inventory.GrantedItems(identity + ":items") - } - for _, item := range granted { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - view := wire.AppendVarint(nil, 2, item.ID) - view = wire.AppendVarint(view, 3, item.Type) - view = wire.AppendVarint(view, 4, item.Count) - bundle = wire.AppendBytes(bundle, 6, view) - } - if len(costumeIDs) != 0 { - grant, err := s.collection.GrantCostumes(identity+":costumes", costumeIDs, s.costumeDesign) - if err != nil { - return nil, err - } - var mileage uint64 - for _, exchange := range grant.Exchanges { - if exchange.ExchangeItemType != 20 || ^uint64(0)-mileage < exchange.ExchangeCount { - return nil, errors.New("mail: unsupported costume overflow exchange") - } - mileage += exchange.ExchangeCount - } - if mileage != 0 { - if _, err := s.wallet.GrantMileageOnce(identity+":costume-overflow", mileage); err != nil { - return nil, err - } - } - bundle = appendCollectionRewardBundle(bundle, s.collection, grant) - } - } - if !containsID(next.Opened, entry.MailID) { - next.Opened = append(next.Opened, entry.MailID) - } - if _, exists := s.history[entry.MailID]; !exists { - history := entry - history.IsOpen = true - history.OpenTime = uint64(openedAt.UnixMilli()) - history.HistoryDeleteTime = uint64(openedAt.Add(mailHistoryPeriod).UnixMilli()) - payload, err := json.Marshal(history) - if err != nil { - return nil, err - } - changes = append(changes, stateio.EntryMutation{Bucket: "history", Key: strconv.FormatUint(entry.MailID, 10), Payload: payload}) - newHistory = append(newHistory, history) - } - } - slices.Sort(next.Opened) - if err := s.commitWithEntries(next, changes); err != nil { - return nil, err - } - for _, entry := range newHistory { - s.history[entry.MailID] = entry - } - return wire.AppendBytes(nil, 1, bundle), nil -} - -func appendCollectionRewardBundle(bundle []byte, collection *player.CollectionStore, grant player.CollectionGrant) []byte { - newCharacters := make(map[uint64]player.Character, len(grant.CharacterIndices)) - for _, index := range grant.CharacterIndices { - if character, found := collection.CharacterByIndex(index); found { - newCharacters[character.ConnectPotentialCostume] = character - bundle = wire.AppendBytes(bundle, 2, player.CharacterWire(character)) - } - } - for _, index := range grant.CostumeIndices { - if costume, found := collection.CostumeByIndex(index); found { - bundle = wire.AppendBytes(bundle, 3, player.CostumeWire(costume)) - } - } - for position, costumeID := range grant.ViewCostumeIDs { - view := wire.AppendVarint(nil, 2, costumeID) - view = wire.AppendVarint(view, 3, 11) - view = wire.AppendVarint(view, 4, 1) - bundle = wire.AppendBytes(bundle, 6, view) - if character, found := newCharacters[costumeID]; found { - charView := wire.AppendVarint(nil, 2, character.ID) - charView = wire.AppendVarint(charView, 3, 6) - charView = wire.AppendVarint(charView, 4, 1) - if position != 0 { - charView = wire.AppendVarint(charView, 6, uint64(position)) - } - bundle = wire.AppendBytes(bundle, 6, charView) - } - } - for _, upgrade := range grant.Upgrades { - info := wire.AppendVarint(nil, 1, upgrade.InvenIndex) - info = wire.AppendVarint(info, 2, 11) - info = wire.AppendVarint(info, 3, upgrade.CostumeID) - if upgrade.Before != 0 { - info = wire.AppendVarint(info, 4, upgrade.Before) - } - info = wire.AppendVarint(info, 5, upgrade.After) - if upgrade.SortID != 0 { - info = wire.AppendVarint(info, 6, upgrade.SortID) - } - bundle = wire.AppendBytes(bundle, 9, info) - } - var mileage uint64 - for _, exchange := range grant.Exchanges { - info := wire.AppendVarint(nil, 1, exchange.OriginalItemType) - info = wire.AppendVarint(info, 2, exchange.OriginalItemID) - info = wire.AppendVarint(info, 3, exchange.OriginalCount) - info = wire.AppendVarint(info, 4, exchange.ExchangeItemType) - if exchange.ExchangeItemID != 0 { - info = wire.AppendVarint(info, 5, exchange.ExchangeItemID) - } - info = wire.AppendVarint(info, 6, exchange.ExchangeCount) - if exchange.SortID != 0 { - info = wire.AppendVarint(info, 7, exchange.SortID) - } - bundle = wire.AppendBytes(bundle, 8, info) - mileage += exchange.ExchangeCount - } - if mileage != 0 { - repaid := wire.AppendVarint(nil, 2, 20) - repaid = wire.AppendVarint(repaid, 3, mileage) - bundle = wire.AppendBytes(bundle, 10, repaid) - } - return bundle -} - -func (s *Service) historyInfo(request []byte) ([]byte, error) { - start, _, err := wire.Varint(request, 2) - if err != nil { - return nil, errors.New("mail: invalid history start index") - } - count, present, err := wire.Varint(request, 3) - if err != nil || !present || count == 0 { - return nil, errors.New("mail: invalid history select count") - } - if count > mailHistoryPageMax { - count = mailHistoryPageMax - } - now := uint64(s.now().UTC().UnixMilli()) - ids := make([]uint64, 0, len(s.history)) - for id, entry := range s.history { - if entry.HistoryDeleteTime > now { - ids = append(ids, id) - } - } - sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] }) - total := len(ids) - var result []byte - selected := uint64(0) - for _, id := range ids { - if start != 0 && id >= start { - continue - } - if selected >= count { - break - } - result = wire.AppendBytes(result, 1, s.history[id].encode()) - selected++ - } - return wire.AppendVarint(result, 2, uint64(total)), nil -} - -func requestMailIDs(request []byte) ([]uint64, error) { - var result []uint64 - err := wire.Walk(request, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - switch field.Type { - case 0: - value, _ := binary.Uvarint(field.Value) - result = append(result, value) - case 2: - values, err := unpack(field.Value) - if err != nil { - return err - } - result = append(result, values...) - default: - return errors.New("mail: invalid InvenIndex wire type") - } - return nil - }) - return result, err -} - -func (s *Service) commit(next stateSnapshot) error { +func (s *Service) commit(ctx command.Context, next stateSnapshot) error { if next.NextDynamicMailID == s.state.NextDynamicMailID && len(next.Opened) == len(s.state.Opened) { equal := true for i := range next.Opened { @@ -848,30 +399,30 @@ func (s *Service) commit(next stateSnapshot) error { return nil } } - return s.persist(next) + return s.persist(ctx, next) } -func (s *Service) commitWithEntries(next stateSnapshot, changes []stateio.EntryMutation) error { +func (s *Service) commitWithEntries(ctx command.Context, next stateSnapshot, changes []stateio.EntryMutation) error { if len(changes) == 0 { - return s.commit(next) + return s.commit(ctx, next) } b, err := json.Marshal(next) if err != nil { return err } - if err := s.storage.SaveWithEntries("mail", b, changes); err != nil { + if err := s.storage.SaveWithEntries(ctx.State, "mail", b, changes); err != nil { return fmt.Errorf("mail: persist state and history: %w", err) } s.state = next return nil } -func (s *Service) persist(next stateSnapshot) error { +func (s *Service) persist(ctx command.Context, next stateSnapshot) error { b, err := json.Marshal(next) if err != nil { return err } - if err := s.storage.SaveWithEntries("mail", b, nil); err != nil { + if err := s.storage.SaveWithEntries(ctx.State, "mail", b, nil); err != nil { return fmt.Errorf("mail: persist state: %w", err) } s.state = next @@ -881,14 +432,13 @@ func (s *Service) persist(next stateSnapshot) error { // EnqueueCompensation creates one durable system mail for an expired, // completed reward. Identity is period-scoped and makes repeated rollover // checks idempotent. -func (s *Service) EnqueueCompensation(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error { +func (s *Service) EnqueueCompensation(ctx command.Context, identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error { grant := compensation{identity: identity, title: title, body: body, rewards: rewards, sentAt: sentAt} if err := grant.validate(); err != nil { return err } - s.mu.Lock() - defer s.mu.Unlock() - return s.enqueueCompensations([]compensation{grant}) + + return s.enqueueCompensations(ctx, []compensation{grant}) } // EnsureStarterLimitedCostumes issues the new-player limited-costume gift at @@ -896,12 +446,11 @@ func (s *Service) EnqueueCompensation(identity, title, body string, rewards []ga // same transaction as the mail, so retries and restarts return without // allocating another mail ID. Six copies mean one acquisition plus five // duplicate upgrades, producing enhancement +5 when claimed. -func (s *Service) EnsureStarterLimitedCostumes(costumeIDs []uint64, sentAt time.Time) error { +func (s *Service) EnsureStarterLimitedCostumes(ctx command.Context, costumeIDs []uint64, sentAt time.Time) error { if s == nil { return errors.New("mail: unavailable starter limited-costume service") } - s.mu.Lock() - defer s.mu.Unlock() + if id, issued := s.issued[starterLimitedCostumeIdentity]; issued { return validateStarterLimitedCostumeMail(s.dynamic[id], s.costumeDesign) } @@ -928,10 +477,10 @@ func (s *Service) EnsureStarterLimitedCostumes(costumeIDs []uint64, sentAt time. if err := grant.validate(); err != nil { return err } - return s.enqueueCompensations([]compensation{grant}) + return s.enqueueCompensations(ctx, []compensation{grant}) } -func validateStarterLimitedCostumeMail(entry MailDBInfo, design player.CostumeDesignSource) error { +func validateStarterLimitedCostumeMail(entry MailDBInfo, design roster.CostumeDesignSource) error { if entry.MailID == 0 || len(entry.RewardTypes) == 0 || len(entry.RewardTypes) != len(entry.RewardIDs) || len(entry.RewardTypes) != len(entry.RewardCounts) { return errors.New("invalid starter gift reward arrays") } @@ -955,12 +504,11 @@ func validateStarterLimitedCostumeMail(entry MailDBInfo, design player.CostumeDe // EnsureStarterPrestigeSkins gives an account its prestige-skin gift if it has // never been issued. Existing and new accounts use the same durable identity; // unclaimed or claimed mail never causes a second gift on restart. -func (s *Service) EnsureStarterPrestigeSkins(designIDs []uint64, sentAt time.Time) error { +func (s *Service) EnsureStarterPrestigeSkins(ctx command.Context, designIDs []uint64, sentAt time.Time) error { if s == nil { return errors.New("mail: unavailable starter prestige-skin service") } - s.mu.Lock() - defer s.mu.Unlock() + if id, issued := s.issued[starterPrestigeSkinIdentity]; issued { return validateStarterPrestigeSkinMail(s.dynamic[id]) } @@ -980,7 +528,7 @@ func (s *Service) EnsureStarterPrestigeSkins(designIDs []uint64, sentAt time.Tim if err := grant.validate(); err != nil { return err } - return s.enqueueCompensations([]compensation{grant}) + return s.enqueueCompensations(ctx, []compensation{grant}) } func validateStarterPrestigeSkinMail(entry MailDBInfo) error { @@ -1002,7 +550,7 @@ func validateStarterPrestigeSkinMail(entry MailDBInfo) error { // identity ledger. The entire batch is prepared before a single atomic write; // neither storage nor memory can retain a partially imported grant spool. // Caller holds s.mu and has validated every grant. -func (s *Service) enqueueCompensations(grants []compensation) error { +func (s *Service) enqueueCompensations(ctx command.Context, grants []compensation) error { next := s.state next.Opened = append([]uint64(nil), s.state.Opened...) var changes []stateio.EntryMutation @@ -1053,7 +601,7 @@ func (s *Service) enqueueCompensations(grants []compensation) error { if err != nil { return err } - if err := s.storage.SaveWithEntries("mail", core, changes); err != nil { + if err := s.storage.SaveWithEntries(ctx.State, "mail", core, changes); err != nil { return err } s.state = next diff --git a/go/internal/server/domain/mail/notifications.go b/go/internal/server/domain/mail/notifications.go new file mode 100644 index 0000000..817f65a --- /dev/null +++ b/go/internal/server/domain/mail/notifications.go @@ -0,0 +1,13 @@ +package mail + +import + +// BeforeDispatch and AfterDispatch observe newly allocated mail inside the +// account request transaction, including mail issued by other domains. +"bd2server/internal/server/domain/command" + +func (s *Service) BeforeDispatch(ctx command.Context, _ string, _ []byte) error { + + s.beforeMailID = s.state.NextDynamicMailID + return nil +} diff --git a/go/internal/server/domain/mail/protocol_adapter.go b/go/internal/server/domain/mail/protocol_adapter.go new file mode 100644 index 0000000..b139381 --- /dev/null +++ b/go/internal/server/domain/mail/protocol_adapter.go @@ -0,0 +1,525 @@ +package mail + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + "sort" + "strconv" +) + +func (s *Service) AfterDispatch(ctx command.Context, _ string, _ []byte, _ []byte) ([]byte, error) { + + // Allocation is monotonic. Without a new ID, no existing row can match + // the notification window, so read-only packets need not scan the inbox. + if s.state.NextDynamicMailID == s.beforeMailID { + return nil, nil + } + now := uint64(s.now().UnixMilli()) + for id, entry := range s.dynamic { + if id >= s.beforeMailID && !containsID(s.state.Opened, id) && entry.ExpiresAt > now { + return wire.AppendVarint(nil, 1, 1), nil // Notify.IsNewMail + } + } + return nil, nil +} + +func packed(values []uint64) []byte { + var result []byte + for _, value := range values { + result = binary.AppendUvarint(result, value) + } + return result +} + +func (m MailDBInfo) encode() []byte { + result := wire.AppendVarint(nil, 1, m.MailID) + if m.MailType != 0 { + result = wire.AppendVarint(result, 2, m.MailType) + } + if m.TemplateID != 0 { + result = wire.AppendVarint(result, 3, m.TemplateID) + } + if m.Sender != "" { + result = wire.AppendString(result, 4, m.Sender) + } + if m.Title != "" { + result = wire.AppendString(result, 5, m.Title) + } + if m.Body != "" { + result = wire.AppendString(result, 6, m.Body) + } + result = wire.AppendVarint(result, 7, m.ExpiresAt) + if len(m.RewardTypes) != 0 { + result = wire.AppendBytes(result, 8, packed(m.RewardTypes)) + } + if len(m.RewardIDs) != 0 { + result = wire.AppendBytes(result, 9, packed(m.RewardIDs)) + } + if len(m.RewardCounts) != 0 { + result = wire.AppendBytes(result, 10, packed(m.RewardCounts)) + } + if m.IsOpen { + result = wire.AppendVarint(result, 11, 1) + } + if m.OpenTime != 0 { + result = wire.AppendVarint(result, 12, m.OpenTime) + } + result = wire.AppendVarint(result, 13, m.SentAt) + if m.HistoryDeleteTime != 0 { + result = wire.AppendVarint(result, 14, m.HistoryDeleteTime) + } + if m.IsCash { + result = wire.AppendVarint(result, 15, 1) + } + return result +} + +func (s *Starter) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/MailInfo" { + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("mail: %s invalid sequence", path) + } + var result []byte + for _, entry := range s.Mails { + result = wire.AppendBytes(result, 1, entry.encode()) + } + result = wire.AppendVarint(result, 2, s.MailCount) + result = wire.AppendVarint(result, 3, s.MaxMailID) + return packetCode, result, true, nil +} + +func unpack(data []byte) ([]uint64, error) { + var result []uint64 + for len(data) != 0 { + value, count := binary.Uvarint(data) + if count <= 0 { + return nil, errors.New("mail: malformed packed values") + } + result, data = append(result, value), data[count:] + } + return result, nil +} + +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/MailInfo" && path != "/MailOpen" && path != "/MailHistoryInfo" && path != "/CashMailInfo" { + return 0, nil, false, nil + } + if s == nil || s.Starter == nil || s.inventory == nil || s.wallet == nil { + return 0, nil, true, errors.New("mail: unavailable service") + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("mail: %s invalid sequence", path) + } + + if path == "/MailInfo" { + grants, err := readGrantSpool(s.grantSpoolPath, ctx.AccountID) + if err != nil { + return 0, nil, true, err + } + if err := s.reloadSeedIfChanged(ctx); err != nil { + return 0, nil, true, err + } + if err := s.enqueueCompensations(ctx, grants); err != nil { + return 0, nil, true, err + } + return packetCode, s.info(), true, nil + } + if path == "/CashMailInfo" { + response, err := s.cashInfo(request) + return 140, response, true, err + } + if path == "/MailHistoryInfo" { + response, err := s.historyInfo(request) + return historyPacketCode, response, true, err + } + response, err := s.open(ctx, request) + return openPacketCode, response, true, err +} + +func (s *Service) info() []byte { + var result []byte + remaining := uint64(0) + for _, entry := range s.Starter.Mails { + if entry.IsCash || containsID(s.state.Opened, entry.MailID) { + continue + } + result = wire.AppendBytes(result, 1, entry.encode()) + remaining++ + } + dynamicIDs := make([]uint64, 0, len(s.dynamic)) + for id := range s.dynamic { + dynamicIDs = append(dynamicIDs, id) + } + slices.Sort(dynamicIDs) + for _, id := range dynamicIDs { + if s.dynamic[id].IsCash || containsID(s.state.Opened, id) { + continue + } + result = wire.AppendBytes(result, 1, s.dynamic[id].encode()) + remaining++ + } + // The official total includes one server-side sentinel row. + result = wire.AppendVarint(result, 2, remaining+1) + maxMailID := s.Starter.MaxMailID + if s.state.NextDynamicMailID > maxMailID+1 { + maxMailID = s.state.NextDynamicMailID - 1 + } + result = wire.AppendVarint(result, 3, maxMailID) + return result +} + +func (s *Service) open(ctx command.Context, request []byte) ([]byte, error) { + ids, err := requestMailIDs(request) + if err != nil || len(ids) == 0 { + return nil, fmt.Errorf("mail: invalid open request: %w", err) + } + selected := make([]MailDBInfo, 0, len(ids)) + seen := make(map[uint64]bool, len(ids)) + for _, id := range ids { + if id == 0 || seen[id] { + return nil, errors.New("mail: duplicate or zero mail ID") + } + seen[id] = true + found := false + for _, entry := range s.Starter.Mails { + if entry.MailID == id { + selected = append(selected, entry) + found = true + break + } + } + if !found { + if entry, exists := s.dynamic[id]; exists { + selected = append(selected, entry) + found = true + } + } + if !found { + return nil, fmt.Errorf("mail: unknown mail %d", id) + } + } + // Validate the entire attendance/cash batch before any reward domain writes. + for _, entry := range selected { + if entry.IsCash { + if s.cashEconomy == nil { + return nil, errors.New("mail: cash reward economy unavailable") + } + if err := validateCashAttachments(mailAttachments(entry)); err != nil { + return nil, err + } + } + if s.isAttendanceMail(entry.MailID) { + if s.attendanceEconomy == nil { + return nil, errors.New("mail: attendance reward economy unavailable") + } + if !containsID(s.state.Opened, entry.MailID) && uint64(s.now().UnixMilli()) >= entry.ExpiresAt { + return nil, errors.New("mail: attendance mail expired") + } + if err := s.validateAttendanceAttachments(mailAttachments(entry)); err != nil { + return nil, err + } + } + } + + var bundle []byte + next := stateSnapshot{Version: s.state.Version, Opened: append([]uint64(nil), s.state.Opened...), NextDynamicMailID: s.state.NextDynamicMailID} + openedAt := s.now().UTC() + changes := make([]stateio.EntryMutation, 0, len(selected)) + newHistory := make([]MailDBInfo, 0, len(selected)) + for _, entry := range selected { + identity := fmt.Sprintf("mail:%d", entry.MailID) + if entry.IsCash { + granted, err := s.cashEconomy.ApplyResolved(ctx, identity+":cash", nil, mailAttachments(entry)) + if err != nil { + return nil, err + } + bundle = append(bundle, granted...) + } else if s.isAttendanceMail(entry.MailID) { + granted, err := s.attendanceEconomy.Apply(ctx, identity+":attendance", nil, mailAttachments(entry)) + if err != nil { + return nil, err + } + bundle = append(bundle, granted...) + } else { + rewards := make([]gamedata.Reward, len(entry.RewardTypes)) + var items []gamedata.BattleReward + var costumeIDs []uint64 + for i := range entry.RewardTypes { + reward := gamedata.Reward{Type: entry.RewardTypes[i], ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]} + rewards[i] = reward + switch { + case currencyRewardTypes[reward.Type]: + currency := wire.AppendVarint(nil, 3, reward.Type) + currency = wire.AppendVarint(currency, 4, reward.Count) + bundle = wire.AppendBytes(bundle, 1, currency) + case reward.Type == 11: + if s.collection == nil || s.costumeDesign == nil || reward.ID == 0 || reward.Count == 0 || reward.Count > 6 { + return nil, errors.New("mail: costume reward service unavailable or reward invalid") + } + for copy := uint64(0); copy < reward.Count; copy++ { + costumeIDs = append(costumeIDs, reward.ID) + } + case reward.Type == 28: + // DataManager recognizes this type, but RewardDBInfoBundle + // carries MyRoomTrophyDBInfo in a separate field. Encoding it as + // ItemDBInfo would make the local state and client model disagree. + return nil, errors.New("mail: my-room trophy rewards require MyRoomTrophyDBInfo") + default: + if !s.supportedItemDBInfoReward(reward) { + return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type) + } + if reward.ID == 0 || reward.Count == 0 { + return nil, errors.New("mail: invalid item reward") + } + items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 + } + } + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":currency", rewards); err != nil { + return nil, err + } + granted, err := s.inventory.GrantOnce(ctx, identity+":items", items) + if err != nil { + return nil, err + } + if len(granted) == 0 { + granted = s.inventory.GrantedItems(identity + ":items") + } + for _, item := range granted { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, view) + } + if len(costumeIDs) != 0 { + grant, err := s.collection.GrantCostumes(ctx, identity+":costumes", costumeIDs, s.costumeDesign) + if err != nil { + return nil, err + } + var mileage uint64 + for _, exchange := range grant.Exchanges { + if exchange.ExchangeItemType != 20 || ^uint64(0)-mileage < exchange.ExchangeCount { + return nil, errors.New("mail: unsupported costume overflow exchange") + } + mileage += exchange.ExchangeCount + } + if mileage != 0 { + if _, err := s.wallet.GrantMileageOnce(ctx, identity+":costume-overflow", mileage); err != nil { + return nil, err + } + } + bundle = appendCollectionRewardBundle(bundle, s.collection, grant) + } + } + if !containsID(next.Opened, entry.MailID) { + next.Opened = append(next.Opened, entry.MailID) + } + if _, exists := s.history[entry.MailID]; !exists { + history := entry + history.IsOpen = true + history.OpenTime = uint64(openedAt.UnixMilli()) + history.HistoryDeleteTime = uint64(openedAt.Add(mailHistoryPeriod).UnixMilli()) + payload, err := json.Marshal(history) + if err != nil { + return nil, err + } + changes = append(changes, stateio.EntryMutation{Bucket: "history", Key: strconv.FormatUint(entry.MailID, 10), Payload: payload}) + newHistory = append(newHistory, history) + } + } + slices.Sort(next.Opened) + if err := s.commitWithEntries(ctx, next, changes); err != nil { + return nil, err + } + for _, entry := range newHistory { + s.history[entry.MailID] = entry + } + return wire.AppendBytes(nil, 1, bundle), nil +} + +func appendCollectionRewardBundle(bundle []byte, collection *roster.CollectionStore, grant roster.CollectionGrant) []byte { + newCharacters := make(map[uint64]roster.Character, len(grant.CharacterIndices)) + for _, index := range grant.CharacterIndices { + if character, found := collection.CharacterByIndex(index); found { + newCharacters[character.ConnectPotentialCostume] = character + bundle = wire.AppendBytes(bundle, 2, roster.CharacterWire(character)) + } + } + for _, index := range grant.CostumeIndices { + if costume, found := collection.CostumeByIndex(index); found { + bundle = wire.AppendBytes(bundle, 3, roster.CostumeWire(costume)) + } + } + for position, costumeID := range grant.ViewCostumeIDs { + view := wire.AppendVarint(nil, 2, costumeID) + view = wire.AppendVarint(view, 3, 11) + view = wire.AppendVarint(view, 4, 1) + bundle = wire.AppendBytes(bundle, 6, view) + if character, found := newCharacters[costumeID]; found { + charView := wire.AppendVarint(nil, 2, character.ID) + charView = wire.AppendVarint(charView, 3, 6) + charView = wire.AppendVarint(charView, 4, 1) + if position != 0 { + charView = wire.AppendVarint(charView, 6, uint64(position)) + } + bundle = wire.AppendBytes(bundle, 6, charView) + } + } + for _, upgrade := range grant.Upgrades { + info := wire.AppendVarint(nil, 1, upgrade.InvenIndex) + info = wire.AppendVarint(info, 2, 11) + info = wire.AppendVarint(info, 3, upgrade.CostumeID) + if upgrade.Before != 0 { + info = wire.AppendVarint(info, 4, upgrade.Before) + } + info = wire.AppendVarint(info, 5, upgrade.After) + if upgrade.SortID != 0 { + info = wire.AppendVarint(info, 6, upgrade.SortID) + } + bundle = wire.AppendBytes(bundle, 9, info) + } + var mileage uint64 + for _, exchange := range grant.Exchanges { + info := wire.AppendVarint(nil, 1, exchange.OriginalItemType) + info = wire.AppendVarint(info, 2, exchange.OriginalItemID) + info = wire.AppendVarint(info, 3, exchange.OriginalCount) + info = wire.AppendVarint(info, 4, exchange.ExchangeItemType) + if exchange.ExchangeItemID != 0 { + info = wire.AppendVarint(info, 5, exchange.ExchangeItemID) + } + info = wire.AppendVarint(info, 6, exchange.ExchangeCount) + if exchange.SortID != 0 { + info = wire.AppendVarint(info, 7, exchange.SortID) + } + bundle = wire.AppendBytes(bundle, 8, info) + mileage += exchange.ExchangeCount + } + if mileage != 0 { + repaid := wire.AppendVarint(nil, 2, 20) + repaid = wire.AppendVarint(repaid, 3, mileage) + bundle = wire.AppendBytes(bundle, 10, repaid) + } + return bundle +} + +func (s *Service) historyInfo(request []byte) ([]byte, error) { + start, _, err := wire.Varint(request, 2) + if err != nil { + return nil, errors.New("mail: invalid history start index") + } + count, present, err := wire.Varint(request, 3) + if err != nil || !present || count == 0 { + return nil, errors.New("mail: invalid history select count") + } + if count > mailHistoryPageMax { + count = mailHistoryPageMax + } + now := uint64(s.now().UTC().UnixMilli()) + ids := make([]uint64, 0, len(s.history)) + for id, entry := range s.history { + if entry.HistoryDeleteTime > now { + ids = append(ids, id) + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] }) + total := len(ids) + var result []byte + selected := uint64(0) + for _, id := range ids { + if start != 0 && id >= start { + continue + } + if selected >= count { + break + } + result = wire.AppendBytes(result, 1, s.history[id].encode()) + selected++ + } + return wire.AppendVarint(result, 2, uint64(total)), nil +} + +func requestMailIDs(request []byte) ([]uint64, error) { + var result []uint64 + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + switch field.Type { + case 0: + value, _ := binary.Uvarint(field.Value) + result = append(result, value) + case 2: + values, err := unpack(field.Value) + if err != nil { + return err + } + result = append(result, values...) + default: + return errors.New("mail: invalid InvenIndex wire type") + } + return nil + }) + return result, err +} + +func (s *Service) cashInfo(request []byte) ([]byte, error) { + start, _, err := wire.Varint(request, 2) + if err != nil || start > math.MaxInt64 { + return nil, errors.New("mail: invalid cash cursor") + } + count, present, err := wire.Varint(request, 3) + if err != nil || !present || count == 0 || count > math.MaxInt32 { + return nil, errors.New("mail: invalid cash select count") + } + if count > mailHistoryPageMax { + count = mailHistoryPageMax + } + entries := map[uint64]MailDBInfo{} + for _, entry := range s.Starter.Mails { + if entry.IsCash && !containsID(s.state.Opened, entry.MailID) { + entries[entry.MailID] = entry + } + } + for id, entry := range s.dynamic { + if entry.IsCash && !containsID(s.state.Opened, id) { + entries[id] = entry + } + } + ids := make([]uint64, 0, len(entries)) + var max uint64 + for id := range entries { + ids = append(ids, id) + if id > max { + max = id + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] }) + var result []byte + var selected uint64 + for _, id := range ids { + if start != 0 && id >= start { + continue + } + if selected >= count { + break + } + result = wire.AppendBytes(result, 1, entries[id].encode()) + selected++ + } + result = wire.AppendVarint(result, 2, uint64(len(entries))) + return wire.AppendVarint(result, 3, max), nil +} diff --git a/go/internal/server/domain/mail/recipient_test.go b/go/internal/server/domain/mail/recipient_test.go new file mode 100644 index 0000000..fa0e07d --- /dev/null +++ b/go/internal/server/domain/mail/recipient_test.go @@ -0,0 +1,127 @@ +package mail_test + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/mail" + "bd2server/internal/server/platform/versionconfig" + accountstate "bd2server/internal/server/storage/account" + "bd2server/internal/server/storage/stateio" + "context" + "encoding/binary" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func mailScalar(field int, value uint64) []byte { + return binary.AppendUvarint(binary.AppendUvarint(nil, uint64(field<<3)), value) +} +func firstMailboxID(body []byte) uint64 { + key, n := binary.Uvarint(body) + if key != 10 || n <= 0 { + return 0 + } + size, m := binary.Uvarint(body[n:]) + if m <= 0 || size > uint64(len(body)-n-m) { + return 0 + } + row := body[n+m : n+m+int(size)] + tag, k := binary.Uvarint(row) + if tag != 8 || k <= 0 { + return 0 + } + id, _ := binary.Uvarint(row[k:]) + return id +} +func TestOperatorCompensationGoesOnlyToTheNamedAccount(t *testing.T) { + root := t.TempDir() + spool := filepath.Join(root, "grants.json") + now := time.Now().UnixMilli() + document := mail.GrantSpool{Version: 2, Grants: []mail.Grant{{AccountID: "original-owner", Identity: "compensation-ticket", Title: "Personal compensation", Body: "This is an individual correction, not a new-player gift", SentAt: now, Rewards: []mail.GrantReward{{Type: 4, Count: 700}}}}} + write := func() { + t.Helper() + raw, e := json.Marshal(document) + if e != nil { + t.Fatal(e) + } + if e = os.WriteFile(spool, raw, 0600); e != nil { + t.Fatal(e) + } + } + write() + query := func(accountID string, claim bool) uint64 { + t.Helper() + directory := filepath.Join(root, accountID) + if e := os.MkdirAll(directory, 0700); e != nil { + t.Fatal(e) + } + repo, e := accountstate.Open(filepath.Join(directory, "state.db")) + if e != nil { + t.Fatal(e) + } + defer func() { _ = repo.Close() }() + tx, e := repo.BeginCommand(context.Background()) + if e != nil { + t.Fatal(e) + } + defer func() { _ = tx.Rollback() }() + ctx := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "login", RequestID: "mail"}, State: tx} + scope := stateio.RootStore{} + wallet, e := inventory.OpenWallet(ctx, scope, inventory.Currency{}) + if e != nil { + t.Fatal(e) + } + items, e := inventory.OpenInventory(ctx, scope, nil) + if e != nil { + t.Fatal(e) + } + service, e := mail.OpenService(ctx, scope, &mail.Starter{Version: versionconfig.State(), MailCount: 1}, items, wallet) + if e != nil { + t.Fatal(e) + } + for _, persist := range []func(command.Context) error{wallet.EnsurePersisted, items.EnsurePersisted, service.EnsurePersisted} { + if e = persist(ctx); e != nil { + t.Fatal(e) + } + } + if e = service.AttachGrantSpoolPath(ctx, spool); e != nil { + t.Fatal(e) + } + _, body, handled, e := service.Handle(ctx, "/MailInfo", mailScalar(1, 1)) + if e != nil || !handled { + t.Fatal("mail query rejected", e) + } + id := firstMailboxID(body) + if claim { + if id == 0 { + t.Fatal("recipient did not receive compensation") + } + req := append(mailScalar(1, 2), mailScalar(2, id)...) + if _, _, _, e = service.Handle(ctx, "/MailOpen", req); e != nil { + t.Fatal(e) + } + } else if id != 0 { + t.Fatal("compensation sent to unrelated player", accountID, id) + } + gold := wallet.Snapshot(ctx).Gold + if e = tx.Commit(); e != nil { + t.Fatal(e) + } + return gold + } + if gold := query("new-player", false); gold != 0 { + t.Fatal("new player acquired original owner compensation") + } + if gold := query("original-owner", true); gold != 700 { + t.Fatal("claim did not credit named recipient exactly once", gold) + } + if gold := query("original-owner", false); gold != 700 { + t.Fatal("restart/import duplicated compensation", gold) + } + if gold := query("new-player", false); gold != 0 { + t.Fatal("recipient claim leaked to other account") + } +} diff --git a/go/internal/server/domain/progression/achievements/events.go b/go/internal/server/domain/progression/achievements/events.go new file mode 100644 index 0000000..18e6b2f --- /dev/null +++ b/go/internal/server/domain/progression/achievements/events.go @@ -0,0 +1,125 @@ +package achievements + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" +) + +type GameplayAchievementRecordedEvent struct { + Identity string + Type, SubType, Count uint64 +} + +// ApplyGameplayProgress keeps one observer boundary on one validated counter +// snapshot. Conditions are applied first and events retain their original +// order. Event receipts and all counter changes are written atomically; the +// next boundary loads fresh state from its transaction, including after retry. +func (s *AchievementService) ApplyGameplayProgress(ctx command.Context, conditions []GameplayAchievementCondition, events []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error) { + + state, err := s.load(ctx) + if err != nil { + return nil, nil, err + } + values := func() map[int]uint64 { + out := map[int]uint64{} + for key, value := range state.Counts { + group, _ := strconv.Atoi(key) // load validated every key. + out[group] = uint64(value) + } + return out + } + before := values() + groups := map[[2]uint64][]int{} + for group, condition := range s.design.Conditions { + key := [2]uint64{condition.Type, condition.SubType} + groups[key] = append(groups[key], group) + } + for key := range groups { + sort.Ints(groups[key]) + } + var changes []stateio.EntryMutation + set := func(group int, value int64) { + key := strconv.Itoa(group) + state.Counts[key] = value + raw, _ := json.Marshal(value) + changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw}) + } + for _, condition := range conditions { + if condition.Value > math.MaxInt64 { + return nil, nil, fmt.Errorf("achievement: invalid absolute value") + } + for _, group := range groups[[2]uint64{condition.Type, condition.SubType}] { + if state.Counts[strconv.Itoa(group)] != int64(condition.Value) { + set(group, int64(condition.Value)) + } + } + } + pending := map[string]string{} + for _, event := range events { + if event.Identity == "" || event.Count == 0 || event.Count > math.MaxInt64 { + return nil, nil, fmt.Errorf("achievement: invalid event") + } + digest := fmt.Sprintf("%d/%d/%d", event.Type, event.SubType, event.Count) + raw, found, err := s.store.LoadEntry(ctx.State, "missions", "achievement_events", event.Identity) + if err != nil { + return nil, nil, err + } + if prior, exists := pending[event.Identity]; exists { + raw, found = []byte(prior), true + } + if found { + if string(raw) != digest { + return nil, nil, fmt.Errorf("achievement: event replay conflicts") + } + continue + } + for _, group := range groups[[2]uint64{event.Type, event.SubType}] { + old := state.Counts[strconv.Itoa(group)] + if old > math.MaxInt64-int64(event.Count) { + return nil, nil, fmt.Errorf("achievement: event overflow") + } + set(group, old+int64(event.Count)) + } + pending[event.Identity] = digest + changes = append(changes, stateio.EntryMutation{Bucket: "achievement_events", Key: event.Identity, Payload: []byte(digest)}) + } + if len(changes) != 0 { + if err := s.store.SaveWithEntries(ctx.State, "missions", nil, changes); err != nil { + return nil, nil, err + } + } + return before, values(), nil +} + +func (s *AchievementService) AchievementValue(ctx command.Context, groupID uint64) (uint64, error) { + + state, err := s.load(ctx) + if err != nil { + return 0, err + } + return uint64(state.Counts[strconv.FormatUint(groupID, 10)]), nil +} + +// SetCondition only accepts exact current-state conditions; cumulative events use RecordEvent. + +func (s *AchievementService) CounterValues(ctx command.Context) (map[int]uint64, error) { + + state, err := s.load(ctx) + if err != nil { + return nil, err + } + out := map[int]uint64{} + for key, value := range state.Counts { + group, err := strconv.Atoi(key) + if err != nil { + return nil, err + } + out[group] = uint64(value) + } + return out, nil +} diff --git a/go/internal/server/domain/progression/achievements/gameplay.go b/go/internal/server/domain/progression/achievements/gameplay.go new file mode 100644 index 0000000..19d0cd5 --- /dev/null +++ b/go/internal/server/domain/progression/achievements/gameplay.go @@ -0,0 +1,263 @@ +package achievements + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "fmt" + "sort" + "strconv" +) + +// GameplayAchievementCounter persists each event in the request transaction. +type GameplayAchievementCounter interface { + RecordEvent(ctx command.Context, _ string, _ uint64, _ uint64, _ uint64) ([][]byte, error) + SetCondition(ctx command.Context, _ uint64, _ uint64, _ uint64) ([][]byte, error) + CounterValues(ctx command.Context) (map[int]uint64, error) +} + +type gameplayAchievementProgressBatch interface { + ApplyGameplayProgress(ctx command.Context, _ []GameplayAchievementCondition, _ []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error) +} +type GameplayAchievementEvent struct { + Type, SubType, Count uint64 + Identity string + StableIdentity bool +} +type GameplayAchievementCondition struct{ Type, SubType, Value uint64 } +type GameplayAchievementSnapshot struct { + Characters map[uint64]roster.Character + Costumes map[uint64]roster.Costume + Equipment map[uint64]assets.Equipment + Items map[[2]uint64]uint64 + Conditions []GameplayAchievementCondition + FieldObjects map[string]gamedata.FieldRewardObject + GachaGrants map[string]uint64 +} +type GameplayAchievementProvider interface { + Snapshot(ctx command.Context) (GameplayAchievementSnapshot, error) + Events(string, []byte, []byte, GameplayAchievementSnapshot, GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error) +} +type GameplayAchievementObserver struct { + counter GameplayAchievementCounter + provider GameplayAchievementProvider + + before GameplayAchievementSnapshot + counters map[int]uint64 + conditionValues map[int]uint64 + version string + ready bool +} + +func NewGameplayAchievementObserver(counter GameplayAchievementCounter, provider GameplayAchievementProvider) (*GameplayAchievementObserver, error) { + if counter == nil || provider == nil { + return nil, fmt.Errorf("achievement: gameplay provider unavailable") + } + return &GameplayAchievementObserver{counter: counter, provider: provider}, nil +} +func (s *GameplayAchievementObserver) BeginLogin(ctx command.Context) { + s.ready = false +} + +func (s *GameplayAchievementObserver) observationVersion() string { + if provider, ok := s.provider.(interface{ ObservationVersion() string }); ok { + return provider.ObservationVersion() + } + return "" +} + +type GameplayCharacterSource interface{ RawAll() []roster.Character } +type GameplayGachaSource interface{ GachaGrantSummary() map[string]uint64 } +type GameplayCostumeSource interface{ Costumes() []roster.Costume } +type GameplayEquipmentSource interface { + All(ctx command.Context) []assets.Equipment +} +type GameplayItemSource interface { + All(ctx command.Context) []assets.Item +} + +// OwnedGameplayAchievementProvider uses before/after authoritative ownership. +// GET conditions are event counters: existing inventory is never a historical +// acquisition total. Conditions are supplied only by proven state projections. +type OwnedGameplayAchievementProvider struct { + Gacha GameplayGachaSource + Characters GameplayCharacterSource + Costumes GameplayCostumeSource + Equipment GameplayEquipmentSource + Items GameplayItemSource + Design *gamedata.AchievementCounterDesign + CharacterGrades, EquipmentGrades map[uint64]uint64 + Conditions func(command.Context) ([]GameplayAchievementCondition, error) + FieldObjects func(command.Context) (map[string]gamedata.FieldRewardObject, error) + StateVersion func() uint64 + TransientVersion func() string +} + +func (p *OwnedGameplayAchievementProvider) ObservationVersion() string { + if p.StateVersion == nil { + return "" + } + transient := "" + if p.TransientVersion != nil { + transient = p.TransientVersion() + } + var costumes uint64 + if source, ok := p.Costumes.(interface{ ObservationVersion() uint64 }); ok { + costumes = source.ObservationVersion() + } + return fmt.Sprintf("%d/%d/%s", p.StateVersion(), costumes, transient) +} + +func (p *OwnedGameplayAchievementProvider) Snapshot(ctx command.Context) (GameplayAchievementSnapshot, error) { + s, err := p.InventorySnapshot(ctx) + if err != nil { + return s, err + } + s.Characters = map[uint64]roster.Character{} + if p.Characters != nil { + for _, v := range p.Characters.RawAll() { + if roster.IsCharmCharacter(v) || roster.IsStoryCharacter(v) { + continue + } + s.Characters[v.InvenIndex] = v + } + } + if p.Conditions != nil { + s.Conditions, err = p.Conditions(ctx) + } + if err == nil && p.FieldObjects != nil { + s.FieldObjects, err = p.FieldObjects(ctx) + } + if p.Gacha != nil { + s.GachaGrants = p.Gacha.GachaGrantSummary() + } + return s, err +} + +// InventorySnapshot projects the three authoritative domains used by event +// missions. It reads a fresh before/after view, without querying unrelated +// field-object, main-quest, character or gacha-history projections. +func (p *OwnedGameplayAchievementProvider) InventorySnapshot(ctx command.Context) (GameplayAchievementSnapshot, error) { + s := GameplayAchievementSnapshot{Costumes: map[uint64]roster.Costume{}, Equipment: map[uint64]assets.Equipment{}, Items: map[[2]uint64]uint64{}} + if p.Costumes != nil { + for _, v := range p.Costumes.Costumes() { + s.Costumes[v.InvenIndex] = v + } + } + if p.Equipment != nil { + for _, v := range p.Equipment.All(ctx) { + s.Equipment[v.InvenIndex] = v + } + } + if p.Items != nil { + for _, v := range p.Items.All(ctx) { + s.Items[[2]uint64{v.Type, v.ID}] += v.Count + } + } + return s, nil +} +func (p *OwnedGameplayAchievementProvider) Events(path string, _ []byte, _ []byte, before, after GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error) { + var events []GameplayAchievementEvent + conditions := map[[2]uint64]bool{} + if p.Design != nil { + for _, condition := range p.Design.Conditions { + conditions[[2]uint64{condition.Type, condition.SubType}] = true + } + } + emit := func(kind, sub, count uint64, id string) { + if count == 0 { + return + } + if p.Design != nil && !conditions[[2]uint64{kind, sub}] { + return + } + events = append(events, GameplayAchievementEvent{Type: kind, SubType: sub, Count: count, Identity: id}) + } + for index, v := range after.Characters { + old, exists := before.Characters[index] + id := "char:" + strconv.FormatUint(index, 10) + if !exists { + emit(19, 0, 1, id) + if grade := p.CharacterGrades[v.ID]; grade != 0 { + emit(20, grade, 1, id) + } + } + if exists && v.Level > old.Level { + emit(22, 0, v.Level-old.Level, id) + } + } + for index, v := range after.Costumes { + old, exists := before.Costumes[index] + id := "costume:" + strconv.FormatUint(index, 10) + if !exists { + emit(26, 0, 1, id) + emit(26, v.ID, 1, id) + } + if exists && v.Level > old.Level { + emit(27, 0, v.Level-old.Level, id) + } + } + for index, v := range after.Equipment { + old, exists := before.Equipment[index] + id := "equipment:" + strconv.FormatUint(index, 10) + if !exists { + emit(7, 0, 1, id) + if grade := p.EquipmentGrades[v.ID]; grade != 0 { + emit(7, grade, 1, id) + } + } + if exists && v.UpgradeAttempts > old.UpgradeAttempts { + successes := uint64(0) + if v.Level > old.Level { + successes = v.Level - old.Level + } + attempts := v.UpgradeAttempts - old.UpgradeAttempts + if successes > attempts { + return nil, fmt.Errorf("achievement: equipment upgrade delta exceeds attempts") + } + if successes > 0 { + emit(9, p.EquipmentGrades[v.ID], successes, id) + } + emit(10, 0, attempts-successes, id) + } + } + if path == "/EatFood" || path == "/EatFoodAuto" { + for key, count := range before.Items { + if key[0] == 5 && count > after.Items[key] { + emit(55, 0, count-after.Items[key], "food:"+strconv.FormatUint(key[1], 10)) + } + } + } + for key, obj := range after.FieldObjects { + if _, exists := before.FieldObjects[key]; !exists { + switch obj.Type { + case 1: + emit(28, 0, 1, key) + case 2: + emit(31, 0, 1, key) + case 5: + emit(30, 0, 1, key) + } + } + } + for identity, count := range after.GachaGrants { + if _, exists := before.GachaGrants[identity]; !exists { + emit(54, 0, count, "gacha-grant:"+identity) + if len(events) > 0 && events[len(events)-1].Identity == "gacha-grant:"+identity { + events[len(events)-1].StableIdentity = true + } + } + } + sort.Slice(events, func(i, j int) bool { + a, b := events[i], events[j] + if a.Type != b.Type { + return a.Type < b.Type + } + if a.SubType != b.SubType { + return a.SubType < b.SubType + } + return a.Identity < b.Identity + }) + return events, nil +} diff --git a/go/internal/server/domain/progression/achievements/protocol_adapter.go b/go/internal/server/domain/progression/achievements/protocol_adapter.go new file mode 100644 index 0000000..7c0c100 --- /dev/null +++ b/go/internal/server/domain/progression/achievements/protocol_adapter.go @@ -0,0 +1,381 @@ +package achievements + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" +) + +func (s *AchievementService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/AchievementInfo" && path != "/AchievementUpdate" { + return 0, nil, false, nil + } + + fail := func(err error) (int, []byte, bool, error) { return 0, nil, true, err } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return fail(ErrInvalidRequest) + } + state, err := s.load(ctx) + if err != nil { + return fail(err) + } + if path == "/AchievementInfo" { + claimed := map[gamedata.AchievementKey]bool{} + if s.claims != nil { + claimed = s.claims.ClaimedAchievementIDs() + } + for key := range claimed { + group := strconv.Itoa(int(key.GroupID)) + if _, found := state.Counts[group]; !found && len(s.design.Groups[int(key.GroupID)]) > 0 { + state.Counts[group] = 0 + } + } + groups := make([]int, 0, len(state.Counts)) + for key := range state.Counts { + group, _ := strconv.Atoi(key) + groups = append(groups, group) + } + sort.Ints(groups) + var response []byte + for _, group := range groups { + for _, content := range s.design.Groups[group] { + row := wire.AppendVarint(nil, 1, uint64(group)) + row = wire.AppendVarint(row, 2, uint64(state.Counts[strconv.Itoa(group)])) + var maxID uint64 + // AchievementPacket.UpdateAchievementCount initializes title MaxClearId + // to 1000; this is the client protocol default, not a granted tier. + if content == 1 { + maxID = 1000 + } + for key := range claimed { + if key.GroupID == uint64(group) && key.ContentsGroup == uint64(content) && key.ID > maxID { + maxID = key.ID + } + } + if maxID > 0 { + row = wire.AppendVarint(row, 3, maxID) + } + if content != 0 { + row = wire.AppendVarint(row, 4, uint64(content)) + } + response = wire.AppendBytes(response, 1, row) + } + } + // Only the mission domain's actual claims advance max_clear_id. + return 166, response, true, nil + } + group, found, err := wire.Varint(request, 2) + if err != nil || !found || group == 0 || group > math.MaxInt32 || len(s.design.Groups[int(group)]) == 0 { + return fail(ErrInvalidRequest) + } + add, found, err := wire.Varint(request, 3) + if err != nil || !found || add == 0 || add > math.MaxInt32 || ctx.SessionID == "" { + return fail(ErrInvalidRequest) + } + receipt := achievementReceipt{Sequence: seq, Group: int(group), Add: int(add)} + replayKey := ctx.SessionID + "/" + strconv.FormatUint(seq, 10) + if raw, found, err := s.store.LoadEntry(ctx.State, "missions", "achievement_replays", replayKey); err != nil { + return fail(err) + } else if found { + var previous achievementReceipt + if err = json.Unmarshal(raw, &previous); err != nil { + return fail(err) + } + if previous == receipt { + return 167, nil, true, nil + } + return fail(fmt.Errorf("achievement: conflicting request sequence")) + } + if previous, ok := state.Receipts[ctx.SessionID]; ok && seq <= previous.Sequence { + if previous == receipt { + return 167, nil, true, nil + } + return fail(fmt.Errorf("achievement: stale or conflicting request sequence")) + } + key := strconv.Itoa(int(group)) + current := state.Counts[key] + if current > math.MaxInt64-int64(add) { + return fail(fmt.Errorf("achievement: counter overflow")) + } + state.Counts[key] = current + int64(add) + state.Receipts[ctx.SessionID] = receipt + raw, err := json.Marshal(state.Counts[key]) + if err != nil { + return fail(err) + } + receiptRaw, err := json.Marshal(receipt) + if err != nil { + return fail(err) + } + changes := []stateio.EntryMutation{{Bucket: "achievement_counts", Key: key, Payload: raw}, {Bucket: "achievement_receipts", Key: ctx.SessionID, Payload: receiptRaw}, {Bucket: "achievement_replays", Key: replayKey, Payload: receiptRaw}} + // Retain a complete retry window so a committed BatchRequest whose response + // was lost can replay several updates, not just the final update in the batch. + if seq > 256 { + entries, err := s.store.ListEntries(ctx.State, "missions", "achievement_replays") + if err != nil { + return fail(err) + } + prefix := ctx.SessionID + "/" + for replayKey := range entries { + if suffix, found := strings.CutPrefix(replayKey, prefix); found { + old, err := strconv.ParseUint(suffix, 10, 64) + if err != nil { + return fail(err) + } + if old <= seq-256 { + changes = append(changes, stateio.EntryMutation{Bucket: "achievement_replays", Key: replayKey, Delete: true}) + } + } + } + } + if err = s.store.SaveWithEntries(ctx.State, "missions", nil, changes); err != nil { + return fail(err) + } + return 167, nil, true, nil +} + +// SyncRecordedHistory must run within the startup account transaction. Retained +// real grants establish a lower bound; inventory never substitutes for history. +func (s *GameplayAchievementObserver) SyncRecordedHistory(ctx command.Context) error { + snapshot, err := s.provider.Snapshot(ctx) + if err != nil { + return err + } + var ids []string + for identity := range snapshot.GachaGrants { + ids = append(ids, identity) + } + sort.Strings(ids) + var events []GameplayAchievementRecordedEvent + for _, identity := range ids { + count := snapshot.GachaGrants[identity] + if count == 0 { + continue + } + events = append(events, GameplayAchievementRecordedEvent{Identity: "gacha-grant:" + identity, Type: 54, Count: count}) + } + if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok { + _, _, err := batch.ApplyGameplayProgress(ctx, snapshot.Conditions, events) + return err + } + for _, condition := range snapshot.Conditions { + if _, err := s.counter.SetCondition(ctx, condition.Type, condition.SubType, condition.Value); err != nil { + return err + } + } + for _, event := range events { + if _, err := s.counter.RecordEvent(ctx, event.Identity, event.Type, event.SubType, event.Count); err != nil { + return err + } + } + return nil +} + +func (s *GameplayAchievementObserver) BeforeDispatch(ctx command.Context, _ string, _ []byte) error { + version := s.observationVersion() + if s.ready && version != "" && version == s.version { + return nil + } + s.ready = false + var err error + s.before, err = s.provider.Snapshot(ctx) + if err != nil { + return err + } + if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok { + s.counters, s.conditionValues, err = batch.ApplyGameplayProgress(ctx, s.before.Conditions, nil) + if err == nil { + s.version = s.observationVersion() + s.ready = true + } + return err + } + s.counters, err = s.counter.CounterValues(ctx) + if err != nil { + return err + } + for _, condition := range s.before.Conditions { + if _, err := s.counter.SetCondition(ctx, condition.Type, condition.SubType, condition.Value); err != nil { + return err + } + } + s.conditionValues, err = s.counter.CounterValues(ctx) + if err != nil { + return err + } + s.version = s.observationVersion() + s.ready = true + return nil +} + +func (s *GameplayAchievementObserver) AfterDispatch(ctx command.Context, path string, request, response []byte) ([]byte, error) { + version := s.observationVersion() + if s.ready && version != "" && version == s.version { + notify := achievementCountNotifications(s.counters, s.conditionValues) + s.counters = s.conditionValues + return notify, nil + } + s.ready = false + after, err := s.provider.Snapshot(ctx) + if err != nil { + return nil, err + } + batch, batches := s.counter.(gameplayAchievementProgressBatch) + if !batches { + for _, condition := range after.Conditions { + if _, err := s.counter.SetCondition(ctx, condition.Type, condition.SubType, condition.Value); err != nil { + return nil, err + } + } + } + events, err := s.provider.Events(path, request, response, s.before, after) + if err != nil { + return nil, err + } + var recorded []GameplayAchievementRecordedEvent + if len(events) > 0 { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || ctx.SessionID == "" { + return nil, fmt.Errorf("achievement: gameplay event sequence unavailable") + } + for i, event := range events { + identity := fmt.Sprintf("%s/%s/%d/%d/%s", ctx.SessionID, path, seq, i, event.Identity) + if event.StableIdentity { + identity = event.Identity + } + recorded = append(recorded, GameplayAchievementRecordedEvent{Identity: identity, Type: event.Type, SubType: event.SubType, Count: event.Count}) + } + } + var values map[int]uint64 + if batches { + _, values, err = batch.ApplyGameplayProgress(ctx, after.Conditions, recorded) + } else { + for _, event := range recorded { + if _, err = s.counter.RecordEvent(ctx, event.Identity, event.Type, event.SubType, event.Count); err != nil { + return nil, err + } + } + values, err = s.counter.CounterValues(ctx) + } + if err != nil { + return nil, err + } + notify := achievementCountNotifications(s.counters, values) + s.before, s.counters, s.conditionValues = after, values, values + s.version = s.observationVersion() + s.ready = true + return notify, nil +} + +func achievementCountNotifications(before, values map[int]uint64) []byte { + var groups []int + for group, value := range values { + if before[group] != value { + groups = append(groups, group) + } + } + sort.Ints(groups) + var notify []byte + for _, group := range groups { + row := wire.AppendVarint(nil, 1, uint64(group)) + row = wire.AppendVarint(row, 2, values[group]) + row = wire.AppendVarint(row, 3, 1) + notify = wire.AppendBytes(notify, 2, row) + } + return notify +} + +// RecordEvent is an authoritative event increment, independently replayable +// by a domain operation identity. It does not infer cumulative counts from +// inventory balances, which lose consumed and discarded items. +func (s *AchievementService) RecordEvent(ctx command.Context, identity string, conditionType, subType, count uint64) ([][]byte, error) { + if identity == "" || count == 0 || count > math.MaxInt64 { + return nil, fmt.Errorf("achievement: invalid event") + } + + digest := fmt.Sprintf("%d/%d/%d", conditionType, subType, count) + if raw, found, err := s.store.LoadEntry(ctx.State, "missions", "achievement_events", identity); err != nil { + return nil, err + } else if found { + if string(raw) != digest { + return nil, fmt.Errorf("achievement: event replay conflicts") + } + return nil, nil + } + state, err := s.load(ctx) + if err != nil { + return nil, err + } + var groups []int + for group, c := range s.design.Conditions { + if c.Type == conditionType && c.SubType == subType { + groups = append(groups, group) + } + } + sort.Ints(groups) + changes := []stateio.EntryMutation{{Bucket: "achievement_events", Key: identity, Payload: []byte(digest)}} + var updates [][]byte + for _, group := range groups { + key := strconv.Itoa(group) + if state.Counts[key] > math.MaxInt64-int64(count) { + return nil, fmt.Errorf("achievement: event overflow") + } + value := state.Counts[key] + int64(count) + raw, _ := json.Marshal(value) + changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw}) + update := wire.AppendVarint(nil, 1, uint64(group)) + update = wire.AppendVarint(update, 2, uint64(value)) + update = wire.AppendVarint(update, 3, 1) + updates = append(updates, update) + } + if err := s.store.SaveWithEntries(ctx.State, "missions", nil, changes); err != nil { + return nil, err + } + return updates, nil +} + +func (s *AchievementService) SetCondition(ctx command.Context, conditionType, subType, value uint64) ([][]byte, error) { + if value > math.MaxInt64 { + return nil, fmt.Errorf("achievement: invalid absolute value") + } + + state, err := s.load(ctx) + if err != nil { + return nil, err + } + var groups []int + for group, c := range s.design.Conditions { + if c.Type == conditionType && c.SubType == subType { + groups = append(groups, group) + } + } + sort.Ints(groups) + var changes []stateio.EntryMutation + var updates [][]byte + for _, group := range groups { + key := strconv.Itoa(group) + if state.Counts[key] == int64(value) { + continue + } + raw, _ := json.Marshal(value) + changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw}) + update := wire.AppendVarint(nil, 1, uint64(group)) + update = wire.AppendVarint(update, 2, value) + updates = append(updates, wire.AppendVarint(update, 3, 1)) + } + if len(changes) == 0 { + return nil, nil + } + if err := s.store.SaveWithEntries(ctx.State, "missions", nil, changes); err != nil { + return nil, err + } + return updates, nil +} diff --git a/go/internal/server/domain/progression/achievements/service.go b/go/internal/server/domain/progression/achievements/service.go new file mode 100644 index 0000000..a81c34e --- /dev/null +++ b/go/internal/server/domain/progression/achievements/service.go @@ -0,0 +1,87 @@ +package achievements + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "strconv" +) + +type AchievementService struct { + design *gamedata.AchievementCounterDesign + store stateio.ScopedEntryStore + + claims AchievementClaimSource +} +type AchievementClaimSource interface { + ClaimedAchievementIDs() map[gamedata.AchievementKey]bool +} +type achievementReceipt struct { + Sequence uint64 `json:"sequence"` + Group int `json:"group"` + Add int `json:"add"` +} +type achievementSnapshot struct { + Counts map[string]int64 `json:"counts"` + Receipts map[string]achievementReceipt `json:"receipts"` +} + +func NewAchievementService(design *gamedata.AchievementCounterDesign, store stateio.Store, claims ...AchievementClaimSource) (*AchievementService, error) { + if design == nil || len(design.Groups) == 0 || store == nil { + return nil, fmt.Errorf("achievement: missing design or storage") + } + entries, ok := store.(stateio.ScopedEntryStore) + if !ok { + return nil, fmt.Errorf("achievement: storage requires atomic entries") + } + service := &AchievementService{design: design, store: entries} + if len(claims) > 0 { + service.claims = claims[0] + } + return service, nil +} + +// Load each request from the transaction snapshot. A rolled-back request must +// never leave an increment or replay receipt in an in-memory cache. +func (s *AchievementService) load(ctx command.Context) (achievementSnapshot, error) { + state := achievementSnapshot{Counts: map[string]int64{}, Receipts: map[string]achievementReceipt{}} + counts, err := s.store.ListEntries(ctx.State, "missions", "achievement_counts") + if err != nil { + return state, err + } + for key, raw := range counts { + var value int64 + if err = json.Unmarshal(raw, &value); err != nil { + return state, fmt.Errorf("achievement: invalid counter: %w", err) + } + state.Counts[key] = value + } + if ctx.SessionID != "" { + raw, found, err := s.store.LoadEntry(ctx.State, "missions", "achievement_receipts", ctx.SessionID) + if err != nil { + return state, err + } + if found { + var receipt achievementReceipt + if err = json.Unmarshal(raw, &receipt); err != nil { + return state, fmt.Errorf("achievement: invalid receipt: %w", err) + } + state.Receipts[ctx.SessionID] = receipt + } + } + if state.Counts == nil || state.Receipts == nil { + return state, fmt.Errorf("achievement: incomplete state") + } + for key, value := range state.Counts { + group, err := strconv.Atoi(key) + if err != nil || len(s.design.Groups[group]) == 0 || value < 0 { + return state, fmt.Errorf("achievement: invalid persisted counter") + } + } + return state, nil +} + +var ErrInvalidRequest = errors.New("world: invalid protobuf request") diff --git a/go/internal/server/missions/achievement_claim_state.go b/go/internal/server/domain/progression/missions/achievement_claim_state.go similarity index 93% rename from go/internal/server/missions/achievement_claim_state.go rename to go/internal/server/domain/progression/missions/achievement_claim_state.go index 00a7ff0..0f21f25 100644 --- a/go/internal/server/missions/achievement_claim_state.go +++ b/go/internal/server/domain/progression/missions/achievement_claim_state.go @@ -1,7 +1,7 @@ package missions import ( - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" "fmt" "math" "strings" @@ -10,8 +10,7 @@ import ( // ClaimedAchievementIDs exposes committed claims to the counter query adapter. // The mission domain remains the sole owner of reward claims. func (s *Service) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool { - s.mu.Lock() - defer s.mu.Unlock() + out := map[gamedata.AchievementKey]bool{} for key := range s.design.Achievements { if contains(s.state.Claimed, "achievement:"+achievementName(key)) { @@ -24,8 +23,7 @@ func (s *Service) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool { // AchievementExperience derives exact earned experience from durable claimed // identities and versioned table rewards. It does not manufacture levels. func (s *Service) AchievementExperience() (uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() + return s.achievementExperienceLocked() } func (s *Service) achievementExperienceLocked() (uint64, error) { diff --git a/go/internal/server/missions/events.go b/go/internal/server/domain/progression/missions/events.go similarity index 63% rename from go/internal/server/missions/events.go rename to go/internal/server/domain/progression/missions/events.go index 9f9e835..9935e6b 100644 --- a/go/internal/server/missions/events.go +++ b/go/internal/server/domain/progression/missions/events.go @@ -4,7 +4,8 @@ import ( "errors" "sort" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" ) // These values are Define_MissionConditionType protocol enums, not mission IDs. @@ -17,57 +18,54 @@ const ( // RecordEvent applies an authoritative gameplay count to every matching regular // mission. The caller supplies quest unlock state; locked and scheduled event // missions cannot become eligible merely because they share the condition. -func (s *Service) RecordEvent(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error { +func (s *Service) RecordEvent(ctx command.Context, conditionType, subType, count uint64, unlocked func(command.Context, uint64, uint64) bool) error { if observer, ok := s.eventHandler.(interface { - RecordEvent(uint64, uint64, uint64, func(uint64, uint64) bool) error + RecordEvent(ctx command.Context, _ uint64, _ uint64, _ uint64, _ func(command.Context, uint64, uint64) bool) error }); ok { - if err := observer.RecordEvent(conditionType, subType, count, unlocked); err != nil { + if err := observer.RecordEvent(ctx, conditionType, subType, count, unlocked); err != nil { return err } } - s.mu.Lock() - defer s.mu.Unlock() - if err := s.rolloverLocked(); err != nil { + + if err := s.rolloverLocked(ctx); err != nil { return err } - return s.recordEventLocked(conditionType, subType, count, unlocked) + return s.recordEventLocked(ctx, conditionType, subType, count, unlocked) } // RecordLogin records at most one connection per daily period. The existing // progress map stores the period marker, so reconnects and restarts cannot // inflate weekly connection missions and no additional save format is needed. -func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error { +func (s *Service) RecordLogin(ctx command.Context, unlocked func(command.Context, uint64, uint64) bool) error { if observer, ok := s.eventHandler.(interface { - RecordLogin(func(uint64, uint64) bool) error + RecordLogin(ctx command.Context, _ func(command.Context, uint64, uint64) bool) error }); ok { - if err := observer.RecordLogin(unlocked); err != nil { + if err := observer.RecordLogin(ctx, unlocked); err != nil { return err } } - s.mu.Lock() - defer s.mu.Unlock() - if err := s.rolloverLocked(); err != nil { + + if err := s.rolloverLocked(ctx); err != nil { return err } if s.state.Progress["0/event/connect"] != 0 { return nil } - return s.recordEventLocked(ConditionConnect, 0, 1, unlocked) + return s.recordEventLocked(ctx, ConditionConnect, 0, 1, unlocked) } // CompleteSingleTargetEvent covers evidence of at least one occurrence, without // inventing a count or subtype for richer missions (e.g. a monster encounter win). -func (s *Service) CompleteSingleTargetEvent(conditionType uint64, unlocked func(uint64, uint64) bool) error { +func (s *Service) CompleteSingleTargetEvent(ctx command.Context, conditionType uint64, unlocked func(command.Context, uint64, uint64) bool) error { if observer, ok := s.eventHandler.(interface { - CompleteSingleTargetEvent(uint64, func(uint64, uint64) bool) error + CompleteSingleTargetEvent(ctx command.Context, _ uint64, _ func(command.Context, uint64, uint64) bool) error }); ok { - if err := observer.CompleteSingleTargetEvent(conditionType, unlocked); err != nil { + if err := observer.CompleteSingleTargetEvent(ctx, conditionType, unlocked); err != nil { return err } } - s.mu.Lock() - defer s.mu.Unlock() - if err := s.rolloverLocked(); err != nil { + + if err := s.rolloverLocked(ctx); err != nil { return err } next := cloneSnapshot(s.state) @@ -75,7 +73,7 @@ func (s *Service) CompleteSingleTargetEvent(conditionType uint64, unlocked func( if key.GroupType > 1 || c.Type != conditionType || c.SubType != 0 || c.TargetValue != 1 || len(c.Params) != 0 { continue } - if (c.UnlockPack != 0 || c.UnlockQuest != 0) && (unlocked == nil || !unlocked(c.UnlockPack, c.UnlockQuest)) { + if (c.UnlockPack != 0 || c.UnlockQuest != 0) && (unlocked == nil || !unlocked(ctx, c.UnlockPack, c.UnlockQuest)) { continue } name := missionName(key) @@ -85,10 +83,10 @@ func (s *Service) CompleteSingleTargetEvent(conditionType uint64, unlocked func( next.Progress[name] = 1 s.applyCompletionDependencies(&next, key) } - return s.commit(next) + return s.commit(ctx, next) } -func (s *Service) recordEventLocked(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error { +func (s *Service) recordEventLocked(ctx command.Context, conditionType, subType, count uint64, unlocked func(command.Context, uint64, uint64) bool) error { if count == 0 { return errors.New("missions: event count is zero") } @@ -101,7 +99,7 @@ func (s *Service) recordEventLocked(conditionType, subType, count uint64, unlock if condition.SubType != 0 && ((condition.SubTypeComparison == 0 && condition.SubType != subType) || (condition.SubTypeComparison == 1 && subType < condition.SubType)) { continue } - if (condition.UnlockPack != 0 || condition.UnlockQuest != 0) && (unlocked == nil || !unlocked(condition.UnlockPack, condition.UnlockQuest)) { + if (condition.UnlockPack != 0 || condition.UnlockQuest != 0) && (unlocked == nil || !unlocked(ctx, condition.UnlockPack, condition.UnlockQuest)) { continue } keys = append(keys, key) @@ -135,5 +133,5 @@ func (s *Service) recordEventLocked(conditionType, subType, count uint64, unlock if conditionType == ConditionConnect { next.Progress["0/event/connect"] = 1 } - return s.commit(next) + return s.commit(ctx, next) } diff --git a/go/internal/server/domain/progression/missions/missions.go b/go/internal/server/domain/progression/missions/missions.go new file mode 100644 index 0000000..86b2133 --- /dev/null +++ b/go/internal/server/domain/progression/missions/missions.go @@ -0,0 +1,654 @@ +// Package missions owns local completion eligibility and claim state for the +// three regular (non-scheduled-event) mission endpoints. Static definitions +// come from GameData; this package never replays an HTTP capture. +package missions + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/mail" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "sort" + "strconv" + "strings" + + "time" +) + +const ( + missionClearPacket = 120 + missionSectionPacket = 122 + achievementClearPacket = 168 +) + +var ErrInvalidRequest = errors.New("missions: invalid request") + +// Service is deliberately given completion signals by authoritative gameplay +// code. A request cannot manufacture a mission completion merely by naming a +// table row. AchievementClear is different: its request carries the exact +// client-calculated completed achievement ids, as in the official protocol. +type AchievementProgressSource interface { + AchievementValue(ctx command.Context, groupID uint64) (uint64, error) +} + +func (s *Service) AttachAchievementProgress(ctx command.Context, source AchievementProgressSource) error { + if source == nil { + return errors.New("missions: nil achievement progress") + } + s.achievementProgress = source + return nil +} + +type Service struct { + eventHandler interface { + Handle(ctx command.Context, _ string, _ []byte) (int, []byte, bool, error) + } + levelRewards *gamedata.AchievementLevelDesign + levelReward uint64 + achievementProgress AchievementProgressSource + + storage stateio.Store + design *gamedata.MissionDesign + inventory *assets.Inventory + wallet *assets.Wallet + mail *mail.Service + state snapshot + now func() time.Time +} + +func (s *Service) AttachWallet(ctx command.Context, wallet *assets.Wallet) error { + if wallet == nil { + return errors.New("missions: nil wallet") + } + s.wallet = wallet + return nil +} + +type snapshot struct { + Version string `json:"version"` + DailyPeriod string `json:"daily_period"` + WeeklyPeriod string `json:"weekly_period"` + Completed []string `json:"completed"` + Claimed []string `json:"claimed"` + Progress map[string]uint64 `json:"progress"` +} + +func Open(ctx command.Context, storage stateio.Store, design *gamedata.MissionDesign, inventory *assets.Inventory) (*Service, error) { + if storage == nil || design == nil || inventory == nil { + return nil, errors.New("missions: invalid service configuration") + } + now := time.Now().UTC() + s := &Service{storage: storage, design: design, inventory: inventory, now: time.Now, state: snapshot{ + Version: versionconfig.State(), DailyPeriod: dailyPeriod(now), WeeklyPeriod: weeklyPeriod(now), Progress: map[string]uint64{}, + }} + b, err := storage.Load(ctx.State, "missions") + if err != nil { + return nil, fmt.Errorf("missions: load state: %w", err) + } + if b == nil { + return s, nil + } + if err := stateio.RequireExactJSONObject(b, "version", "daily_period", "weekly_period", "completed", "claimed", "progress"); err != nil { + return nil, fmt.Errorf("missions: incompatible state layout: %w", err) + } + if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() || s.state.DailyPeriod == "" || s.state.WeeklyPeriod == "" { + return nil, errors.New("missions: malformed state") + } + if s.state.Progress == nil { + return nil, errors.New("missions: progress must be an object") + } + return s, nil +} + +func (s *Service) AttachMail(ctx command.Context, mailbox *mail.Service) error { + if mailbox == nil { + return errors.New("missions: nil compensation mailbox") + } + + s.mail = mailbox + return nil +} + +func (s *Service) EnsurePersisted(ctx command.Context) error { + + b, err := s.storage.Load(ctx.State, "missions") + if err != nil { + return err + } + if b != nil { + return nil + } + return s.persist(ctx, cloneSnapshot(s.state)) +} + +// CompleteMission is the only way normal mission eligibility enters this +// package. It is persistent and idempotent. Gameplay/event handlers should +// call it only after they have independently verified the table condition. +func (s *Service) CompleteMission(ctx command.Context, key gamedata.MissionKey) error { + + if err := s.rolloverLocked(ctx); err != nil { + return err + } + if _, ok := s.design.Missions[key]; !ok { + return fmt.Errorf("missions: unknown mission %+v", key) + } + condition := s.design.Conditions[key] + value := condition.TargetValue + if value == 0 { + value = 1 + } + return s.setProgressLocked(ctx, key, value) +} + +func (s *Service) SetProgress(ctx command.Context, key gamedata.MissionKey, value uint64) error { + + if err := s.rolloverLocked(ctx); err != nil { + return err + } + return s.setProgressLocked(ctx, key, value) +} + +func (s *Service) setProgressLocked(ctx command.Context, key gamedata.MissionKey, value uint64) error { + if _, ok := s.design.Missions[key]; !ok { + return fmt.Errorf("missions: unknown mission %+v", key) + } + name := missionName(key) + if s.state.Progress[name] >= value { + return nil + } + next := cloneSnapshot(s.state) + next.Progress[name] = value + condition := s.design.Conditions[key] + target := condition.TargetValue + if target == 0 { + target = 1 + } + if s.state.Progress[name] < target && value >= target { + s.applyCompletionDependencies(&next, key) + } + return s.commit(ctx, next) +} + +func (s *Service) applyCompletionDependencies(next *snapshot, completed gamedata.MissionKey) { + queue := []gamedata.MissionKey{completed} + seen := map[string]bool{} + for len(queue) != 0 { + current := queue[0] + queue = queue[1:] + currentName := missionName(current) + if seen[currentName] { + continue + } + seen[currentName] = true + for key, condition := range s.design.Conditions { + if key.GroupType == 2 { + continue + } + matched := condition.Type == 30 && key.GroupType == current.GroupType + matched = matched || (condition.Type == 31 && condition.SubType == current.GroupType) + if !matched { + continue + } + name := missionName(key) + target := condition.TargetValue + if target == 0 { + target = 1 + } + before := next.Progress[name] + if before >= target { + continue + } + next.Progress[name] = min(before+1, target) + if before < target && next.Progress[name] >= target { + queue = append(queue, key) + } + } + } +} + +// AttachEventHandler delegates scheduled event operations before regular +// mission processing. The event service validates calendar and player state. +func (s *Service) AttachEventHandler(ctx command.Context, handler interface { + Handle(ctx command.Context, _ string, _ []byte) (int, []byte, bool, error) +}) error { + if handler == nil { + return errors.New("missions: nil event handler") + } + s.eventHandler = handler + return nil +} + +func dailyPeriod(now time.Time) string { return now.UTC().Format("2006-01-02") } + +func weeklyPeriod(now time.Time) string { + now = now.UTC() + daysSinceMonday := (int(now.Weekday()) + 6) % 7 + return now.AddDate(0, 0, -daysSinceMonday).Format("2006-01-02") +} + +func (s *Service) rolloverLocked(ctx command.Context) error { + if s.now == nil { + return errors.New("missions: missing clock") + } + now := s.now().UTC() + daily, weekly := dailyPeriod(now), weeklyPeriod(now) + if daily == s.state.DailyPeriod && weekly == s.state.WeeklyPeriod { + return nil + } + if s.mail == nil { + return errors.New("missions: compensation mailbox is not attached") + } + next := cloneSnapshot(s.state) + if daily != next.DailyPeriod { + if err := s.compensatePeriodLocked(ctx, 0, next.DailyPeriod, now); err != nil { + return err + } + clearMissionPeriod(&next, 0) + next.DailyPeriod = daily + } + if weekly != next.WeeklyPeriod { + if err := s.compensatePeriodLocked(ctx, 1, next.WeeklyPeriod, now); err != nil { + return err + } + clearMissionPeriod(&next, 1) + next.WeeklyPeriod = weekly + } + return s.commit(ctx, next) +} + +func (s *Service) compensatePeriodLocked(ctx command.Context, groupType uint64, period string, now time.Time) error { + label := "日常" + if groupType == 1 { + label = "周常" + } + for _, key := range s.completedUnclaimedForType(groupType) { + rewards := s.design.Missions[key] + if len(rewards) == 0 { + continue + } + identity := fmt.Sprintf("expired-mission:%d:%s:%s", groupType, period, missionName(key)) + title := label + "任务到期补发" + body := fmt.Sprintf("%s周期 %s 已结束。任务 %d/%d 已完成但未领取,奖励由系统自动补发。", label, period, key.GroupID, key.ID) + if err := s.mail.EnqueueCompensation(ctx, identity, title, body, rewards, now); err != nil { + return fmt.Errorf("missions: enqueue expired mission %+v: %w", key, err) + } + } + for _, key := range s.eligibleUnclaimedSectionsForType(groupType) { + rewards := s.design.Sections[key].Rewards + if len(rewards) == 0 { + continue + } + identity := fmt.Sprintf("expired-section:%d:%s:%d", groupType, period, key.ID) + title := label + "阶段奖励到期补发" + body := fmt.Sprintf("%s周期 %s 已结束。阶段奖励 %d 已达成但未领取,奖励由系统自动补发。", label, period, key.ID) + if err := s.mail.EnqueueCompensation(ctx, identity, title, body, rewards, now); err != nil { + return fmt.Errorf("missions: enqueue expired section %+v: %w", key, err) + } + } + return nil +} + +func (s *Service) completedUnclaimedForType(groupType uint64) []gamedata.MissionKey { + var result []gamedata.MissionKey + for key, condition := range s.design.Conditions { + if key.GroupType != groupType { + continue + } + target := condition.TargetValue + if target == 0 { + target = 1 + } + if s.state.Progress[missionName(key)] >= target && !contains(s.state.Claimed, "mission:"+missionName(key)) { + result = append(result, key) + } + } + sort.Slice(result, func(i, j int) bool { + if result[i].GroupID != result[j].GroupID { + return result[i].GroupID < result[j].GroupID + } + return result[i].ID < result[j].ID + }) + return result +} + +func (s *Service) eligibleUnclaimedSectionsForType(groupType uint64) []gamedata.SectionRewardKey { + completed := uint64(len(s.completedForType(groupType))) + var result []gamedata.SectionRewardKey + for key, section := range s.design.Sections { + identity := fmt.Sprintf("section:%d/%d", key.GroupType, key.ID) + if key.GroupType == groupType && section.SectionValue != 0 && completed >= section.SectionValue && !contains(s.state.Claimed, identity) { + result = append(result, key) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func (s *Service) completedForType(groupType uint64) []gamedata.MissionKey { + var result []gamedata.MissionKey + for key, condition := range s.design.Conditions { + if key.GroupType != groupType { + continue + } + target := condition.TargetValue + if target == 0 { + target = 1 + } + if s.state.Progress[missionName(key)] >= target { + result = append(result, key) + } + } + return result +} + +func clearMissionPeriod(state *snapshot, groupType uint64) { + prefix := strconv.FormatUint(groupType, 10) + "/" + for name := range state.Progress { + if strings.HasPrefix(name, prefix) { + delete(state.Progress, name) + } + } + state.Completed = filterPeriodIdentities(state.Completed, groupType) + state.Claimed = filterPeriodIdentities(state.Claimed, groupType) +} + +func filterPeriodIdentities(values []string, groupType uint64) []string { + missionPrefix := "mission:" + strconv.FormatUint(groupType, 10) + "/" + sectionPrefix := "section:" + strconv.FormatUint(groupType, 10) + "/" + result := values[:0] + for _, value := range values { + if !strings.HasPrefix(value, missionPrefix) && !strings.HasPrefix(value, sectionPrefix) { + result = append(result, value) + } + } + return result +} + +func (s *Service) achievementInfo() []byte { + // AchievementInfo contains progress and last completed IDs. This local + // account currently records acknowledgement/claim state through the + // inventory idempotency key, but deliberately does not fabricate progress. + return nil +} + +// The official all-clear request omits ContentsGroup (protobuf value zero). +// In that form GroupID+ID must identify exactly one static row; ambiguity is +// rejected rather than resolved by map iteration order. +func (s *Service) achievementDesign(contents, groupID, id uint64) (gamedata.AchievementKey, gamedata.AchievementDesign, bool) { + if contents != 0 { + key := gamedata.AchievementKey{ContentsGroup: contents, GroupID: groupID, ID: id} + design, ok := s.design.Achievements[key] + return key, design, ok + } + var foundKey gamedata.AchievementKey + var found gamedata.AchievementDesign + matched := false + for key, design := range s.design.Achievements { + if key.GroupID != groupID || key.ID != id { + continue + } + if matched { + return gamedata.AchievementKey{}, gamedata.AchievementDesign{}, false + } + foundKey, found, matched = key, design, true + } + return foundKey, found, matched +} + +func (s *Service) progressMissionKeys() []gamedata.MissionKey { + result := make([]gamedata.MissionKey, 0, len(s.state.Progress)) + for name, value := range s.state.Progress { + if value == 0 { + continue + } + for key := range s.design.Missions { + if missionName(key) == name { + result = append(result, key) + break + } + } + } + sort.Slice(result, func(i, j int) bool { + a, b := result[i], result[j] + if a.GroupType != b.GroupType { + return a.GroupType < b.GroupType + } + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + return a.ID < b.ID + }) + return result +} + +func (s *Service) completedMissionKeys() []gamedata.MissionKey { + var result []gamedata.MissionKey + for key, condition := range s.design.Conditions { + target := condition.TargetValue + if target == 0 { + target = 1 + } + name := missionName(key) + if s.state.Progress[name] >= target && !contains(s.state.Claimed, "mission:"+name) { + result = append(result, key) + } + } + sort.Slice(result, func(i, j int) bool { + a, b := result[i], result[j] + if a.GroupType != b.GroupType { + return a.GroupType < b.GroupType + } + if a.GroupID != b.GroupID { + return a.GroupID < b.GroupID + } + return a.ID < b.ID + }) + return result +} + +func (s *Service) claimMissions(ctx command.Context, keys []gamedata.MissionKey) ([]assets.Item, error) { + next := cloneSnapshot(s.state) + var result []assets.Item + for _, key := range keys { + condition := s.design.Conditions[key] + target := condition.TargetValue + if target == 0 { + target = 1 + } + if s.state.Progress[missionName(key)] < target { + return nil, fmt.Errorf("%w: mission is not complete", ErrInvalidRequest) + } + rewards, ok := s.design.Missions[key] + if !ok { + return nil, fmt.Errorf("%w: unknown mission %+v", ErrInvalidRequest, key) + } + identity := "mission:" + missionName(key) + items, err := s.grantRewards(ctx, identity, rewards) + if err != nil { + return nil, err + } + result = append(result, items...) + if !contains(next.Claimed, identity) { + next.Claimed = append(next.Claimed, identity) + } + } + if err := s.commit(ctx, next); err != nil { + return nil, err + } + return result, nil +} + +func (s *Service) eligibleSections(groupType uint64) []gamedata.SectionRewardKey { + var result []gamedata.SectionRewardKey + for key := range s.design.Sections { + // Proto3 omits group_type for the official bulk request. Zero means + // all mission groups there, not an invalid group. + if (groupType == 0 || key.GroupType == groupType) && s.sectionEligible(key) { + result = append(result, key) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +// Section reward thresholds are authoritative static values. The completed +// set, rather than the claimed set, is deliberately counted as the client does. +func (s *Service) sectionEligible(key gamedata.SectionRewardKey) bool { + section, ok := s.design.Sections[key] + if !ok { + return false + } + if section.SectionValue == 0 { + return false + } + var count uint64 + for _, identity := range s.state.Claimed { + var group, missionGroup, missionID uint64 + if _, err := fmt.Sscanf(identity, "mission:%d/%d/%d", &group, &missionGroup, &missionID); err == nil && group == key.GroupType { + count++ + } + } + return count >= section.SectionValue +} + +func (s *Service) claimSections(ctx command.Context, keys []gamedata.SectionRewardKey) ([]assets.Item, error) { + next := cloneSnapshot(s.state) + var result []assets.Item + for _, key := range keys { + section, ok := s.design.Sections[key] + if !ok || !s.sectionEligible(key) { + return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest) + } + identity := fmt.Sprintf("section:%d/%d", key.GroupType, key.ID) + items, err := s.grantRewards(ctx, identity, section.Rewards) + if err != nil { + return nil, err + } + result = append(result, items...) + if !contains(next.Claimed, identity) { + next.Claimed = append(next.Claimed, identity) + } + } + if err := s.commit(ctx, next); err != nil { + return nil, err + } + return result, nil +} + +func (s *Service) grantRewards(ctx command.Context, identity string, rewards []gamedata.Reward) ([]assets.Item, error) { + if s.wallet != nil { + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":currency", rewards); err != nil { + return nil, err + } + } + var stack []gamedata.BattleReward + for _, reward := range rewards { + if reward.Type == 2 || reward.Type == 3 || reward.Type == 4 || reward.Type == 12 || reward.Type == 20 { + if s.wallet == nil { + return nil, errors.New("missions: currency reward wallet unavailable") + } + continue + } + if reward.ID == 0 || reward.Count == 0 { + // Type 12 is an account resource whose full/overflow conversion is + // server-dynamic. It is intentionally not fabricated as ItemDBInfo. + continue + } + stack = append(stack, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 + } + if len(stack) == 0 { + return nil, nil + } + items, err := s.inventory.GrantOnce(ctx, identity+":items", stack) + if err != nil { + return nil, err + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + return items, nil +} + +func (s *Service) commit(ctx command.Context, next snapshot) error { + sort.Strings(next.Completed) + sort.Strings(next.Claimed) + if next.DailyPeriod == s.state.DailyPeriod && next.WeeklyPeriod == s.state.WeeklyPeriod && equalStrings(next.Completed, s.state.Completed) && equalStrings(next.Claimed, s.state.Claimed) && equalProgress(next.Progress, s.state.Progress) { + return nil + } + return s.persist(ctx, next) +} + +func (s *Service) persist(ctx command.Context, next snapshot) error { + b, err := json.Marshal(next) + if err != nil { + return err + } + if err := s.storage.Save(ctx.State, "missions", b); err != nil { + return err + } + s.state = next + return nil +} +func equalProgress(a, b map[string]uint64) bool { + if len(a) != len(b) { + return false + } + for key, value := range a { + if b[key] != value { + return false + } + } + return true +} + +func cloneSnapshot(in snapshot) snapshot { + progress := make(map[string]uint64, len(in.Progress)) + maps.Copy(progress, in.Progress) + return snapshot{Version: in.Version, DailyPeriod: in.DailyPeriod, WeeklyPeriod: in.WeeklyPeriod, Completed: append([]string(nil), in.Completed...), Claimed: append([]string(nil), in.Claimed...), Progress: progress} +} +func contains(values []string, want string) bool { + return slices.Contains(values, want) +} +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} +func missionName(k gamedata.MissionKey) string { + return fmt.Sprintf("%d/%d/%d", k.GroupType, k.GroupID, k.ID) +} +func achievementName(k gamedata.AchievementKey) string { + return fmt.Sprintf("%d/%d/%d", k.ContentsGroup, k.GroupID, k.ID) +} + +type achievementClaim struct { + GroupID uint64 + IDs []uint64 +} + +func decode(b []byte) (uint64, int) { + var value uint64 + for i, x := range b { + value |= uint64(x&127) << (7 * i) + if x < 128 { + return value, i + 1 + } + if i == 9 { + return 0, 0 + } + } + return 0, 0 +} diff --git a/go/internal/server/domain/progression/missions/protocol_adapter.go b/go/internal/server/domain/progression/missions/protocol_adapter.go new file mode 100644 index 0000000..a361349 --- /dev/null +++ b/go/internal/server/domain/progression/missions/protocol_adapter.go @@ -0,0 +1,622 @@ +package missions + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "time" +) + +func (s *Service) userLevelReward(ctx command.Context, request []byte) ([]byte, error) { + if s.levelRewards == nil { + return nil, fmt.Errorf("missions: user level rewards unavailable") + } + if err := requireSeq(request); err != nil { + return nil, err + } + ids, err := packed(request, 2) + if err != nil || len(ids) == 0 { + return nil, ErrInvalidRequest + } + exp, err := s.achievementExperienceLocked() + if err != nil { + return nil, err + } + level := s.levelRewards.Level(exp) + next := s.levelReward + var fresh []gamedata.AchievementLevel + var previous uint64 + for _, id := range ids { + if id == 0 || id <= previous { + return nil, ErrInvalidRequest + } + previous = id + index := -1 + for i, l := range s.levelRewards.Levels { + if l.ID == id { + index = i + break + } + } + if index < 0 || id > level { + return nil, ErrInvalidRequest + } + if id <= s.levelReward { + continue + } + expected := uint64(0) + for _, l := range s.levelRewards.Levels { + if l.ID > next { + expected = l.ID + break + } + } + if id != expected { + return nil, fmt.Errorf("%w: user level reward requires preceding claims", ErrInvalidRequest) + } + fresh = append(fresh, s.levelRewards.Levels[index]) + next = id + } + var items []assets.Item + var currencies []gamedata.Reward + for _, l := range fresh { + granted, err := s.grantRewards(ctx, fmt.Sprintf("user-level:%d", l.ID), l.Rewards) + if err != nil { + return nil, err + } + items = append(items, granted...) + for _, r := range l.Rewards { + if r.Type == 2 || r.Type == 3 || r.Type == 4 || r.Type == 12 || r.Type == 20 { + currencies = append(currencies, r) + } + } + } + if next != s.levelReward { + raw, err := json.Marshal(levelRewardSnapshot{versionconfig.State(), next}) + if err != nil { + return nil, err + } + if err := s.storage.(stateio.ScopedEntryStore).PutEntry(ctx.State, "missions", "user_level_rewards", "state", raw); err != nil { + return nil, err + } + s.levelReward = next + } + bundle := rewardBundle(items) + for _, r := range currencies { + item := wire.AppendVarint(nil, 3, r.Type) + item = wire.AppendVarint(item, 4, r.Count) + bundle = wire.AppendBytes(bundle, 1, item) + } + return wire.AppendBytes(nil, 1, bundle), nil +} + +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if s.eventHandler != nil && path == "/MissionUpdate" { + regular, event, err := splitMissionUpdates(request) + if err != nil { + return 119, nil, true, err + } + if len(event) > 0 { + if len(regular) > 0 { + + err = s.validateRegularUpdates(regular) + + if err != nil { + return 119, nil, true, err + } + } + if _, _, handled, err := s.eventHandler.Handle(ctx, path, event); err != nil || !handled { + if err == nil { + err = ErrInvalidRequest + } + return 119, nil, true, err + } + if len(regular) == 0 { + return 119, nil, true, nil + } + request = regular + } + } + if s.eventHandler != nil && path == "/MissionClear" { + if code, response, handled, err := s.eventHandler.Handle(ctx, path, request); handled { + return code, response, handled, err + } + } + + if err := s.rolloverLocked(ctx); err != nil { + return 0, nil, true, err + } + switch path { + case "/UserLevelReward": + response, err := s.userLevelReward(ctx, request) + return 73, response, true, err + case "/MissionInfo": + if err := requireSeq(request); err != nil { + return 118, nil, true, err + } + return 118, s.missionInfo(), true, nil + case "/MissionUpdate": + if err := s.update(ctx, request); err != nil { + return 119, nil, true, err + } + return 119, nil, true, nil + case "/AchievementInfo": + if err := requireSeq(request); err != nil { + return 166, nil, true, err + } + return 166, s.achievementInfo(), true, nil + case "/MissionClear": + response, err := s.clearMission(ctx, request) + return missionClearPacket, response, true, err + case "/MissionSectionReward": + response, err := s.clearSection(ctx, request) + return missionSectionPacket, response, true, err + case "/AchievementClear": + response, err := s.clearAchievements(ctx, request) + return achievementClearPacket, response, true, err + default: + return 0, nil, false, nil + } +} + +// Each update carries its own event identity; a client can acknowledge regular +// and scheduled missions together in one request. +func splitMissionUpdates(request []byte) (regular, event []byte, err error) { + if err = requireSeq(request); err != nil { + return + } + seq, _, _ := wire.Varint(request, 1) + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return ErrInvalidRequest + } + uid, e := scalar(f.Value, 4) + if e != nil { + return e + } + if uid == 0 { + if len(regular) == 0 { + regular = wire.AppendVarint(nil, 1, seq) + } + regular = wire.AppendBytes(regular, 2, f.Value) + } else { + if len(event) == 0 { + event = wire.AppendVarint(nil, 1, seq) + } + event = wire.AppendBytes(event, 2, f.Value) + } + return nil + }) + return +} + +func (s *Service) validateRegularUpdates(request []byte) error { + return wire.Walk(request, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return ErrInvalidRequest + } + group, err := scalar(f.Value, 1) + if err != nil || group == 0 { + return ErrInvalidRequest + } + id, err := scalar(f.Value, 2) + if err != nil || id == 0 { + return ErrInvalidRequest + } + if _, err = scalar(f.Value, 3); err != nil { + return ErrInvalidRequest + } + matches := 0 + for key := range s.design.Missions { + if key.GroupType != 2 && key.GroupID == group && key.ID == id { + matches++ + } + } + if matches != 1 { + return ErrInvalidRequest + } + return nil + }) +} + +func (s *Service) missionInfo() []byte { + var response []byte + for _, key := range s.progressMissionKeys() { + entry := wire.AppendVarint(nil, 1, key.GroupID) + entry = wire.AppendVarint(entry, 2, key.ID) + if key.GroupType != 0 { + entry = wire.AppendVarint(entry, 3, key.GroupType) + } + entry = wire.AppendVarint(entry, 4, s.state.Progress[missionName(key)]) + if contains(s.state.Claimed, "mission:"+missionName(key)) { + entry = wire.AppendVarint(entry, 5, 1) + } + response = wire.AppendBytes(response, 1, entry) + } + for _, identity := range s.state.Claimed { + var groupType, id uint64 + if _, err := fmt.Sscanf(identity, "section:%d/%d", &groupType, &id); err != nil { + continue + } + entry := wire.AppendVarint(nil, 1, groupType) + entry = wire.AppendVarint(entry, 2, id) + response = wire.AppendBytes(response, 2, entry) + } + now := s.now().UTC() + daily := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + daysUntilMonday := (8 - int(now.Weekday())) % 7 + if daysUntilMonday == 0 { + daysUntilMonday = 7 + } + weekly := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 0, 0, 0, 0, time.UTC) + response = wire.AppendVarint(response, 3, uint64(daily.UnixMilli())) + response = wire.AppendVarint(response, 4, uint64(weekly.UnixMilli())) + return response +} + +func (s *Service) update(ctx command.Context, request []byte) error { + if err := requireSeq(request); err != nil { + return err + } + var updates int + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + if field.Type != 2 { + return ErrInvalidRequest + } + groupID, err := scalar(field.Value, 1) + if err != nil || groupID == 0 { + return ErrInvalidRequest + } + id, err := scalar(field.Value, 2) + if err != nil || id == 0 { + return ErrInvalidRequest + } + value, err := scalar(field.Value, 3) + if err != nil { + return ErrInvalidRequest + } + var key gamedata.MissionKey + found := false + for candidate := range s.design.Missions { + if candidate.GroupID == groupID && candidate.ID == id && candidate.GroupType != 2 { + if found { + return ErrInvalidRequest + } + key, found = candidate, true + } + } + if !found { + return ErrInvalidRequest + } + if err := s.setProgressLocked(ctx, key, value); err != nil { + return err + } + updates++ + return nil + }) + if err != nil { + return err + } + if updates == 0 { + return ErrInvalidRequest + } + return nil +} + +func (s *Service) clearMission(ctx command.Context, request []byte) ([]byte, error) { + if err := requireSeq(request); err != nil { + return nil, err + } + all, err := boolean(request, 2) + if err != nil { + return nil, err + } + groupType, err := scalar(request, 3) + if err != nil { + return nil, err + } + groupID, err := scalar(request, 4) + if err != nil { + return nil, err + } + id, err := scalar(request, 5) + if err != nil { + return nil, err + } + if groupType == 2 { + return nil, errors.New("missions: scheduled event missions require an event service") + } + var keys []gamedata.MissionKey + if all { + if groupType != 0 || groupID != 0 || id != 0 { + return nil, fmt.Errorf("%w: bulk MissionClear has identifiers", ErrInvalidRequest) + } + keys = s.completedMissionKeys() + } else { + if groupID == 0 || id == 0 { + return nil, fmt.Errorf("%w: MissionClear identity", ErrInvalidRequest) + } + keys = []gamedata.MissionKey{{GroupType: groupType, GroupID: groupID, ID: id}} + } + items, err := s.claimMissions(ctx, keys) + if err != nil { + return nil, err + } + bundle := rewardBundle(items) + for _, key := range keys { + for _, reward := range s.design.Missions[key] { + if reward.Type == 3 || reward.Type == 4 { + entry := wire.AppendVarint(nil, 3, reward.Type) + entry = wire.AppendVarint(entry, 4, reward.Count) + bundle = wire.AppendBytes(bundle, 1, entry) + } + } + } + return bundle, nil +} + +func (s *Service) clearSection(ctx command.Context, request []byte) ([]byte, error) { + if err := requireSeq(request); err != nil { + return nil, err + } + groupType, err := scalar(request, 2) + if err != nil { + return nil, fmt.Errorf("%w: section group type", ErrInvalidRequest) + } + all, err := boolean(request, 3) + if err != nil { + return nil, err + } + id, err := scalar(request, 4) + if err != nil { + return nil, err + } + var keys []gamedata.SectionRewardKey + if all { + if id != 0 { + return nil, fmt.Errorf("%w: bulk section id", ErrInvalidRequest) + } + keys = s.eligibleSections(groupType) + } else { + if id == 0 { + return nil, fmt.Errorf("%w: section id", ErrInvalidRequest) + } + key := gamedata.SectionRewardKey{GroupType: groupType, ID: id} + if !s.sectionEligible(key) { + return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest) + } + keys = []gamedata.SectionRewardKey{key} + } + items, err := s.claimSections(ctx, keys) + if err != nil { + return nil, err + } + var response []byte + for _, item := range items { + response = wire.AppendBytes(response, 1, assets.ItemWire(item)) + } + for _, key := range keys { + for _, reward := range s.design.Sections[key].Rewards { + if reward.Type == 3 || reward.Type == 4 { + entry := wire.AppendVarint(nil, 3, reward.Type) + entry = wire.AppendVarint(entry, 4, reward.Count) + response = wire.AppendBytes(response, 1, entry) + } + } + } + return response, nil +} + +func (s *Service) clearAchievements(ctx command.Context, request []byte) ([]byte, error) { + if err := requireSeq(request); err != nil { + return nil, err + } + contents, err := scalar(request, 2) + if err != nil { + return nil, fmt.Errorf("%w: achievement contents group", ErrInvalidRequest) + } + claims, err := achievementClaims(request) + if err != nil || len(claims) == 0 { + return nil, fmt.Errorf("%w: achievement clear info", ErrInvalidRequest) + } + requested := map[gamedata.AchievementKey]bool{} + for _, claim := range claims { + for _, id := range claim.IDs { + key, _, ok := s.achievementDesign(contents, claim.GroupID, id) + if !ok { + return nil, fmt.Errorf("%w: unknown achievement", ErrInvalidRequest) + } + requested[key] = true + } + } + for key := range requested { + if contains(s.state.Claimed, "achievement:"+achievementName(key)) { + continue + } + for earlier := range s.design.Achievements { + if earlier.ContentsGroup != key.ContentsGroup || earlier.GroupID != key.GroupID || earlier.ID >= key.ID { + continue + } + if !requested[earlier] && !contains(s.state.Claimed, "achievement:"+achievementName(earlier)) { + return nil, fmt.Errorf("missions: achievement tier %v requires earlier tier %v to be claimed", key, earlier) + } + } + } + // Validate the whole batch before granting any reward. + for _, claim := range claims { + for _, id := range claim.IDs { + key, d, ok := s.achievementDesign(contents, claim.GroupID, id) + if !ok { + return nil, fmt.Errorf("%w: unknown achievement", ErrInvalidRequest) + } + if contains(s.state.Claimed, "achievement:"+achievementName(key)) { + continue + } + if d.Target > 0 { + if s.achievementProgress == nil { + return nil, errors.New("missions: achievement progress unavailable") + } + group := d.CounterGroup + if group == 0 { + group = key.GroupID + } + value, err := s.achievementProgress.AchievementValue(ctx, group) + if err != nil { + return nil, err + } + if float64(value) < d.Target { + return nil, fmt.Errorf("missions: achievement %v requires %g progress, got %d", key, d.Target, value) + } + } + } + } + var allItems []assets.Item + var addExp uint64 + var currencyRewards []gamedata.Reward + next := cloneSnapshot(s.state) + for _, claim := range claims { + for _, id := range claim.IDs { + key, design, ok := s.achievementDesign(contents, claim.GroupID, id) + if !ok { + return nil, fmt.Errorf("%w: unknown achievement %+v", ErrInvalidRequest, key) + } + identity := "achievement:" + achievementName(key) + if contains(next.Claimed, identity) { + continue + } + items, err := s.grantRewards(ctx, identity, design.Rewards) + if err != nil { + return nil, err + } + if ^uint64(0)-addExp < design.AddExp { + return nil, errors.New("missions: achievement exp overflow") + } + addExp += design.AddExp + for _, reward := range design.Rewards { + if reward.Type == 2 || reward.Type == 3 || reward.Type == 4 || reward.Type == 12 || reward.Type == 20 { + currencyRewards = append(currencyRewards, reward) + } + } + allItems = append(allItems, items...) + next.Claimed = append(next.Claimed, identity) + } + } + if addExp > uint64(^uint(0)>>1) { + return nil, errors.New("missions: achievement exp overflow") + } + if err := s.commit(ctx, next); err != nil { + return nil, err + } + response := wire.AppendVarint(nil, 1, addExp) + bundle := rewardBundle(allItems) + for _, reward := range currencyRewards { + item := wire.AppendVarint(nil, 3, reward.Type) + item = wire.AppendVarint(item, 4, reward.Count) + bundle = wire.AppendBytes(bundle, 1, item) + } + return wire.AppendBytes(response, 2, bundle), nil +} + +func rewardBundle(items []assets.Item) []byte { + var bundle []byte + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + bundle = wire.AppendBytes(bundle, 6, view) + } + return bundle +} + +func requireSeq(b []byte) error { + seq, err := scalar(b, 1) + if err != nil || seq == 0 { + return ErrInvalidRequest + } + return nil +} + +func scalar(b []byte, number int) (uint64, error) { + value, found, err := wire.Varint(b, number) + if err != nil { + return 0, ErrInvalidRequest + } + if !found { + return 0, nil + } + return value, nil +} + +func boolean(b []byte, number int) (bool, error) { + value, err := scalar(b, number) + if err != nil || value > 1 { + return false, ErrInvalidRequest + } + return value == 1, nil +} + +func achievementClaims(b []byte) ([]achievementClaim, error) { + var claims []achievementClaim + err := wire.Walk(b, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return ErrInvalidRequest + } + group, err := scalar(field.Value, 1) + if err != nil || group == 0 { + return ErrInvalidRequest + } + ids, err := packed(field.Value, 2) + if err != nil || len(ids) == 0 { + return ErrInvalidRequest + } + claims = append(claims, achievementClaim{group, ids}) + return nil + }) + return claims, err +} + +func packed(b []byte, number int) ([]uint64, error) { + var values []uint64 + err := wire.Walk(b, func(field wire.Field) error { + if field.Number != number { + return nil + } + if field.Type == 0 { + value, _ := decode(field.Value) + values = append(values, value) + return nil + } + if field.Type != 2 { + return ErrInvalidRequest + } + for remaining := field.Value; len(remaining) > 0; { + value, n := decode(remaining) + if n == 0 { + return ErrInvalidRequest + } + values = append(values, value) + remaining = remaining[n:] + } + return nil + }) + return values, err +} diff --git a/go/internal/server/domain/progression/missions/user_level_reward.go b/go/internal/server/domain/progression/missions/user_level_reward.go new file mode 100644 index 0000000..daf64b6 --- /dev/null +++ b/go/internal/server/domain/progression/missions/user_level_reward.go @@ -0,0 +1,62 @@ +package missions + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" +) + +type levelRewardSnapshot struct { + Version string `json:"version"` + LevelReward uint64 `json:"level_reward"` +} + +// AttachUserLevelRewards reads the dedicated claim bucket in the same store as missions. +func (s *Service) AttachUserLevelRewards(ctx command.Context, d *gamedata.AchievementLevelDesign) error { + if err := d.Validate(); err != nil { + return err + } + + entries, ok := s.storage.(stateio.ScopedEntryStore) + if !ok { + return fmt.Errorf("missions: user level rewards require entry storage") + } + raw, _, err := entries.LoadEntry(ctx.State, "missions", "user_level_rewards", "state") + if err != nil { + return err + } + var state levelRewardSnapshot + if raw != nil { + if err := stateio.RequireExactJSONObject(raw, "version", "level_reward"); err != nil { + return err + } + if err := json.Unmarshal(raw, &state); err != nil { + return err + } + if state.Version != versionconfig.State() { + return fmt.Errorf("missions: incompatible user level reward version") + } + if state.LevelReward != 0 { + found := false + for _, l := range d.Levels { + found = found || l.ID == state.LevelReward + } + if !found { + return fmt.Errorf("missions: claimed user level missing from GameData") + } + } + } + s.levelRewards = d + s.levelReward = state.LevelReward + return nil +} +func (s *Service) LevelRewardCount() (uint64, error) { + + if s.levelRewards == nil { + return 0, fmt.Errorf("missions: user level rewards unavailable") + } + return s.levelReward, nil +} diff --git a/go/internal/server/domain/progression/pictorial/permanent_buffs.go b/go/internal/server/domain/progression/pictorial/permanent_buffs.go new file mode 100644 index 0000000..902a4e7 --- /dev/null +++ b/go/internal/server/domain/progression/pictorial/permanent_buffs.go @@ -0,0 +1,11 @@ +package pictorial + +import "bd2server/internal/server/domain/command" + +import "bd2server/internal/server/design/gamedata" + +// AttachPermanentBuffs makes reward-owned account buffs available to the same +// Snapshot used by AllCharRefresh, login and authoritative stat consumers. +func (s *Service) AttachPermanentBuffs(provider func(command.Context) ([]gamedata.PictorialBuffStat, error)) { + s.permanentBuffs = provider +} diff --git a/go/internal/server/domain/progression/pictorial/protocol_adapter.go b/go/internal/server/domain/progression/pictorial/protocol_adapter.go new file mode 100644 index 0000000..bc1d226 --- /dev/null +++ b/go/internal/server/domain/progression/pictorial/protocol_adapter.go @@ -0,0 +1,44 @@ +package pictorial + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "fmt" +) + +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/PictorialBookInfo" && path != "/AllCharRefresh" { + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("pictorial: %s missing sequence", path) + } + entries, buffs, err := s.Snapshot(ctx) + if err != nil { + return 0, nil, true, err + } + var response []byte + if path == "/PictorialBookInfo" { + for _, entry := range entries { + book := wire.AppendVarint(nil, 1, entry.GroupID) + book = wire.AppendVarint(book, 2, entry.ID) + response = wire.AppendBytes(response, 1, book) + } + return 111, response, true, nil + } + for _, buff := range buffs { + response = wire.AppendBytes(response, 1, BuffWire(buff)) + } + return 165, response, true, nil +} + +func BuffWire(buff gamedata.PictorialBuffStat) []byte { + proto := wire.AppendVarint(nil, 1, buff.StatType) + proto = wire.AppendDouble(proto, 2, buff.Value) + if buff.Category != 0 { + proto = wire.AppendVarint(proto, 3, buff.Category) + } + return proto +} diff --git a/go/internal/server/pictorial/service.go b/go/internal/server/domain/progression/pictorial/service.go similarity index 75% rename from go/internal/server/pictorial/service.go rename to go/internal/server/domain/progression/pictorial/service.go index c41b123..e6363a5 100644 --- a/go/internal/server/pictorial/service.go +++ b/go/internal/server/domain/progression/pictorial/service.go @@ -3,23 +3,23 @@ package pictorial import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" "errors" "fmt" "math" "sort" "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" ) type Owned interface { - PictorialCharacters() []player.Character - PictorialCostumes() []player.Costume - PictorialItems() []player.Item - PictorialEquipment() []player.Equipment - PictorialDiscovered() []player.Pictorial + PictorialCharacters() []roster.Character + PictorialCostumes() []roster.Costume + PictorialItems(ctx command.Context) []assets.Item + PictorialEquipment(ctx command.Context) []assets.Equipment + PictorialDiscovered() []roster.Pictorial } type Service struct { @@ -27,60 +27,24 @@ type Service struct { Owned Owned // AwakeContributions is injected by the character-awakening domain so all // server-side maximum-HP consumers use the same derived account state. - AwakeContributions func(player.Character) ([]gamedata.StatContribution, error) - EquipmentContributions func(player.Character) ([]gamedata.StatContribution, error) - PotentialContributions func(player.Character) ([]gamedata.StatContribution, error) - permanentBuffs func() ([]gamedata.PictorialBuffStat, error) + AwakeContributions func(command.Context, roster.Character) ([]gamedata.StatContribution, error) + EquipmentContributions func(command.Context, roster.Character) ([]gamedata.StatContribution, error) + PotentialContributions func(command.Context, roster.Character) ([]gamedata.StatContribution, error) + permanentBuffs func(command.Context) ([]gamedata.PictorialBuffStat, error) baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP } -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/PictorialBookInfo" && path != "/AllCharRefresh" { - return 0, nil, false, nil - } - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 { - return 0, nil, true, fmt.Errorf("pictorial: %s missing sequence", path) - } - entries, buffs, err := s.Snapshot() - if err != nil { - return 0, nil, true, err - } - var response []byte - if path == "/PictorialBookInfo" { - for _, entry := range entries { - book := wire.AppendVarint(nil, 1, entry.GroupID) - book = wire.AppendVarint(book, 2, entry.ID) - response = wire.AppendBytes(response, 1, book) - } - return 111, response, true, nil - } - for _, buff := range buffs { - response = wire.AppendBytes(response, 1, BuffWire(buff)) - } - return 165, response, true, nil -} - type Entry struct { GroupID uint64 ID uint64 BuffID uint64 } -func BuffWire(buff gamedata.PictorialBuffStat) []byte { - proto := wire.AppendVarint(nil, 1, buff.StatType) - proto = wire.AppendDouble(proto, 2, buff.Value) - if buff.Category != 0 { - proto = wire.AppendVarint(proto, 3, buff.Category) - } - return proto -} - // Snapshot computes both network responses from the exact same owned-account // view. Identical buff IDs on distinct completed pictorial entries contribute // once per entry (e.g. three different collection quests all use buff 2002). // Duplicate inventory instances of the SAME entry cannot contribute twice. -func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) { +func (s *Service) Snapshot(ctx command.Context) ([]Entry, []gamedata.PictorialBuffStat, error) { if s == nil || s.Design == nil || s.Owned == nil { return nil, nil, errors.New("pictorial: missing design or account ownership") } @@ -108,7 +72,7 @@ func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) { // is sufficient even when zero is the stored level. } equipment := map[uint64]bool{} - for _, owned := range s.Owned.PictorialEquipment() { + for _, owned := range s.Owned.PictorialEquipment(ctx) { equipment[owned.ID] = true } items := map[[2]uint64]bool{} @@ -118,7 +82,7 @@ func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) { for _, book := range s.Owned.PictorialDiscovered() { books[Entry{GroupID: book.ID, ID: book.GroupID}] = true } - for _, owned := range s.Owned.PictorialItems() { + for _, owned := range s.Owned.PictorialItems(ctx) { if owned.Count != 0 { items[[2]uint64{owned.Type, owned.ID}] = true if _, found := itemOrder[owned.ID]; !found && (owned.Type == 13 || owned.Type == 17) { @@ -197,7 +161,7 @@ func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) { type key struct{ category, stat uint64 } values := map[key]float64{} if s.permanentBuffs != nil { - permanent, err := s.permanentBuffs() + permanent, err := s.permanentBuffs(ctx) if err != nil { return nil, nil, err } @@ -239,8 +203,8 @@ func allPresent(ids []uint64, available map[uint64]bool) bool { return len(ids) != 0 } -func (s *Service) Contributions() ([]gamedata.StatContribution, error) { - _, buffs, err := s.Snapshot() +func (s *Service) Contributions(ctx command.Context) ([]gamedata.StatContribution, error) { + _, buffs, err := s.Snapshot(ctx) if err != nil { return nil, err } @@ -268,7 +232,7 @@ func (s *Service) Contributions() ([]gamedata.StatContribution, error) { return result, nil } -func (s *Service) MaxHealth(character player.Character) (uint64, error) { +func (s *Service) MaxHealth(ctx command.Context, character roster.Character) (uint64, error) { if s == nil || s.Design == nil { return 0, errors.New("pictorial: missing character stat design") } @@ -281,15 +245,15 @@ func (s *Service) MaxHealth(character player.Character) (uint64, error) { } value, _ = s.baseHealth.LoadOrStore(cacheKey, base.Health) } - contributions, err := s.Contributions() + contributions, err := s.Contributions(ctx) if err != nil { return 0, err } - for _, calculate := range []func(player.Character) ([]gamedata.StatContribution, error){s.AwakeContributions, s.EquipmentContributions, s.PotentialContributions} { + for _, calculate := range []func(command.Context, roster.Character) ([]gamedata.StatContribution, error){s.AwakeContributions, s.EquipmentContributions, s.PotentialContributions} { if calculate == nil { continue } - additional, err := calculate(character) + additional, err := calculate(ctx, character) if err != nil { return 0, err } diff --git a/go/internal/server/player/achievement_projection.go b/go/internal/server/domain/roster/achievement_projection.go similarity index 91% rename from go/internal/server/player/achievement_projection.go rename to go/internal/server/domain/roster/achievement_projection.go index 5598e33..38b5654 100644 --- a/go/internal/server/player/achievement_projection.go +++ b/go/internal/server/domain/roster/achievement_projection.go @@ -1,11 +1,10 @@ -package player +package roster // GachaGrantSummary is a read-only projection of real durable purchase grants. // It deliberately excludes grants without a schedule group; ordinary quest // rewards also use ViewCostumeIDs and must not be counted as gacha draws. func (s *CollectionStore) GachaGrantSummary() map[string]uint64 { - s.mu.Lock() - defer s.mu.Unlock() + out := map[string]uint64{} for identity, grant := range s.data.Grants { if grant.GachaGroupID != 0 && len(grant.ViewCostumeIDs) > 0 { diff --git a/go/internal/server/domain/roster/auto_recovery.go b/go/internal/server/domain/roster/auto_recovery.go new file mode 100644 index 0000000..52f924c --- /dev/null +++ b/go/internal/server/domain/roster/auto_recovery.go @@ -0,0 +1,7 @@ +package roster + +// AutoRecoveryResult uses Define_AutoReviveDisabledType's protocol values. +type AutoRecoveryResult struct { + Caster, Experience, Catalyst, Disabled uint64 + Characters []Character +} diff --git a/go/internal/server/domain/roster/char_awake.go b/go/internal/server/domain/roster/char_awake.go new file mode 100644 index 0000000..79b19dc --- /dev/null +++ b/go/internal/server/domain/roster/char_awake.go @@ -0,0 +1,82 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "errors" + "fmt" +) + +type CharAwakeService struct { + design *gamedata.CharAwakeDesign + collection *CollectionStore + characters *CharacterStore + inventory *assets.Inventory + wallet *assets.Wallet +} + +func NewCharAwakeService(design *gamedata.CharAwakeDesign, collection *CollectionStore, characters *CharacterStore, inventory *assets.Inventory, wallet *assets.Wallet) (*CharAwakeService, error) { + if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil { + return nil, errors.New("player: incomplete character awakening service") + } + for uniqueID, progress := range collection.CharAwakeStates() { + if err := design.ValidateProgress(uniqueID, progress.ImprintLevels, progress.IsAwake); err != nil { + return nil, fmt.Errorf("player: invalid saved character awakening progress: %w", err) + } + } + return &CharAwakeService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil +} + +func (s *CharAwakeService) validateCosts(costs []gamedata.CharAwakeCost, materials []assets.Item) ([]assets.Item, uint64, error) { + want := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + key := [2]uint64{cost.Type, cost.ID} + if cost.Count > ^uint64(0)-want[key] { + return nil, 0, errors.New("character awakening cost overflow") + } + want[key] += cost.Count + } + got := make(map[[2]uint64]uint64, len(materials)) + var items []assets.Item + var gold uint64 + for _, material := range materials { + key := [2]uint64{material.Type, material.ID} + switch material.Type { + case 4: + if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { + return nil, 0, errors.New("invalid character awakening currency") + } + gold = material.Count + case 8: + items = append(items, material) + default: + return nil, 0, fmt.Errorf("unsupported character awakening material type %d", material.Type) + } + if material.Count > ^uint64(0)-got[key] { + return nil, 0, errors.New("submitted character awakening material overflow") + } + got[key] += material.Count + } + if len(got) != len(want) { + return nil, 0, fmt.Errorf("material kinds mismatch: request=%v GameData=%v", got, want) + } + for key, count := range want { + if got[key] != count { + return nil, 0, fmt.Errorf("material %d/%d=%d want %d", key[0], key[1], got[key], count) + } + } + return items, gold, nil +} + +// Contributions exposes server-side derived stats for CharInfo, revival and +// battle calculators. The client independently computes the same values from +// CharAwakeInfo, so neither side relies on a persisted derived number. +func (s *CharAwakeService) Contributions(ctx command.Context, character Character) ([]gamedata.StatContribution, error) { + uniqueID, ok := s.design.CharacterUniqueID(character.ID) + if !ok { + return nil, nil + } + progress, _ := s.collection.CharAwakeState(uniqueID) + return s.design.CharAwakeContributions(uniqueID, progress.ImprintLevels, progress.IsAwake) +} diff --git a/go/internal/server/player/collection.go b/go/internal/server/domain/roster/collection.go similarity index 72% rename from go/internal/server/player/collection.go rename to go/internal/server/domain/roster/collection.go index 4e784bf..9e4557e 100644 --- a/go/internal/server/player/collection.go +++ b/go/internal/server/domain/roster/collection.go @@ -1,23 +1,19 @@ -package player +package roster import ( - "encoding/hex" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" "encoding/json" "errors" "fmt" "maps" - "math" "slices" "sort" "strconv" "strings" - "sync" - "time" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" + "time" ) type CostumeUpgrade struct { @@ -159,123 +155,24 @@ type collectionSnapshot struct { // save. It is shared by gacha, CharInfo, CostumeInfo and CharGrowth so a roll // cannot exist only in the result animation and disappear after relogging. type CollectionStore struct { - mu sync.Mutex - store stateio.AtomicEntryStore - base []Costume - baseCharacters []Character - data collectionSnapshot - persisted bool + store stateio.ScopedEntryStore + base []Costume + baseCharacters []Character + data collectionSnapshot + persisted bool + observationVersion uint64 } -func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore, error) { - if store == nil { - return nil, errors.New("player: nil collection store") - } - entries, ok := store.(stateio.AtomicEntryStore) - if !ok { - return nil, errors.New("player: collection store requires atomic entry storage") - } - s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{ - Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001, - BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{}, - CostumePotential: map[string][]uint64{}, - CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{}, - CharAwake: map[string]CharAwakeProgress{}, - Friendships: map[string]FriendshipEntry{}, - StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{}, - GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{}, - }} - b, err := store.Load("collection") - if err != nil { - return nil, err - } - if b == nil { - if err := stateio.RequireNoEntries(entries, collectionDomain, collectionEntryBuckets[:]...); err != nil { - return nil, fmt.Errorf("player: invalid collection storage: %w", err) - } - return s, nil - } - if err := rejectInlineCollectionEntries(b); err != nil { - return nil, err - } - if err := json.Unmarshal(b, &s.data); err != nil { - return nil, fmt.Errorf("player: decode collection: %w", err) - } - if err := loadCollectionEntries(entries, &s.data); err != nil { - return nil, err - } - if s.data.Version != versionconfig.State() || s.data.NextCharacterIndex < 920000001 || s.data.NextCostumeIndex < 930000001 { - return nil, errors.New("player: invalid collection save") - } - if s.data.BaseCostumeLevels == nil { - s.data.BaseCostumeLevels = map[string]uint64{} - } - if s.data.CostumePotential == nil { - return nil, errors.New("player: collection save requires costume_potential; migrate the development save") - } - if s.data.CharAwake == nil { - return nil, errors.New("player: collection save requires char_awake; migrate the development save") - } - if err := s.validateCostumeBurstStorage(); err != nil { - return nil, err - } - if err := validateFriendshipEntries(s.data.Friendships); err != nil { - return nil, err - } - for key, progress := range s.data.CharAwake { - uniqueID, parseErr := strconv.ParseUint(key, 10, 64) - if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) { - return nil, errors.New("player: invalid char_awake ledger") - } - } - if s.data.GachaSelections == nil { - s.data.GachaSelections = map[string][]GachaSelection{} - } - if s.data.GachaSelectionChanges == nil { - s.data.GachaSelectionChanges = map[string]uint64{} - } - for key, count := range s.data.GachaSelectionChanges { - groupID, parseErr := strconv.ParseUint(key, 10, 64) - if parseErr != nil || groupID == 0 || key != strconv.FormatUint(groupID, 10) || count == 0 { - return nil, errors.New("player: invalid gacha selection change ledger") - } - } - if s.data.StepUpProgress == nil { - s.data.StepUpProgress = map[string]uint64{} - } - if s.data.GachaUsers == nil { - s.data.GachaUsers = map[string]GachaUserState{} - } - if s.data.GachaFixed == nil { - s.data.GachaFixed = map[string]GachaFixedState{} - } - if s.data.GachaApplied == nil { - s.data.GachaApplied = map[string]bool{} - } - if s.data.GachaPointExchange == nil { - s.data.GachaPointExchange = map[string]GachaPointExchange{} - } - if marker, exists := s.data.Grants[FirstGachaCompletedIdentity]; exists && !emptyCollectionGrant(marker) { - return nil, errors.New("player: invalid first-gacha completion marker") - } - if err := validateCharacters(s.data.Characters); err != nil && len(s.data.Characters) != 0 { - return nil, err - } - s.persisted = true - return s, nil -} +func (s *CollectionStore) EnsurePersisted(ctx command.Context) error { -func (s *CollectionStore) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - b, err := s.store.Load("collection") + b, err := s.store.Load(ctx.State, "collection") if err != nil { return err } if b != nil { return nil } - return s.commit(cloneCollection(s.data)) + return s.commit(ctx, cloneCollection(s.data)) } func emptyCollectionGrant(grant CollectionGrant) bool { @@ -288,12 +185,11 @@ func emptyCollectionGrant(grant CollectionGrant) bool { // BindBaseCharacters attaches the authoritative base roster and rejects a // persisted collection that overlaps it. It never rewrites account state. -func (s *CollectionStore) BindBaseCharacters(base []Character) error { +func (s *CollectionStore) BindBaseCharacters(ctx command.Context, base []Character) error { if err := validateCharacters(base); err != nil && len(base) != 0 { return fmt.Errorf("player: invalid base characters: %w", err) } - s.mu.Lock() - defer s.mu.Unlock() + seen := make(map[uint64]uint64, len(base)+len(s.data.Characters)) for _, character := range base { if prior := seen[character.ID]; prior != 0 { @@ -314,12 +210,11 @@ func (s *CollectionStore) BindBaseCharacters(base []Character) error { // AttachRewardCostume joins an already-earned quest costume to the same // account-owned view used by gacha and starter costumes. The quest condition // must be checked by the caller; this does not grant an unearned costume. -func (s *CollectionStore) AttachRewardCostume(reward Costume) error { +func (s *CollectionStore) AttachRewardCostume(ctx command.Context, reward Costume) error { if reward.InvenIndex == 0 || reward.ID == 0 || reward.UseChar == 0 { return errors.New("player: invalid earned quest costume") } - s.mu.Lock() - defer s.mu.Unlock() + for _, existing := range s.base { if existing.InvenIndex == reward.InvenIndex || existing.ID == reward.ID { return fmt.Errorf("player: duplicate earned quest costume %d", reward.InvenIndex) @@ -331,23 +226,22 @@ func (s *CollectionStore) AttachRewardCostume(reward Costume) error { } } s.base = append(s.base, reward) + s.observationVersion++ return nil } func (s *CollectionStore) StepUpProgress(groupID uint64) uint64 { - s.mu.Lock() - defer s.mu.Unlock() + return s.data.StepUpProgress[strconv.FormatUint(groupID, 10)] } // CompleteStepUp atomically advances one group. Repeating the same completed // step is allowed so a lost HTTP response can be retried without reopening it. -func (s *CollectionStore) CompleteStepUp(groupID, step uint64) error { +func (s *CollectionStore) CompleteStepUp(ctx command.Context, groupID, step uint64) error { if groupID == 0 || step == 0 { return errors.New("player: invalid step-up completion") } - s.mu.Lock() - defer s.mu.Unlock() + key := strconv.FormatUint(groupID, 10) completed := s.data.StepUpProgress[key] if completed >= step { @@ -358,15 +252,14 @@ func (s *CollectionStore) CompleteStepUp(groupID, step uint64) error { } next := cloneCollection(s.data) next.StepUpProgress[key] = step - return s.commit(next) + return s.commit(ctx, next) } -func (s *CollectionStore) SaveGachaSelections(groupID uint64, selections []GachaSelection, changeLimit uint64) error { +func (s *CollectionStore) SaveGachaSelections(ctx command.Context, groupID uint64, selections []GachaSelection, changeLimit uint64) error { if groupID == 0 || len(selections) == 0 { return errors.New("player: invalid gacha selection") } - s.mu.Lock() - defer s.mu.Unlock() + key := strconv.FormatUint(groupID, 10) normalized := append([]GachaSelection(nil), selections...) sort.Slice(normalized, func(i, j int) bool { return normalized[i].Slot < normalized[j].Slot }) @@ -381,7 +274,7 @@ func (s *CollectionStore) SaveGachaSelections(groupID uint64, selections []Gacha if changeLimit != 0 { next.GachaSelectionChanges[key]++ } - return s.commit(next) + return s.commit(ctx, next) } func equalGachaSelections(a, b []GachaSelection) bool { @@ -397,14 +290,12 @@ func equalGachaSelections(a, b []GachaSelection) bool { } func (s *CollectionStore) GachaSelections(groupID uint64) []GachaSelection { - s.mu.Lock() - defer s.mu.Unlock() + return append([]GachaSelection(nil), s.data.GachaSelections[strconv.FormatUint(groupID, 10)]...) } func (s *CollectionStore) AllGachaSelections() []GachaSelection { - s.mu.Lock() - defer s.mu.Unlock() + var result []GachaSelection for _, selections := range s.data.GachaSelections { result = append(result, selections...) @@ -419,8 +310,7 @@ func (s *CollectionStore) AllGachaSelections() []GachaSelection { } func (s *CollectionStore) GachaSelectionChangeCounts() []GachaSelectionChangeCount { - s.mu.Lock() - defer s.mu.Unlock() + result := make([]GachaSelectionChangeCount, 0, len(s.data.GachaSelectionChanges)) for key, count := range s.data.GachaSelectionChanges { groupID, err := strconv.ParseUint(key, 10, 64) @@ -432,28 +322,26 @@ func (s *CollectionStore) GachaSelectionChangeCounts() []GachaSelectionChangeCou return result } -func (s *CollectionStore) SetPreview(eventIndex uint64, costumeIDs []uint64) error { +func (s *CollectionStore) SetPreview(ctx command.Context, eventIndex uint64, costumeIDs []uint64) error { if eventIndex == 0 || len(costumeIDs) == 0 { return errors.New("player: infinite preview requires an event index and costumes") } - s.mu.Lock() - defer s.mu.Unlock() + next := cloneCollection(s.data) next.LatestPreview = append([]uint64(nil), costumeIDs...) next.PreviewEventIndex = eventIndex next.PreviewLocked = false - return s.commit(next) + return s.commit(ctx, next) } // LockPreview records the official Resemara confirmation boundary. The event // index is created and persisted with the preview; locking only changes the // confirmation bit, so an unlocked preview remains representable in GachaInfo. -func (s *CollectionStore) LockPreview(eventIndex uint64) error { +func (s *CollectionStore) LockPreview(ctx command.Context, eventIndex uint64) error { if eventIndex == 0 { return errors.New("player: invalid infinite preview event index") } - s.mu.Lock() - defer s.mu.Unlock() + if len(s.data.LatestPreview) == 0 || s.data.PreviewEventIndex == 0 { return errors.New("player: no complete infinite gacha preview to lock") } @@ -462,43 +350,41 @@ func (s *CollectionStore) LockPreview(eventIndex uint64) error { } next := cloneCollection(s.data) next.PreviewLocked = true - return s.commit(next) + return s.commit(ctx, next) } func (s *CollectionStore) PreviewLock() (uint64, bool) { - s.mu.Lock() - defer s.mu.Unlock() + return s.data.PreviewEventIndex, s.data.PreviewLocked } func (s *CollectionStore) LatestPreview() []uint64 { - s.mu.Lock() - defer s.mu.Unlock() + return append([]uint64(nil), s.data.LatestPreview...) } -func (s *CollectionStore) ConfirmInfinite(identity string, design *gamedata.InfiniteGachaDesign) (CollectionGrant, error) { +func (s *CollectionStore) ConfirmInfinite(ctx command.Context, identity string, design *gamedata.InfiniteGachaDesign) (CollectionGrant, error) { if identity == "" || design == nil { return CollectionGrant{}, errors.New("player: invalid collection grant") } - s.mu.Lock() + preview := append([]uint64(nil), s.data.LatestPreview...) locked := s.data.PreviewLocked && s.data.PreviewEventIndex != 0 - s.mu.Unlock() + if len(preview) != design.Count { return CollectionGrant{}, errors.New("player: no complete infinite gacha preview to confirm") } if !locked { return CollectionGrant{}, errors.New("player: infinite gacha preview is not locked") } - return s.grantCostumes(identity, preview, design.Character, nil) + return s.grantCostumes(ctx, identity, preview, design.Character, nil) } -func (s *CollectionStore) GrantRegular(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) { +func (s *CollectionStore) GrantRegular(ctx command.Context, identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) { if identity == "" || len(costumeIDs) == 0 || design == nil { return CollectionGrant{}, errors.New("player: invalid regular gacha grant") } - return s.GrantCostumes(identity, costumeIDs, design) + return s.GrantCostumes(ctx, identity, costumeIDs, design) } type CostumeDesignSource interface { @@ -509,40 +395,39 @@ type CostumeDesignSource interface { // GameData-backed reward catalog. It is shared by gacha and new-player mail; // the identity makes retry return the original characters, costumes and // upgrade records without applying the copies twice. -func (s *CollectionStore) GrantCostumes(identity string, costumeIDs []uint64, design CostumeDesignSource) (CollectionGrant, error) { +func (s *CollectionStore) GrantCostumes(ctx command.Context, identity string, costumeIDs []uint64, design CostumeDesignSource) (CollectionGrant, error) { if identity == "" || len(costumeIDs) == 0 || design == nil { return CollectionGrant{}, errors.New("player: invalid costume reward grant") } - return s.grantCostumes(identity, costumeIDs, design.Character, nil) + return s.grantCostumes(ctx, identity, costumeIDs, design.Character, nil) } // RecordGrantMarker persists an account-level completion/purchase fact that // has no character or costume payload. It is intentionally separate from an // inventory reward marker so purchase-count protocol state remains correct // even after the granted consumable has been used. -func (s *CollectionStore) RecordGrantMarker(identity string) error { +func (s *CollectionStore) RecordGrantMarker(ctx command.Context, identity string) error { if identity == "" { return errors.New("player: invalid collection marker") } - s.mu.Lock() - defer s.mu.Unlock() + if _, exists := s.data.Grants[identity]; exists { return nil } next := cloneCollection(s.data) next.Grants[identity] = CollectionGrant{} - return s.commit(next) + return s.commit(ctx, next) } // GrantGachaPointCostume debits one pickup exchange cost and grants the // selected costume in the same collection commit. The grant identity makes a // transport retry return the original result without spending points or // incrementing ExchangeItemCount twice. -func (s *CollectionStore) GrantGachaPointCostume(identity string, group gamedata.GachaGroupDesign, costumeID uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) { +func (s *CollectionStore) GrantGachaPointCostume(ctx command.Context, identity string, group gamedata.GachaGroupDesign, costumeID uint64, design *gamedata.RegularGachaCatalog) (CollectionGrant, error) { if identity == "" || group.ID == 0 || group.PickUpExchangeCost == 0 || costumeID == 0 || design == nil { return CollectionGrant{}, errors.New("player: invalid gacha point costume exchange") } - return s.grantCostumes(identity, []uint64{costumeID}, design.Character, func(next *collectionSnapshot, _ *CollectionGrant) error { + return s.grantCostumes(ctx, identity, []uint64{costumeID}, design.Character, func(next *collectionSnapshot, _ *CollectionGrant) error { key := strconv.FormatUint(group.ID, 10) user, ok := next.GachaUsers[key] if !ok || user.Point < group.PickUpExchangeCost { @@ -562,14 +447,13 @@ func (s *CollectionStore) GrantGachaPointCostume(identity string, group gamedata // GrantGachaPointEquipment records an equipment pickup exchange in the same // collection ledger used by costume pickup exchanges. The equipment instance -// itself belongs to EquipmentInventory; the request transaction makes both +// itself belongs to assets.EquipmentInventory; the request transaction makes both // domain writes atomic. -func (s *CollectionStore) GrantGachaPointEquipment(identity string, groupID, exchangeCost uint64) (CollectionGrant, error) { +func (s *CollectionStore) GrantGachaPointEquipment(ctx command.Context, identity string, groupID, exchangeCost uint64) (CollectionGrant, error) { if identity == "" || groupID == 0 || exchangeCost == 0 { return CollectionGrant{}, errors.New("player: invalid gacha point equipment exchange") } - s.mu.Lock() - defer s.mu.Unlock() + if grant, ok := s.data.Grants[identity]; ok { return cloneGrant(grant), nil } @@ -589,17 +473,17 @@ func (s *CollectionStore) GrantGachaPointEquipment(identity string, groupID, exc next.GachaPointExchange[identity] = GachaPointExchange{GroupID: groupID, Count: exchangeCost} grant := CollectionGrant{} next.Grants[identity] = grant - if err := s.commit(next); err != nil { + if err := s.commit(ctx, next); err != nil { return CollectionGrant{}, err } return grant, nil } -func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog, purchase GachaPurchase) (CollectionGrant, error) { +func (s *CollectionStore) GrantRegularPurchase(ctx command.Context, identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog, purchase GachaPurchase) (CollectionGrant, error) { if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 { return CollectionGrant{}, errors.New("player: invalid regular gacha purchase") } - return s.grantCostumesSorted(identity, costumeIDs, purchase.RewardSortIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error { + return s.grantCostumesSorted(ctx, identity, costumeIDs, purchase.RewardSortIDs, design.Character, func(next *collectionSnapshot, grant *CollectionGrant) error { if err := applyStepUpProgress(next, purchase.StepUpGroupID, purchase.StepUpStep); err != nil { return err } @@ -619,16 +503,15 @@ func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uin }) } -// GrantEquipmentPurchase records only the durable gacha accounting. Equipment -// instances belong to EquipmentInventory (they have a different wire type), +// GrantEquipmentPurchase records only the durable gacha accounting. assets.Equipment +// instances belong to assets.EquipmentInventory (they have a different wire type), // while points and fixed counters share GachaUserDBInfo/GachaFixedDBInfo with // costume draws. The stored empty grant is the idempotency marker. -func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64, purchase GachaPurchase) (CollectionGrant, error) { +func (s *CollectionStore) GrantEquipmentPurchase(ctx command.Context, identity string, count uint64, purchase GachaPurchase) (CollectionGrant, error) { if identity == "" || count == 0 || purchase.Group.ID == 0 { return CollectionGrant{}, errors.New("player: invalid equipment gacha purchase") } - s.mu.Lock() - defer s.mu.Unlock() + if grant, ok := s.data.Grants[identity]; ok { return cloneGrant(grant), nil } @@ -638,7 +521,7 @@ func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64, return CollectionGrant{}, err } next.Grants[identity] = cloneGrant(grant) - if err := s.commit(next); err != nil { + if err := s.commit(ctx, next); err != nil { return CollectionGrant{}, err } return grant, nil @@ -646,34 +529,32 @@ func (s *CollectionStore) GrantEquipmentPurchase(identity string, count uint64, // GrantEquipmentDraw records a standalone ticket draw without inventing a // schedule group, points, purchase counts, or fixed-pity state. -func (s *CollectionStore) GrantEquipmentDraw(identity string) (CollectionGrant, error) { +func (s *CollectionStore) GrantEquipmentDraw(ctx command.Context, identity string) (CollectionGrant, error) { if identity == "" { return CollectionGrant{}, errors.New("player: invalid equipment draw identity") } - s.mu.Lock() - defer s.mu.Unlock() + if grant, ok := s.data.Grants[identity]; ok { return cloneGrant(grant), nil } next := cloneCollection(s.data) grant := CollectionGrant{} next.Grants[identity] = grant - if err := s.commit(next); err != nil { + if err := s.commit(ctx, next); err != nil { return CollectionGrant{}, err } return grant, nil } -func (s *CollectionStore) grantCostumes(identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) { - return s.grantCostumesSorted(identity, costumeIDs, nil, character, mutate) +func (s *CollectionStore) grantCostumes(ctx command.Context, identity string, costumeIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) { + return s.grantCostumesSorted(ctx, identity, costumeIDs, nil, character, mutate) } -func (s *CollectionStore) grantCostumesSorted(identity string, costumeIDs, sortIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) { +func (s *CollectionStore) grantCostumesSorted(ctx command.Context, identity string, costumeIDs, sortIDs []uint64, character func(uint64) (gamedata.CharacterDesign, bool), mutate func(*collectionSnapshot, *CollectionGrant) error) (CollectionGrant, error) { if len(sortIDs) != 0 && len(sortIDs) != len(costumeIDs) { return CollectionGrant{}, errors.New("player: costume reward sort count mismatch") } - s.mu.Lock() - defer s.mu.Unlock() + if grant, ok := s.data.Grants[identity]; ok { return cloneGrant(grant), nil } @@ -752,7 +633,7 @@ func (s *CollectionStore) grantCostumesSorted(identity string, costumeIDs, sortI } } next.Grants[identity] = cloneGrant(grant) - if err := s.commit(next); err != nil { + if err := s.commit(ctx, next); err != nil { return CollectionGrant{}, err } return grant, nil @@ -771,14 +652,12 @@ func costumeOverflowExchange(costumeID, invenIndex, sortID uint64, design gameda } func (s *CollectionStore) Characters() []Character { - s.mu.Lock() - defer s.mu.Unlock() + return append([]Character(nil), s.data.Characters...) } func (s *CollectionStore) FindCharacter(index uint64) (Character, bool) { - s.mu.Lock() - defer s.mu.Unlock() + for _, c := range s.data.Characters { if c.InvenIndex == index { return c, true @@ -789,9 +668,8 @@ func (s *CollectionStore) FindCharacter(index uint64) (Character, bool) { // CanUpdateCharacter validates a growth mutation before any currency or // inventory is charged. Promotion changes character.ID but not InvenIndex. -func (s *CollectionStore) CanUpdateCharacter(oldID uint64, character Character) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *CollectionStore) CanUpdateCharacter(ctx command.Context, oldID uint64, character Character) error { + return s.validateCharacterUpdate(oldID, character) } @@ -821,9 +699,8 @@ func (s *CollectionStore) validateCharacterUpdate(oldID uint64, character Charac return nil } -func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *CollectionStore) UpdateCharacter(ctx command.Context, oldID uint64, character Character) error { + if err := s.validateCharacterUpdate(oldID, character); err != nil { return err } @@ -831,15 +708,14 @@ func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) err for i := range next.Characters { if next.Characters[i].InvenIndex == character.InvenIndex { next.Characters[i] = character - return s.commit(next) + return s.commit(ctx, next) } } return fmt.Errorf("player: collection character %d not found", character.InvenIndex) } func (s *CollectionStore) Costumes() []Costume { - s.mu.Lock() - defer s.mu.Unlock() + return s.costumesLocked() } @@ -873,8 +749,7 @@ func (s *CollectionStore) CostumeByID(id uint64) (Costume, bool) { if id == 0 { return Costume{}, false } - s.mu.Lock() - defer s.mu.Unlock() + var result Costume found := false for _, costume := range s.costumesLocked() { @@ -895,49 +770,12 @@ func (s *CollectionStore) CostumeBurstReplay(invenIndex, target uint64) (Costume if invenIndex == 0 || target == 0 { return CostumeBurstUpgradeRecord{}, false } - s.mu.Lock() - defer s.mu.Unlock() + record, found := s.data.CostumeBurstUpgrades[costumeBurstUpgradeKey(invenIndex, target)] record.Body = append([]byte(nil), record.Body...) return record, found } -// ApplyCostumeBurst atomically advances one owned costume and stores the exact -// successful reply. expectedCurrent is a compare-and-swap guard against a -// stale request overwriting a newer level. -func (s *CollectionStore) ApplyCostumeBurst(invenIndex, expectedCurrent, target uint64, record CostumeBurstUpgradeRecord) error { - if invenIndex == 0 || expectedCurrent == math.MaxUint64 || target != expectedCurrent+1 { - return errors.New("player: invalid costume burst transition") - } - if record.Level != target || record.CostumeID == 0 { - return errors.New("player: inconsistent costume burst record") - } - if err := validateCostumeBurstRecord(record); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - costume, found := s.costumeByIndexLocked(invenIndex) - if !found { - return fmt.Errorf("player: costume %d not found", invenIndex) - } - if costume.ID != record.CostumeID { - return errors.New("player: costume burst record design mismatch") - } - if costume.BurstLevel != expectedCurrent { - return errors.New("player: stale costume burst level") - } - ledgerKey := costumeBurstUpgradeKey(invenIndex, target) - if _, exists := s.data.CostumeBurstUpgrades[ledgerKey]; exists { - return errors.New("player: costume burst transition already recorded") - } - next := cloneCollection(s.data) - next.CostumeBurstLevels[strconv.FormatUint(invenIndex, 10)] = target - record.Body = append([]byte(nil), record.Body...) - next.CostumeBurstUpgrades[ledgerKey] = record - return s.commit(next) -} - func (s *CollectionStore) costumeByIndexLocked(index uint64) (Costume, bool) { for _, costume := range s.costumesLocked() { if costume.InvenIndex == index { @@ -961,73 +799,10 @@ func parseCostumeBurstUpgradeKey(key string) (uint64, uint64, bool) { return index, level, indexErr == nil && levelErr == nil && index != 0 && level != 0 && key == costumeBurstUpgradeKey(index, level) } -func validateCostumeBurstRecord(record CostumeBurstUpgradeRecord) error { - if record.CostumeID == 0 || record.Level == 0 || record.Code != 578 || len(record.Digest) != costumeBurstDigestHexSize { - return errors.New("player: invalid costume burst upgrade record") - } - if _, err := hex.DecodeString(record.Digest); err != nil { - return errors.New("player: invalid costume burst upgrade digest") - } - level, found, err := wire.Varint(record.Body, 1) - if err != nil || !found || level != record.Level { - return errors.New("player: invalid costume burst upgrade response") - } - fieldCount := 0 - if err := wire.Walk(record.Body, func(field wire.Field) error { - if field.Number != 1 || field.Type != 0 { - return errors.New("unexpected costume burst response field") - } - fieldCount++ - return nil - }); err != nil || fieldCount != 1 { - return errors.New("player: invalid costume burst upgrade response") - } - return nil -} - const costumeBurstDigestHexSize = 64 -func (s *CollectionStore) validateCostumeBurstStorage() error { - if s.data.CostumeBurstLevels == nil || s.data.CostumeBurstUpgrades == nil { - return errors.New("player: collection save requires costume burst ledgers") - } - owned := make(map[uint64]Costume, len(s.base)+len(s.data.Costumes)) - for _, costume := range append(append([]Costume(nil), s.base...), s.data.Costumes...) { - if costume.InvenIndex == 0 || costume.ID == 0 { - return errors.New("player: invalid costume burst ownership") - } - if _, exists := owned[costume.InvenIndex]; exists { - return errors.New("player: duplicate costume burst inventory index") - } - owned[costume.InvenIndex] = costume - } - for key, level := range s.data.CostumeBurstLevels { - index, err := strconv.ParseUint(key, 10, 64) - costume, found := owned[index] - if err != nil || index == 0 || key != strconv.FormatUint(index, 10) || level == 0 || !found || level < costume.BurstLevel { - return fmt.Errorf("player: invalid costume burst level entry %q", key) - } - } - for key, record := range s.data.CostumeBurstUpgrades { - index, level, valid := parseCostumeBurstUpgradeKey(key) - costume, found := owned[index] - current := costume.BurstLevel - if overlay, exists := s.data.CostumeBurstLevels[strconv.FormatUint(index, 10)]; exists { - current = overlay - } - if !valid || !found || record.CostumeID != costume.ID || record.Level != level || level > current { - return fmt.Errorf("player: invalid costume burst upgrade entry %q", key) - } - if err := validateCostumeBurstRecord(record); err != nil { - return fmt.Errorf("player: invalid costume burst upgrade entry %q: %w", key, err) - } - } - return nil -} +func (s *CollectionStore) ValidateCostumePotentialActivation(ctx command.Context, costumeIndex uint64, nodes []uint64) error { -func (s *CollectionStore) ValidateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error { - s.mu.Lock() - defer s.mu.Unlock() return s.validateCostumePotentialActivation(costumeIndex, nodes) } @@ -1062,9 +837,8 @@ func (s *CollectionStore) validateCostumePotentialActivation(costumeIndex uint64 return nil } -func (s *CollectionStore) ActivateCostumePotential(costumeIndex uint64, nodes []uint64) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *CollectionStore) ActivateCostumePotential(ctx command.Context, costumeIndex uint64, nodes []uint64) error { + if err := s.validateCostumePotentialActivation(costumeIndex, nodes); err != nil { return err } @@ -1072,22 +846,20 @@ func (s *CollectionStore) ActivateCostumePotential(costumeIndex uint64, nodes [] key := strconv.FormatUint(costumeIndex, 10) next.CostumePotential[key] = append(next.CostumePotential[key], nodes...) slices.Sort(next.CostumePotential[key]) - return s.commit(next) + return s.commit(ctx, next) } func (s *CollectionStore) CharAwakeState(uniqueCharID uint64) (CharAwakeProgress, bool) { if uniqueCharID == 0 { return CharAwakeProgress{}, false } - s.mu.Lock() - defer s.mu.Unlock() + progress, ok := s.data.CharAwake[strconv.FormatUint(uniqueCharID, 10)] return progress, ok } func (s *CollectionStore) CharAwakeStates() map[uint64]CharAwakeProgress { - s.mu.Lock() - defer s.mu.Unlock() + result := make(map[uint64]CharAwakeProgress, len(s.data.CharAwake)) for key, progress := range s.data.CharAwake { uniqueID, err := strconv.ParseUint(key, 10, 64) @@ -1100,7 +872,7 @@ func (s *CollectionStore) CharAwakeStates() map[uint64]CharAwakeProgress { // UpdateCharAwake atomically advances one UniqueCharId ledger entry. expected // rejects stale concurrent requests before one response can overwrite another. -func (s *CollectionStore) UpdateCharAwake(uniqueCharID uint64, expected, nextProgress CharAwakeProgress) error { +func (s *CollectionStore) UpdateCharAwake(ctx command.Context, uniqueCharID uint64, expected, nextProgress CharAwakeProgress) error { if uniqueCharID == 0 || nextProgress == (CharAwakeProgress{}) { return errors.New("player: invalid character awakening progress") } @@ -1112,8 +884,7 @@ func (s *CollectionStore) UpdateCharAwake(uniqueCharID uint64, expected, nextPro if expected.IsAwake && !nextProgress.IsAwake { return errors.New("player: character awakening cannot be removed") } - s.mu.Lock() - defer s.mu.Unlock() + key := strconv.FormatUint(uniqueCharID, 10) current := s.data.CharAwake[key] if current != expected { @@ -1121,19 +892,17 @@ func (s *CollectionStore) UpdateCharAwake(uniqueCharID uint64, expected, nextPro } next := cloneCollection(s.data) next.CharAwake[key] = nextProgress - return s.commit(next) + return s.commit(ctx, next) } func (s *CollectionStore) Grant(identity string) (CollectionGrant, bool) { - s.mu.Lock() - defer s.mu.Unlock() + grant, ok := s.data.Grants[identity] return cloneGrant(grant), ok } func (s *CollectionStore) FirstGachaCompleted() bool { - s.mu.Lock() - defer s.mu.Unlock() + _, completed := s.data.Grants[FirstGachaCompletedIdentity] return completed } @@ -1148,8 +917,7 @@ func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) { } func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) { - s.mu.Lock() - defer s.mu.Unlock() + return s.costumeByIndexLocked(index) } @@ -1238,7 +1006,7 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot { return out } -func (s *CollectionStore) commit(next collectionSnapshot) error { +func (s *CollectionStore) commit(ctx command.Context, next collectionSnapshot) error { changes, err := diffCollectionEntries(s.data, next) if err != nil { return err @@ -1253,7 +1021,7 @@ func (s *CollectionStore) commit(next collectionSnapshot) error { if core == nil && len(changes) == 0 { return nil } - if err := s.store.SaveWithEntries("collection", core, changes); err != nil { + if err := s.store.SaveWithEntries(ctx.State, "collection", core, changes); err != nil { return fmt.Errorf("player: persist collection: %w", err) } s.data = next diff --git a/go/internal/server/player/collection_entries.go b/go/internal/server/domain/roster/collection_entries.go similarity index 77% rename from go/internal/server/player/collection_entries.go rename to go/internal/server/domain/roster/collection_entries.go index e0c1b7c..7a39bc1 100644 --- a/go/internal/server/player/collection_entries.go +++ b/go/internal/server/domain/roster/collection_entries.go @@ -1,4 +1,4 @@ -package player +package roster import ( "encoding/json" @@ -8,7 +8,8 @@ import ( "sort" "strconv" - "bd2server/internal/server/stateio" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" ) const collectionDomain = "collection" @@ -36,8 +37,8 @@ func rejectInlineCollectionEntries(raw []byte) error { return nil } -func loadEntryMap[T any](store stateio.EntryStore, bucket string) (map[string]T, error) { - entries, err := store.ListEntries(collectionDomain, bucket) +func loadEntryMap[T any](ctx command.Context, store stateio.ScopedEntryStore, bucket string) (map[string]T, error) { + entries, err := store.ListEntries(ctx.State, collectionDomain, bucket) if err != nil { return nil, fmt.Errorf("player: load collection %s: %w", bucket, err) } @@ -55,58 +56,58 @@ func loadEntryMap[T any](store stateio.EntryStore, bucket string) (map[string]T, return result, nil } -func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) error { +func loadCollectionEntries(ctx command.Context, store stateio.ScopedEntryStore, data *collectionSnapshot) error { var err error - if data.Friendships, err = loadEntryMap[FriendshipEntry](store, "friendships"); err != nil { + if data.Friendships, err = loadEntryMap[FriendshipEntry](ctx, store, "friendships"); err != nil { return err } - if data.Characters, err = loadIndexedEntries[Character](store, "characters", func(c Character) uint64 { return c.InvenIndex }); err != nil { + if data.Characters, err = loadIndexedEntries[Character](ctx, store, "characters", func(c Character) uint64 { return c.InvenIndex }); err != nil { return err } - if data.Costumes, err = loadIndexedEntries[Costume](store, "costumes", func(c Costume) uint64 { return c.InvenIndex }); err != nil { + if data.Costumes, err = loadIndexedEntries[Costume](ctx, store, "costumes", func(c Costume) uint64 { return c.InvenIndex }); err != nil { return err } - if data.Grants, err = loadEntryMap[CollectionGrant](store, "grants"); err != nil { + if data.Grants, err = loadEntryMap[CollectionGrant](ctx, store, "grants"); err != nil { return err } - if data.GachaApplied, err = loadEntryMap[bool](store, "gacha_applied"); err != nil { + if data.GachaApplied, err = loadEntryMap[bool](ctx, store, "gacha_applied"); err != nil { return err } - if data.GachaUsers, err = loadEntryMap[GachaUserState](store, "gacha_users"); err != nil { + if data.GachaUsers, err = loadEntryMap[GachaUserState](ctx, store, "gacha_users"); err != nil { return err } - if data.GachaFixed, err = loadEntryMap[GachaFixedState](store, "gacha_fixed"); err != nil { + if data.GachaFixed, err = loadEntryMap[GachaFixedState](ctx, store, "gacha_fixed"); err != nil { return err } - if data.StepUpProgress, err = loadEntryMap[uint64](store, "step_up_progress"); err != nil { + if data.StepUpProgress, err = loadEntryMap[uint64](ctx, store, "step_up_progress"); err != nil { return err } - if data.GachaPointExchange, err = loadEntryMap[GachaPointExchange](store, "gacha_point_exchanges"); err != nil { + if data.GachaPointExchange, err = loadEntryMap[GachaPointExchange](ctx, store, "gacha_point_exchanges"); err != nil { return err } - if data.GachaSelections, err = loadEntryMap[[]GachaSelection](store, "gacha_selections"); err != nil { + if data.GachaSelections, err = loadEntryMap[[]GachaSelection](ctx, store, "gacha_selections"); err != nil { return err } - if data.GachaSelectionChanges, err = loadEntryMap[uint64](store, "gacha_selection_changes"); err != nil { + if data.GachaSelectionChanges, err = loadEntryMap[uint64](ctx, store, "gacha_selection_changes"); err != nil { return err } - if data.CostumePotential, err = loadEntryMap[[]uint64](store, "costume_potential"); err != nil { + if data.CostumePotential, err = loadEntryMap[[]uint64](ctx, store, "costume_potential"); err != nil { return err } - if data.CostumeBurstLevels, err = loadEntryMap[uint64](store, "costume_burst_levels"); err != nil { + if data.CostumeBurstLevels, err = loadEntryMap[uint64](ctx, store, "costume_burst_levels"); err != nil { return err } - if data.CostumeBurstUpgrades, err = loadEntryMap[CostumeBurstUpgradeRecord](store, "costume_burst_upgrades"); err != nil { + if data.CostumeBurstUpgrades, err = loadEntryMap[CostumeBurstUpgradeRecord](ctx, store, "costume_burst_upgrades"); err != nil { return err } - if data.CharAwake, err = loadEntryMap[CharAwakeProgress](store, "char_awake"); err != nil { + if data.CharAwake, err = loadEntryMap[CharAwakeProgress](ctx, store, "char_awake"); err != nil { return err } return nil } -func loadIndexedEntries[T any](store stateio.EntryStore, bucket string, index func(T) uint64) ([]T, error) { - values, err := loadEntryMap[T](store, bucket) +func loadIndexedEntries[T any](ctx command.Context, store stateio.ScopedEntryStore, bucket string, index func(T) uint64) ([]T, error) { + values, err := loadEntryMap[T](ctx, store, bucket) if err != nil { return nil, err } diff --git a/go/internal/server/domain/roster/costume_burst.go b/go/internal/server/domain/roster/costume_burst.go new file mode 100644 index 0000000..618371e --- /dev/null +++ b/go/internal/server/domain/roster/costume_burst.go @@ -0,0 +1,108 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "errors" + "fmt" + "math" +) + +const costumeBurstPacketCode = 578 + +type costumeBurstReply struct { + Digest string + Code int + Body []byte +} + +// CostumeBurstService applies one adjacent costume-burst level from the +// authoritative GameData row. The account request transaction makes the +// wallet, inventory, and collection writes one atomic SQLite operation. +type CostumeBurstService struct { + design *gamedata.CostumeBurstDesign + collection *CollectionStore + inventory *assets.Inventory + wallet *assets.Wallet + + replies map[string]map[uint64]costumeBurstReply +} + +func NewCostumeBurstService(design *gamedata.CostumeBurstDesign, collection *CollectionStore, inventory *assets.Inventory, wallet *assets.Wallet) (*CostumeBurstService, error) { + if design == nil || collection == nil || inventory == nil || wallet == nil { + return nil, errors.New("player: incomplete costume burst service") + } + return &CostumeBurstService{ + design: design, collection: collection, inventory: inventory, wallet: wallet, + replies: make(map[string]map[uint64]costumeBurstReply), + }, nil +} + +// BeginLogin creates the replay cache for the newly authenticated login. +func (s *CostumeBurstService) BeginLogin(ctx command.Context) { + id := ctx.SessionID + + if id == "" { + return + } + if s.replies[id] != nil { + return + } + if len(s.replies) >= 1024 { + for oldID := range s.replies { + if oldID != id { + delete(s.replies, oldID) + break + } + } + } + s.replies[id] = make(map[uint64]costumeBurstReply) +} + +func validateCostumeBurstMaterials(costs []gamedata.PromotionCost, materials []assets.Item) ([]assets.Item, uint64, error) { + if len(costs) == 0 || len(materials) == 0 { + return nil, 0, errors.New("player: CostumeBurst has no material") + } + want := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + if cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type == 8 && cost.ID == 0) || (cost.Type != 4 && cost.Type != 8) { + return nil, 0, errors.New("player: invalid CostumeBurst GameData cost") + } + key := [2]uint64{cost.Type, cost.ID} + if want[key] > math.MaxUint64-cost.Count { + return nil, 0, errors.New("player: CostumeBurst GameData cost overflow") + } + want[key] += cost.Count + } + got := make(map[[2]uint64]uint64, len(materials)) + items := make([]assets.Item, 0, len(materials)) + var gold uint64 + for _, material := range materials { + if material.Type != 4 && material.Type != 8 { + return nil, 0, fmt.Errorf("player: unsupported CostumeBurst material type %d", material.Type) + } + key := [2]uint64{material.Type, material.ID} + if got[key] > math.MaxUint64-material.Count { + return nil, 0, errors.New("player: CostumeBurst submitted material overflow") + } + got[key] += material.Count + if material.Type == 4 { + if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { + return nil, 0, errors.New("player: invalid CostumeBurst currency") + } + gold = material.Count + } else { + items = append(items, material) + } + } + if len(got) != len(want) { + return nil, 0, errors.New("player: CostumeBurst material kinds mismatch") + } + for key, count := range want { + if got[key] != count { + return nil, 0, fmt.Errorf("player: CostumeBurst material %d/%d=%d want %d", key[0], key[1], got[key], count) + } + } + return items, gold, nil +} diff --git a/go/internal/server/domain/roster/costume_potential.go b/go/internal/server/domain/roster/costume_potential.go new file mode 100644 index 0000000..0bbf6ad --- /dev/null +++ b/go/internal/server/domain/roster/costume_potential.go @@ -0,0 +1,102 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "errors" + "fmt" + "math" +) + +type CostumePotentialService struct { + design *gamedata.CostumePotentialDesign + collection *CollectionStore + characters *CharacterStore + inventory *assets.Inventory + wallet *assets.Wallet + connectStore stateio.Store +} + +func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collection *CollectionStore, characters *CharacterStore, inventory *assets.Inventory, wallet *assets.Wallet) (*CostumePotentialService, error) { + if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil { + return nil, errors.New("player: incomplete costume potential service") + } + return &CostumePotentialService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil +} + +// Contributions follows CharStatInfo.GetCostumeNodeBuffStat: type 2 nodes +// contribute across every owned costume of the same UniqueCharId, while type +// 1 nodes contribute only from CharDBInfo.ConnectPotentialCostume. Values are +// already fractions for the percent options 2, 4 and 6; they are not equipment +// option percentages and must not be divided by 100 or rounded individually. +func (s *CostumePotentialService) Contributions(ctx command.Context, character Character) ([]gamedata.StatContribution, error) { + if s == nil || s.design == nil || s.collection == nil { + return nil, errors.New("player: missing costume potential contribution source") + } + unique, ok := s.design.CharacterUnique[character.ID] + if !ok || unique == 0 { + return nil, fmt.Errorf("player: unknown costume potential character %d", character.ID) + } + var result []gamedata.StatContribution + connectedFound := character.ConnectPotentialCostume == 0 + for _, costume := range s.collection.Costumes() { + if s.design.CostumeUnique[costume.ID] != unique { + continue + } + if costume.ID == character.ConnectPotentialCostume { + connectedFound = true + } + seen := map[uint64]bool{} + for _, id := range costume.PotentialIDs { + if seen[id] { + return nil, errors.New("player: duplicate active costume potential stat node") + } + seen[id] = true + node, found := s.design.Nodes[costume.ID][id] + if !found { + return nil, fmt.Errorf("player: unknown active potential node %d/%d", costume.ID, id) + } + if node.NodeType != 2 && (node.NodeType != 1 || costume.ID != character.ConnectPotentialCostume) { + continue + } + if math.IsNaN(node.StatValue) || math.IsInf(node.StatValue, 0) || node.StatValue < 0 { + return nil, errors.New("player: invalid costume potential stat value") + } + contribution := gamedata.StatContribution{Option: node.StatType} + switch node.StatType { + case 1, 2: + contribution.Stat = gamedata.StatHealth + case 3, 4: + contribution.Stat = gamedata.StatAttack + case 5, 6: + contribution.Stat = gamedata.StatMagic + case 7: + contribution.Stat = gamedata.StatDefencePercent + case 8: + contribution.Stat = gamedata.StatMagicResistancePercent + case 9: + contribution.Stat = gamedata.StatCriticalChance + case 10: + contribution.Stat = gamedata.StatCriticalDamage + case 11, 12, 13, 14, 15, 19: + contribution.Stat = gamedata.StatElementDamage + case 16, 17, 18, 20: + contribution.Stat = gamedata.StatElementResistance + default: + return nil, fmt.Errorf("player: unsupported costume potential stat option %d", node.StatType) + } + if node.StatType == 2 || node.StatType == 4 || node.StatType == 6 { + contribution.Percent = node.StatValue + } else { + contribution.Flat = node.StatValue + } + result = append(result, contribution) + } + } + if !connectedFound { + return nil, errors.New("player: connected potential costume is not owned by this character") + } + return result, nil +} diff --git a/go/internal/server/domain/roster/costume_potential_connect.go b/go/internal/server/domain/roster/costume_potential_connect.go new file mode 100644 index 0000000..495510e --- /dev/null +++ b/go/internal/server/domain/roster/costume_potential_connect.go @@ -0,0 +1,46 @@ +package roster + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "fmt" +) + +type potentialConnectReceipt struct { + Digest string + Body []byte +} + +func (s *CostumePotentialService) AttachConnectStore(store stateio.Store) error { + if store == nil { + return fmt.Errorf("player: missing potential connect store") + } + s.connectStore = store + return nil +} + +func (s *CharacterStore) setPotentialConnection(ctx command.Context, index, costume uint64) error { + + for i, c := range s.characters { + if c.InvenIndex == index { + next := append([]Character(nil), s.characters...) + next[i].ConnectPotentialCostume = costume + if err := s.persist(ctx, next); err != nil { + + return err + } + s.characters = next + + return nil + } + } + collection := s.collection + + if collection != nil { + if c, ok := collection.FindCharacter(index); ok { + c.ConnectPotentialCostume = costume + return collection.UpdateCharacter(ctx, c.ID, c) + } + } + return fmt.Errorf("player: unknown potential connection character") +} diff --git a/go/internal/server/domain/roster/deck/auto_recovery.go b/go/internal/server/domain/roster/deck/auto_recovery.go new file mode 100644 index 0000000..e974aa9 --- /dev/null +++ b/go/internal/server/domain/roster/deck/auto_recovery.go @@ -0,0 +1,21 @@ +package deck + +import "bd2server/internal/server/domain/command" + +import ( + "bd2server/internal/server/domain/roster" +) + +func (s *Store) AttachAutoRecovery(f func(ctx command.Context, _ uint64, _ uint64, _ []uint64) (roster.AutoRecoveryResult, error)) { + + s.autoRecovery = f +} +func (s *Store) AttachAutoRecoveryAllowed(f func(ctx command.Context) (bool, error)) { + + s.autoRecoveryAllowed = f +} + +type autoRecoveryReceipt struct { + Digest string + Body []byte +} diff --git a/go/internal/server/domain/roster/deck/deck.go b/go/internal/server/domain/roster/deck/deck.go new file mode 100644 index 0000000..cd3ea90 --- /dev/null +++ b/go/internal/server/domain/roster/deck/deck.go @@ -0,0 +1,292 @@ +// Package deck owns local deck, field-party, waypoint, and selected-costume +// state. It stores typed JSON, never captured protobuf/base64 envelopes. +package deck + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "maps" + "os" +) + +type DeckEntry struct { + CharacterInvenIndex uint64 `json:"character_inven_index"` + // CostumeInvenIndex is retained as the persisted Go/JSON name for the + // development save format. On the wire DeckDBInfo field 2 is Position: a + // zero-based battle-grid cell (or -1 while unassigned), not a costume + // inventory index. + CostumeInvenIndex uint64 `json:"costume_inven_index"` + Slot uint64 `json:"slot"` +} +type FieldEntry struct { + Slot uint64 `json:"slot"` + CharacterInvenIndex uint64 `json:"character_inven_index"` + CostumeInvenIndex uint64 `json:"costume_inven_index"` +} +type Seed struct { + Version string `json:"version"` + FieldDeck []FieldEntry `json:"field_deck"` + FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` + AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"` +} +type state struct { + Version string `json:"version"` + Deck []DeckEntry `json:"deck"` + FieldDeck []FieldEntry `json:"field_deck"` + FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` + Waypoints map[uint64][]uint64 `json:"waypoints"` + Costumes map[uint64]uint64 `json:"costumes"` + Packs map[uint64]uint64 `json:"packs"` + HighestTotalBattlePower uint64 `json:"highest_total_battle_power"` + PortraitCostumeID uint64 `json:"portrait_costume_id"` + AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"` +} + +// PortraitCostume exposes the current portrait without changing the frozen deck schema. +func (s *Store) PortraitCostume() uint64 { + + return s.state.PortraitCostumeID +} + +type Store struct { + autoRecovery func(ctx command.Context, _ uint64, _ uint64, _ []uint64) (roster.AutoRecoveryResult, error) + autoRecoveryAllowed func(ctx command.Context) (bool, error) + fieldSettingsDesign *gamedata.FieldSettingsDesign + fieldSettingsPack func(command.Context) (int, error) + + storage stateio.ScopedEntryStore + state state + presets map[uint64]Preset + presetSlots uint64 + presetDesign gamedata.PresetDesign + costumeSettings map[uint64]CostumeSetting + wallet *assets.Wallet + characters *roster.CharacterStore + equipment *assets.EquipmentInventory + collection *roster.CollectionStore + + replies map[string]deckReply + waypointDesign func(uint64) (gamedata.WaypointPack, error) + waypointPack func(ctx command.Context, _ uint64, _ bool) error +} + +type deckReply struct { + code int + body []byte +} + +func (s *Store) CurrentDeck() []DeckEntry { + + return append([]DeckEntry(nil), s.state.Deck...) +} + +func LoadSeed(path string) (Seed, error) { + b, e := os.ReadFile(path) + if e != nil { + return Seed{}, fmt.Errorf("deck: read seed: %w", e) + } + var s Seed + if e = json.Unmarshal(b, &s); e != nil { + return Seed{}, fmt.Errorf("deck: decode seed: %w", e) + } + if e = s.validate(); e != nil { + return Seed{}, e + } + return s, nil +} +func (s Seed) validate() error { + if s.Version != versionconfig.State() { + return errors.New("deck: wrong seed version") + } + return validField(s.FieldDeck) +} +func validField(entries []FieldEntry) error { + if len(entries) == 0 || len(entries) > 5 { + return errors.New("deck: invalid field deck size") + } + characters := map[uint64]bool{} + costumes := map[uint64]bool{} + sequences := map[uint64]bool{} + for _, e := range entries { + if e.Slot == 0 || e.Slot > 5 || e.CharacterInvenIndex == 0 || + characters[e.CharacterInvenIndex] || sequences[e.Slot] || + (e.CostumeInvenIndex != 0 && costumes[e.CostumeInvenIndex]) { + return errors.New("deck: invalid field deck") + } + characters[e.CharacterInvenIndex] = true + sequences[e.Slot] = true + if e.CostumeInvenIndex != 0 { + costumes[e.CostumeInvenIndex] = true + } + } + for slot := uint64(1); slot <= uint64(len(entries)); slot++ { + if !sequences[slot] { + return errors.New("deck: field deck has a missing sequence") + } + } + return nil +} + +func validDeck(entries []DeckEntry) error { + if len(entries) == 0 || len(entries) > 5 { + return errors.New("deck: invalid battle deck size") + } + characters := map[uint64]bool{} + positions := map[uint64]bool{} + sequences := map[uint64]bool{} + for _, entry := range entries { + position := entry.CostumeInvenIndex + unassigned := position == ^uint64(0) // int32 -1 sign-extends in protobuf varints. + if entry.CharacterInvenIndex == 0 || (!unassigned && position > 11) || entry.Slot == 0 || entry.Slot > 5 || + characters[entry.CharacterInvenIndex] || (!unassigned && positions[position]) || sequences[entry.Slot] { + return errors.New("deck: invalid battle deck") + } + characters[entry.CharacterInvenIndex] = true + if !unassigned { + positions[position] = true + } + sequences[entry.Slot] = true + } + return nil +} +func NewStore(seed Seed, designs ...gamedata.PresetDesign) (*Store, error) { + if e := seed.validate(); e != nil { + return nil, e + } + var design gamedata.PresetDesign + if len(designs) > 1 { + return nil, errors.New("deck: multiple preset designs") + } + if len(designs) == 1 { + design = designs[0] + if err := design.Validate(); err != nil { + return nil, err + } + } + return &Store{presetDesign: design, state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64][]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: design.BaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil +} +func OpenStore(ctx command.Context, storage stateio.Store, seed Seed, designs ...gamedata.PresetDesign) (*Store, error) { + s, e := NewStore(seed, designs...) + if e != nil { + return nil, e + } + entries, ok := storage.(stateio.ScopedEntryStore) + if storage == nil || !ok { + return nil, errors.New("deck: nil storage") + } + s.storage = entries + b, e := storage.Load(ctx.State, "deck") + if e != nil { + return nil, fmt.Errorf("deck: load state: %w", e) + } + if b == nil { + if e = stateio.RequireNoEntries(entries, ctx.State, "deck", "presets", "preset_config", "costume_settings", "field_settings"); e != nil { + return nil, fmt.Errorf("deck: invalid entry storage: %w", e) + } + return s, nil + } + if e = stateio.RequireExactJSONObject(b, "version", "deck", "field_deck", "field_char_control_deck_type", "waypoints", "costumes", "packs", "highest_total_battle_power", "portrait_costume_id", "auto_revive_catalyst"); e != nil { + return nil, fmt.Errorf("deck: incompatible state layout: %w", e) + } + var loaded state + if e = json.Unmarshal(b, &loaded); e != nil { + return nil, fmt.Errorf("deck: malformed state: %w", e) + } + if loaded.Version != versionconfig.State() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil { + return nil, errors.New("deck: invalid saved state") + } + if e = validWaypointState(loaded.Waypoints); e != nil { + return nil, e + } + s.state = loaded + if e = s.loadPresetEntries(ctx); e != nil { + return nil, e + } + return s, nil +} +func (s *Store) EnsurePersisted(ctx command.Context) error { + + b, e := s.storage.Load(ctx.State, "deck") + if e != nil { + return e + } + if b != nil { + return nil + } + return s.commit(ctx, clone(s.state)) +} +func (s *Store) commit(ctx command.Context, next state) error { + if s.storage != nil { + b, e := json.MarshalIndent(next, "", " ") + if e != nil { + return e + } + if e = s.storage.Save(ctx.State, "deck", append(b, '\n')); e != nil { + return e + } + } + s.state = next + return nil +} +func clone(x state) state { + y := x + y.Deck = append([]DeckEntry(nil), x.Deck...) + y.FieldDeck = append([]FieldEntry(nil), x.FieldDeck...) + y.Waypoints = map[uint64][]uint64{} + for k, v := range x.Waypoints { + y.Waypoints[k] = append([]uint64(nil), v...) + } + y.Costumes = map[uint64]uint64{} + maps.Copy(y.Costumes, x.Costumes) + y.Packs = map[uint64]uint64{} + maps.Copy(y.Packs, x.Packs) + return y +} + +func (s *Store) validateOwnedDeckLocked(ctx command.Context, entries []DeckEntry) error { + if s.characters == nil { + return nil + } + for _, entry := range entries { + if _, found := s.characters.Find(ctx, entry.CharacterInvenIndex); !found { + return fmt.Errorf("deck: battle deck references unknown character %d", entry.CharacterInvenIndex) + } + } + return nil +} + +func (s *Store) validateOwnedFieldDeckLocked(ctx command.Context, entries []FieldEntry) error { + if s.characters == nil || s.collection == nil { + return nil + } + for _, entry := range entries { + character, found := s.characters.Find(ctx, entry.CharacterInvenIndex) + if !found { + return fmt.Errorf("deck: field deck references unknown character %d", entry.CharacterInvenIndex) + } + if roster.IsStoryCharacter(character) && !s.temporaryAllowed(ctx, character) { + return fmt.Errorf("deck: field character unavailable in this pack") + } + if entry.CostumeInvenIndex == 0 { + continue + } + if (roster.IsStoryCharacter(character) || roster.IsCharmCharacter(character)) && character.UseCostume == entry.CostumeInvenIndex { + continue + } + costume, found := s.collection.CostumeByIndex(entry.CostumeInvenIndex) + if !found { + return fmt.Errorf("deck: field deck references unknown costume %d", entry.CostumeInvenIndex) + } + if costume.UseChar != entry.CharacterInvenIndex { + return fmt.Errorf("deck: costume %d does not belong to character %d", entry.CostumeInvenIndex, entry.CharacterInvenIndex) + } + } + return nil +} diff --git a/go/internal/server/deck/field_settings.go b/go/internal/server/domain/roster/deck/field_settings.go similarity index 50% rename from go/internal/server/deck/field_settings.go rename to go/internal/server/domain/roster/deck/field_settings.go index b715d33..e8e27ea 100644 --- a/go/internal/server/deck/field_settings.go +++ b/go/internal/server/domain/roster/deck/field_settings.go @@ -1,11 +1,9 @@ package deck import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "encoding/binary" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/roster" "encoding/json" "fmt" "time" @@ -19,46 +17,44 @@ type fieldSettings struct { // A new formal entry owns quick slots and automatic recovery preferences; the // existing deck core and historical protobuf schema keep their current layout. -func (s *Store) AttachFieldSettings(d *gamedata.FieldSettingsDesign) error { +func (s *Store) AttachFieldSettings(ctx command.Context, d *gamedata.FieldSettingsDesign) error { if d == nil || d.TalentSlots <= 0 { return fmt.Errorf("deck: invalid field settings design") } - s.mu.Lock() - defer s.mu.Unlock() + s.fieldSettingsDesign = d - _, e := s.loadFieldSettings() + _, e := s.loadFieldSettings(ctx) return e } -func (s *Store) AttachFieldSettingsPack(source func() (int, error)) error { +func (s *Store) AttachFieldSettingsPack(source func(command.Context) (int, error)) error { if source == nil { return fmt.Errorf("deck: nil field pack provider") } - s.mu.Lock() - defer s.mu.Unlock() + s.fieldSettingsPack = source return nil } -func (s *Store) temporaryAllowed(c player.Character) bool { +func (s *Store) temporaryAllowed(ctx command.Context, c roster.Character) bool { if s.fieldSettingsDesign == nil || s.fieldSettingsPack == nil { return false } - p, e := s.fieldSettingsPack() + p, e := s.fieldSettingsPack(ctx) return e == nil && s.fieldSettingsDesign.CharacterTemporaryPack[c.ID] == p && p != 0 } -func (s *Store) loadFieldSettings() (fieldSettings, error) { +func (s *Store) loadFieldSettings(ctx command.Context) (fieldSettings, error) { d := s.fieldSettingsDesign if d == nil { return fieldSettings{}, fmt.Errorf("deck: field settings unavailable") } v := fieldSettings{TalentIDs: make([]uint64, d.TalentSlots)} - raw, ok, e := s.storage.LoadEntry("deck", "field_settings", "state") + raw, ok, e := s.storage.LoadEntry(ctx.State, "deck", "field_settings", "state") if e != nil || !ok { return v, e } if e = stateExactFieldSettings(raw, &v); e != nil { return v, e } - if e = s.validateFieldSettings(v, false); e != nil { + if e = s.validateFieldSettings(ctx, v, false); e != nil { return v, e } return v, nil @@ -73,7 +69,7 @@ func stateExactFieldSettings(raw []byte, v *fieldSettings) error { } return json.Unmarshal(raw, v) } -func (s *Store) validateFieldSettings(v fieldSettings, live bool) error { +func (s *Store) validateFieldSettings(ctx command.Context, v fieldSettings, live bool) error { if len(v.TalentIDs) != s.fieldSettingsDesign.TalentSlots { return fmt.Errorf("deck: talent slot count") } @@ -85,10 +81,10 @@ func (s *Store) validateFieldSettings(v fieldSettings, live bool) error { } if s.characters != nil { for _, c := range s.characters.RawAll() { - if live && player.CharacterExpired(c, time.Now()) { + if live && roster.CharacterExpired(c, time.Now()) { continue } - if !player.IsStoryCharacter(c) || s.temporaryAllowed(c) || !live && s.fieldSettingsDesign.CharacterTemporaryPack[c.ID] != 0 { + if !roster.IsStoryCharacter(c) || s.temporaryAllowed(ctx, c) || !live && s.fieldSettingsDesign.CharacterTemporaryPack[c.ID] != 0 { owned[c.ID] = true } } @@ -110,8 +106,8 @@ func (s *Store) validateFieldSettings(v fieldSettings, live bool) error { if s.characters == nil { return fmt.Errorf("deck: character provider unavailable") } - c, ok := s.characters.Find(v.Caster) - if !ok || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) || s.fieldSettingsDesign.CharacterTalentClass[c.ID] != 10 { + c, ok := s.characters.Find(ctx, v.Caster) + if !ok || roster.IsStoryCharacter(c) || roster.IsCharmCharacter(c) || s.fieldSettingsDesign.CharacterTalentClass[c.ID] != 10 { return fmt.Errorf("deck: invalid automatic recovery caster") } } @@ -120,27 +116,26 @@ func (s *Store) validateFieldSettings(v fieldSettings, live bool) error { } return nil } -func (s *Store) TalentCharacterIDs() ([]uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - v, e := s.loadFieldSettings() - return s.projectTalentSlots(v.TalentIDs), e +func (s *Store) TalentCharacterIDs(ctx command.Context) ([]uint64, error) { + + v, e := s.loadFieldSettings(ctx) + return s.projectTalentSlots(ctx, v.TalentIDs), e } -func (s *Store) projectTalentSlots(ids []uint64) []uint64 { +func (s *Store) projectTalentSlots(ctx command.Context, ids []uint64) []uint64 { out := append([]uint64(nil), ids...) owned := map[uint64]bool{} charm := map[uint64]bool{} if s.characters != nil { for _, c := range s.characters.RawAll() { - if player.CharacterExpired(c, time.Now()) { + if roster.CharacterExpired(c, time.Now()) { continue } - if player.IsCharmCharacter(c) { + if roster.IsCharmCharacter(c) { owned[c.ID] = true charm[c.ID] = true continue } - if !player.IsStoryCharacter(c) || s.temporaryAllowed(c) { + if !roster.IsStoryCharacter(c) || s.temporaryAllowed(ctx, c) { owned[c.ID] = true } } @@ -153,7 +148,7 @@ func (s *Store) projectTalentSlots(ids []uint64) []uint64 { if s.fieldSettingsDesign.CharacterTemporaryPack[id] != 0 && !charm[id] { p := 0 if s.fieldSettingsPack != nil { - p, _ = s.fieldSettingsPack() + p, _ = s.fieldSettingsPack(ctx) } if p != s.fieldSettingsDesign.CharacterTemporaryPack[id] { out[i] = 0 @@ -162,81 +157,12 @@ func (s *Store) projectTalentSlots(ids []uint64) []uint64 { } return out } -func (s *Store) AutoReviveSettings() (bool, uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() - v, e := s.loadFieldSettings() +func (s *Store) AutoReviveSettings(ctx command.Context) (bool, uint64, error) { + + v, e := s.loadFieldSettings(ctx) return v.AutoRevive, v.Caster, e } func (s *Store) FieldControlType() uint64 { - s.mu.RLock() - defer s.mu.RUnlock() + return s.state.FieldCharControlDeckType } -func (s *Store) handleFieldSettings(path string, req []byte) (int, []byte, bool, error) { - if e := checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.loadFieldSettings() - if e != nil { - return 0, nil, true, e - } - code := 71 - var body []byte - if path == "/TalentSlotSave" { - var ids []uint64 - e = wire.Walk(req, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 0 && f.Type != 2 { - return fmt.Errorf("deck: invalid talent slots") - } - b := f.Value - for len(b) > 0 { - n, k := binary.Uvarint(b) - if k <= 0 { - return fmt.Errorf("deck: invalid packed talent slots") - } - ids = append(ids, n) - if len(ids) > s.fieldSettingsDesign.TalentSlots { - return fmt.Errorf("deck: too many talent slots") - } - b = b[k:] - } - return nil - }) - if e != nil { - return 0, nil, true, e - } - v.TalentIDs = ids - } else { - code = 372 - on, _, e := wire.Varint(req, 2) - if e != nil || on > 1 { - return 0, nil, true, fmt.Errorf("deck: invalid auto-recovery switch") - } - caster, _, e := wire.Varint(req, 3) - if e != nil { - return 0, nil, true, e - } - v.AutoRevive = on != 0 - v.Caster = caster - body = wire.AppendVarint(nil, 1, on) - body = wire.AppendVarint(body, 2, caster) - } - if e = s.validateFieldSettings(v, path == "/TalentSlotSave"); e != nil { - return 0, nil, true, e - } - raw, e := json.Marshal(v) - if e == nil { - core, err := json.Marshal(s.state) - if err != nil { - return 0, nil, true, err - } - e = s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "field_settings", Key: "state", Payload: raw}}) - } - return code, body, true, e -} diff --git a/go/internal/server/domain/roster/deck/preset.go b/go/internal/server/domain/roster/deck/preset.go new file mode 100644 index 0000000..72d797c --- /dev/null +++ b/go/internal/server/domain/roster/deck/preset.go @@ -0,0 +1,288 @@ +package deck + +import ( + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +type Preset struct { + Name string `json:"name"` + ResourceID uint64 `json:"resource_id"` + ResourceColor uint64 `json:"resource_color"` + Slot uint64 `json:"slot"` + Decks []PresetDeck `json:"decks"` + Blesses []PresetBless `json:"blesses"` +} + +type PresetDeck struct { + Deck DeckEntry `json:"deck"` + CostumeIndex uint64 `json:"costume_index"` + Equipment []PresetEquipmentItem `json:"equipment"` + Team uint64 `json:"team"` +} + +type PresetEquipmentItem struct { + Type uint64 `json:"type"` + Index uint64 `json:"index"` +} + +type PresetBless struct { + DeckType uint64 `json:"deck_type"` + IDs []uint64 `json:"ids"` +} + +type CostumeSetting struct { + CharacterIndex uint64 `json:"character_index"` + Sequence []CostumeSettingItem `json:"sequence"` + BattleMode uint64 `json:"battle_mode"` + MonsterID uint64 `json:"monster_id"` +} + +type CostumeSettingItem struct { + CostumeIndex int64 `json:"costume_index"` + BurstLevel uint64 `json:"burst_level"` +} + +func (s *Store) AttachPresetRuntime(ctx command.Context, wallet *assets.Wallet, characters *roster.CharacterStore, equipment *assets.EquipmentInventory, collection *roster.CollectionStore) error { + if wallet == nil || characters == nil || equipment == nil || collection == nil { + return errors.New("deck: incomplete preset runtime") + } + + s.wallet, s.characters, s.equipment, s.collection = wallet, characters, equipment, collection + return s.validatePresetOwnershipLocked(ctx) +} + +func (s *Store) BeginLogin(ctx command.Context) { + id := ctx.SessionID + + if id == "" { + return + } + s.replies = map[string]deckReply{} +} + +func (s *Store) PresetSlotCount() uint64 { + + return s.presetSlots +} + +func (s *Store) loadPresetEntries(ctx command.Context) error { + rawConfig, found, err := s.storage.LoadEntry(ctx.State, "deck", "preset_config", "slots") + if err != nil { + return err + } + if found { + if err := json.Unmarshal(rawConfig, &s.presetSlots); err != nil || s.presetSlots < s.presetDesign.BaseCount || s.presetSlots > s.presetDesign.Maximum { + return errors.New("deck: invalid preset slot configuration") + } + } + raw, err := s.storage.ListEntries(ctx.State, "deck", "presets") + if err != nil { + return err + } + for key, payload := range raw { + slot, err := strconv.ParseUint(key, 10, 64) + if err != nil || key != strconv.FormatUint(slot, 10) { + return fmt.Errorf("deck: invalid preset key %q", key) + } + var preset Preset + if err := json.Unmarshal(payload, &preset); err != nil || preset.Slot != slot { + return fmt.Errorf("deck: invalid preset %q", key) + } + if err := s.validatePresetShape(preset, s.presetSlots); err != nil { + return fmt.Errorf("deck: invalid preset %q: %w", key, err) + } + s.presets[slot] = preset + } + rawSettings, err := s.storage.ListEntries(ctx.State, "deck", "costume_settings") + if err != nil { + return err + } + for key, payload := range rawSettings { + index, err := strconv.ParseUint(key, 10, 64) + if err != nil || index == 0 || key != strconv.FormatUint(index, 10) { + return fmt.Errorf("deck: invalid costume setting key %q", key) + } + var setting CostumeSetting + if err := json.Unmarshal(payload, &setting); err != nil || setting.CharacterIndex != index { + return fmt.Errorf("deck: invalid costume setting %q", key) + } + s.costumeSettings[index] = setting + } + return nil +} + +func (s *Store) validatePresetOwnershipLocked(ctx command.Context) error { + if s.characters == nil || s.collection == nil || s.equipment == nil { + return nil + } + for _, preset := range s.presets { + if err := s.validatePresetOwnedLocked(ctx, preset); err != nil { + return fmt.Errorf("deck: saved preset %d: %w", preset.Slot, err) + } + } + for _, setting := range s.costumeSettings { + if _, found := s.characters.Find(ctx, setting.CharacterIndex); !found { + return fmt.Errorf("deck: costume setting references unknown character %d", setting.CharacterIndex) + } + for _, item := range setting.Sequence { + if item.CostumeIndex > 0 { + if _, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex)); !found { + return fmt.Errorf("deck: costume setting references unknown costume %d", item.CostumeIndex) + } + } + } + } + return nil +} + +func (s *Store) validatePresetShape(p Preset, slotCount uint64) error { + if p.Slot >= slotCount || (p.ResourceID != 0 && !s.presetDesign.Icons[p.ResourceID]) || p.ResourceColor > 5 || !validPresetName(p.Name) || len(p.Decks) > 5 { + return errors.New("invalid metadata or deck count") + } + characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{} + for _, deck := range p.Decks { + if deck.Deck.CharacterInvenIndex == 0 || deck.Deck.CostumeInvenIndex > 11 || deck.Deck.Slot == 0 || deck.Deck.Slot > 5 || deck.Team != 0 || + characters[deck.Deck.CharacterInvenIndex] || positions[deck.Deck.CostumeInvenIndex] || sequences[deck.Deck.Slot] { + return errors.New("invalid deck entry") + } + characters[deck.Deck.CharacterInvenIndex] = true + positions[deck.Deck.CostumeInvenIndex] = true + sequences[deck.Deck.Slot] = true + if len(deck.Equipment) != 5 { + return errors.New("preset deck requires five equipment slots") + } + seen := map[uint64]bool{} + for _, item := range deck.Equipment { + if item.Type >= 5 || seen[item.Type] { + return errors.New("invalid preset equipment slot") + } + seen[item.Type] = true + } + } + return nil +} + +func validPresetName(value string) bool { + if !utf8.ValidString(value) || utf8.RuneCountInString(value) < 1 || utf8.RuneCountInString(value) > 16 || strings.Contains(value, "<") || strings.Contains(value, ">") { + return false + } + for _, r := range value { + if unicode.IsControl(r) { + return false + } + } + return true +} + +func (s *Store) validatePresetOwnedLocked(ctx command.Context, p Preset) error { + if err := s.validatePresetShape(p, s.presetSlots); err != nil { + return err + } + ownedEquipment := make(map[uint64]assets.Equipment) + for _, item := range s.equipment.All(ctx) { + ownedEquipment[item.InvenIndex] = item + } + seenEquipment := map[uint64]bool{} + for _, deck := range p.Decks { + if _, found := s.characters.Find(ctx, deck.Deck.CharacterInvenIndex); !found { + return fmt.Errorf("unknown character %d", deck.Deck.CharacterInvenIndex) + } + if deck.CostumeIndex != 0 { + costume, found := s.collection.CostumeByIndex(deck.CostumeIndex) + if !found || costume.UseChar != deck.Deck.CharacterInvenIndex { + return fmt.Errorf("unknown costume %d", deck.CostumeIndex) + } + } + for _, reference := range deck.Equipment { + if reference.Index == 0 { + continue + } + if seenEquipment[reference.Index] || ownedEquipment[reference.Index].InvenIndex == 0 { + return fmt.Errorf("invalid or repeated equipment %d", reference.Index) + } + seenEquipment[reference.Index] = true + } + binding := assets.PresetEquipmentBinding{CharacterIndex: deck.Deck.CharacterInvenIndex, Equipment: make([]uint64, 5)} + for _, reference := range deck.Equipment { + binding.Equipment[reference.Type] = reference.Index + } + if err := s.equipment.ValidatePresetEquipment(ctx, []assets.PresetEquipmentBinding{binding}); err != nil { + return err + } + } + return nil +} + +func (s *Store) presetCacheKey(ctx command.Context, kind string, seq uint64) string { + return kind + ":" + ctx.SessionID + ":" + strconv.FormatUint(seq, 10) +} + +func (s *Store) persistPresetLocked(ctx command.Context, p Preset) error { + payload, err := json.Marshal(p) + if err != nil { + return err + } + core, err := s.corePayloadLocked() + if err != nil { + return err + } + return s.storage.SaveWithEntries(ctx.State, "deck", core, []stateio.EntryMutation{{Bucket: "presets", Key: strconv.FormatUint(p.Slot, 10), Payload: payload}}) +} + +func (s *Store) corePayloadLocked() ([]byte, error) { + payload, err := json.MarshalIndent(s.state, "", " ") + if err != nil { + return nil, err + } + return append(payload, '\n'), nil +} + +func (s *Store) cachedReplyLocked(ctx command.Context, kind string, seq uint64) (deckReply, bool) { + reply, found := s.replies[s.presetCacheKey(ctx, kind, seq)] + if found { + reply.body = append([]byte(nil), reply.body...) + } + return reply, found +} + +func (s *Store) rememberReplyLocked(ctx command.Context, kind string, seq uint64, code int, body []byte) { + s.replies[s.presetCacheKey(ctx, kind, seq)] = deckReply{code: code, body: append([]byte(nil), body...)} +} + +func (s *Store) validateCostumeSettingLocked(ctx command.Context, setting CostumeSetting) error { + if s.characters == nil || s.collection == nil { + return errors.New("deck: costume setting runtime unavailable") + } + if setting.BattleMode != 0 || setting.MonsterID != 0 { + return errors.New("deck: ordinary costume setting requires normal battle mode") + } + if _, found := s.characters.Find(ctx, setting.CharacterIndex); !found { + return fmt.Errorf("deck: unknown costume setting character %d", setting.CharacterIndex) + } + for _, item := range setting.Sequence { + if item.CostumeIndex <= 0 { + if item.CostumeIndex != 0 && item.CostumeIndex != -1 { + return fmt.Errorf("deck: invalid costume setting sentinel %d", item.CostumeIndex) + } + continue + } + costume, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex)) + if !found || costume.UseChar != setting.CharacterIndex { + return fmt.Errorf("deck: costume %d does not belong to character %d", item.CostumeIndex, setting.CharacterIndex) + } + if item.BurstLevel > costume.BurstLevel { + return fmt.Errorf("deck: costume %d burst level exceeds owned level", item.CostumeIndex) + } + } + return nil +} diff --git a/go/internal/server/domain/roster/deck/protocol_adapter.go b/go/internal/server/domain/roster/deck/protocol_adapter.go new file mode 100644 index 0000000..4e185f9 --- /dev/null +++ b/go/internal/server/domain/roster/deck/protocol_adapter.go @@ -0,0 +1,1268 @@ +package deck + +import ( + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "maps" + "math" + "slices" + "sort" + "strconv" +) + +// Handle implements session.Handler. Every mutation validates its complete +// typed request before committing a replacement JSON state. +func (s *Store) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + switch path { + case "/TalentSlotSave", "/CharAutoReviveSet": + return s.handleFieldSettings(ctx, path, req) + case "/PresetInfo": + return s.handlePresetInfo(req) + case "/PresetSave": + return s.handlePresetSave(ctx, req) + case "/PresetAddSlot": + return s.handlePresetAddSlot(ctx, req) + case "/PresetInfoChange": + return s.handlePresetInfoChange(ctx, req) + case "/PresetDelete": + return s.handlePresetDelete(ctx, req) + case "/PresetUse": + return s.handlePresetUse(ctx, req) + case "/DeckCostumeSettingInfo": + return s.handleCostumeSettingInfo(req) + case "/DeckCostumeSettingSave": + return s.handleCostumeSettingSave(ctx, req) + case "/DeckInfo": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + + out := encodeDeck(s.state.Deck) + if s.fieldSettingsDesign != nil { + v, e := s.loadFieldSettings(ctx) + if e != nil { + return 0, nil, true, e + } + for _, id := range s.projectTalentSlots(ctx, v.TalentIDs) { + out = wire.AppendVarint(out, 2, id) + } + } + return 8, out, true, nil + case "/FieldDeckInfo": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + + return 273, encodeField(s.visibleFieldDeckLocked(ctx)), true, nil + case "/DeckCharAutoRevive": + return s.handleAutoRecovery(ctx, req) + case "/WaypointInfo": + return s.handleWaypoint(ctx, path, req) + case "/DeckSave": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + x, e := triples(req) + if e != nil { + return 0, nil, true, e + } + + if e = s.validateOwnedDeckLocked(ctx, x); e != nil { + return 0, nil, true, e + } + n := clone(s.state) + n.Deck = x + e = s.commit(ctx, n) + return 10, nil, true, e + case "/FieldDeckSave": + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + x, e := fieldEntries(req) + if e != nil { + return 0, nil, true, e + } + + if e = s.validateOwnedFieldDeckLocked(ctx, x); e != nil { + return 0, nil, true, e + } + n := clone(s.state) + n.FieldDeck = x + e = s.commit(ctx, n) + return 274, nil, true, e + case "/SaveFieldCharControlDeckType": + // Define_FieldCharControllDeckType is a proto3 enum whose valid values + // are BATTLE=0, FIELD=1 and STORY=2. BATTLE is the protobuf default, so + // the generated client deliberately omits field 2 when it switches out + // of story mode after the final quest. An absent field is therefore a + // real value 0, not a malformed request. + v, _, e := wire.Varint(req, 2) + if e != nil || v > 2 { + return 0, nil, true, errors.New("deck: invalid field control type") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + + n := clone(s.state) + n.FieldCharControlDeckType = v + e = s.commit(ctx, n) + return 288, nil, true, e + case "/WaypointSave", "/WaypointUse": + return s.handleWaypoint(ctx, path, req) + case "/CostumeUse": + return s.handleCostumeUse(ctx, req) + case "/SaveTotalBattlePower": + power, ok, e := wire.Varint(req, 2) + if e != nil || !ok || power == 0 { + return 0, nil, true, errors.New("deck: invalid total battle power") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + + n := clone(s.state) + if power > n.HighestTotalBattlePower { + n.HighestTotalBattlePower = power + } + if e = s.commit(ctx, n); e != nil { + return 0, nil, true, e + } + return 258, wire.AppendVarint(nil, 1, n.HighestTotalBattlePower), true, nil + case "/UserPortraitChange": + costumeID, ok, e := wire.Varint(req, 2) + if e != nil || !ok || costumeID == 0 { + return 0, nil, true, errors.New("deck: invalid portrait costume") + } + if e = checkSeq(req); e != nil { + return 0, nil, true, e + } + + n := clone(s.state) + n.PortraitCostumeID = costumeID + if e = s.commit(ctx, n); e != nil { + return 0, nil, true, e + } + return 75, wire.AppendVarint(nil, 1, costumeID), true, nil + } + return 0, nil, false, nil +} + +func (s *Store) handleWaypoint(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + seq, err := requestSequence(req) + if err != nil { + return 0, nil, true, err + } + pack, found, err := wire.Varint(req, 2) + if err != nil || !found || pack == 0 || pack > math.MaxInt32 { + return 0, nil, true, errors.New("deck: invalid waypoint pack") + } + + if s.waypointDesign == nil || s.waypointPack == nil { + return 0, nil, true, errors.New("deck: waypoint runtime unavailable") + } + if err = s.waypointPack(ctx, pack, path == "/WaypointUse"); err != nil { + return 0, nil, true, err + } + design, err := s.waypointDesign(pack) + if err != nil { + return 0, nil, true, err + } + if path == "/WaypointInfo" { + ids := append([]uint64(nil), s.state.Waypoints[pack]...) + slices.Sort(ids) + var packed []byte + for _, id := range ids { + if _, known := design.Points[id]; !known { + return 0, nil, true, fmt.Errorf("deck: saved waypoint %d absent from pack%d", id, pack) + } + packed = binary.AppendUvarint(packed, id) + } + if len(packed) == 0 { + return 31, nil, true, nil + } + return 31, wire.AppendBytes(nil, 1, packed), true, nil + } + id, found, err := wire.Varint(req, 3) + if err != nil || !found || id == 0 || id > math.MaxInt32 { + return 0, nil, true, errors.New("deck: invalid waypoint") + } + if _, known := design.Points[id]; !known { + return 0, nil, true, errors.New("deck: unknown waypoint") + } + if path == "/WaypointSave" { + if hasWaypoint(s.state.Waypoints[pack], id) { + return 32, nil, true, nil + } + next := clone(s.state) + next.Waypoints[pack] = append(next.Waypoints[pack], id) + err = s.commit(ctx, next) + return 32, nil, true, err + } + end, found, err := wire.Varint(req, 4) + if err != nil || !found || end == 0 || end > math.MaxInt32 || end == id { + return 0, nil, true, errors.New("deck: invalid waypoint destination") + } + target, known := design.Points[end] + if !known || target.MapID == 0 || !hasWaypoint(s.state.Waypoints[pack], end) || !hasWaypoint(s.state.Waypoints[pack], id) { + return 0, nil, true, errors.New("deck: waypoint is not activated") + } + moves, found, err := wire.Varint(req, 5) + if err != nil || !found || moves != 1 { + return 0, nil, true, errors.New("deck: invalid waypoint move count") + } + if reply, ok := s.cachedReplyLocked(ctx, "waypoint-use", seq); ok { + return reply.code, reply.body, true, nil + } + if design.PriceUnit != 0 { + if s.wallet == nil || ctx.SessionID == "" { + return 0, nil, true, errors.New("deck: waypoint wallet session unavailable") + } + identity := fmt.Sprintf("waypoint:%s:%d", ctx.SessionID, seq) + switch design.PriceType { + case 4: + _, err = s.wallet.SpendGoldOnce(ctx, identity, design.PriceUnit) + case 3: + _, err = s.wallet.SpendFreeJewelryOnce(ctx, identity, design.PriceUnit) + case 2: + _, err = s.wallet.SpendJewelryOnce(ctx, identity, design.PriceUnit) + default: + err = errors.New("deck: unsupported waypoint currency") + } + if err != nil { + return 0, nil, true, err + } + } + // The client performs its warp and sends SaveUserPosition with scene coordinates. + s.rememberReplyLocked(ctx, "waypoint-use", seq, 33, nil) + return 33, nil, true, nil +} + +func decodePreset(data []byte) (Preset, error) { + var p Preset + name, _, err := wire.Bytes(data, 1) + if err != nil { + return p, err + } + p.Name = string(name) + p.ResourceID, _, err = wire.Varint(data, 2) + if err != nil { + return p, err + } + p.ResourceColor, _, err = wire.Varint(data, 3) + if err != nil { + return p, err + } + p.Slot, _, err = wire.Varint(data, 4) + if err != nil { + return p, err + } + err = wire.Walk(data, func(field wire.Field) error { + if field.Type != 2 { + return nil + } + switch field.Number { + case 5: + deck, err := decodePresetDeck(field.Value) + if err != nil { + return err + } + p.Decks = append(p.Decks, deck) + case 6: + bless, err := decodePresetBless(field.Value) + if err != nil { + return err + } + p.Blesses = append(p.Blesses, bless) + } + return nil + }) + return p, err +} + +func decodePresetDeck(data []byte) (PresetDeck, error) { + var result PresetDeck + base, found, err := wire.Bytes(data, 1) + if err != nil || !found { + return result, errors.New("deck: preset missing deck base") + } + character, ok, err := wire.Varint(base, 1) + if err != nil || !ok || character == 0 { + return result, errors.New("deck: invalid preset character") + } + position, _, err := wire.Varint(base, 2) + if err != nil { + return result, err + } + sequence, ok, err := wire.Varint(base, 3) + if err != nil || !ok || sequence == 0 { + return result, errors.New("deck: invalid preset sequence") + } + result.Deck = DeckEntry{CharacterInvenIndex: character, CostumeInvenIndex: position, Slot: sequence} + result.CostumeIndex, _, err = wire.Varint(data, 2) + if err != nil { + return result, err + } + result.Team, _, err = wire.Varint(data, 4) + if err != nil { + return result, err + } + err = wire.Walk(data, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return errors.New("deck: invalid preset equipment") + } + typeID, _, err := wire.Varint(field.Value, 1) + if err != nil { + return err + } + index, _, err := wire.Varint(field.Value, 2) + if err != nil { + return err + } + result.Equipment = append(result.Equipment, PresetEquipmentItem{Type: typeID, Index: index}) + return nil + }) + sort.Slice(result.Equipment, func(i, j int) bool { return result.Equipment[i].Type < result.Equipment[j].Type }) + return result, err +} + +func decodePresetBless(data []byte) (PresetBless, error) { + var result PresetBless + result.DeckType, _, _ = wire.Varint(data, 1) + err := wire.Walk(data, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + values, err := repeatedUint64(field) + if err != nil { + return err + } + result.IDs = append(result.IDs, values...) + return nil + }) + return result, err +} + +func repeatedUint64(field wire.Field) ([]uint64, error) { + if field.Type == 0 { + value, count := binary.Uvarint(field.Value) + if count <= 0 { + return nil, wire.ErrMalformed + } + return []uint64{value}, nil + } + if field.Type != 2 { + return nil, wire.ErrMalformed + } + var out []uint64 + for offset := 0; offset < len(field.Value); { + value, count := binary.Uvarint(field.Value[offset:]) + if count <= 0 { + return nil, wire.ErrMalformed + } + out = append(out, value) + offset += count + } + return out, nil +} + +func presetWire(p Preset) []byte { + var out []byte + if p.Name != "" { + out = wire.AppendString(out, 1, p.Name) + } + if p.ResourceID != 0 { + out = wire.AppendVarint(out, 2, p.ResourceID) + } + if p.ResourceColor != 0 { + out = wire.AppendVarint(out, 3, p.ResourceColor) + } + if p.Slot != 0 { + out = wire.AppendVarint(out, 4, p.Slot) + } + for _, deck := range p.Decks { + out = wire.AppendBytes(out, 5, presetDeckWire(deck)) + } + for _, bless := range p.Blesses { + var b []byte + if bless.DeckType != 0 { + b = wire.AppendVarint(b, 1, bless.DeckType) + } + for _, id := range bless.IDs { + b = wire.AppendVarint(b, 2, id) + } + out = wire.AppendBytes(out, 6, b) + } + return out +} + +func presetDeckWire(deck PresetDeck) []byte { + base := wire.AppendVarint(nil, 1, deck.Deck.CharacterInvenIndex) + if deck.Deck.CostumeInvenIndex != 0 { + base = wire.AppendVarint(base, 2, deck.Deck.CostumeInvenIndex) + } + base = wire.AppendVarint(base, 3, deck.Deck.Slot) + out := wire.AppendBytes(nil, 1, base) + if deck.CostumeIndex != 0 { + out = wire.AppendVarint(out, 2, deck.CostumeIndex) + } + for _, item := range deck.Equipment { + var b []byte + if item.Type != 0 { + b = wire.AppendVarint(b, 1, item.Type) + } + if item.Index != 0 { + b = wire.AppendVarint(b, 2, item.Index) + } + out = wire.AppendBytes(out, 3, b) + } + if deck.Team != 0 { + out = wire.AppendVarint(out, 4, deck.Team) + } + return out +} + +func requestSequence(request []byte) (uint64, error) { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, errors.New("deck: invalid request sequence") + } + return seq, nil +} + +func (s *Store) handlePresetInfo(request []byte) (int, []byte, bool, error) { + if _, err := requestSequence(request); err != nil { + return 0, nil, true, err + } + + slots := make([]uint64, 0, len(s.presets)) + for slot := range s.presets { + if slot < s.presetSlots { + slots = append(slots, slot) + } + } + slices.Sort(slots) + var response []byte + for _, slot := range slots { + response = wire.AppendBytes(response, 1, presetWire(s.presets[slot])) + } + return 178, response, true, nil +} + +func (s *Store) handlePresetSave(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, err := requestSequence(request) + if err != nil { + return 0, nil, true, err + } + raw, found, err := wire.Bytes(request, 2) + if err != nil || !found { + return 0, nil, true, errors.New("deck: PresetSave missing preset") + } + preset, err := decodePreset(raw) + if err != nil { + return 0, nil, true, fmt.Errorf("deck: decode preset: %w", err) + } + + if reply, found := s.cachedReplyLocked(ctx, "save", seq); found { + return reply.code, reply.body, true, nil + } + if s.characters == nil || s.collection == nil || s.equipment == nil { + return 0, nil, true, errors.New("deck: preset runtime unavailable") + } + if err := s.validatePresetOwnedLocked(ctx, preset); err != nil { + return 0, nil, true, fmt.Errorf("deck: invalid preset: %w", err) + } + if err := s.persistPresetLocked(ctx, preset); err != nil { + return 0, nil, true, fmt.Errorf("deck: persist preset: %w", err) + } + s.presets[preset.Slot] = preset + s.rememberReplyLocked(ctx, "save", seq, 179, nil) + return 179, nil, true, nil +} + +func (s *Store) handlePresetAddSlot(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, err := requestSequence(request) + if err != nil { + return 0, nil, true, err + } + count, found, err := wire.Varint(request, 2) + if err != nil || !found || count == 0 { + return 0, nil, true, errors.New("deck: PresetAddSlot invalid count") + } + + if reply, found := s.cachedReplyLocked(ctx, "add-slot", seq); found { + return reply.code, reply.body, true, nil + } + if s.wallet == nil || s.presetDesign.Validate() != nil { + return 0, nil, true, errors.New("deck: preset wallet/design unavailable") + } + if s.presetSlots > s.presetDesign.Maximum || count > s.presetDesign.Maximum-s.presetSlots { + return 0, nil, true, errors.New("deck: preset slot limit exceeded") + } + if count > ^uint64(0)/s.presetDesign.Price { + return 0, nil, true, errors.New("deck: preset slot price overflow") + } + identity := "preset-slot:" + ctx.SessionID + ":" + strconv.FormatUint(seq, 10) + var spendErr error + switch s.presetDesign.PriceType { + case 4: + _, spendErr = s.wallet.SpendGoldOnce(ctx, identity, count*s.presetDesign.Price) + case 3: + _, spendErr = s.wallet.SpendFreeJewelryOnce(ctx, identity, count*s.presetDesign.Price) + case 2: + _, spendErr = s.wallet.SpendJewelryOnce(ctx, identity, count*s.presetDesign.Price) + case 12: + _, spendErr = s.wallet.SpendCatalystOnce(ctx, identity, count*s.presetDesign.Price) + } + if err := spendErr; err != nil { + return 0, nil, true, fmt.Errorf("deck: buy preset slot: %w", err) + } + next := s.presetSlots + count + payload, err := json.Marshal(next) + if err != nil { + return 0, nil, true, err + } + core, err := s.corePayloadLocked() + if err != nil { + return 0, nil, true, err + } + if err := s.storage.SaveWithEntries(ctx.State, "deck", core, []stateio.EntryMutation{{Bucket: "preset_config", Key: "slots", Payload: payload}}); err != nil { + return 0, nil, true, fmt.Errorf("deck: persist preset slots: %w", err) + } + s.presetSlots = next + s.rememberReplyLocked(ctx, "add-slot", seq, 180, nil) + return 180, nil, true, nil +} + +func (s *Store) handlePresetInfoChange(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, err := requestSequence(request) + if err != nil { + return 0, nil, true, err + } + nameBytes, _, err := wire.Bytes(request, 2) + if err != nil { + return 0, nil, true, errors.New("deck: invalid preset name") + } + resourceID, _, err := wire.Varint(request, 3) + if err != nil { + return 0, nil, true, errors.New("deck: invalid preset icon") + } + color, _, err := wire.Varint(request, 4) + if err != nil { + return 0, nil, true, errors.New("deck: invalid preset color") + } + // Ordinary preset slots are zero-based. Proto3 omits slot=0, so an absent + // field 5 is the first slot rather than a malformed request. + slot, _, err := wire.Varint(request, 5) + if err != nil { + return 0, nil, true, errors.New("deck: invalid preset slot") + } + + if reply, found := s.cachedReplyLocked(ctx, "info-change", seq); found { + return reply.code, reply.body, true, nil + } + preset, exists := s.presets[slot] + if !exists { + preset = Preset{Slot: slot, Decks: []PresetDeck{}, Blesses: []PresetBless{}} + } + preset.Name, preset.ResourceID, preset.ResourceColor = string(nameBytes), resourceID, color + if err := s.validatePresetShape(preset, s.presetSlots); err != nil { + return 0, nil, true, fmt.Errorf("deck: invalid preset metadata: %w", err) + } + if err := s.persistPresetLocked(ctx, preset); err != nil { + return 0, nil, true, err + } + s.presets[slot] = preset + s.rememberReplyLocked(ctx, "info-change", seq, 278, nil) + return 278, nil, true, nil +} + +func (s *Store) handlePresetDelete(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, err := requestSequence(request) + if err != nil { + return 0, nil, true, err + } + var slots []uint64 + if err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + values, err := repeatedUint64(field) + if err != nil { + return err + } + slots = append(slots, values...) + return nil + }); err != nil || len(slots) == 0 { + return 0, nil, true, errors.New("deck: invalid preset delete slots") + } + + if reply, found := s.cachedReplyLocked(ctx, "delete", seq); found { + return reply.code, reply.body, true, nil + } + seen := make(map[uint64]bool, len(slots)) + changes := make([]stateio.EntryMutation, 0, len(slots)) + for _, slot := range slots { + if slot >= s.presetSlots || seen[slot] { + return 0, nil, true, errors.New("deck: invalid or duplicate preset delete slot") + } + seen[slot] = true + changes = append(changes, stateio.EntryMutation{Bucket: "presets", Key: strconv.FormatUint(slot, 10), Delete: true}) + } + core, err := s.corePayloadLocked() + if err != nil { + return 0, nil, true, err + } + if err := s.storage.SaveWithEntries(ctx.State, "deck", core, changes); err != nil { + return 0, nil, true, err + } + for slot := range seen { + delete(s.presets, slot) + } + // 2.35.10 contains the request/response classes but no PacketCode enum + // member. Code zero is the same compatibility fallback used for other + // unnumbered local endpoints; it must not be treated as an official value. + s.rememberReplyLocked(ctx, "delete", seq, 0, nil) + return 0, nil, true, nil +} + +func (s *Store) handlePresetUse(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, err := requestSequence(request) + if err != nil { + return 0, nil, true, err + } + // PresetUse is also zero-based and slot=0 is omitted by proto3. + slot, _, err := wire.Varint(request, 2) + if err != nil { + return 0, nil, true, errors.New("deck: PresetUse missing slot") + } + + if reply, found := s.cachedReplyLocked(ctx, "use", seq); found { + return reply.code, reply.body, true, nil + } + preset, found := s.presets[slot] + if !found || len(preset.Decks) == 0 { + return 0, nil, true, errors.New("deck: PresetUse references an empty slot") + } + if s.characters == nil || s.collection == nil || s.equipment == nil { + return 0, nil, true, errors.New("deck: preset runtime unavailable") + } + if err := s.validatePresetOwnedLocked(ctx, preset); err != nil { + return 0, nil, true, fmt.Errorf("deck: stale preset: %w", err) + } + deckEntries := make([]DeckEntry, 0, len(preset.Decks)) + assignments := make(map[uint64]uint64, len(preset.Decks)) + bindings := make([]assets.PresetEquipmentBinding, 0, len(preset.Decks)) + for _, entry := range preset.Decks { + deckEntries = append(deckEntries, entry.Deck) + assignments[entry.Deck.CharacterInvenIndex] = entry.CostumeIndex + equipment := make([]uint64, 5) + for _, item := range entry.Equipment { + equipment[item.Type] = item.Index + } + bindings = append(bindings, assets.PresetEquipmentBinding{CharacterIndex: entry.Deck.CharacterInvenIndex, Equipment: equipment}) + } + if _, err := s.characters.ApplyPresetCostumes(ctx, assignments); err != nil { + return 0, nil, true, fmt.Errorf("deck: apply preset costumes: %w", err) + } + affected, err := s.equipment.ApplyPresetEquipment(ctx, bindings) + if err != nil { + return 0, nil, true, fmt.Errorf("deck: apply preset equipment: %w", err) + } + next := clone(s.state) + next.Deck = append([]DeckEntry(nil), deckEntries...) + maps.Copy(next.Costumes, assignments) + if err := s.commit(ctx, next); err != nil { + return 0, nil, true, fmt.Errorf("deck: persist applied preset: %w", err) + } + var response []byte + for _, entry := range deckEntries { + base := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex) + if entry.CostumeInvenIndex != 0 { + base = wire.AppendVarint(base, 2, entry.CostumeInvenIndex) + } + base = wire.AppendVarint(base, 3, entry.Slot) + response = wire.AppendBytes(response, 1, base) + } + characterSet := make(map[uint64]bool, len(assignments)+len(affected)) + for index := range assignments { + characterSet[index] = true + } + for _, character := range affected { + characterSet[character.InvenIndex] = true + } + indices := make([]uint64, 0, len(characterSet)) + for index := range characterSet { + indices = append(indices, index) + } + slices.Sort(indices) + for _, index := range indices { + if character, found := s.characters.Find(ctx, index); found { + response = wire.AppendBytes(response, 2, roster.CharacterWire(character)) + } + } + for _, binding := range bindings { + info := wire.AppendVarint(nil, 1, binding.CharacterIndex) + for _, index := range binding.Equipment { + info = wire.AppendVarint(info, 2, index) + } + response = wire.AppendBytes(response, 3, info) + } + s.rememberReplyLocked(ctx, "use", seq, 409, response) + return 409, response, true, nil +} + +func costumeSettingWire(setting CostumeSetting) []byte { + out := wire.AppendVarint(nil, 1, setting.CharacterIndex) + for _, item := range setting.Sequence { + entry := wire.AppendVarint(nil, 1, uint64(item.CostumeIndex)) + if item.BurstLevel != 0 { + entry = wire.AppendVarint(entry, 2, item.BurstLevel) + } + out = wire.AppendBytes(out, 2, entry) + } + if setting.BattleMode != 0 { + out = wire.AppendVarint(out, 3, setting.BattleMode) + } + if setting.MonsterID != 0 { + out = wire.AppendVarint(out, 4, setting.MonsterID) + } + return out +} + +func decodeCostumeSetting(data []byte) (CostumeSetting, error) { + var result CostumeSetting + character, found, err := wire.Varint(data, 1) + if err != nil || !found || character == 0 { + return result, errors.New("deck: costume setting missing character") + } + result.CharacterIndex = character + result.BattleMode, _, err = wire.Varint(data, 3) + if err != nil { + return result, err + } + result.MonsterID, _, err = wire.Varint(data, 4) + if err != nil { + return result, err + } + err = wire.Walk(data, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + if field.Type != 2 { + return errors.New("deck: invalid costume setting sequence") + } + raw, found, err := wire.Varint(field.Value, 1) + if err != nil || !found { + return errors.New("deck: costume setting entry missing costume") + } + burst, _, err := wire.Varint(field.Value, 2) + if err != nil { + return err + } + result.Sequence = append(result.Sequence, CostumeSettingItem{CostumeIndex: int64(raw), BurstLevel: burst}) + return nil + }) + return result, err +} + +func (s *Store) handleCostumeSettingInfo(request []byte) (int, []byte, bool, error) { + if _, err := requestSequence(request); err != nil { + return 0, nil, true, err + } + + indices := make([]uint64, 0, len(s.costumeSettings)) + for index := range s.costumeSettings { + indices = append(indices, index) + } + slices.Sort(indices) + var response []byte + for _, index := range indices { + response = wire.AppendBytes(response, 1, costumeSettingWire(s.costumeSettings[index])) + } + return 397, response, true, nil +} + +func (s *Store) handleCostumeSettingSave(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, err := requestSequence(request) + if err != nil { + return 0, nil, true, err + } + raw, found, err := wire.Bytes(request, 2) + if err != nil || !found { + return 0, nil, true, errors.New("deck: DeckCostumeSettingSave missing setting") + } + setting, err := decodeCostumeSetting(raw) + if err != nil { + return 0, nil, true, err + } + + if reply, found := s.cachedReplyLocked(ctx, "costume-setting-save", seq); found { + return reply.code, reply.body, true, nil + } + if err := s.validateCostumeSettingLocked(ctx, setting); err != nil { + return 0, nil, true, err + } + payload, err := json.Marshal(setting) + if err != nil { + return 0, nil, true, err + } + change := stateio.EntryMutation{Bucket: "costume_settings", Key: strconv.FormatUint(setting.CharacterIndex, 10), Payload: payload} + core, err := s.corePayloadLocked() + if err != nil { + return 0, nil, true, err + } + if err := s.storage.SaveWithEntries(ctx.State, "deck", core, []stateio.EntryMutation{change}); err != nil { + return 0, nil, true, err + } + s.costumeSettings[setting.CharacterIndex] = setting + s.rememberReplyLocked(ctx, "costume-setting-save", seq, 398, nil) + return 398, nil, true, nil +} + +func (s *Store) handleFieldSettings(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + if e := checkSeq(req); e != nil { + return 0, nil, true, e + } + + v, e := s.loadFieldSettings(ctx) + if e != nil { + return 0, nil, true, e + } + code := 71 + var body []byte + if path == "/TalentSlotSave" { + var ids []uint64 + e = wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 0 && f.Type != 2 { + return fmt.Errorf("deck: invalid talent slots") + } + b := f.Value + for len(b) > 0 { + n, k := binary.Uvarint(b) + if k <= 0 { + return fmt.Errorf("deck: invalid packed talent slots") + } + ids = append(ids, n) + if len(ids) > s.fieldSettingsDesign.TalentSlots { + return fmt.Errorf("deck: too many talent slots") + } + b = b[k:] + } + return nil + }) + if e != nil { + return 0, nil, true, e + } + v.TalentIDs = ids + } else { + code = 372 + on, _, e := wire.Varint(req, 2) + if e != nil || on > 1 { + return 0, nil, true, fmt.Errorf("deck: invalid auto-recovery switch") + } + caster, _, e := wire.Varint(req, 3) + if e != nil { + return 0, nil, true, e + } + v.AutoRevive = on != 0 + v.Caster = caster + body = wire.AppendVarint(nil, 1, on) + body = wire.AppendVarint(body, 2, caster) + } + if e = s.validateFieldSettings(ctx, v, path == "/TalentSlotSave"); e != nil { + return 0, nil, true, e + } + raw, e := json.Marshal(v) + if e == nil { + core, err := json.Marshal(s.state) + if err != nil { + return 0, nil, true, err + } + e = s.storage.SaveWithEntries(ctx.State, "deck", core, []stateio.EntryMutation{{Bucket: "field_settings", Key: "state", Payload: raw}}) + } + return code, body, true, e +} + +func checkSeq(req []byte) error { + v, ok, e := wire.Varint(req, 1) + if e != nil || !ok || v == 0 || v > 2147483647 { + return errors.New("deck: invalid request sequence") + } + return nil +} + +func triples(req []byte) ([]DeckEntry, error) { + var out []DeckEntry + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return errors.New("deck: deck field") + } + a, aok, e := wire.Varint(f.Value, 1) + if e != nil || !aok || a == 0 { + return errors.New("deck: deck character") + } + position, _, e := wire.Varint(f.Value, 2) + if e != nil || (position > 11 && position != ^uint64(0)) { + return errors.New("deck: invalid deck position") + } + sequence, sequenceOK, e := wire.Varint(f.Value, 3) + if e != nil || !sequenceOK || sequence == 0 || sequence > 5 { + return errors.New("deck: invalid deck sequence") + } + out = append(out, DeckEntry{CharacterInvenIndex: a, CostumeInvenIndex: position, Slot: sequence}) + return nil + }) + if e != nil { + return nil, e + } + if len(out) == 0 { + return nil, errors.New("deck: empty deck") + } + if e := validDeck(out); e != nil { + return nil, e + } + return out, nil +} + +func fieldEntries(req []byte) ([]FieldEntry, error) { + var out []FieldEntry + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return errors.New("deck: field deck entry is not a message") + } + slot, slotOK, err := wire.Varint(f.Value, 1) + if err != nil || !slotOK || slot == 0 { + return errors.New("deck: invalid field deck slot") + } + character, characterOK, err := wire.Varint(f.Value, 2) + if err != nil || !characterOK || character == 0 { + return errors.New("deck: invalid field deck character") + } + costume, _, err := wire.Varint(f.Value, 3) + if err != nil { + return errors.New("deck: invalid field deck costume") + } + out = append(out, FieldEntry{Slot: slot, CharacterInvenIndex: character, CostumeInvenIndex: costume}) + return nil + }) + if e != nil { + return nil, e + } + if len(out) == 0 { + return nil, errors.New("deck: empty field deck") + } + return out, validField(out) +} + +func encodeDeck(xs []DeckEntry) []byte { + var b []byte + for _, x := range xs { + v := wire.AppendVarint(nil, 1, x.CharacterInvenIndex) + v = wire.AppendVarint(v, 2, x.CostumeInvenIndex) + v = wire.AppendVarint(v, 3, x.Slot) + b = wire.AppendBytes(b, 1, v) + } + return b +} + +func encodeField(xs []FieldEntry) []byte { + var b []byte + for _, x := range xs { + v := wire.AppendVarint(nil, 1, x.Slot) + v = wire.AppendVarint(v, 2, x.CharacterInvenIndex) + v = wire.AppendVarint(v, 3, x.CostumeInvenIndex) + b = wire.AppendBytes(b, 1, v) + } + return b +} + +func (s *Store) handleCostumeUse(ctx command.Context, req []byte) (int, []byte, bool, error) { + fail := func(e error) (int, []byte, bool, error) { return 41, nil, true, e } + if e := checkSeq(req); e != nil { + return fail(e) + } + assignments := map[uint64]uint64{} + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return fmt.Errorf("deck: invalid costume use entry") + } + cost, _, e := wire.Varint(f.Value, 1) + if e != nil || cost == 0 || cost > math.MaxInt64 { + return fmt.Errorf("deck: invalid costume index") + } + char, _, e := wire.Varint(f.Value, 2) + if e != nil || char == 0 || char > math.MaxInt64 { + return fmt.Errorf("deck: invalid costume character") + } + if _, ok := assignments[char]; ok { + return fmt.Errorf("deck: repeated costume character") + } + assignments[char] = cost + return nil + }) + if e != nil { + return fail(e) + } + if len(assignments) == 0 { + return fail(fmt.Errorf("deck: missing costume assignments")) + } + + if s.characters != nil { + if s.collection == nil { + return fail(fmt.Errorf("deck: costume collection unavailable")) + } + for char, cost := range assignments { + if _, ok := s.characters.Find(ctx, char); !ok { + return fail(fmt.Errorf("deck: unknown costume character")) + } + c, ok := s.collection.CostumeByIndex(cost) + if !ok || c.UseChar != char { + return fail(fmt.Errorf("deck: costume not owned by character")) + } + } + if _, e = s.characters.ApplyPresetCostumes(ctx, assignments); e != nil { + return fail(e) + } + } + n := clone(s.state) + maps.Copy(n.Costumes, assignments) + return 41, nil, true, s.commit(ctx, n) +} + +func (s *Store) handleAutoRecovery(ctx command.Context, req []byte) (int, []byte, bool, error) { + fail := func(e error) (int, []byte, bool, error) { return 373, nil, true, e } + if e := checkSeq(req); e != nil { + return fail(e) + } + caster, _, e := wire.Varint(req, 2) + if e != nil { + return fail(e) + } + seq, _, _ := wire.Varint(req, 1) + + key := ctx.SessionID + ":" + fmt.Sprint(seq) + digest := fmt.Sprintf("%x", sha256.Sum256(req)) + if s.storage != nil { + raw, ok, err := s.storage.LoadEntry(ctx.State, "deck", "auto_recovery", key) + if err != nil { + return fail(err) + } + if ok { + var r autoRecoveryReceipt + if err = json.Unmarshal(raw, &r); err != nil { + return fail(err) + } + if r.Digest != digest { + return fail(fmt.Errorf("deck: changed automatic recovery replay")) + } + return 373, r.Body, true, nil + } + } + n := clone(s.state) + if len(n.Deck) == 0 { + return fail(fmt.Errorf("deck: automatic recovery has no battle deck")) + } + result := roster.AutoRecoveryResult{Caster: caster, Catalyst: n.AutoReviveCatalyst} + if s.wallet != nil { + result.Catalyst = s.wallet.CatalystBalance(ctx) + } + mode := uint64(2) + var settings fieldSettings + if s.characters != nil { + settings, e = s.loadFieldSettings(ctx) + if e != nil { + return fail(e) + } + if caster != settings.Caster { + return fail(fmt.Errorf("deck: automatic recovery caster differs from saved setting")) + } + if n.FieldCharControlDeckType == 2 { + allExhausted := true + for _, v := range n.Deck { + hp, err := s.characters.CurrentHealth(ctx, v.CharacterInvenIndex) + if err != nil { + return fail(err) + } + if hp > 0 { + allExhausted = false + } + } + if allExhausted { + mode = 3 + } + } + field := s.visibleFieldDeckLocked(ctx) + var targets []uint64 + seen := map[uint64]bool{} + add := func(id uint64) error { + if seen[id] { + return nil + } + seen[id] = true + c, ok := s.characters.Find(ctx, id) + if !ok || roster.IsStoryCharacter(c) || roster.IsCharmCharacter(c) { + return nil + } + hp, err := s.characters.CurrentHealth(ctx, id) + if err != nil { + return err + } + if hp == 0 { + targets = append(targets, id) + } + return nil + } + if n.FieldCharControlDeckType == 0 { //nolint:staticcheck // QF1003 + for _, v := range n.Deck { + if e = add(v.CharacterInvenIndex); e != nil { + return fail(e) + } + } + } else if n.FieldCharControlDeckType == 1 { + for _, v := range field { + if e = add(v.CharacterInvenIndex); e != nil { + return fail(e) + } + } + } + allowed := true + if s.autoRecoveryAllowed != nil { + allowed, e = s.autoRecoveryAllowed(ctx) + if e != nil { + return fail(e) + } + } + if settings.AutoRevive && allowed && len(targets) > 0 { + if s.autoRecovery == nil { + return fail(fmt.Errorf("deck: automatic recovery executor unavailable")) + } + result, e = s.autoRecovery(ctx, seq, caster, targets) + if e != nil { + return fail(e) + } + settings.Caster = result.Caster + if len(result.Characters) > 0 { + mode = 1 + } + } + // On recovery failure, replace fatigued party members with living, + // permanent owned characters in inventory order. Keep the fatigued + // member when no replacement exists so the client can show exhaustion. + if mode != 1 && len(targets) > 0 { + all := s.characters.RawAll() + sort.Slice(all, func(i, j int) bool { return all[i].InvenIndex < all[j].InvenIndex }) + used := map[uint64]bool{} + for _, v := range n.Deck { + used[v.CharacterInvenIndex] = true + } + for _, v := range field { + used[v.CharacterInvenIndex] = true + } + replacements := map[uint64]roster.Character{} + for _, id := range targets { + for _, c := range all { + if used[c.InvenIndex] || roster.IsStoryCharacter(c) || roster.IsCharmCharacter(c) { + continue + } + hp, err := s.characters.CurrentHealth(ctx, c.InvenIndex) + if err != nil { + return fail(err) + } + if hp == 0 { + continue + } + replacements[id] = c + used[c.InvenIndex] = true + break + } + if _, ok := replacements[id]; !ok { + mode = 3 + } + } + for i, v := range n.Deck { + if c, ok := replacements[v.CharacterInvenIndex]; ok { + n.Deck[i].CharacterInvenIndex = c.InvenIndex + } + } + for i, v := range n.FieldDeck { + if c, ok := replacements[v.CharacterInvenIndex]; ok { + n.FieldDeck[i].CharacterInvenIndex = c.InvenIndex + n.FieldDeck[i].CostumeInvenIndex = c.UseCostume + } + } + } + } else if caster != 0 { + return fail(fmt.Errorf("deck: automatic recovery character provider unavailable")) + } + var out []byte + for _, v := range n.Deck { + b := wire.AppendVarint(nil, 1, v.CharacterInvenIndex) + b = wire.AppendVarint(b, 2, v.CostumeInvenIndex) + b = wire.AppendVarint(b, 3, v.Slot) + out = wire.AppendBytes(out, 1, b) + } + for _, v := range n.FieldDeck { + b := wire.AppendVarint(nil, 1, v.Slot) + b = wire.AppendVarint(b, 2, v.CharacterInvenIndex) + b = wire.AppendVarint(b, 3, v.CostumeInvenIndex) + out = wire.AppendBytes(out, 2, b) + } + for _, c := range result.Characters { + out = wire.AppendVarint(out, 3, c.InvenIndex) + out = wire.AppendBytes(out, 5, roster.CharacterWire(c)) + } + out = wire.AppendVarint(out, 4, mode) + if result.Caster != 0 { + out = wire.AppendVarint(out, 9, result.Caster) + } + out = wire.AppendVarint(out, 6, result.Experience) + out = wire.AppendVarint(out, 7, result.Catalyst) + out = wire.AppendVarint(out, 8, result.Disabled) + if s.storage != nil { + core, err := json.Marshal(n) + if err != nil { + return fail(err) + } + raw, err := json.Marshal(autoRecoveryReceipt{digest, out}) + if err != nil { + return fail(err) + } + changes := []stateio.EntryMutation{{Bucket: "auto_recovery", Key: key, Payload: raw}} + if s.characters != nil { + raw, err = json.Marshal(settings) + if err != nil { + return fail(err) + } + changes = append(changes, stateio.EntryMutation{Bucket: "field_settings", Key: "state", Payload: raw}) + } + if err = s.storage.SaveWithEntries(ctx.State, "deck", core, changes); err != nil { + return fail(err) + } + } + s.state = n + return 373, out, true, nil +} diff --git a/go/internal/server/domain/roster/deck/story_party.go b/go/internal/server/domain/roster/deck/story_party.go new file mode 100644 index 0000000..f1a79b2 --- /dev/null +++ b/go/internal/server/domain/roster/deck/story_party.go @@ -0,0 +1,26 @@ +package deck + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/roster" +) + +func (s *Store) CurrentFieldDeck(ctx command.Context) []FieldEntry { + + return s.visibleFieldDeckLocked(ctx) +} + +func (s *Store) visibleFieldDeckLocked(ctx command.Context) []FieldEntry { + var out []FieldEntry + for _, entry := range s.state.FieldDeck { + if s.characters != nil { + c, ok := s.characters.Find(ctx, entry.CharacterInvenIndex) + if !ok || roster.IsStoryCharacter(c) && !s.temporaryAllowed(ctx, c) { + continue + } + } + entry.Slot = uint64(len(out) + 1) + out = append(out, entry) + } + return out +} diff --git a/go/internal/server/domain/roster/deck/waypoint.go b/go/internal/server/domain/roster/deck/waypoint.go new file mode 100644 index 0000000..9f583c2 --- /dev/null +++ b/go/internal/server/domain/roster/deck/waypoint.go @@ -0,0 +1,43 @@ +package deck + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "errors" + "math" + "slices" +) + +func (s *Store) ConfigureWaypoints(ctx command.Context, load func(uint64) (gamedata.WaypointPack, error), validate func(ctx command.Context, _ uint64, _ bool) error) error { + if load == nil || validate == nil { + return errors.New("deck: incomplete waypoint runtime") + } + + s.waypointDesign = load + s.waypointPack = validate + return nil +} + +func (s *Store) ActivatedWaypoint(pack, id uint64) bool { + + return hasWaypoint(s.state.Waypoints[pack], id) +} +func hasWaypoint(points []uint64, id uint64) bool { + return slices.Contains(points, id) +} + +func validWaypointState(packs map[uint64][]uint64) error { + for pack, points := range packs { + if pack == 0 || pack > math.MaxInt32 || points == nil { + return errors.New("deck: invalid saved waypoint pack") + } + seen := map[uint64]bool{} + for _, id := range points { + if id == 0 || id > math.MaxInt32 || seen[id] { + return errors.New("deck: invalid saved waypoint activation") + } + seen[id] = true + } + } + return nil +} diff --git a/go/internal/server/domain/roster/eat_food.go b/go/internal/server/domain/roster/eat_food.go new file mode 100644 index 0000000..a7589a2 --- /dev/null +++ b/go/internal/server/domain/roster/eat_food.go @@ -0,0 +1,164 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" +) + +// FoodService participates in the account request transaction: consuming the +// stack, saving health and recording the replay response commit together. +type FoodService struct { + store stateio.ScopedEntryStore + design *gamedata.FoodDesign + inventory *assets.Inventory + characters *CharacterStore + + currentPack func(command.Context) (int, error) + battleActive func(command.Context) bool +} + +type foodReply struct { + Digest string `json:"digest"` + Body []byte `json:"body"` +} + +// AttachContext supplies authoritative pack and active-battle state; the client +// omits PackId in ordinary recovery requests, which means the current pack. +func (s *FoodService) AttachContext(ctx command.Context, currentPack func(command.Context) (int, error), battleActive func(command.Context) bool) error { + if currentPack == nil || battleActive == nil { + return errors.New("player: incomplete food context") + } + + s.currentPack = currentPack + s.battleActive = battleActive + return nil +} + +// MaxHealth reads the shared stat calculator without replacing it with field HP. +func (s *CharacterStore) MaxHealth(ctx command.Context, index uint64) (uint64, error) { + var character Character + for _, candidate := range s.RawAll() { + if candidate.InvenIndex == index { + character = candidate + break + } + } + if character.InvenIndex == 0 { + return 0, fmt.Errorf("player: unknown health character %d", index) + } + + calculate := s.maxHealth + + if calculate != nil { + return calculate(ctx, character) + } + if character.HP == 0 { + return 0, errors.New("player: maximum health unavailable") + } + return character.HP, nil +} + +// CurrentHealth resolves persisted current HP independently from maximum HP. +// A missing current_hp entry retains the owned character record's HP, including +// zero. Recomputing equipment or other maximum-health stats must never heal it. +func (s *CharacterStore) CurrentHealth(ctx command.Context, index uint64) (uint64, error) { + var saved Character + for _, character := range s.RawAll() { + if character.InvenIndex == index { + saved = character + break + } + } + if saved.InvenIndex == 0 { + return 0, fmt.Errorf("player: unknown health character %d", index) + } + return s.savedCurrentHealth(ctx, saved) +} + +func (s *CharacterStore) savedCurrentHealth(ctx command.Context, saved Character) (uint64, error) { + if s.store == nil { + return saved.HP, nil + } + data, found, err := s.store.LoadEntry(ctx.State, "characters", "current_hp", strconv.FormatUint(saved.InvenIndex, 10)) + if err != nil { + return 0, err + } + if !found { + return saved.HP, nil + } + var hp uint64 + if json.Unmarshal(data, &hp) != nil { + return 0, errors.New("player: invalid saved current health") + } + return hp, nil +} + +// SetCurrentHealth accepts server-validated battle results and recovery only. +// Food requests never accept health from the caller. +func (s *CharacterStore) SetCurrentHealth(ctx command.Context, index, hp uint64) error { + maximum, err := s.MaxHealth(ctx, index) + if err != nil { + return err + } + if hp > maximum { + return errors.New("player: current health exceeds maximum") + } + raw, _ := json.Marshal(hp) + return s.store.PutEntry(ctx.State, "characters", "current_hp", strconv.FormatUint(index, 10), raw) +} + +func (s *CharacterStore) resetCurrentHealth(ctx command.Context, index uint64) error { + _, err := s.store.DeleteEntry(ctx.State, "characters", "current_hp", strconv.FormatUint(index, 10)) + return err +} + +func (s *FoodService) recoverCharacter(ctx command.Context, index uint64, items []assets.Item) (Character, error) { + character, found := s.characters.Find(ctx, index) + if !found { + return Character{}, errors.New("player: EatFood character is not owned") + } + maximum, err := s.characters.MaxHealth(ctx, index) + if err != nil { + return Character{}, err + } + current, err := s.characters.CurrentHealth(ctx, index) + if err != nil { + return Character{}, err + } + var total uint64 + seen := map[uint64]bool{} + for _, item := range items { + if item.Type != 5 || item.InvenIndex == 0 || item.Count == 0 || item.Count > math.MaxInt32 || seen[item.InvenIndex] { + return Character{}, errors.New("player: invalid EatFood inventory stack") + } + seen[item.InvenIndex] = true + food, exists := s.design.Foods[item.ID] + if !exists { + return Character{}, errors.New("player: unknown EatFood dish") + } + value, err := food.Recovery(character.ID, maximum, item.Count) + if err != nil { + return Character{}, err + } + if value > math.MaxUint64-total { + return Character{}, errors.New("player: EatFood recovery overflow") + } + total += value + } + if current > maximum { + current = maximum + } + if total >= maximum-current { + character.HP = maximum + } else { + character.HP = current + total + } + return character, nil +} diff --git a/go/internal/server/player/equipment_craft_runtime.go b/go/internal/server/domain/roster/equipment_craft_runtime.go similarity index 85% rename from go/internal/server/player/equipment_craft_runtime.go rename to go/internal/server/domain/roster/equipment_craft_runtime.go index 90b99b3..8324c95 100644 --- a/go/internal/server/player/equipment_craft_runtime.go +++ b/go/internal/server/domain/roster/equipment_craft_runtime.go @@ -1,6 +1,7 @@ -package player +package roster import ( + "bd2server/internal/server/domain/command" "errors" "fmt" "math" @@ -9,39 +10,39 @@ import ( // AddTalentExperience applies the same clamp as the client: talent experience // may reach the cumulative threshold of the current level, but this request // never promotes the talent level itself. -func (s *CharacterStore) AddTalentExperience(index, gain, maximum uint64) (Character, error) { +func (s *CharacterStore) AddTalentExperience(ctx command.Context, index, gain, maximum uint64) (Character, error) { if s == nil || index == 0 || maximum == 0 { return Character{}, errors.New("player: invalid talent experience update") } - s.mu.Lock() + for position, current := range s.characters { if current.InvenIndex != index { continue } if current.TalentExp > maximum { - s.mu.Unlock() + return Character{}, fmt.Errorf("player: character %d talent experience exceeds current threshold", index) } if gain > maximum-current.TalentExp { gain = maximum - current.TalentExp } if current.TalentExp > math.MaxUint64-gain { - s.mu.Unlock() + return Character{}, errors.New("player: talent experience overflow") } current.TalentExp += gain next := append([]Character(nil), s.characters...) next[position] = current - if err := s.persist(next); err != nil { - s.mu.Unlock() + if err := s.persist(ctx, next); err != nil { + return Character{}, fmt.Errorf("player: persist equipment making talent experience: %w", err) } s.characters = next - s.mu.Unlock() + return current, nil } collection := s.collection - s.mu.Unlock() + if collection == nil { return Character{}, fmt.Errorf("player: unknown equipment making character %d", index) } @@ -59,7 +60,7 @@ func (s *CharacterStore) AddTalentExperience(index, gain, maximum uint64) (Chara return Character{}, errors.New("player: talent experience overflow") } current.TalentExp += gain - if err := collection.UpdateCharacter(current.ID, current); err != nil { + if err := collection.UpdateCharacter(ctx, current.ID, current); err != nil { return Character{}, fmt.Errorf("player: persist collection equipment making talent experience: %w", err) } return current, nil diff --git a/go/internal/server/domain/roster/equipment_projection.go b/go/internal/server/domain/roster/equipment_projection.go new file mode 100644 index 0000000..4e10428 --- /dev/null +++ b/go/internal/server/domain/roster/equipment_projection.go @@ -0,0 +1,26 @@ +package roster + +import ( + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" +) + +func equipmentCharacter(c Character) assets.EquipmentCharacter { + return assets.EquipmentCharacter{InvenIndex: c.InvenIndex, ID: c.ID, TalentLevel: c.TalentLevel, TalentExp: c.TalentExp, Response: CharacterWire(c)} +} +func (s *CharacterStore) EquipmentCharacters() []assets.EquipmentCharacter { + values := s.RawAll() + result := make([]assets.EquipmentCharacter, 0, len(values)) + for _, c := range values { + result = append(result, equipmentCharacter(c)) + } + return result +} +func (s *CharacterStore) EquipmentCharacter(ctx command.Context, index uint64) (assets.EquipmentCharacter, bool) { + c, found := s.Find(ctx, index) + return equipmentCharacter(c), found +} +func (s *CharacterStore) AddEquipmentTalentExperience(ctx command.Context, index, gain, maximum uint64) (assets.EquipmentCharacter, error) { + c, err := s.AddTalentExperience(ctx, index, gain, maximum) + return equipmentCharacter(c), err +} diff --git a/go/internal/server/domain/roster/friendship.go b/go/internal/server/domain/roster/friendship.go new file mode 100644 index 0000000..e20134c --- /dev/null +++ b/go/internal/server/domain/roster/friendship.go @@ -0,0 +1,134 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + + assets "bd2server/internal/server/domain/inventory" + "errors" + "math" + + "time" +) + +// FriendshipService applies inventory gifts and counseling inside the account +// request transaction. Static IDs, thresholds and rewards come from GameData. +type FriendshipService struct { + design *gamedata.FriendshipDesign + awake *gamedata.CharAwakeDesign + potential *gamedata.CostumePotentialDesign + collection *CollectionStore + inventory *assets.Inventory + wallet *assets.Wallet + + now func() time.Time +} + +func NewFriendshipService(design *gamedata.FriendshipDesign, awake *gamedata.CharAwakeDesign, potential *gamedata.CostumePotentialDesign, collection *CollectionStore, inventory *assets.Inventory, wallet *assets.Wallet) (*FriendshipService, error) { + if design == nil || awake == nil || potential == nil || collection == nil || inventory == nil || wallet == nil { + return nil, errors.New("player: incomplete friendship service") + } + s := &FriendshipService{design: design, awake: awake, potential: potential, collection: collection, inventory: inventory, wallet: wallet, now: time.Now} + for _, entry := range collection.FriendshipEntries() { + if entry.Daily != nil && entry.Daily.Used > design.Default.MaxCounselingAP { + return nil, errors.New("player: saved daily friendship AP exceeds maximum") + } + if entry.State == nil { + continue + } + state := entry.State + if _, owned := s.owned(state.CostumeID); !owned { + return nil, errors.New("player: saved friendship costume is not owned") + } + level, ok := design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}] + if !ok || (level.NextEXP != 0 && state.EXP >= level.NextEXP) || (state.Level >= design.Default.MaxLevels[2] && state.EXP != 0) { + return nil, errors.New("player: saved friendship level is not in GameData") + } + if state.CounselingCount > design.Default.MaxCounselingAPByCostume && state.Level < design.Default.MaxLevels[2] { + return nil, errors.New("player: invalid saved costume counseling count") + } + for _, id := range state.Sessions { + if _, ok := design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: id}]; !ok { + return nil, errors.New("player: saved counseling session is not in GameData") + } + } + } + return s, nil +} + +func (s *FriendshipService) FriendshipAP() (uint64, error) { + + daily := s.daily(s.collection.FriendshipEntries(), s.now().UTC().Format("2006-01-02")) + if daily.Used >= s.design.Default.MaxCounselingAP { + return 0, nil + } + return s.design.Default.MaxCounselingAP - daily.Used, nil +} + +func (s *FriendshipService) daily(entries map[string]FriendshipEntry, day string) FriendshipDaily { + if entry := entries["daily"]; entry.Daily != nil && entry.Daily.Day == day { + return *entry.Daily + } + return FriendshipDaily{Day: day} +} + +func (s *FriendshipService) owned(id uint64) (Costume, bool) { + costumeID, ok := s.design.Costumes[id] + if !ok { + return Costume{}, false + } + return s.collection.CostumeByID(costumeID) +} + +func (s *FriendshipService) maximum(costume Costume) uint64 { + max := s.design.Default.MaxLevels[0] + unique := s.potential.CostumeUnique[costume.ID] + awake, exists := s.collection.CharAwakeState(unique) + rule, hasRule := s.awake.Characters[unique] + if !exists || !hasRule || !awake.IsAwake { + return max + } + for i, level := range awake.ImprintLevels { + if len(rule.ImprintGrowth[i]) == 0 || level != uint64(len(rule.ImprintGrowth[i])) { + return max + } + } + max = s.design.Default.MaxLevels[1] + nodes := s.potential.Nodes[costume.ID] + if len(nodes) == 0 || len(costume.PotentialIDs) != len(nodes) { + return max + } + for _, id := range costume.PotentialIDs { + if _, ok := nodes[id]; !ok { + return max + } + } + return s.design.Default.MaxLevels[2] +} + +func (s *FriendshipService) advance(state FriendshipState, max, exp uint64) (FriendshipState, []gamedata.Reward, error) { + if state.EXP > math.MaxInt32-exp { + return state, nil, errors.New("player: friendship experience overflow") + } + state.EXP += exp + var rewards []gamedata.Reward + for state.Level < max { + rule, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}] + if !ok || rule.NextEXP == 0 { + return state, nil, errors.New("player: missing friendship level threshold") + } + if state.EXP < rule.NextEXP { + break + } + state.EXP -= rule.NextEXP + state.Level++ + next, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}] + if !ok { + return state, nil, errors.New("player: missing friendship level reward") + } + rewards = append(rewards, next.Rewards...) + } + if state.Level >= max { + state.EXP = 0 + } + return state, rewards, nil +} diff --git a/go/internal/server/domain/roster/friendship_state.go b/go/internal/server/domain/roster/friendship_state.go new file mode 100644 index 0000000..0c182d9 --- /dev/null +++ b/go/internal/server/domain/roster/friendship_state.go @@ -0,0 +1,64 @@ +package roster + +import ( + "strconv" +) + +type FriendshipState struct { + CostumeID uint64 `json:"costume_id"` + Level uint64 `json:"level"` + EXP uint64 `json:"exp"` + LastCounselingDate uint64 `json:"last_counseling_date"` + CounselingDay string `json:"counseling_day,omitempty"` + CounselingCount uint64 `json:"counseling_count,omitempty"` + Sessions []uint64 `json:"sessions,omitempty"` +} + +type FriendshipDaily struct { + Day string `json:"day"` + Used uint64 `json:"used"` +} + +type FriendshipReply struct { + Digest string `json:"digest"` + Code int `json:"code"` + Body []byte `json:"body"` +} + +// FriendshipEntry keeps progress and successful request replies in the existing +// collection domain. Each record has exactly one kind, so collection commits +// can update one costume, the daily counter and a replay together. +type FriendshipEntry struct { + State *FriendshipState `json:"state,omitempty"` + Daily *FriendshipDaily `json:"daily,omitempty"` + Reply *FriendshipReply `json:"reply,omitempty"` +} + +func friendshipStateKey(id uint64) string { return "costume:" + strconv.FormatUint(id, 10) } + +func cloneFriendshipEntries(in map[string]FriendshipEntry) map[string]FriendshipEntry { + out := make(map[string]FriendshipEntry, len(in)) + for key, entry := range in { + if entry.State != nil { + state := *entry.State + state.Sessions = append([]uint64(nil), state.Sessions...) + entry.State = &state + } + if entry.Daily != nil { + daily := *entry.Daily + entry.Daily = &daily + } + if entry.Reply != nil { + reply := *entry.Reply + reply.Body = append([]byte(nil), reply.Body...) + entry.Reply = &reply + } + out[key] = entry + } + return out +} + +func (s *CollectionStore) FriendshipEntries() map[string]FriendshipEntry { + + return cloneFriendshipEntries(s.data.Friendships) +} diff --git a/go/internal/server/player/gacha_batch.go b/go/internal/server/domain/roster/gacha_batch.go similarity index 65% rename from go/internal/server/player/gacha_batch.go rename to go/internal/server/domain/roster/gacha_batch.go index 86f5617..02626b3 100644 --- a/go/internal/server/player/gacha_batch.go +++ b/go/internal/server/domain/roster/gacha_batch.go @@ -1,12 +1,14 @@ -package player +package roster -import "errors" +import ( + "bd2server/internal/server/domain/command" + "errors" + // GachaBatchResponse returns the immutable result of a completed batch. The + // digest binds the full ordered request, including repeated gacha IDs. +) + +func (s *CollectionStore) GachaBatchResponse(ctx command.Context, identity, digest string) ([]byte, bool, error) { -// GachaBatchResponse returns the immutable result of a completed batch. The -// digest binds the full ordered request, including repeated gacha IDs. -func (s *CollectionStore) GachaBatchResponse(identity, digest string) ([]byte, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() grant, found := s.data.Grants[identity] if !found { return nil, false, nil @@ -19,12 +21,11 @@ func (s *CollectionStore) GachaBatchResponse(identity, digest string) ([]byte, b // RecordGachaBatch participates in the caller's account operation alongside // every reward, daily allowance and fixed-pity update in the batch. -func (s *CollectionStore) RecordGachaBatch(identity, digest string, response []byte) error { +func (s *CollectionStore) RecordGachaBatch(ctx command.Context, identity, digest string, response []byte) error { if identity == "" || digest == "" || len(response) == 0 { return errors.New("player: invalid gacha batch result") } - s.mu.Lock() - defer s.mu.Unlock() + if prior, found := s.data.Grants[identity]; found { if prior.GachaRequestDigest != digest { return errors.New("player: gacha batch request identity conflict") @@ -33,5 +34,5 @@ func (s *CollectionStore) RecordGachaBatch(identity, digest string, response []b } next := cloneCollection(s.data) next.Grants[identity] = CollectionGrant{GachaRequestDigest: digest, GachaResponse: append([]byte(nil), response...)} - return s.commit(next) + return s.commit(ctx, next) } diff --git a/go/internal/server/player/gacha_state.go b/go/internal/server/domain/roster/gacha_state.go similarity index 94% rename from go/internal/server/player/gacha_state.go rename to go/internal/server/domain/roster/gacha_state.go index a923bb1..503bad7 100644 --- a/go/internal/server/player/gacha_state.go +++ b/go/internal/server/domain/roster/gacha_state.go @@ -1,6 +1,7 @@ -package player +package roster import ( + "bd2server/internal/server/domain/command" "errors" "fmt" "math" @@ -94,8 +95,7 @@ func applyGachaPurchase(next *collectionSnapshot, identity string, costumeIDs [] } func (s *CollectionStore) GachaUsers() []GachaUserState { - s.mu.Lock() - defer s.mu.Unlock() + result := make([]GachaUserState, 0, len(s.data.GachaUsers)) for _, value := range s.data.GachaUsers { result = append(result, value) @@ -108,8 +108,7 @@ func (s *CollectionStore) GachaUsers() []GachaUserState { // rebuilt from durable, date-scoped purchase markers. Historical counts never // leak into a new reset day and no storage migration is required. func (s *CollectionStore) GachaUsersForDay(day string) []GachaUserState { - s.mu.Lock() - defer s.mu.Unlock() + byGroup := make(map[uint64]GachaUserState, len(s.data.GachaUsers)) for _, value := range s.data.GachaUsers { value.OneFreePickCount = 0 @@ -151,8 +150,7 @@ func (s *CollectionStore) GachaUsersForDay(day string) []GachaUserState { } func (s *CollectionStore) GachaDailyCount(day string, groupID, buyType uint64) uint64 { - s.mu.Lock() - defer s.mu.Unlock() + prefix := dailyGachaPrefix(day, groupID, buyType) var count uint64 for key, applied := range s.data.GachaApplied { @@ -164,14 +162,12 @@ func (s *CollectionStore) GachaDailyCount(day string, groupID, buyType uint64) u } func (s *CollectionStore) GachaUser(groupID uint64) GachaUserState { - s.mu.Lock() - defer s.mu.Unlock() + return s.data.GachaUsers[strconv.FormatUint(groupID, 10)] } func (s *CollectionStore) GachaFixedStates() []GachaFixedState { - s.mu.Lock() - defer s.mu.Unlock() + result := make([]GachaFixedState, 0, len(s.data.GachaFixed)) for _, value := range s.data.GachaFixed { value.ApplySort = -1 @@ -187,20 +183,18 @@ func (s *CollectionStore) GachaFixedStates() []GachaFixedState { } func (s *CollectionStore) GachaFixedCount(fixedID, fixedType uint64) uint64 { - s.mu.Lock() - defer s.mu.Unlock() + return s.data.GachaFixed[gachaFixedKey(fixedID, fixedType)].Count } // ExchangeGachaPoint performs the authoritative point debit. The exchange is // saved before the wallet credit; a retry can therefore safely recover a // wallet write using the same identity without spending the points twice. -func (s *CollectionStore) ExchangeGachaPoint(identity string, groupID, count uint64) (GachaPointExchange, error) { +func (s *CollectionStore) ExchangeGachaPoint(ctx command.Context, identity string, groupID, count uint64) (GachaPointExchange, error) { if identity == "" || groupID == 0 || count == 0 { return GachaPointExchange{}, errors.New("player: invalid gacha point exchange") } - s.mu.Lock() - defer s.mu.Unlock() + if exchange, ok := s.data.GachaPointExchange[identity]; ok { if exchange.GroupID != groupID || exchange.Count != count { return GachaPointExchange{}, errors.New("player: gacha point exchange identity conflict") @@ -221,7 +215,7 @@ func (s *CollectionStore) ExchangeGachaPoint(identity string, groupID, count uin next.GachaUsers[key] = user exchange := GachaPointExchange{GroupID: groupID, Count: count} next.GachaPointExchange[identity] = exchange - if err := s.commit(next); err != nil { + if err := s.commit(ctx, next); err != nil { return GachaPointExchange{}, err } return exchange, nil diff --git a/go/internal/server/domain/roster/growth.go b/go/internal/server/domain/roster/growth.go new file mode 100644 index 0000000..e6d93fb --- /dev/null +++ b/go/internal/server/domain/roster/growth.go @@ -0,0 +1,245 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "time" +) + +type characterSnapshot struct { + Version string `json:"version"` + CharacterOrder []uint64 `json:"character_order"` +} + +// CharacterStore owns mutable character growth separately from immutable +// character design data. The seed supplies the initially owned instances. +type CharacterStore struct { + store stateio.ScopedEntryStore + characters []Character + persisted map[uint64]bool + persistedOrder []uint64 + persistedCore bool + inventory *assets.Inventory + gameDataRoot string + gameDataVersion string + grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) + collection *CollectionStore + wallet *assets.Wallet + maxHealth func(command.Context, Character) (uint64, error) + promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) + talentGrowth *gamedata.TalentGrowthDesign + immortal *gamedata.ImmortalDesign + immortalReplies map[string]talentUpgradeReply + + talentReplies map[string]map[string]talentUpgradeReply + talentApplied map[string]talentUpgradeReply +} + +type talentUpgradeReply struct { + Digest string `json:"digest"` + Code int `json:"code"` + Body []byte `json:"body,omitempty"` +} + +func (s *CharacterStore) AttachWallet(ctx command.Context, wallet *assets.Wallet) error { + if wallet == nil { + return errors.New("player: nil growth wallet") + } + + s.wallet = wallet + return nil +} + +func (s *CharacterStore) AttachImmortalDesign(ctx command.Context, design *gamedata.ImmortalDesign) error { + if design == nil { + return errors.New("player: nil immortal design") + } + s.immortal = design + return nil +} + +func (s *CharacterStore) AttachTalentGrowth(ctx command.Context, design *gamedata.TalentGrowthDesign) error { + if design == nil { + return errors.New("player: nil talent growth design") + } + + s.talentGrowth = design + return nil +} + +// AttachMaxHealth makes growth and post-battle revival consume the same +// GameData/collection calculator as AllCharRefresh and BattleEnter. +func (s *CharacterStore) AttachMaxHealth(ctx command.Context, maxHealth func(command.Context, Character) (uint64, error)) error { + if maxHealth == nil { + return errors.New("player: nil maximum health calculator") + } + + s.maxHealth = maxHealth + return nil +} + +func (s *CharacterStore) AttachCollection(ctx command.Context, collection *CollectionStore) error { + if collection == nil { + return errors.New("player: nil collection store") + } + + s.collection = collection + return nil +} + +func OpenCharacterStore(ctx command.Context, store stateio.Store, seed []Character, inventory *assets.Inventory, gameDataRoot, gameDataVersion string) (*CharacterStore, error) { + if store == nil || inventory == nil { + return nil, errors.New("player: invalid character store configuration") + } + entries, ok := store.(stateio.ScopedEntryStore) + if !ok { + return nil, errors.New("player: character store requires atomic entries") + } + s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, talentReplies: make(map[string]map[string]talentUpgradeReply), talentApplied: make(map[string]talentUpgradeReply)} + s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { + return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials) + } + s.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) { + return gamedata.CharacterGrowthPromotions(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, submitted) + } + data, err := entries.Load(ctx.State, "characters") + if err != nil { + return nil, err + } + if data == nil { + orphaned, err := entries.ListEntries(ctx.State, "characters", "characters") + if err != nil { + return nil, fmt.Errorf("player: list character entries: %w", err) + } + if len(orphaned) != 0 { + return nil, errors.New("player: character entries exist without core") + } + applied, err := entries.ListEntries(ctx.State, "characters", "talent_upgrades") + if err != nil { + return nil, err + } + if len(applied) != 0 { + return nil, errors.New("player: talent upgrade entries exist without character core") + } + return s, validateCharacters(s.characters) + } + saved, loaded, err := loadCharacterEntries(ctx, entries, data) + if err != nil { + return nil, err + } + s.characters = loaded + s.persistedOrder = append([]uint64{}, saved.CharacterOrder...) + s.persistedCore = true + seen := make(map[uint64]bool, len(s.characters)) + for _, character := range s.characters { + seen[character.InvenIndex] = true + s.persisted[character.InvenIndex] = true + } + for _, character := range seed { + if !seen[character.InvenIndex] { + return nil, fmt.Errorf("player: current character state omits seeded inventory index %d", character.InvenIndex) + } + } + if err := s.loadTalentUpgradeLedger(ctx, entries); err != nil { + return nil, err + } + if err := validateCharacters(s.characters); err != nil { + return nil, err + } + return s, nil +} + +func (s *CharacterStore) loadTalentUpgradeLedger(ctx command.Context, entries stateio.ScopedEntryStore) error { + rows, err := entries.ListEntries(ctx.State, "characters", "talent_upgrades") + if err != nil { + return err + } + for key, payload := range rows { + parts := strings.Split(key, ":") + if len(parts) != 2 { + return fmt.Errorf("player: invalid talent upgrade ledger key %q", key) + } + index, indexErr := strconv.ParseUint(parts[0], 10, 64) + level, levelErr := strconv.ParseUint(parts[1], 10, 64) + var reply talentUpgradeReply + if indexErr != nil || levelErr != nil || index == 0 || level < 2 || json.Unmarshal(payload, &reply) != nil || + reply.Code != talentSkillUpgradePacketCode || len(reply.Digest) != 64 { + return fmt.Errorf("player: invalid talent upgrade ledger entry %q", key) + } + if _, err := hex.DecodeString(reply.Digest); err != nil { + return fmt.Errorf("player: invalid talent upgrade digest %q", key) + } + s.talentApplied[key] = reply + } + return nil +} + +func (s *CharacterStore) EnsurePersisted(ctx command.Context) error { + + return s.persist(ctx, append([]Character(nil), s.characters...)) +} + +func validateCharacters(characters []Character) error { + seen := make(map[uint64]bool, len(characters)) + for _, character := range characters { + if character.InvenIndex == 0 || character.ID == 0 || character.Level == 0 || seen[character.InvenIndex] { + return errors.New("player: invalid saved character") + } + seen[character.InvenIndex] = true + } + return nil +} + +func (s *CharacterStore) All(ctx command.Context) []Character { + characters := s.RawAll() + active := characters[:0] + for _, c := range characters { + if !CharacterExpired(c, time.Now()) { + active = append(active, c) + } + } + characters = active + for i := range characters { + if hp, err := s.savedCurrentHealth(ctx, characters[i]); err == nil { + characters[i].HP = hp + } + } + return characters +} + +// RawAll is for account-ownership queries used by the stat calculator itself. +// Calling All() from that calculator would recursively calculate its inputs. +func (s *CharacterStore) RawAll() []Character { + + characters := append([]Character(nil), s.characters...) + collection := s.collection + + if collection != nil { + characters = append(characters, collection.Characters()...) + } + return characters +} + +func (s *CharacterStore) Find(ctx command.Context, inventoryIndex uint64) (Character, bool) { + for _, character := range s.RawAll() { + if character.InvenIndex == inventoryIndex { + if CharacterExpired(character, time.Now()) { + return Character{}, false + } + if hp, err := s.savedCurrentHealth(ctx, character); err == nil { + character.HP = hp + } + return character, true + } + } + return Character{}, false +} diff --git a/go/internal/server/player/growth_entries.go b/go/internal/server/domain/roster/growth_entries.go similarity index 84% rename from go/internal/server/player/growth_entries.go rename to go/internal/server/domain/roster/growth_entries.go index 53a76e2..7efdef0 100644 --- a/go/internal/server/player/growth_entries.go +++ b/go/internal/server/domain/roster/growth_entries.go @@ -1,4 +1,4 @@ -package player +package roster import ( "encoding/json" @@ -7,11 +7,12 @@ import ( "reflect" "strconv" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" ) -func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characterSnapshot, []Character, error) { +func loadCharacterEntries(ctx command.Context, store stateio.ScopedEntryStore, core []byte) (characterSnapshot, []Character, error) { var fields map[string]json.RawMessage if err := json.Unmarshal(core, &fields); err != nil { return characterSnapshot{}, nil, fmt.Errorf("player: decode character core: %w", err) @@ -26,7 +27,7 @@ func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characte if saved.Version != versionconfig.State() || saved.CharacterOrder == nil { return characterSnapshot{}, nil, errors.New("player: invalid character save version or order") } - rows, err := store.ListEntries("characters", "characters") + rows, err := store.ListEntries(ctx.State, "characters", "characters") if err != nil { return characterSnapshot{}, nil, fmt.Errorf("player: list character entries: %w", err) } @@ -64,11 +65,11 @@ func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characte return saved, characters, nil } -func (s *CharacterStore) persist(next []Character) error { - return s.persistWithChanges(next, nil) +func (s *CharacterStore) persist(ctx command.Context, next []Character) error { + return s.persistWithChanges(ctx, next, nil) } -func (s *CharacterStore) persistWithChanges(next []Character, additional []stateio.EntryMutation) error { +func (s *CharacterStore) persistWithChanges(ctx command.Context, next []Character, additional []stateio.EntryMutation) error { if err := validateCharacters(next); err != nil { return err } @@ -113,7 +114,7 @@ func (s *CharacterStore) persistWithChanges(next []Character, additional []state if core == nil && len(changes) == 0 { return nil } - if err := s.store.SaveWithEntries("characters", core, changes); err != nil { + if err := s.store.SaveWithEntries(ctx.State, "characters", core, changes); err != nil { return err } s.persisted = current diff --git a/go/internal/server/domain/roster/item_craft.go b/go/internal/server/domain/roster/item_craft.go new file mode 100644 index 0000000..3150090 --- /dev/null +++ b/go/internal/server/domain/roster/item_craft.go @@ -0,0 +1,34 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "fmt" +) + +type itemCraftReceipt struct { + Digest string + Body []byte +} +type ItemCraftService struct { + design *gamedata.ItemCraftDesign + talents *gamedata.TalentUseDesign + store stateio.Store + items *assets.Inventory + characters *CharacterStore + wallet *assets.Wallet + known func(ctx command.Context, _ uint64) bool + context func(command.Context) (int, bool, error) +} + +func NewItemCraftService(d *gamedata.ItemCraftDesign, talents *gamedata.TalentUseDesign, store stateio.Store, items *assets.Inventory, characters *CharacterStore, wallet *assets.Wallet, known func(ctx command.Context, _ uint64) bool) (*ItemCraftService, error) { + if d == nil || talents == nil || store == nil || items == nil || characters == nil || wallet == nil || known == nil { + return nil, fmt.Errorf("craft: missing dependencies") + } + return &ItemCraftService{design: d, talents: talents, store: store, items: items, characters: characters, wallet: wallet, known: known}, nil +} +func (s *ItemCraftService) AttachContext(ctx command.Context, context func(command.Context) (int, bool, error)) { + s.context = context +} diff --git a/go/internal/server/player/item_craft_batch.go b/go/internal/server/domain/roster/item_craft_batch.go similarity index 92% rename from go/internal/server/player/item_craft_batch.go rename to go/internal/server/domain/roster/item_craft_batch.go index 0a50860..4362142 100644 --- a/go/internal/server/player/item_craft_batch.go +++ b/go/internal/server/domain/roster/item_craft_batch.go @@ -1,7 +1,8 @@ -package player +package roster import ( - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + assets "bd2server/internal/server/domain/inventory" "fmt" "math" ) @@ -9,7 +10,7 @@ import ( // Batch requests specify the missing final quantity and can supply existing // intermediate resources plus raw resources. Only EquipmentResource recipes // participate in this graph; conversion recipes would introduce cycles. -func (s *ItemCraftService) prepareAlchemyBatch(c Character, recipe gamedata.ItemCraftRecipe, count uint64, materials []Item) (gain, catalyst, maximum uint64, err error) { +func (s *ItemCraftService) prepareAlchemyBatch(c Character, recipe gamedata.ItemCraftRecipe, count uint64, materials []assets.Item) (gain, catalyst, maximum uint64, err error) { if recipe.Category != 1 || recipe.Result.Count == 0 || count%recipe.Result.Count != 0 { return 0, 0, 0, fmt.Errorf("craft: invalid batch output") } diff --git a/go/internal/server/domain/roster/observation.go b/go/internal/server/domain/roster/observation.go new file mode 100644 index 0000000..a247f83 --- /dev/null +++ b/go/internal/server/domain/roster/observation.go @@ -0,0 +1,7 @@ +package roster + +// ObservationVersion covers earned costumes attached without a storage write. +func (s *CollectionStore) ObservationVersion() uint64 { + + return s.observationVersion +} diff --git a/go/internal/server/domain/roster/preset_runtime.go b/go/internal/server/domain/roster/preset_runtime.go new file mode 100644 index 0000000..b99faea --- /dev/null +++ b/go/internal/server/domain/roster/preset_runtime.go @@ -0,0 +1,103 @@ +package roster + +import ( + "bd2server/internal/server/domain/command" + "errors" + "fmt" + "slices" +) + +func (s *CharacterStore) ApplyPresetCostumes(ctx command.Context, assignments map[uint64]uint64) ([]Character, error) { + if len(assignments) == 0 { + return nil, errors.New("player: empty preset costume assignments") + } + + base := append([]Character(nil), s.characters...) + collection := s.collection + + if collection == nil { + return nil, errors.New("player: preset costumes require collection store") + } + collectionCharacters := collection.Characters() + seenCostumes := make(map[uint64]bool) + updated := make(map[uint64]Character, len(assignments)) + for characterIndex, costumeIndex := range assignments { + var current Character + found := false + for _, character := range base { + if character.InvenIndex == characterIndex { + current, found = character, true + break + } + } + if !found { + for _, character := range collectionCharacters { + if character.InvenIndex == characterIndex { + current, found = character, true + break + } + } + } + if !found { + return nil, fmt.Errorf("player: preset references unknown character %d", characterIndex) + } + if costumeIndex == 0 { + current.CostumeID = 0 + current.UseCostume = 0 + } else { + if seenCostumes[costumeIndex] { + return nil, fmt.Errorf("player: preset repeats costume %d", costumeIndex) + } + costume, found := collection.CostumeByIndex(costumeIndex) + if !found { + return nil, fmt.Errorf("player: preset references unknown costume %d", costumeIndex) + } + seenCostumes[costumeIndex] = true + current.CostumeID = costume.ID + current.UseCostume = costumeIndex + } + updated[characterIndex] = current + } + + nextBase := append([]Character(nil), base...) + baseChanged := false + for i := range nextBase { + if character, ok := updated[nextBase[i].InvenIndex]; ok { + nextBase[i] = character + delete(updated, character.InvenIndex) + baseChanged = true + } + } + if baseChanged { + + if err := s.persist(ctx, nextBase); err != nil { + + return nil, err + } + s.characters = nextBase + + } + for index, character := range updated { + old, found := collection.FindCharacter(index) + if !found { + return nil, fmt.Errorf("player: preset collection character %d disappeared", index) + } + if err := collection.UpdateCharacter(ctx, old.ID, character); err != nil { + return nil, err + } + } + indices := make([]uint64, 0, len(assignments)) + for index := range assignments { + indices = append(indices, index) + } + slices.Sort(indices) + result := make([]Character, 0, len(indices)) + for _, index := range indices { + character, found := s.Find(ctx, index) + if !found { + return nil, fmt.Errorf("player: preset character %d disappeared after update", index) + } + result = append(result, character) + } + return result, nil +} diff --git a/go/internal/server/domain/roster/profile.go b/go/internal/server/domain/roster/profile.go new file mode 100644 index 0000000..8a9ad19 --- /dev/null +++ b/go/internal/server/domain/roster/profile.go @@ -0,0 +1,109 @@ +package roster + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "time" + "unicode" + "unicode/utf8" +) + +type masterTitle struct { + Name string `json:"name"` + Month uint64 `json:"month"` + Day uint64 `json:"day"` +} + +// MasterTitleService persists the identity whose empty name sends IntroUI +// back to the tutorial even when LoginUser advertises another saved pack. +// It uses one entry in the existing progress domain, leaving its core intact. +type MasterTitleService struct { + store stateio.ScopedEntryStore + defaultTitle masterTitle +} + +func OpenMasterTitleService(ctx command.Context, store stateio.Store, defaultName string) (*MasterTitleService, error) { + entries, ok := store.(stateio.ScopedEntryStore) + if !ok || !validMasterTitleName(defaultName, false) { + return nil, errors.New("player: invalid master title service") + } + s := &MasterTitleService{store: entries, defaultTitle: masterTitle{Name: defaultName}} + if _, err := s.load(ctx); err != nil { + return nil, err + } + return s, nil +} + +func validMasterTitleName(name string, fromRequest bool) bool { + if !utf8.ValidString(name) || len(name) == 0 || len(name) > 24 || (fromRequest && len(name) < 4) { + return false + } + for _, r := range name { + if unicode.IsSpace(r) || unicode.IsControl(r) { + return false + } + if fromRequest { + lower := unicode.ToLower(r) + if !((lower >= '0' && lower <= '9') || (lower >= 'a' && lower <= 'z') || (r >= 0xAC00 && r <= 0xD7A3) || (r >= 0x4E00 && r <= 0x9FD5) || (r >= 0x3041 && r <= 0x30FE) || (r >= 0x0180 && r <= 0x024F)) { //nolint:staticcheck // QF1001 + return false + } + } + } + return true +} + +func validMasterBirthday(month, day uint64) bool { + if month == 0 && day == 0 { + return true + } + if month < 1 || month > 12 || day < 1 || day > 31 { + return false + } + // The client explicitly uses leap year 2024 for birthday selection. + return uint64(time.Date(2024, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()) >= day +} + +func (s *MasterTitleService) load(ctx command.Context) (masterTitle, error) { + raw, found, err := s.store.LoadEntry(ctx.State, "progress", "master_title", "identity") + if err != nil { + return masterTitle{}, err + } + if !found { + return s.defaultTitle, nil + } + if err := stateio.RequireExactJSONObject(raw, "name", "month", "day"); err != nil { + return masterTitle{}, err + } + var title masterTitle + if json.Unmarshal(raw, &title) != nil || !validMasterTitleName(title.Name, false) || !validMasterBirthday(title.Month, title.Day) { + return masterTitle{}, errors.New("player: invalid saved master title") + } + return title, nil +} + +// EnsurePersisted records the existing account display name once, with an +// unknown birthday. Startup calls it after progress core initialization. +func (s *MasterTitleService) EnsurePersisted(ctx command.Context) error { + _, found, err := s.store.LoadEntry(ctx.State, "progress", "master_title", "identity") + if err != nil { + return err + } + if found { + _, err = s.load(ctx) + return err + } + core, err := s.store.Load(ctx.State, "progress") + if err != nil { + return err + } + if len(core) == 0 { + return errors.New("player: master title requires initialized progress") + } + payload, err := json.Marshal(s.defaultTitle) + if err != nil { + return err + } + return s.store.SaveWithEntries(ctx.State, "progress", nil, []stateio.EntryMutation{{Bucket: "master_title", Key: "identity", Payload: payload}}) +} diff --git a/go/internal/server/domain/roster/protocol_character_adapter.go b/go/internal/server/domain/roster/protocol_character_adapter.go new file mode 100644 index 0000000..4bff820 --- /dev/null +++ b/go/internal/server/domain/roster/protocol_character_adapter.go @@ -0,0 +1,143 @@ +package roster + +import ( + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" +) + +func (s *Starter) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + var code int + var response []byte + switch path { + case "/ItemInfo": + code = 21 + for _, entry := range s.Items { + response = wire.AppendBytes(response, 1, assets.ItemWire(entry)) + } + case "/CostumeInfo": + code = 40 + for _, entry := range s.Costumes { + response = wire.AppendBytes(response, 1, CostumeWire(entry)) + } + case "/CharInfo": + code = 9 + for _, entry := range s.Characters { + var character []byte + character = add(character, 1, entry.InvenIndex) + character = add(character, 2, entry.ID) + character = add(character, 3, entry.HP) + character = add(character, 4, entry.Level) + character = add(character, 5, entry.CostumeID) + character = add(character, 6, entry.Exp) + character = add(character, 7, entry.UseCostume) + character = add(character, 8, entry.TalentLevel) + character = add(character, 9, entry.TalentExp) + character = add(character, 10, entry.SolidarityReward) + character = add(character, 11, entry.ExpiryTime) + character = add(character, 13, entry.ConnectPotentialCostume) + response = wire.AppendBytes(response, 1, character) + } + response = add(response, 2, s.FieldCharControlDeckType) + default: + return 0, nil, false, nil + } + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path) + } + return code, response, true, nil +} + +func (s *MasterTitleService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/MasterTitleInfo" && path != "/MasterTitleInfoUpdate" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, errors.New("player: invalid master title sequence") + } + if path == "/MasterTitleInfo" { + title, err := s.load(ctx) + if err != nil { + return 0, nil, true, err + } + body := wire.AppendString(nil, 1, title.Name) + if title.Month != 0 { + body = wire.AppendVarint(body, 2, title.Month) + } + if title.Day != 0 { + body = wire.AppendVarint(body, 3, title.Day) + } + return 590, body, true, nil + } + rawName, found, err := wire.Bytes(request, 2) + if err != nil || !found || !validMasterTitleName(string(rawName), true) { + return 0, nil, true, errors.New("player: invalid master title name") + } + month, _, err := wire.Varint(request, 3) + if err != nil { + return 0, nil, true, err + } + day, _, err := wire.Varint(request, 4) + if err != nil || !validMasterBirthday(month, day) { + return 0, nil, true, errors.New("player: invalid master title birthday") + } + next := masterTitle{Name: string(rawName), Month: month, Day: day} + current, err := s.load(ctx) + if err != nil { + return 0, nil, true, err + } + if current == next { + return 592, nil, true, nil + } + core, err := s.store.Load(ctx.State, "progress") + if err != nil { + return 0, nil, true, err + } + if len(core) == 0 { + return 0, nil, true, errors.New("player: master title requires initialized progress") + } + payload, err := json.Marshal(next) + if err != nil { + return 0, nil, true, err + } + if err := s.store.SaveWithEntries(ctx.State, "progress", nil, []stateio.EntryMutation{{Bucket: "master_title", Key: "identity", Payload: payload}}); err != nil { + return 0, nil, true, err + } + return 592, nil, true, nil +} + +func add(dst []byte, field int, value uint64) []byte { + if value != 0 { + return wire.AppendVarint(dst, field, value) + } + return dst +} + +func decodeVarints(proto []byte, fields map[int]*uint64) error { + return wire.Walk(proto, func(field wire.Field) error { + value, known := fields[field.Number] + if !known { + return fmt.Errorf("player: unsupported starter field %d", field.Number) + } + if field.Type != 0 { + return fmt.Errorf("player: starter field %d wire type %d", field.Number, field.Type) + } + *value, _ = binary.Uvarint(field.Value) + return nil + }) +} + +// CollectionRewardBundle encodes a persisted character/costume grant for +// any reward-bearing response. Its copy upgrades and post-max exchanges are +// identical to recruitment rewards and are replayed from the collection ledger. +func CollectionRewardBundle(c *CollectionStore, g CollectionGrant) []byte { + return recruitRewardBundle(c, g) +} diff --git a/go/internal/server/domain/roster/protocol_costume_adapter.go b/go/internal/server/domain/roster/protocol_costume_adapter.go new file mode 100644 index 0000000..ec0bb75 --- /dev/null +++ b/go/internal/server/domain/roster/protocol_costume_adapter.go @@ -0,0 +1,1263 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/ownership" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "slices" + "strconv" + "strings" + "time" +) + +func CostumeWire(v Costume) []byte { + out := ownership.Costume{InvenIndex: v.InvenIndex, ID: v.ID, Level: v.Level, UseChar: v.UseChar, SortID: v.SortID, PotentialIDs: v.PotentialIDs, DesignID: v.DesignID, BurstLevel: v.BurstLevel, TimeValue: v.TimeValue} + for _, p := range v.Pictorialbook { + out.Pictorialbook = append(out.Pictorialbook, ownership.Pictorial{ID: p.ID, GroupID: p.GroupID}) + } + return ownership.EncodeCostume(out) +} + +func (s *RecruitService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/MercenaryScout" && path != "/CharScoutInfo" && path != "/CharSpecialScoutBuy" && path != "/CharSpecialScoutReset" { + return 0, nil, false, nil + } + + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, errors.New("player: invalid recruitment sequence") + } + if path == "/CharScoutInfo" { + state, e := s.specialState() + if e != nil { + return 0, nil, true, e + } + if e = s.persistSpecial(ctx, state); e != nil { + return 0, nil, true, e + } + return 148, s.scoutInfo(state), true, nil + } + if ctx.SessionID == "" { + return 0, nil, true, errors.New("player: recruitment requires authenticated session") + } + digestBytes := sha256.Sum256(request) + digest := hex.EncodeToString(digestBytes[:]) + keyBytes := sha256.Sum256([]byte(ctx.SessionID + ":" + path + ":" + strconv.FormatUint(seq, 10))) + replyKey := "recruit-reply:" + hex.EncodeToString(keyBytes[:]) + if b, ok, e := s.collection.GachaBatchResponse(ctx, replyKey, digest); ok || e != nil { + return recruitCode(path), b, true, e + } + var state specialRecruitState + if path != "/MercenaryScout" { + state, err = s.specialState() + if err != nil { + return 0, nil, true, err + } + } + if path == "/CharSpecialScoutReset" { + if state.Count >= s.design.ResetLimit { + return 0, nil, true, errors.New("player: scout reset limit reached") + } + if !s.wallet.CanSpendFreeJewelry(s.design.ResetCount) { + return 0, nil, true, errors.New("player: insufficient scout reset jewelry") + } + state, err = s.rollSpecial(state.Count + 1) + if err != nil { + return 0, nil, true, err + } + if _, err = s.wallet.SpendFreeJewelryOnce(ctx, replyKey, s.design.ResetCount); err != nil { + return 0, nil, true, err + } + if err = s.persistSpecial(ctx, state); err != nil { + return 0, nil, true, err + } + var body []byte + for _, id := range state.IDs { + body = wire.AppendVarint(body, 1, id) + } + body = wire.AppendVarint(body, 2, state.Next) + if err = s.collection.RecordGachaBatch(ctx, replyKey, digest, body); err != nil { + return 0, nil, true, err + } + return 150, body, true, nil + } + npc, found, err := wire.Varint(request, 2) + if err != nil || !found || npc == 0 || npc > math.MaxInt32 { + return 0, nil, true, errors.New("player: invalid recruit NPC") + } + id := npc + if path == "/MercenaryScout" { + id, err = s.resolver(ctx, npc) + if err != nil { + return 0, nil, true, err + } + } + r, ok := s.design.Rules[id] + if !ok { + return 0, nil, true, errors.New("player: missing recruit rule") + } + if path == "/MercenaryScout" && r.Type != 0 { + return 0, nil, true, errors.New("player: NPC is not ordinary recruit") + } + if path == "/CharSpecialScoutBuy" { + available := false + for _, v := range state.IDs { + if v == id { + available = true + } + } + if r.Type != 1 || !available { + return 0, nil, true, errors.New("player: special recruit is not appearing") + } + } + identity := recruitIdentity(id) + if _, ok := s.collection.Grant(identity); ok { + return 0, nil, true, errors.New("player: character already recruited") + } + materials, err := assets.DecodeItemRequest(request, 3, "MercenaryScout") + if err != nil { + return 0, nil, true, err + } + items, gold, err := validateCostumeBurstMaterials(r.Costs, materials) + if err != nil || gold != 0 { + return 0, nil, true, fmt.Errorf("player: invalid recruit materials: %v", err) + } + if err = s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, err + } + if err = s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, err + } + grant, err := s.collection.GrantCostumes(ctx, identity, []uint64{r.CostumeID}, s.catalog) + if err != nil { + return 0, nil, true, err + } + var rewards []gamedata.Reward + for _, x := range grant.Exchanges { + rewards = append(rewards, gamedata.Reward{Type: x.ExchangeItemType, ID: x.ExchangeItemID, Count: x.ExchangeCount}) + } + if len(rewards) > 0 { + if _, err = s.wallet.GrantQuestOnce(ctx, identity+":exchange", rewards); err != nil { + return 0, nil, true, err + } + } + if path == "/CharSpecialScoutBuy" { + if err = s.persistSpecial(ctx, state); err != nil { + return 0, nil, true, err + } + } + body := wire.AppendBytes(nil, 1, recruitRewardBundle(s.collection, grant)) + if err = s.collection.RecordGachaBatch(ctx, replyKey, digest, body); err != nil { + return 0, nil, true, err + } + return recruitCode(path), body, true, nil +} + +func (s *FriendshipService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/FriendshipInfo" && path != "/FriendshipGift" && path != "/FriendshipCounseling" { + return 0, nil, false, nil + } + + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, errors.New("player: friendship invalid sequence") + } + entries := s.collection.FriendshipEntries() + if path == "/FriendshipInfo" { + return 612, s.info(entries), true, nil + } + session := ctx.SessionID + if session == "" { + return 0, nil, true, errors.New("player: friendship mutation requires an authenticated session") + } + keyHash := sha256.Sum256([]byte(session + ":" + strconv.FormatUint(seq, 10))) + key := "reply:" + hex.EncodeToString(keyHash[:]) + digestHash := sha256.Sum256(request) + digest := hex.EncodeToString(digestHash[:]) + code := 614 + if path == "/FriendshipCounseling" { + code = 613 + } + if entry := entries[key]; entry.Reply != nil { + if entry.Reply.Digest != digest || entry.Reply.Code != code { + return 0, nil, true, errors.New("player: friendship sequence reused with different request") + } + return code, append([]byte(nil), entry.Reply.Body...), true, nil + } + id, found, err := wire.Varint(request, 2) + if err != nil || !found || id == 0 || id > math.MaxInt32 { + return 0, nil, true, errors.New("player: friendship invalid costume") + } + costume, owned := s.owned(id) + if !owned { + return 0, nil, true, fmt.Errorf("player: friendship costume %d is not owned", id) + } + state := FriendshipState{CostumeID: id, Level: 1} + if entry := entries[friendshipStateKey(id)]; entry.State != nil { + state = *entry.State + state.Sessions = append([]uint64(nil), state.Sessions...) + } + max := s.maximum(costume) + var body []byte + if code == 614 { + body, err = s.gift(ctx, request, state, max, key, digest) + } else { + body, err = s.counsel(ctx, request, state, max, entries, key, digest) + } + return code, body, true, err +} + +func (s *CostumePotentialService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path == "/CostumePotentialConnect" { + return s.connect(ctx, request) + } + if path != "/CostumeNodeActivation" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, errors.New("player: CostumeNodeActivation missing sequence") + } + characterIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: CostumeNodeActivation missing character") + } + costumeIndex, found, err := wire.Varint(request, 3) + if err != nil || !found || costumeIndex == 0 { + return 0, nil, true, errors.New("player: CostumeNodeActivation missing costume") + } + var nodes []uint64 + var materials []assets.Item + err = wire.Walk(request, func(field wire.Field) error { + switch field.Number { + case 4: + if field.Type != 0 && field.Type != 2 { + return errors.New("player: invalid potential node field") + } + for data := field.Value; len(data) != 0; { + id, count := binary.Uvarint(data) + if count <= 0 || id == 0 { + return errors.New("player: invalid potential node ID") + } + nodes = append(nodes, id) + data = data[count:] + } + case 5: + if field.Type != 2 { + return errors.New("player: invalid potential material field") + } + var item assets.Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) { + return errors.New("player: incomplete potential material") + } + materials = append(materials, item) + } + return nil + }) + if err != nil { + return 0, nil, true, err + } + if len(nodes) == 0 || len(materials) == 0 { + return 0, nil, true, errors.New("player: CostumeNodeActivation has no nodes or materials") + } + character, found := s.characters.Find(ctx, characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: unknown potential character %d", characterIndex) + } + costume, found := s.collection.CostumeByIndex(costumeIndex) + if !found || costume.UseChar != characterIndex { + return 0, nil, true, fmt.Errorf("player: potential costume %d is not owned by character %d", costumeIndex, characterIndex) + } + if err := s.collection.ValidateCostumePotentialActivation(ctx, costumeIndex, nodes); err != nil { + return 0, nil, true, err + } + costs, err := s.design.Validate(costume.ID, character.ID, 0, costume.PotentialIDs, nodes) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate costume potential GameData: %w", err) + } + want := make(map[[2]uint64]uint64) + for _, cost := range costs { + key := [2]uint64{cost.Type, cost.ID} + if cost.Count > ^uint64(0)-want[key] { + return 0, nil, true, errors.New("player: costume potential cost overflow") + } + want[key] += cost.Count + } + got := make(map[[2]uint64]uint64) + var items []assets.Item + var gold uint64 + for _, material := range materials { + key := [2]uint64{material.Type, material.ID} + if material.Type == 4 { + if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { + return 0, nil, true, errors.New("player: invalid costume potential currency") + } + gold = material.Count + } else { + if material.Type != 8 { + return 0, nil, true, fmt.Errorf("player: unsupported costume potential material type %d", material.Type) + } + items = append(items, material) + } + if material.Count > ^uint64(0)-got[key] { + return 0, nil, true, errors.New("player: submitted costume potential material overflow") + } + got[key] += material.Count + } + if len(got) != len(want) { + return 0, nil, true, fmt.Errorf("player: costume potential material kinds mismatch: request=%v GameData=%v", got, want) + } + for key, count := range want { + if got[key] != count { + return 0, nil, true, fmt.Errorf("player: costume potential material %d/%d=%d want %d", key[0], key[1], got[key], count) + } + } + if len(items) != 0 { + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate costume potential items: %w", err) + } + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for costume potential") + } + sortedNodes := append([]uint64(nil), nodes...) + slices.Sort(sortedNodes) + parts := make([]string, len(sortedNodes)) + for i, id := range sortedNodes { + parts[i] = strconv.FormatUint(id, 10) + } + identity := "costume-potential:" + strconv.FormatUint(costumeIndex, 10) + ":" + strings.Join(parts, ",") + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(ctx, identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: spend costume potential gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: consume costume potential items: %w", err) + } + } + if err := s.collection.ActivateCostumePotential(ctx, costumeIndex, nodes); err != nil { + return 0, nil, true, fmt.Errorf("player: persist costume potential: %w", err) + } + return 261, nil, true, nil +} + +func (s *CostumeBurstService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/CostumeBurst" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, errors.New("player: CostumeBurst missing sequence") + } + costumeID, found, err := wire.Varint(request, 2) + if err != nil || !found || costumeID == 0 || costumeID > math.MaxInt32 { + return 0, nil, true, errors.New("player: CostumeBurst missing costume") + } + materials, err := assets.DecodeItemRequest(request, 3, "CostumeBurst") + if err != nil { + return 0, nil, true, err + } + for _, material := range materials { + if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 || + material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 || + material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 { + return 0, nil, true, errors.New("player: CostumeBurst material exceeds protocol range") + } + } + + digestBytes := sha256.Sum256(request) + digest := hex.EncodeToString(digestBytes[:]) + + sessionID := ctx.SessionID + if sessionID == "" { + return 0, nil, true, errors.New("player: CostumeBurst requires an authenticated session") + } + if s.replies[sessionID] == nil { + s.replies[sessionID] = make(map[uint64]costumeBurstReply) + } + if reply, ok := s.replies[sessionID][seq]; ok { + if reply.Digest != digest { + return 0, nil, true, errors.New("player: CostumeBurst sequence reused with different request") + } + return reply.Code, append([]byte(nil), reply.Body...), true, nil + } + + costume, found := s.collection.CostumeByID(costumeID) + if !found { + return 0, nil, true, fmt.Errorf("player: CostumeBurst costume %d is not owned", costumeID) + } + if costume.BurstLevel != 0 { + if applied, ok := s.collection.CostumeBurstReplay(costume.InvenIndex, costume.BurstLevel); ok && applied.Digest == digest { + reply := costumeBurstReply{Digest: digest, Code: applied.Code, Body: append([]byte(nil), applied.Body...)} + s.replies[sessionID][seq] = reply + return reply.Code, append([]byte(nil), reply.Body...), true, nil + } + } + rule, err := s.design.UpgradeRule(costume.ID, costume.BurstLevel) + if err != nil { + return 0, nil, true, fmt.Errorf("player: resolve CostumeBurst rule: %w", err) + } + items, gold, err := validateCostumeBurstMaterials(rule.Costs, materials) + if err != nil { + return 0, nil, true, err + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for CostumeBurst") + } + if len(items) != 0 { + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate CostumeBurst items: %w", err) + } + } + + identity := "costume-burst:" + strconv.FormatUint(costume.InvenIndex, 10) + ":" + strconv.FormatUint(rule.NextLevel, 10) + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(ctx, identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: consume CostumeBurst gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: consume CostumeBurst items: %w", err) + } + } + body := wire.AppendVarint(nil, 1, rule.NextLevel) + record := CostumeBurstUpgradeRecord{CostumeID: costume.ID, Level: rule.NextLevel, Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)} + if err := s.collection.ApplyCostumeBurst(ctx, costume.InvenIndex, costume.BurstLevel, rule.NextLevel, record); err != nil { + return 0, nil, true, fmt.Errorf("player: persist CostumeBurst: %w", err) + } + reply := costumeBurstReply{Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)} + s.replies[sessionID][seq] = reply + return reply.Code, append([]byte(nil), reply.Body...), true, nil +} + +func (s *RecruitService) specialState() (specialRecruitState, error) { + g, ok := s.collection.Grant("special-recruit-state") + if !ok { + return s.rollSpecial(0) + } + ids, e := recruitIDs(g.GachaResponse, 1) + if e != nil { + return specialRecruitState{}, e + } + count, _, e := wire.Varint(g.GachaResponse, 2) + if e != nil { + return specialRecruitState{}, e + } + next, _, e := wire.Varint(g.GachaResponse, 3) + if e != nil { + return specialRecruitState{}, e + } + dayBytes, dayOK, e := wire.Bytes(g.GachaResponse, 4) + if e != nil || !dayOK { + return specialRecruitState{}, errors.New("player: missing scout rotation day") + } + day := string(dayBytes) + if _, e = time.Parse("2006-01-02", day); e != nil { + return specialRecruitState{}, errors.New("player: invalid scout rotation day") + } + state := specialRecruitState{IDs: ids, Count: count, Next: next, Day: day} + if e != nil { + return state, e + } + seen := map[uint64]bool{} + if next == 0 || next > math.MaxInt64 || count > s.design.ResetLimit || len(ids) > int(s.design.AppearCount) { + return state, errors.New("player: invalid saved scout rotation") + } + for _, id := range ids { + r, ok := s.design.Rules[id] + if !ok || r.Type != 1 || r.AppearProb == 0 || seen[id] { + return state, errors.New("player: invalid saved scout appearance") + } + seen[id] = true + } + if state.Day != s.now().UTC().Format("2006-01-02") { + state.Count = 0 + state.Day = s.now().UTC().Format("2006-01-02") + } + if next <= uint64(s.now().UnixMilli()) { + return s.rollSpecial(state.Count) + } + return state, nil +} + +func (s *RecruitService) persistSpecial(ctx command.Context, state specialRecruitState) error { + var b []byte + for _, id := range state.IDs { + b = wire.AppendVarint(b, 1, id) + } + b = wire.AppendVarint(b, 2, state.Count) + b = wire.AppendVarint(b, 3, state.Next) + b = wire.AppendString(b, 4, state.Day) + return s.collection.saveRecruitState(ctx, b) +} + +func (s *RecruitService) scoutInfo(state specialRecruitState) []byte { + var b []byte + for _, id := range state.IDs { + if _, done := s.collection.Grant(recruitIdentity(id)); !done { + b = wire.AppendVarint(b, 1, id) + } + } + b = wire.AppendVarint(b, 2, state.Count) + b = wire.AppendVarint(b, 3, state.Next) + var ids []uint64 + for id := range s.design.Rules { + if _, ok := s.collection.Grant(recruitIdentity(id)); ok { + ids = append(ids, id) + } + } + slices.Sort(ids) + for _, id := range ids { + b = wire.AppendVarint(b, 4, id) + } + return b +} + +func recruitRewardBundle(c *CollectionStore, g CollectionGrant) []byte { + var b []byte + for _, idx := range g.CharacterIndices { + if v, ok := c.CharacterByIndex(idx); ok { + b = wire.AppendBytes(b, 2, CharacterWire(v)) + b = wire.AppendBytes(b, 6, assets.ItemWire(assets.Item{ID: v.ID, Type: 6, Count: 1})) + } + } + for _, idx := range g.CostumeIndices { + if v, ok := c.CostumeByIndex(idx); ok { + b = wire.AppendBytes(b, 3, CostumeWire(v)) + } + } + for _, id := range g.ViewCostumeIDs { + b = wire.AppendBytes(b, 6, assets.ItemWire(assets.Item{ID: id, Type: 11, Count: 1})) + } + for _, u := range g.Upgrades { + v := wire.AppendVarint(nil, 1, u.InvenIndex) + v = wire.AppendVarint(v, 2, 11) + v = wire.AppendVarint(v, 3, u.CostumeID) + v = wire.AppendVarint(v, 4, u.Before) + v = wire.AppendVarint(v, 5, u.After) + b = wire.AppendBytes(b, 9, v) + } + for _, x := range g.Exchanges { + v := wire.AppendVarint(nil, 1, x.OriginalItemType) + v = wire.AppendVarint(v, 2, x.OriginalItemID) + v = wire.AppendVarint(v, 3, x.OriginalCount) + v = wire.AppendVarint(v, 4, x.ExchangeItemType) + v = wire.AppendVarint(v, 5, x.ExchangeItemID) + v = wire.AppendVarint(v, 6, x.ExchangeCount) + b = wire.AppendBytes(b, 8, v) + r := wire.AppendVarint(nil, 2, x.ExchangeItemType) + r = wire.AppendVarint(r, 3, x.ExchangeCount) + b = wire.AppendBytes(b, 10, r) + } + return b +} + +func recruitIDs(b []byte, n int) ([]uint64, error) { + var ids []uint64 + err := wire.Walk(b, func(f wire.Field) error { + if f.Number == n { + if f.Type != 0 { + return errors.New("player: invalid scout state IDs") + } + id, _ := binary.Uvarint(f.Value) + ids = append(ids, id) + } + return nil + }) + return ids, err +} + +func validateFriendshipEntries(entries map[string]FriendshipEntry) error { + for key, entry := range entries { + kinds := 0 + if entry.State != nil { + kinds++ + } + if entry.Daily != nil { + kinds++ + } + if entry.Reply != nil { + kinds++ + } + if kinds != 1 { + return fmt.Errorf("player: invalid friendship entry %q", key) + } + if state := entry.State; state != nil { + if key != friendshipStateKey(state.CostumeID) || state.CostumeID == 0 || state.CostumeID > math.MaxInt32 || state.Level == 0 || state.Level > math.MaxInt32 || state.EXP > math.MaxInt32 || state.LastCounselingDate > math.MaxInt64 { + return fmt.Errorf("player: invalid friendship state %q", key) + } + if state.LastCounselingDate == 0 { + if state.CounselingDay != "" || state.CounselingCount != 0 || len(state.Sessions) != 0 { + return errors.New("player: friendship counseling state has no date") + } + } else if state.CounselingCount == 0 || state.CounselingDay != time.UnixMilli(int64(state.LastCounselingDate)).UTC().Format("2006-01-02") { + return errors.New("player: inconsistent friendship counseling date") + } + seen := map[uint64]bool{} + for _, id := range state.Sessions { + if id == 0 || id > math.MaxInt32 || seen[id] { + return errors.New("player: invalid friendship counseling sessions") + } + seen[id] = true + } + } + if entry.Daily != nil && (key != "daily" || entry.Daily.Day == "") { + return errors.New("player: invalid friendship daily entry") + } + if entry.Daily != nil { + if parsed, err := time.Parse("2006-01-02", entry.Daily.Day); err != nil || parsed.Format("2006-01-02") != entry.Daily.Day { + return errors.New("player: invalid friendship daily date") + } + } + if reply := entry.Reply; reply != nil { + if !strings.HasPrefix(key, "reply:") || len(key) != len("reply:")+64 || (reply.Code != 613 && reply.Code != 614) || len(reply.Digest) != 64 || len(reply.Body) == 0 { + return errors.New("player: invalid friendship replay") + } + if _, err := hex.DecodeString(reply.Digest); err != nil { + return errors.New("player: invalid friendship request digest") + } + if _, err := hex.DecodeString(strings.TrimPrefix(key, "reply:")); err != nil { + return errors.New("player: invalid friendship replay key") + } + if err := validateFriendshipReply(*reply); err != nil { + return err + } + } + } + return nil +} + +func validateFriendshipReply(reply FriendshipReply) error { + infoCount := 0 + err := wire.Walk(reply.Body, func(field wire.Field) error { + switch field.Number { + case 1: + if field.Type != 2 { + return errors.New("invalid friendship reward bundle") + } + return wire.Walk(field.Value, func(wire.Field) error { return nil }) + case 2: + if field.Type != 2 { + return errors.New("invalid friendship response info") + } + infoCount++ + id, found, err := wire.Varint(field.Value, 1) + if err != nil || !found || id == 0 || id > math.MaxInt32 { + return errors.New("invalid friendship response costume") + } + level, found, err := wire.Varint(field.Value, 2) + if err != nil || !found || level == 0 || level > math.MaxInt32 { + return errors.New("invalid friendship response level") + } + case 3, 4: + if field.Type != 0 || (reply.Code == 614 && field.Number == 4) { + return errors.New("invalid friendship response scalar") + } + value, _, err := wire.Varint(reply.Body, field.Number) + if err != nil || value > math.MaxInt32 || (reply.Code == 613 && field.Number == 3 && value > 1) { + return errors.New("invalid friendship response value") + } + default: + return errors.New("unexpected friendship response field") + } + return nil + }) + if err != nil || infoCount != 1 { + return errors.New("player: invalid friendship replay response") + } + return nil +} + +func (s *CollectionStore) ApplyFriendship(ctx command.Context, state FriendshipState, daily *FriendshipDaily, replayKey string, reply FriendshipReply) error { + + next := cloneCollection(s.data) + next.Friendships[friendshipStateKey(state.CostumeID)] = FriendshipEntry{State: &state} + if daily != nil { + next.Friendships["daily"] = FriendshipEntry{Daily: daily} + } + if _, exists := next.Friendships[replayKey]; exists { + return errors.New("player: friendship request already applied") + } + next.Friendships[replayKey] = FriendshipEntry{Reply: &reply} + if err := validateFriendshipEntries(next.Friendships); err != nil { + return err + } + return s.commit(ctx, next) +} + +func friendshipWire(state FriendshipState) []byte { + b := wire.AppendVarint(nil, 1, state.CostumeID) + b = wire.AppendVarint(b, 2, state.Level) + if state.EXP != 0 { + b = wire.AppendVarint(b, 3, state.EXP) + } + if state.LastCounselingDate != 0 { + b = wire.AppendVarint(b, 4, state.LastCounselingDate) + } + return b +} + +func (s *FriendshipService) info(entries map[string]FriendshipEntry) []byte { + ids := make([]uint64, 0, len(s.design.Costumes)) + for id := range s.design.Costumes { + if _, ok := s.owned(id); ok { + ids = append(ids, id) + } + } + slices.Sort(ids) + var body []byte + for _, id := range ids { + state := FriendshipState{CostumeID: id, Level: 1} + if entry := entries[friendshipStateKey(id)]; entry.State != nil { + state = *entry.State + } + body = wire.AppendBytes(body, 1, friendshipWire(state)) + if len(state.Sessions) != 0 { + counsel := wire.AppendVarint(nil, 1, id) + for _, session := range state.Sessions { + counsel = wire.AppendVarint(counsel, 2, session) + } + body = wire.AppendBytes(body, 2, counsel) + } + } + return body +} + +func (s *FriendshipService) gift(ctx command.Context, request []byte, state FriendshipState, max uint64, key, digest string) ([]byte, error) { + if state.Level >= max { + return nil, errors.New("player: friendship level is at its unlocked maximum") + } + items, err := assets.DecodeItemRequest(request, 3, "FriendshipGift") + if err != nil { + return nil, err + } + var exp uint64 + seen := map[uint64]bool{} + for _, item := range items { + if seen[item.InvenIndex] || item.InvenIndex > math.MaxInt64 || item.ID > math.MaxInt32 || item.Type > math.MaxInt32 || item.Count > math.MaxInt32 { + return nil, errors.New("player: invalid friendship gift item") + } + seen[item.InvenIndex] = true + rule, ok := s.design.Gifts[[2]uint64{item.Type, item.ID}] + if !ok { + return nil, errors.New("player: item is not a friendship gift") + } + unit := rule.Experience(state.CostumeID) + if unit == 0 || item.Count > math.MaxInt32/unit || exp > math.MaxInt32-unit*item.Count { + return nil, errors.New("player: invalid friendship gift experience") + } + exp += unit * item.Count + } + if err := s.inventory.CanConsume(ctx, items); err != nil { + return nil, err + } + next, rewards, err := s.advance(state, max, exp) + if err != nil { + return nil, err + } + if err := s.inventory.Consume(ctx, items); err != nil { + return nil, err + } + bundle, err := s.grant(ctx, key, rewards) + if err != nil { + return nil, err + } + body := wire.AppendBytes(nil, 1, bundle) + body = wire.AppendBytes(body, 2, friendshipWire(next)) + body = wire.AppendVarint(body, 3, exp) + if err := s.collection.ApplyFriendship(ctx, next, nil, key, FriendshipReply{Digest: digest, Code: 614, Body: body}); err != nil { + return nil, err + } + return body, nil +} + +func (s *FriendshipService) counsel(ctx command.Context, request []byte, state FriendshipState, max uint64, entries map[string]FriendshipEntry, key, digest string) ([]byte, error) { + session, found, err := wire.Varint(request, 3) + if err != nil || !found || session == 0 || session > math.MaxInt32 { + return nil, errors.New("player: invalid counseling session") + } + rule, exists := s.design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: session}] + if !exists { + return nil, errors.New("player: counseling session does not belong to costume") + } + choice, _, err := wire.Varint(request, 4) + if err != nil || choice >= rule.ChoiceCount { + return nil, errors.New("player: invalid counseling choice") + } + quick, _, err := wire.Varint(request, 5) + if err != nil || quick > 1 { + return nil, errors.New("player: invalid counseling quick flag") + } + if quick == 1 && (uint64(len(state.Sessions)) < s.design.Default.QuickCounselingUnlockCount || state.Level >= max || choice != 1) { + return nil, errors.New("player: quick counseling is not available") + } + now := s.now() + day := now.UTC().Format("2006-01-02") + daily := s.daily(entries, day) + free := state.Level >= s.design.Default.MaxLevels[2] + if !free { + if daily.Used >= s.design.Default.MaxCounselingAP { + return nil, errors.New("player: no daily friendship AP remaining") + } + if state.CounselingDay == day && state.CounselingCount >= s.design.Default.MaxCounselingAPByCostume { + return nil, errors.New("player: costume daily counseling limit reached") + } + } + correct := quick == 1 || choice == s.design.Default.CorrectSelectDialogIndex + exp := s.design.Default.IncorrectEXP + if correct { + exp = s.design.Default.CorrectEXP + } + if state.Level >= max { + exp = 0 + } + next, rewards, err := s.advance(state, max, exp) + if err != nil { + return nil, err + } + completed := slices.Contains(state.Sessions, session) + if !free { + rewards = append(rewards, s.design.Default.CounselingRewards...) + daily.Used++ + } else if !completed && quick == 0 { + // At the final cap the client allows unrestricted story playback. A + // previously unseen story earns its default reward once; repeated + // playback does not create a source of unlimited account currency. + rewards = append(rewards, s.design.Default.CounselingRewards...) + } + if next.CounselingDay != day { + next.CounselingDay = day + next.CounselingCount = 0 + } + next.CounselingCount++ + if now.UnixMilli() <= 0 { + return nil, errors.New("player: invalid counseling time") + } + next.LastCounselingDate = uint64(now.UnixMilli()) + if quick == 0 { + if !completed { + next.Sessions = append(next.Sessions, session) + slices.Sort(next.Sessions) + } + } + bundle, err := s.grant(ctx, key, rewards) + if err != nil { + return nil, err + } + body := wire.AppendBytes(nil, 1, bundle) + body = wire.AppendBytes(body, 2, friendshipWire(next)) + if correct { + body = wire.AppendVarint(body, 3, 1) + } + if exp != 0 { + body = wire.AppendVarint(body, 4, exp) + } + if err := s.collection.ApplyFriendship(ctx, next, &daily, key, FriendshipReply{Digest: digest, Code: 613, Body: body}); err != nil { + return nil, err + } + return body, nil +} + +func (s *FriendshipService) grant(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) { + if len(rewards) == 0 { + return nil, nil + } + var items []gamedata.BattleReward + for _, reward := range rewards { + if reward.Type == 0 || reward.Count == 0 || reward.Count > math.MaxInt32 { + return nil, errors.New("player: invalid friendship reward") + } + switch reward.Type { + case 2, 3, 4, 12, 20: + default: + if reward.ID == 0 || reward.ID > math.MaxInt32 { + return nil, errors.New("player: invalid friendship item reward") + } + items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 + } + } + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":currency", rewards); err != nil { + return nil, err + } + granted, err := s.inventory.GrantOnce(ctx, identity+":items", items) + if err != nil { + return nil, err + } + var bundle []byte + for _, item := range granted { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + } + for _, reward := range rewards { + view := assets.Item{ID: reward.ID, Type: reward.Type, Count: reward.Count} + bundle = wire.AppendBytes(bundle, 6, assets.ItemWire(view)) + switch reward.Type { + case 2, 3, 4, 12, 20: + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(view)) + } + } + return bundle, nil +} + +func (s *CostumePotentialService) connect(ctx command.Context, request []byte) (int, []byte, bool, error) { + + fail := func(e error) (int, []byte, bool, error) { return 267, nil, true, e } + seq, ok, err := wire.Varint(request, 1) + if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.connectStore == nil || ctx.SessionID == "" { + return fail(fmt.Errorf("player: invalid potential connection session/sequence")) + } + var receipts map[string]potentialConnectReceipt + b, err := s.connectStore.Load(ctx.State, "potentialconnect") + if err != nil { + return fail(err) + } + if b != nil { + if err = json.Unmarshal(b, &receipts); err != nil || receipts == nil { + return fail(fmt.Errorf("player: invalid potential connection receipts")) + } + } else { + receipts = map[string]potentialConnectReceipt{} + } + for _, r := range receipts { + hash, e := hex.DecodeString(r.Digest) + if e != nil || len(hash) != sha256.Size { + return fail(fmt.Errorf("player: malformed potential connection receipt digest")) + } + if e = wire.Walk(r.Body, func(wire.Field) error { return nil }); e != nil { + return fail(e) + } + } + seqFields := 0 + if err = wire.Walk(request, func(f wire.Field) error { + if f.Number == 1 { + seqFields++ + if f.Type != 0 { + return fmt.Errorf("player: invalid potential sequence wire") + } + } + return nil + }); err != nil { + return fail(err) + } + if seqFields != 1 { + return fail(fmt.Errorf("player: duplicate potential sequence")) + } + key := fmt.Sprintf("%s:%d", ctx.SessionID, seq) + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + if prior, ok := receipts[key]; ok { + if prior.Digest != digest { + return fail(fmt.Errorf("player: changed potential connection replay")) + } + return 267, prior.Body, true, nil + } + var characters []Character + seen := map[uint64]bool{} + oldHP := map[uint64]uint64{} + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 || len(characters) >= 4096 { + return fmt.Errorf("player: invalid potential connection list") + } + fields := map[int]bool{} + if e := wire.Walk(f.Value, func(field wire.Field) error { + if field.Number == 1 || field.Number == 2 { + if field.Type != 0 || fields[field.Number] { + return fmt.Errorf("player: duplicate or invalid connection scalar") + } + fields[field.Number] = true + } + return nil + }); e != nil { + return e + } + index, ok, e := wire.Varint(f.Value, 1) + if e != nil || !ok || index == 0 || index > math.MaxInt64 || seen[index] { + return fmt.Errorf("player: duplicate or invalid potential character") + } + costume, _, e := wire.Varint(f.Value, 2) + if e != nil || costume == 0 || costume > math.MaxInt32 { + return fmt.Errorf("player: potential connection requires a costume design ID") + } + c, owned := s.characters.Find(ctx, index) + if !owned || IsStoryCharacter(c) || IsCharmCharacter(c) { + return fmt.Errorf("player: potential character is not permanent owned") + } + unique := s.design.CharacterUnique[c.ID] + characterType, knownType := s.design.CharacterTypes[c.ID] + if unique == 0 || !knownType || characterType != 0 || !s.design.CostumeActive[costume] || s.design.CostumeUnique[costume] != unique || len(s.design.Nodes[costume]) == 0 { + return fmt.Errorf("player: incompatible or unavailable potential costume") + } + ownedCostume := false + for _, v := range s.collection.Costumes() { + if v.ID == costume && v.UseChar == index { + ownedCostume = true + for _, id := range v.PotentialIDs { + if _, ok := s.design.Nodes[costume][id]; !ok { + return fmt.Errorf("player: invalid saved potential node") + } + } + break + } + } + if !ownedCostume { + return fmt.Errorf("player: potential costume not owned by requested character") + } + hp, e := s.characters.CurrentHealth(ctx, index) + if e != nil { + return e + } + oldHP[index] = hp + c.ConnectPotentialCostume = costume + characters = append(characters, c) + seen[index] = true + return nil + }) + if err != nil { + return fail(err) + } + if len(characters) == 0 { + return fail(fmt.Errorf("player: empty potential connection list")) + } + // All links are validated before writes. The parent account transaction + // includes both character ownership domains, HP and the response receipt. + for _, c := range characters { + if err = s.characters.setPotentialConnection(ctx, c.InvenIndex, c.ConnectPotentialCostume); err != nil { + return fail(err) + } + } + var out []byte + for _, c := range characters { + maximum, e := s.characters.MaxHealth(ctx, c.InvenIndex) + if e != nil { + return fail(e) + } + hp := min(oldHP[c.InvenIndex], maximum) + if e = s.characters.SetCurrentHealth(ctx, c.InvenIndex, hp); e != nil { + return fail(e) + } + c.HP = hp + out = wire.AppendBytes(out, 1, CharacterWire(c)) + } + receipts[key] = potentialConnectReceipt{Digest: digest, Body: out} + b, err = json.Marshal(receipts) + if err != nil { + return fail(err) + } + if err = s.connectStore.Save(ctx.State, "potentialconnect", b); err != nil { + return fail(err) + } + return 267, out, true, nil +} + +func OpenCollectionStore(ctx command.Context, store stateio.Store, base []Costume) (*CollectionStore, error) { + if store == nil { + return nil, errors.New("player: nil collection store") + } + entries, ok := store.(stateio.ScopedEntryStore) + if !ok { + return nil, errors.New("player: collection store requires atomic entry storage") + } + s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{ + Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001, + BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{}, + CostumePotential: map[string][]uint64{}, + CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{}, + CharAwake: map[string]CharAwakeProgress{}, + Friendships: map[string]FriendshipEntry{}, + StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{}, + GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{}, + }} + b, err := store.Load(ctx.State, "collection") + if err != nil { + return nil, err + } + if b == nil { + if err := stateio.RequireNoEntries(entries, ctx.State, collectionDomain, collectionEntryBuckets[:]...); err != nil { + return nil, fmt.Errorf("player: invalid collection storage: %w", err) + } + return s, nil + } + if err := rejectInlineCollectionEntries(b); err != nil { + return nil, err + } + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, fmt.Errorf("player: decode collection: %w", err) + } + if err := loadCollectionEntries(ctx, entries, &s.data); err != nil { + return nil, err + } + if s.data.Version != versionconfig.State() || s.data.NextCharacterIndex < 920000001 || s.data.NextCostumeIndex < 930000001 { + return nil, errors.New("player: invalid collection save") + } + if s.data.BaseCostumeLevels == nil { + s.data.BaseCostumeLevels = map[string]uint64{} + } + if s.data.CostumePotential == nil { + return nil, errors.New("player: collection save requires costume_potential; migrate the development save") + } + if s.data.CharAwake == nil { + return nil, errors.New("player: collection save requires char_awake; migrate the development save") + } + if err := s.validateCostumeBurstStorage(); err != nil { + return nil, err + } + if err := validateFriendshipEntries(s.data.Friendships); err != nil { + return nil, err + } + for key, progress := range s.data.CharAwake { + uniqueID, parseErr := strconv.ParseUint(key, 10, 64) + if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) { + return nil, errors.New("player: invalid char_awake ledger") + } + } + if s.data.GachaSelections == nil { + s.data.GachaSelections = map[string][]GachaSelection{} + } + if s.data.GachaSelectionChanges == nil { + s.data.GachaSelectionChanges = map[string]uint64{} + } + for key, count := range s.data.GachaSelectionChanges { + groupID, parseErr := strconv.ParseUint(key, 10, 64) + if parseErr != nil || groupID == 0 || key != strconv.FormatUint(groupID, 10) || count == 0 { + return nil, errors.New("player: invalid gacha selection change ledger") + } + } + if s.data.StepUpProgress == nil { + s.data.StepUpProgress = map[string]uint64{} + } + if s.data.GachaUsers == nil { + s.data.GachaUsers = map[string]GachaUserState{} + } + if s.data.GachaFixed == nil { + s.data.GachaFixed = map[string]GachaFixedState{} + } + if s.data.GachaApplied == nil { + s.data.GachaApplied = map[string]bool{} + } + if s.data.GachaPointExchange == nil { + s.data.GachaPointExchange = map[string]GachaPointExchange{} + } + if marker, exists := s.data.Grants[FirstGachaCompletedIdentity]; exists && !emptyCollectionGrant(marker) { + return nil, errors.New("player: invalid first-gacha completion marker") + } + if err := validateCharacters(s.data.Characters); err != nil && len(s.data.Characters) != 0 { + return nil, err + } + s.persisted = true + return s, nil +} + +// ApplyCostumeBurst atomically advances one owned costume and stores the exact +// successful reply. expectedCurrent is a compare-and-swap guard against a +// stale request overwriting a newer level. +func (s *CollectionStore) ApplyCostumeBurst(ctx command.Context, invenIndex, expectedCurrent, target uint64, record CostumeBurstUpgradeRecord) error { + if invenIndex == 0 || expectedCurrent == math.MaxUint64 || target != expectedCurrent+1 { + return errors.New("player: invalid costume burst transition") + } + if record.Level != target || record.CostumeID == 0 { + return errors.New("player: inconsistent costume burst record") + } + if err := validateCostumeBurstRecord(record); err != nil { + return err + } + + costume, found := s.costumeByIndexLocked(invenIndex) + if !found { + return fmt.Errorf("player: costume %d not found", invenIndex) + } + if costume.ID != record.CostumeID { + return errors.New("player: costume burst record design mismatch") + } + if costume.BurstLevel != expectedCurrent { + return errors.New("player: stale costume burst level") + } + ledgerKey := costumeBurstUpgradeKey(invenIndex, target) + if _, exists := s.data.CostumeBurstUpgrades[ledgerKey]; exists { + return errors.New("player: costume burst transition already recorded") + } + next := cloneCollection(s.data) + next.CostumeBurstLevels[strconv.FormatUint(invenIndex, 10)] = target + record.Body = append([]byte(nil), record.Body...) + next.CostumeBurstUpgrades[ledgerKey] = record + return s.commit(ctx, next) +} + +func validateCostumeBurstRecord(record CostumeBurstUpgradeRecord) error { + if record.CostumeID == 0 || record.Level == 0 || record.Code != 578 || len(record.Digest) != costumeBurstDigestHexSize { + return errors.New("player: invalid costume burst upgrade record") + } + if _, err := hex.DecodeString(record.Digest); err != nil { + return errors.New("player: invalid costume burst upgrade digest") + } + level, found, err := wire.Varint(record.Body, 1) + if err != nil || !found || level != record.Level { + return errors.New("player: invalid costume burst upgrade response") + } + fieldCount := 0 + if err := wire.Walk(record.Body, func(field wire.Field) error { + if field.Number != 1 || field.Type != 0 { + return errors.New("unexpected costume burst response field") + } + fieldCount++ + return nil + }); err != nil || fieldCount != 1 { + return errors.New("player: invalid costume burst upgrade response") + } + return nil +} + +func (s *CollectionStore) validateCostumeBurstStorage() error { + if s.data.CostumeBurstLevels == nil || s.data.CostumeBurstUpgrades == nil { + return errors.New("player: collection save requires costume burst ledgers") + } + owned := make(map[uint64]Costume, len(s.base)+len(s.data.Costumes)) + for _, costume := range append(append([]Costume(nil), s.base...), s.data.Costumes...) { + if costume.InvenIndex == 0 || costume.ID == 0 { + return errors.New("player: invalid costume burst ownership") + } + if _, exists := owned[costume.InvenIndex]; exists { + return errors.New("player: duplicate costume burst inventory index") + } + owned[costume.InvenIndex] = costume + } + for key, level := range s.data.CostumeBurstLevels { + index, err := strconv.ParseUint(key, 10, 64) + costume, found := owned[index] + if err != nil || index == 0 || key != strconv.FormatUint(index, 10) || level == 0 || !found || level < costume.BurstLevel { + return fmt.Errorf("player: invalid costume burst level entry %q", key) + } + } + for key, record := range s.data.CostumeBurstUpgrades { + index, level, valid := parseCostumeBurstUpgradeKey(key) + costume, found := owned[index] + current := costume.BurstLevel + if overlay, exists := s.data.CostumeBurstLevels[strconv.FormatUint(index, 10)]; exists { + current = overlay + } + if !valid || !found || record.CostumeID != costume.ID || record.Level != level || level > current { + return fmt.Errorf("player: invalid costume burst upgrade entry %q", key) + } + if err := validateCostumeBurstRecord(record); err != nil { + return fmt.Errorf("player: invalid costume burst upgrade entry %q: %w", key, err) + } + } + return nil +} diff --git a/go/internal/server/domain/roster/protocol_growth_adapter.go b/go/internal/server/domain/roster/protocol_growth_adapter.go new file mode 100644 index 0000000..91427a2 --- /dev/null +++ b/go/internal/server/domain/roster/protocol_growth_adapter.go @@ -0,0 +1,542 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/ownership" + "bd2server/internal/server/protocol/wire" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "slices" + "strconv" +) + +func CharacterWire(v Character) []byte { + out := ownership.Character{InvenIndex: v.InvenIndex, ID: v.ID, HP: v.HP, Level: v.Level, CostumeID: v.CostumeID, Exp: v.Exp, UseCostume: v.UseCostume, TalentLevel: v.TalentLevel, TalentExp: v.TalentExp, SolidarityReward: v.SolidarityReward, ExpiryTime: v.ExpiryTime, ConnectPotentialCostume: v.ConnectPotentialCostume} + for _, p := range v.Pictorialbook { + out.Pictorialbook = append(out.Pictorialbook, ownership.Pictorial{ID: p.ID, GroupID: p.GroupID}) + } + return ownership.EncodeCharacter(out) +} + +func (s *CharacterStore) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path == "/CharImmortal" { + return s.charImmortal(ctx, request) + } + if path == "/TalentSkillUpgrade" { + return s.talentSkillUpgrade(ctx, request) + } + if path != "/CharGrowth" { + return 0, nil, false, nil + } + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 { + return 0, nil, true, errors.New("player: CharGrowth missing character") + } + var materials []assets.Item + if err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return errors.New("player: invalid growth material") + } + var item assets.Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) { + return errors.New("player: incomplete growth material") + } + materials = append(materials, item) + return nil + }); err != nil { + return 0, nil, true, err + } + if len(materials) == 0 { + return 0, nil, true, errors.New("player: CharGrowth has no materials") + } + + position := -1 + for i, current := range s.characters { + if current.InvenIndex == index { + position = i + break + } + } + var current Character + fromCollection := false + if position >= 0 { + current = s.characters[position] + } else if s.collection != nil { + current, fromCollection = s.collection.FindCharacter(index) + } + if position < 0 && !fromCollection { + return 0, nil, true, fmt.Errorf("player: unknown character inventory index %d", index) + } + isPromotion := false + for _, material := range materials { + if material.Type == 4 { + isPromotion = true + break + } + } + if isPromotion { + return s.promoteCharacter(ctx, current, position, fromCollection, materials) + } + growthMaterials := make([]gamedata.GrowthMaterial, len(materials)) + for i, material := range materials { + if material.Type != 8 { + return 0, nil, true, fmt.Errorf("player: unsupported growth material type %d", material.Type) + } + growthMaterials[i] = gamedata.GrowthMaterial{ID: material.ID, Count: material.Count} + } + newLevel, newExp, refunds, err := s.grow(current, growthMaterials) + if err != nil { + return 0, nil, true, fmt.Errorf("player: calculate character growth: %w", err) + } + current.Level = newLevel + current.Exp = newExp + if s.maxHealth != nil { + maxHealth := s.maxHealth + + hp, healthErr := maxHealth(ctx, current) + + current.HP, err = hp, healthErr + if err != nil { + return 0, nil, true, fmt.Errorf("player: calculate grown character maximum health: %w", err) + } + } + returned, err := s.inventory.ConsumeAndRefund(ctx, materials, refunds) + if err != nil { + return 0, nil, true, fmt.Errorf("player: consume growth material: %w", err) + } + if fromCollection { + if err := s.collection.UpdateCharacter(ctx, current.ID, current); err != nil { + return 0, nil, true, fmt.Errorf("player: persist collection character growth: %w", err) + } + } else { + next := append([]Character(nil), s.characters...) + next[position] = current + if err := s.persist(ctx, next); err != nil { + return 0, nil, true, fmt.Errorf("player: persist character growth: %w", err) + } + s.characters = next + } + if err := s.resetCurrentHealth(ctx, current.InvenIndex); err != nil { + return 0, nil, true, err + } + response := wire.AppendBytes(nil, 1, CharacterWire(current)) + var bundle []byte + for _, item := range returned { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + } + response = wire.AppendBytes(response, 2, bundle) + return 433, response, true, nil +} + +func (s *CharAwakeService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + switch path { + case "/CharAwakeInfo": + return s.info(request) + case "/CharImprintLevelUp": + return s.imprintLevelUp(ctx, request) + case "/CharAwakeActive": + return s.awakeActive(ctx, request) + default: + return 0, nil, false, nil + } +} + +func (s *CharacterStore) promoteCharacter(ctx command.Context, current Character, position int, fromCollection bool, materials []assets.Item) (int, []byte, bool, error) { + var items []assets.Item + requested := make(map[[2]uint64]uint64) + var gold uint64 + for _, material := range materials { + if material.Type == 4 { //nolint:staticcheck // QF1003 + if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { + return 0, nil, true, errors.New("player: invalid promotion currency") + } + gold = material.Count + } else if material.Type == 8 { + items = append(items, material) + key := [2]uint64{8, material.ID} + if material.Count > ^uint64(0)-requested[key] { + return 0, nil, true, errors.New("player: promotion material overflow") + } + requested[key] += material.Count + } else { + return 0, nil, true, fmt.Errorf("player: unsupported promotion item type %d", material.Type) + } + } + if gold == 0 { + return 0, nil, true, errors.New("player: promotion has no gold cost") + } + submitted := make([]gamedata.PromotionCost, 0, len(requested)+1) + for key, count := range requested { + submitted = append(submitted, gamedata.PromotionCost{Type: key[0], ID: key[1], Count: count}) + } + submitted = append(submitted, gamedata.PromotionCost{Type: 4, Count: gold}) + result, err := s.promoteGrowth(current, submitted) + if err != nil { + return 0, nil, true, fmt.Errorf("player: calculate combined character promotion: %w; request=%+v", err, materials) + } + if gold != 0 && (s.wallet == nil || !s.wallet.CanSpendGold(gold)) { + return 0, nil, true, errors.New("player: insufficient gold for promotion") + } + if len(items) == 0 { + return 0, nil, true, errors.New("player: promotion has no item material") + } + previousID := current.ID + current.ID = result.CharacterID + current.Level = result.Level + current.Exp = result.Exp + if fromCollection { + if err := s.collection.CanUpdateCharacter(ctx, previousID, current); err != nil { + return 0, nil, true, fmt.Errorf("player: validate promoted collection character: %w", err) + } + } + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate promotion items: %w", err) + } + if s.maxHealth != nil { + maxHealth := s.maxHealth + + hp, healthErr := maxHealth(ctx, current) + + if healthErr != nil { + return 0, nil, true, fmt.Errorf("player: calculate promoted character health: %w", healthErr) + } + current.HP = hp + } + if gold != 0 { + identity := "char-promote:" + strconv.FormatUint(current.InvenIndex, 10) + ":" + strconv.FormatUint(current.ID, 10) + if _, err := s.wallet.SpendGoldOnce(ctx, identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: consume promotion gold: %w", err) + } + } + returned, err := s.inventory.ConsumeAndRefund(ctx, items, result.Refunds) + if err != nil { + return 0, nil, true, fmt.Errorf("player: consume promotion items: %w", err) + } + if fromCollection { + if err := s.collection.UpdateCharacter(ctx, previousID, current); err != nil { + return 0, nil, true, fmt.Errorf("player: persist promoted collection character: %w", err) + } + } else { + next := append([]Character(nil), s.characters...) + next[position] = current + if err := s.persist(ctx, next); err != nil { + return 0, nil, true, fmt.Errorf("player: persist promoted character: %w", err) + } + s.characters = next + } + if err := s.resetCurrentHealth(ctx, current.InvenIndex); err != nil { + return 0, nil, true, err + } + response := wire.AppendBytes(nil, 1, CharacterWire(current)) + var bundle []byte + for _, item := range returned { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + } + if len(bundle) != 0 { + response = wire.AppendBytes(response, 2, bundle) + } + return 433, response, true, nil +} + +// charImmortal completes the automatic post-battle revival for characters +// whose TalentSkillTable.ClassType is 14. The story character 6010 has +// ValueList[0]=10000 at every talent level (100%). The authoritative maximum +// Maximum HP is calculated separately from the persisted current HP and the +// restored value is saved explicitly for subsequent character snapshots. +func (s *CharacterStore) charImmortal(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 0, nil, true, errors.New("player: CharImmortal missing sequence") + } + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + + key := fmt.Sprintf("%s:%d", ctx.SessionID, seq) + prior, already := s.immortalReplies[key] + + if already { + if prior.Digest != digest { + return 0, nil, true, errors.New("player: immortal replay payload changed") + } + return prior.Code, append([]byte(nil), prior.Body...), true, nil + } + var indices []uint64 + err = wire.Walk(request, func(field wire.Field) error { + if field.Number != 2 { + return nil + } + if field.Type != 0 && field.Type != 2 { + return errors.New("player: CharImmortal invalid inventory index field") + } + for data := field.Value; len(data) != 0; { + index, count := binary.Uvarint(data) + if count <= 0 || index == 0 { + return errors.New("player: CharImmortal invalid inventory index") + } + indices = append(indices, index) + data = data[count:] + } + return nil + }) + if err != nil { + return 0, nil, true, err + } + if len(indices) == 0 || len(indices) > 5 { + return 0, nil, true, fmt.Errorf("player: CharImmortal invalid character count %d", len(indices)) + } + seen := make(map[uint64]bool, len(indices)) + var response []byte + for _, index := range indices { + if seen[index] { + return 0, nil, true, fmt.Errorf("player: CharImmortal duplicate character %d", index) + } + seen[index] = true + character, found := s.Find(ctx, index) + if !found { + return 0, nil, true, fmt.Errorf("player: CharImmortal unknown character %d", index) + } + if character.HP != 0 { + return 0, nil, true, errors.New("player: immortal requires a defeated character") + } + if !s.immortal.CanRestore(character.ID, character.TalentLevel) { + return 0, nil, true, errors.New("player: character has no supported immortal talent at current level") + } + maximum, err := s.MaxHealth(ctx, index) + if err != nil { + return 0, nil, true, err + } + character.HP = maximum + response = wire.AppendBytes(response, 1, CharacterWire(character)) + } + for _, index := range indices { + maximum, err := s.MaxHealth(ctx, index) + if err != nil { + return 0, nil, true, err + } + if err := s.SetCurrentHealth(ctx, index, maximum); err != nil { + return 0, nil, true, err + } + } + + if s.immortalReplies == nil { + s.immortalReplies = map[string]talentUpgradeReply{} + } + if len(s.immortalReplies) >= 1024 { + s.immortalReplies = map[string]talentUpgradeReply{} + } + s.immortalReplies[key] = talentUpgradeReply{Digest: digest, Code: 96, Body: append([]byte(nil), response...)} + + return 96, response, true, nil +} + +func validAwakeSequence(request []byte, path string) error { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return fmt.Errorf("player: %s missing sequence", path) + } + return nil +} + +func (s *CharAwakeService) info(request []byte) (int, []byte, bool, error) { + if err := validAwakeSequence(request, "CharAwakeInfo"); err != nil { + return 0, nil, true, err + } + states := s.collection.CharAwakeStates() + ids := make([]uint64, 0, len(states)) + for id := range states { + ids = append(ids, id) + } + slices.Sort(ids) + var response []byte + for _, id := range ids { + progress := states[id] + var entry []byte + entry = wire.AppendVarint(entry, 1, id) + for i, level := range progress.ImprintLevels { + if level != 0 { + entry = wire.AppendVarint(entry, 2+i, level) + } + } + if progress.IsAwake { + entry = wire.AppendVarint(entry, 5, 1) + } + response = wire.AppendBytes(response, 1, entry) + } + return 326, response, true, nil +} + +func decodeAwakeMaterials(request []byte, fieldNumber int) ([]assets.Item, error) { + var materials []assets.Item + err := wire.Walk(request, func(field wire.Field) error { + if field.Number != fieldNumber { + return nil + } + if field.Type != 2 { + return errors.New("player: invalid character awakening material field") + } + var item assets.Item + if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { + return err + } + if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) { + return errors.New("player: incomplete character awakening material") + } + materials = append(materials, item) + return nil + }) + return materials, err +} + +func (s *CharAwakeService) imprintLevelUp(ctx command.Context, request []byte) (int, []byte, bool, error) { + if err := validAwakeSequence(request, "CharImprintLevelUp"); err != nil { + return 0, nil, true, err + } + characterIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: CharImprintLevelUp missing character") + } + var targets []gamedata.CharImprintTarget + err = wire.Walk(request, func(field wire.Field) error { + if field.Number != 3 { + return nil + } + if field.Type != 2 { + return errors.New("player: invalid imprint target field") + } + var target gamedata.CharImprintTarget + if err := decodeVarints(field.Value, map[int]*uint64{1: &target.Slot, 2: &target.TargetLevel}); err != nil { + return err + } + if target.Slot == 0 || target.TargetLevel == 0 { + return errors.New("player: incomplete imprint target") + } + targets = append(targets, target) + return nil + }) + if err != nil || len(targets) == 0 { + if err == nil { + err = errors.New("player: CharImprintLevelUp has no targets") + } + return 0, nil, true, err + } + materials, err := decodeAwakeMaterials(request, 4) + if err != nil || len(materials) == 0 { + if err == nil { + err = errors.New("player: CharImprintLevelUp has no materials") + } + return 0, nil, true, err + } + character, found := s.characters.Find(ctx, characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: unknown imprint character %d", characterIndex) + } + uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint character growth: %w", err) + } + current, _ := s.collection.CharAwakeState(uniqueID) + costs, levels, err := s.design.ImprintCosts(uniqueID, current.ImprintLevels, targets) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint GameData: %w", err) + } + items, gold, err := s.validateCosts(costs, materials) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint materials: %w", err) + } + if len(items) != 0 { + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate imprint inventory: %w", err) + } + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for character imprint") + } + identity := "char-imprint:" + strconv.FormatUint(uniqueID, 10) + for _, level := range levels { + identity += ":" + strconv.FormatUint(level, 10) //nolint:modernize // stringsbuilder + } + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(ctx, identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: spend imprint gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: consume imprint materials: %w", err) + } + } + next := current + next.ImprintLevels = levels + if err := s.collection.UpdateCharAwake(ctx, uniqueID, current, next); err != nil { + return 0, nil, true, fmt.Errorf("player: persist character imprint: %w", err) + } + return 327, nil, true, nil +} + +func (s *CharAwakeService) awakeActive(ctx command.Context, request []byte) (int, []byte, bool, error) { + if err := validAwakeSequence(request, "CharAwakeActive"); err != nil { + return 0, nil, true, err + } + characterIndex, found, err := wire.Varint(request, 2) + if err != nil || !found || characterIndex == 0 { + return 0, nil, true, errors.New("player: CharAwakeActive missing character") + } + materials, err := decodeAwakeMaterials(request, 3) + if err != nil || len(materials) == 0 { + if err == nil { + err = errors.New("player: CharAwakeActive has no materials") + } + return 0, nil, true, err + } + character, found := s.characters.Find(ctx, characterIndex) + if !found { + return 0, nil, true, fmt.Errorf("player: unknown awakening character %d", characterIndex) + } + uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening character growth: %w", err) + } + current, _ := s.collection.CharAwakeState(uniqueID) + costs, err := s.design.AwakeCosts(uniqueID, current.ImprintLevels, current.IsAwake) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening GameData: %w", err) + } + items, gold, err := s.validateCosts(costs, materials) + if err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening materials: %w", err) + } + if len(items) != 0 { + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate awakening inventory: %w", err) + } + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for character awakening") + } + identity := "char-awake:" + strconv.FormatUint(uniqueID, 10) + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(ctx, identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: spend awakening gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: consume awakening materials: %w", err) + } + } + next := current + next.IsAwake = true + if err := s.collection.UpdateCharAwake(ctx, uniqueID, current, next); err != nil { + return 0, nil, true, fmt.Errorf("player: persist character awakening: %w", err) + } + return 328, nil, true, nil +} diff --git a/go/internal/server/domain/roster/protocol_talent_adapter.go b/go/internal/server/domain/roster/protocol_talent_adapter.go new file mode 100644 index 0000000..1d3e9e7 --- /dev/null +++ b/go/internal/server/domain/roster/protocol_talent_adapter.go @@ -0,0 +1,1372 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "slices" + "sort" + "strconv" + "time" +) + +func (s *TalentUseService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path == "/CharHealing" { + return s.charHealing(ctx, request) + } + if path != "/TalentSkillUse" { + return 0, nil, false, nil + } + + fail := func(e error) (int, []byte, bool, error) { return 43, nil, true, e } + seq, ok, err := wire.Varint(request, 1) + if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || ctx.SessionID == "" { + return fail(fmt.Errorf("player: invalid talent request sequence")) + } + index, _, err := wire.Varint(request, 2) + if err != nil || index > math.MaxInt64 { + return fail(fmt.Errorf("player: invalid talent caster")) + } + food, _, err := wire.Varint(request, 4) + if err != nil || food > math.MaxInt64 || (index == 0) == (food == 0) { + return fail(fmt.Errorf("player: talent requires a character or a skill food")) + } + var targets []uint64 + seen := map[uint64]bool{} + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 3 { + return nil + } + if f.Type != 0 && f.Type != 2 { + return fmt.Errorf("player: invalid talent target wire") + } + for raw := f.Value; len(raw) > 0; { + v, n := binary.Uvarint(raw) + if n <= 0 || v == 0 || v > math.MaxInt32 || seen[v] || len(targets) >= 4096 { + return fmt.Errorf("player: invalid or duplicate talent target") + } + raw = raw[n:] + seen[v] = true + targets = append(targets, v) + } + return nil + }) + if err != nil { + return fail(err) + } + state, err := s.load(ctx) + if err != nil { + return fail(err) + } + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + key := ctx.SessionID + ":" + fmt.Sprint(seq) + if prior, ok := state.Receipts[key]; ok { + if prior.Digest != digest { + return fail(fmt.Errorf("player: changed talent replay")) + } + return 43, prior.Body, true, nil + } + var character Character + var meta gamedata.TalentUseCharacter + var foodItem assets.Item + if food > 0 { + for _, item := range s.inventory.All(ctx) { + if item.InvenIndex == food && item.Type == 5 && item.Count > 0 { + foodItem = item + break + } + } + group, exists := s.design.Foods[foodItem.ID] + if !exists { + return fail(fmt.Errorf("player: unavailable skill food")) + } + meta = gamedata.TalentUseCharacter{Group: group, MaxLevel: 1} + character.TalentLevel = 1 + } else { + var owned bool + character, owned = s.characters.Find(ctx, index) + if !owned || character.TalentLevel == 0 { + return fail(fmt.Errorf("player: talent caster unavailable")) + } + var exists bool + meta, exists = s.design.Characters[character.ID] + if !exists || character.TalentLevel > meta.MaxLevel { + return fail(fmt.Errorf("player: character talent design unavailable")) + } + } + rule, ok := s.design.Rules[[2]uint64{meta.Group, character.TalentLevel}] + if !ok { + return fail(fmt.Errorf("player: talent level design unavailable")) + } + if s.context == nil { + return fail(fmt.Errorf("player: talent field context unavailable")) + } + pack, mapID, battle, err := s.context(ctx) + if err != nil { + return fail(err) + } + if battle || meta.BannedPacks[pack] { + return fail(fmt.Errorf("player: talent unavailable in current field")) + } + if rule.Class == 7 || rule.Class == 8 || rule.Class == 9 || rule.Class == 10 || rule.Class == 14 { + return fail(fmt.Errorf("player: talent class %d uses its dedicated crafting/recovery packet", rule.Class)) + } + day := s.now().UTC().Add(9*time.Hour - s.design.ResetSchedule.DailyReset).Format("2006-01-02") + current := state.Skills[rule.Group] + dailyLimit := rule.Class == 3 || rule.Class == 4 || rule.Class == 20 + if (rule.Reset == 1 || dailyLimit) && current.Day != day { + current.Count = 0 + current.Day = day + } + if dailyLimit && (len(rule.Values) == 0 || current.Count >= uint64(rule.Values[0])) { + return fail(fmt.Errorf("player: talent daily usage limit reached")) + } + cooldown := talentHasCooldown(rule.Class) + if cooldown && current.End > s.now().UnixMilli() { + return fail(fmt.Errorf("player: talent effect is already active")) + } + identity := "talent-use:" + hex.EncodeToString(sha256Sum([]byte(key))) + multiplier := uint64(1) + if rule.Class != 4 && rule.Class != 20 && len(targets) > 0 { + multiplier = uint64(len(targets)) + } + if food > 0 { + rule.Catalyst = 0 + rule.Experience = 0 + foodItem.Count = 1 + if err = s.inventory.CanConsume(ctx, []assets.Item{foodItem}); err != nil { + return fail(err) + } + } + if rule.Catalyst > math.MaxUint64/multiplier || rule.Catalyst > 0 && !s.wallet.CanSpendCatalyst(rule.Catalyst*multiplier) { + return fail(fmt.Errorf("player: insufficient talent catalyst")) + } + var extra []byte + success := true + if talentNPCClass(rule.Class) { + if len(targets) != 1 || s.design.NPCs == nil { + return fail(fmt.Errorf("player: talent requires one NPC")) + } + npcs, e := s.design.NPCs(pack) + if e != nil { + return fail(e) + } + npc, ok := npcs[targets[0]] + if !ok || npc.MapID != mapID { + return fail(fmt.Errorf("player: talent NPC outside current map")) + } + matched := -1 + for i, g := range npc.Groups { + if g == rule.Group { + matched = i + break + } + } + if matched < 0 { + return fail(fmt.Errorf("player: NPC does not support talent")) + } + npcKey := fmt.Sprintf("%d/%d/%d", pack, npc.ID, rule.Group) + if rule.Class != 16 && state.NPCs[npcKey] > s.now().UnixMilli() { + return fail(fmt.Errorf("player: NPC talent cooldown active")) + } + if rule.Class == 1 || rule.Class == 16 { + probability := 100.0 + if len(rule.Values) > 0 { + probability = rule.Values[0] + } + draw, e := rand.Int(rand.Reader, big.NewInt(1000000)) + if e != nil { + return fail(e) + } + success = float64(draw.Int64()) < probability*10000 + } + if success && npc.Rewards[matched] > 0 { + rewards, ok := s.design.Rewards[npc.Rewards[matched]] + if !ok { + return fail(fmt.Errorf("player: missing NPC talent reward")) + } + bundle, e := s.economy.Apply(ctx, identity, nil, rewards) + if e != nil { + return fail(e) + } + extra, e = talentBundleFields(bundle) + if e != nil { + return fail(e) + } + } + if rule.Class == 19 && len(npc.CharmCharacters) > 0 { + b, e := s.applyCharm(ctx, identity, character, rule, targets) + if e != nil { + return fail(e) + } + extra = append(extra, b...) + } + end := s.now().UnixMilli() + if rule.Class == 16 { + if len(rule.Values) < 2 || rule.Values[1] > 100 { + return fail(fmt.Errorf("player: invalid bargain discount")) + } + if success { + state.Discounts[ctx.SessionID+":"+fmt.Sprintf("%d/%d", pack, npc.ID)] = uint64(rule.Values[1]) + } + } else if rule.Class == 1 && rule.Reset != 0 { + end = s.nextNPCReset(rule.Reset).UnixMilli() + } else if len(rule.Values) > 1 { + end += int64(rule.Values[1]) * 1000 + } + state.NPCs[npcKey] = end + row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, npc.ID), 2, rule.Group), 3, uint64(end)) + extra = wire.AppendBytes(extra, 2, row) + if !success && rule.Reputation > 0 { + effect := s.effects[100] + if effect == nil { + return fail(fmt.Errorf("player: failed talent reputation executor unavailable")) + } + b, e := effect(ctx, identity, character, rule, targets) + if e != nil { + return fail(e) + } + extra = append(extra, b...) + } + } else if effect := s.effects[rule.Class]; effect != nil { + extra, err = effect(ctx, identity, character, rule, targets) + if err != nil { + return fail(err) + } + } else if rule.Class == 4 || rule.Class == 18 || rule.Class == 20 { + return fail(fmt.Errorf("player: field talent executor unavailable")) + } else if len(targets) > 0 && rule.Class != 3 && rule.Class != 11 { + return fail(fmt.Errorf("player: unexpected talent targets")) + } + if food > 0 { + if err = s.inventory.Consume(ctx, []assets.Item{foodItem}); err != nil { + return fail(err) + } + } + if rule.Catalyst > 0 { + if rule.Catalyst > math.MaxUint64/multiplier { + return fail(fmt.Errorf("player: talent cost overflow")) + } + if _, err = s.wallet.SpendCatalystOnce(ctx, identity, rule.Catalyst*multiplier); err != nil { + return fail(err) + } + } + gain := rule.Experience + if gain > 0 { + maximum, e := s.experienceMaximum(character) + if e != nil { + return fail(e) + } + if character.TalentExp >= maximum { + gain = 0 + } else if gain > maximum-character.TalentExp { + gain = maximum - character.TalentExp + } + if gain > 0 { + if _, e = s.characters.AddTalentExperience(ctx, index, gain, maximum); e != nil { + return fail(e) + } + } + } + current.Count++ + current.Settled = false + current.Level = rule.Level + current.Day = day + current.End = s.now().UnixMilli() + if cooldown && len(rule.Values) > 1 { + current.End += int64(rule.Values[1]) * 1000 + } + state.Skills[rule.Group] = current + out := append([]byte(nil), extra...) + out = wire.AppendBytes(out, 1, talentUseWire(rule.Group, current, rule)) + if gain > 0 { + out = wire.AppendVarint(out, 10, gain) + } + if success { + out = wire.AppendVarint(out, 11, 1) + } + state.Receipts[key] = talentUseReceipt{Digest: digest, Body: out} + b, err := json.Marshal(state) + if err != nil { + return fail(err) + } + if err = s.store.Save(ctx.State, "talentuse", b); err != nil { + return fail(err) + } + return 43, out, true, nil +} + +func (s *TalentDispatchService) Handle(ctx command.Context, path string, req []byte) (int, []byte, bool, error) { + if path != "/DispatchInfo" && path != "/DispatchReward" { + return 0, nil, false, nil + } + + seq, ok, e := wire.Varint(req, 1) + if e != nil || !ok || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, fmt.Errorf("dispatch: missing sequence") + } + st, e := s.load(ctx) + if e != nil { + return 0, nil, true, e + } + if path == "/DispatchInfo" { + var b []byte + ids := []uint64{} + for id, r := range st.Rows { + if !r.Claimed { + ids = append(ids, id) + } + } + slices.Sort(ids) + for _, id := range ids { + b = wire.AppendBytes(b, 1, s.rowWire(st.Rows[id])) + } + return 0, b, true, nil + } + if ctx.SessionID == "" { + return 108, nil, true, fmt.Errorf("dispatch: claim session unavailable") + } + var ids []uint64 + e = wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 0 && f.Type != 2 { + return fmt.Errorf("dispatch: invalid ids") + } + b := f.Value + for len(b) > 0 { + n, k := binary.Uvarint(b) + if k <= 0 || n == 0 || n > math.MaxInt32 || len(ids) >= len(s.design) { + return fmt.Errorf("dispatch: invalid packed ids") + } + ids = append(ids, n) + b = b[k:] + } + return nil + }) + if e != nil || len(ids) == 0 { + return 0, nil, true, fmt.Errorf("dispatch: missing reward ids") + } + digest := sha256.Sum256(req) + key := fmt.Sprintf("%s:%d", ctx.SessionID, seq) + if receipt, ok := st.Claims[key]; ok { + if receipt.Digest != fmt.Sprintf("%x", digest) { + return 0, nil, true, fmt.Errorf("dispatch: conflicting claim sequence") + } + return 108, append([]byte(nil), receipt.Body...), true, nil + } + seen := map[uint64]bool{} + for _, id := range ids { + r, ok := st.Rows[id] + if !ok || seen[id] || s.now().UnixMilli() < r.End { + return 0, nil, true, fmt.Errorf("dispatch: reward not ready") + } + seen[id] = true + } + var body []byte + for _, id := range ids { + r := st.Rows[id] + if r.Claimed { + continue + } + if !r.Claimed { + bundle, e := s.economy.Apply(ctx, r.Identity+fmt.Sprintf(":dispatch:%d", id), nil, r.Rewards) + if e != nil { + return 0, nil, true, e + } + r.Bundle = bundle + r.Claimed = true + st.Rows[id] = r + } + if e = wire.Walk(r.Bundle, func(f wire.Field) error { + if f.Number == 1 && f.Type == 2 { + body = wire.AppendBytes(body, 1, f.Value) + } + return nil + }); e != nil { + return 0, nil, true, e + } + } + st.Claims[key] = talentDispatchClaim{Digest: fmt.Sprintf("%x", digest), Body: body} + return 108, body, true, s.save(ctx, st) +} + +func (s *ItemCraftService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + var recipes map[uint64]gamedata.ItemCraftRecipe + var code int + switch path { + case "/Cooking": + recipes = s.design.Cooking + code = 48 + case "/Alchemy": + recipes = s.design.Alchemy + code = 49 + case "/AlchemyBatch": + recipes = s.design.Alchemy + code = 262 + default: + return 0, nil, false, nil + } + + fail := func(err error) (int, []byte, bool, error) { return code, nil, true, err } + if ctx.SessionID == "" || s.context == nil { + return fail(fmt.Errorf("craft: session/context unavailable")) + } + values := map[int]uint64{} + err := wire.Walk(request, func(f wire.Field) error { + if f.Number >= 1 && f.Number <= 4 { + if f.Type != 0 { + return fmt.Errorf("craft: invalid scalar") + } + if _, exists := values[f.Number]; exists { + return fmt.Errorf("craft: duplicate scalar") + } + v, _, err := wire.Varint(request, f.Number) + if err != nil { + return err + } + values[f.Number] = v + } + return nil + }) + if err != nil { + return fail(err) + } + seq, index, recipeID, count := values[1], values[2], values[3], values[4] + if seq == 0 || seq > math.MaxInt32 || index == 0 || index > math.MaxInt64 || recipeID == 0 || recipeID > math.MaxInt32 || count == 0 || count > math.MaxInt32 { + return fail(fmt.Errorf("craft: invalid request")) + } + keyHash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", ctx.SessionID, path, seq))) + key := hex.EncodeToString(keyHash[:]) + digestHash := sha256.Sum256(request) + digest := hex.EncodeToString(digestHash[:]) + var receipts map[string]itemCraftReceipt + raw, err := s.store.Load(ctx.State, "itemcraft") + if err != nil { + return fail(err) + } + if raw != nil && json.Unmarshal(raw, &receipts) != nil { + return fail(fmt.Errorf("craft: invalid receipt state")) + } + if receipts == nil { + receipts = map[string]itemCraftReceipt{} + } + if r, exists := receipts[key]; exists { + if r.Digest != digest { + return fail(fmt.Errorf("craft: changed request replay")) + } + return code, r.Body, true, nil + } + pack, battle, err := s.context(ctx) + if err != nil { + return fail(err) + } + if battle || pack <= 0 { + return fail(fmt.Errorf("craft: unavailable in current scene")) + } + recipe, exists := recipes[recipeID] + if !exists { + return fail(fmt.Errorf("craft: unknown recipe")) + } + if recipe.Class == 7 && !s.known(ctx, recipeID) { + return fail(fmt.Errorf("craft: recipe not learned")) + } + character, owned := s.characters.Find(ctx, index) + if !owned || IsStoryCharacter(character) { + return fail(fmt.Errorf("craft: unavailable producer")) + } + talent, exists := s.talents.Characters[character.ID] + if !exists || talent.BannedPacks[pack] { + return fail(fmt.Errorf("craft: producer talent blocked in pack")) + } + current := s.talents.Rules[[2]uint64{talent.Group, character.TalentLevel}] + limitIndex := 0 + if recipe.Class == 7 { + limitIndex = 1 + } + // Batch count is the missing intermediate quantity requested by equipment + // making (EquipmentMakingUI.OnClickUI), not the AlchemyUI craft slider. + // Its full material graph and int32 quantities are validated below. + if path != "/AlchemyBatch" && (len(current.Values) <= limitIndex || current.Values[limitIndex] < 1 || math.IsNaN(current.Values[limitIndex]) || math.IsInf(current.Values[limitIndex], 0) || count > uint64(current.Values[limitIndex])) { + return fail(fmt.Errorf("craft: count exceeds talent limit")) + } + gain, catalyst, maximum, err := s.talents.CraftTalent(character.ID, character.TalentLevel, recipe.Class, recipe.TalentLevel, count, character.TalentExp) + if err != nil { + return fail(err) + } + materials, err := assets.DecodeItemRequest(request, 5, path) + if err != nil { + return fail(err) + } + if path == "/AlchemyBatch" { + gain, catalyst, maximum, err = s.prepareAlchemyBatch(character, recipe, count, materials) + } else { + err = assets.ValidateMakingMaterials(recipe.Costs, count, materials) + // Conversion recipes charge per produced resource, as AlchemyUI.GetNeededCurrency does. + if err == nil && recipe.Class == 8 && recipe.Category == 2 { + if recipe.Result.Count == 0 || catalyst > math.MaxInt32/recipe.Result.Count { + err = fmt.Errorf("craft: catalyst overflow") + } else { + catalyst *= recipe.Result.Count + } + } + } + if err != nil { + return fail(err) + } + if err = s.items.CanConsume(ctx, materials); err != nil { + return fail(err) + } + if catalyst > 0 && !s.wallet.CanSpendCatalyst(catalyst) { + return fail(fmt.Errorf("craft: insufficient catalyst")) + } + if recipe.Result.Count > math.MaxInt32/count { + return fail(fmt.Errorf("craft: result quantity overflow")) + } + result := recipe.Result + result.Count *= count + if path == "/AlchemyBatch" { + result.Count = count + } + if err = s.items.Consume(ctx, materials); err != nil { + return fail(err) + } + if catalyst > 0 { + if _, err = s.wallet.SpendCatalystOnce(ctx, "itemcraft:"+key, catalyst); err != nil { + return fail(err) + } + } + granted, err := s.items.GrantOnce(ctx, "itemcraft:"+key, []gamedata.BattleReward{result}) + if err != nil { + return fail(err) + } + if gain > 0 { + if _, err = s.characters.AddTalentExperience(ctx, index, gain, maximum); err != nil { + return fail(err) + } + } + var response []byte + for _, item := range granted { + response = wire.AppendBytes(response, 1, assets.ItemWire(item)) + } + if gain > 0 { + response = wire.AppendVarint(response, 2, gain) + } + receipts[key] = itemCraftReceipt{Digest: digest, Body: response} + raw, err = json.Marshal(receipts) + if err != nil { + return fail(err) + } + if err = s.store.Save(ctx.State, "itemcraft", raw); err != nil { + return fail(err) + } + return code, response, true, nil +} + +func (s *FoodService) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path == "/EatFoodAuto" { + return s.eatFoodAuto(ctx, request) + } + if path != "/EatFood" { + return 0, nil, false, nil + } + var seq, pack, index uint64 + seenHeaders := map[int]bool{} + parseErr := wire.Walk(request, func(field wire.Field) error { + if field.Number == 4 { + return nil + } + target := map[int]*uint64{1: &seq, 2: &pack, 3: &index}[field.Number] + if target == nil || field.Type != 0 || seenHeaders[field.Number] { + return errors.New("player: invalid EatFood header") + } + seenHeaders[field.Number] = true + *target, _ = binary.Uvarint(field.Value) + return nil + }) + if parseErr != nil || seq == 0 || seq > math.MaxInt32 || pack > math.MaxInt32 || index == 0 || index > math.MaxInt64 { + return 0, nil, true, errors.New("player: invalid EatFood request") + } + items, err := assets.DecodeItemRequest(request, 4, "EatFood") + if err != nil || len(items) == 0 { + return 0, nil, true, errors.New("player: EatFood requires food stacks") + } + + if ctx.SessionID == "" || s.currentPack == nil || s.battleActive == nil { + return 0, nil, true, errors.New("player: EatFood session or context unavailable") + } + keyDigest := sha256.Sum256([]byte(ctx.SessionID + ":EatFood:" + strconv.FormatUint(seq, 10))) + key := hex.EncodeToString(keyDigest[:]) + digest := sha256.Sum256(request) + digestString := hex.EncodeToString(digest[:]) + prior, found, err := s.store.LoadEntry(ctx.State, "characters", "food_requests", key) + if err != nil { + return 0, nil, true, err + } + if found { + var reply foodReply + if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString { + return 0, nil, true, errors.New("player: EatFood sequence reused with different request") + } + return 22, append([]byte(nil), reply.Body...), true, nil + } + currentPack, err := s.currentPack(ctx) + if err != nil || currentPack <= 0 || (pack != 0 && pack != uint64(currentPack)) { + return 0, nil, true, errors.New("player: EatFood pack unavailable") + } + if s.battleActive(ctx) { + return 0, nil, true, errors.New("player: cannot eat food during battle") + } + character, err := s.recoverCharacter(ctx, index, items) + if err != nil { + return 0, nil, true, err + } + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, err + } + body := wire.AppendBytes(nil, 1, CharacterWire(character)) + if err := s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, err + } + healthRaw, _ := json.Marshal(character.HP) + replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body}) + if err := s.store.SaveWithEntries(ctx.State, "characters", nil, []stateio.EntryMutation{{Bucket: "current_hp", Key: strconv.FormatUint(index, 10), Payload: healthRaw}, {Bucket: "food_requests", Key: key, Payload: replyRaw}}); err != nil { + return 0, nil, true, err + } + return 22, body, true, nil +} + +func talentUseWire(group uint64, state talentUseState, rule gamedata.TalentUseRule) []byte { + b := wire.AppendVarint(nil, 1, group) + if state.End > 0 { + b = wire.AppendVarint(b, 2, uint64(state.End)) + } + if talentHasCooldown(rule.Class) && len(rule.Values) > 1 { + b = wire.AppendVarint(b, 3, uint64(rule.Values[1])) + } + return wire.AppendVarint(b, 4, state.Count) +} + +func talentBundleFields(bundle []byte) ([]byte, error) { + var out []byte + err := wire.Walk(bundle, func(f wire.Field) error { + to := map[int]int{1: 3, 2: 5, 3: 6, 4: 4}[f.Number] + if to > 0 { + out = wire.AppendBytes(out, to, f.Value) + } + return nil + }) + return out, err +} + +func (s *TalentUseService) PackInfo(ctx command.Context, pack int) ([]byte, error) { + + v, e := s.load(ctx) + if e != nil { + return nil, e + } + var groups []uint64 + for g := range v.Skills { + groups = append(groups, g) + } + slices.Sort(groups) + var b []byte + for _, g := range groups { + state := v.Skills[g] + rule := s.design.Rules[[2]uint64{g, state.Level}] + day := s.now().UTC().Add(9*time.Hour - s.design.ResetSchedule.DailyReset).Format("2006-01-02") + if (rule.Reset == 1 || rule.Class == 3 || rule.Class == 4 || rule.Class == 20) && state.Day != day { + state.Count = 0 + } + b = wire.AppendBytes(b, 13, talentUseWire(g, state, rule)) + } + var npcKeys []string + for key := range v.NPCs { + npcKeys = append(npcKeys, key) + } + sort.Strings(npcKeys) + for _, key := range npcKeys { + end := v.NPCs[key] + var p int + var npc, g uint64 + if _, e = fmt.Sscanf(key, "%d/%d/%d", &p, &npc, &g); e != nil { + return nil, e + } + if p == pack { + row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, npc), 2, g), 3, uint64(end)) + b = wire.AppendBytes(b, 5, row) + } + } + return b, nil +} + +func (s *CharacterStore) talentSkillUpgrade(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > math.MaxInt32 { + return 0, nil, true, errors.New("player: TalentSkillUpgrade missing sequence") + } + index, found, err := wire.Varint(request, 2) + if err != nil || !found || index == 0 || index > math.MaxInt64 { + return 0, nil, true, errors.New("player: TalentSkillUpgrade missing character") + } + materials, err := assets.DecodeItemRequest(request, 3, "TalentSkillUpgrade") + if err != nil { + return 0, nil, true, err + } + for _, material := range materials { + if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 || + material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 || + material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 { + return 0, nil, true, errors.New("player: TalentSkillUpgrade material exceeds protocol range") + } + } + + digestBytes := sha256.Sum256(request) + digest := hex.EncodeToString(digestBytes[:]) + sessionID := ctx.SessionID + if sessionID == "" { + return 0, nil, true, errors.New("player: TalentSkillUpgrade requires an authenticated session") + } + if s.talentReplies[sessionID] == nil { + s.talentReplies[sessionID] = make(map[string]talentUpgradeReply) + } + replies := s.talentReplies[sessionID] + cacheKey := "seq:" + strconv.FormatUint(seq, 10) + if reply, ok := replies[cacheKey]; ok { + if reply.Digest != digest { + return 0, nil, true, errors.New("player: TalentSkillUpgrade sequence reused with different request") + } + return reply.Code, append([]byte(nil), reply.Body...), true, nil + } + if s.talentGrowth == nil || s.wallet == nil || s.inventory == nil { + return 0, nil, true, errors.New("player: talent upgrade unavailable") + } + + position := -1 + var current Character + fromCollection := false + for i, character := range s.characters { + if character.InvenIndex == index { + position, current = i, character + break + } + } + if position < 0 && s.collection != nil { + current, fromCollection = s.collection.FindCharacter(index) + } + if position < 0 && !fromCollection { + return 0, nil, true, fmt.Errorf("player: unknown talent character inventory index %d", index) + } + previousLedgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10) + if reply, ok := s.talentApplied[previousLedgerKey]; ok && reply.Digest == digest { + replies[cacheKey] = reply + return reply.Code, append([]byte(nil), reply.Body...), true, nil + } + rule, err := s.talentGrowth.UpgradeRule(current.ID, current.TalentLevel) + if err != nil { + return 0, nil, true, fmt.Errorf("player: resolve talent upgrade: %w", err) + } + if current.TalentExp < rule.RequiredTotalExp { + return 0, nil, true, fmt.Errorf("player: character %d talent experience %d is below required %d", index, current.TalentExp, rule.RequiredTotalExp) + } + + items, gold, err := validateTalentUpgradeMaterials(rule.Costs, materials) + if err != nil { + return 0, nil, true, err + } + if gold != 0 && !s.wallet.CanSpendGold(gold) { + return 0, nil, true, errors.New("player: insufficient gold for talent upgrade") + } + if len(items) != 0 { + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: validate talent upgrade items: %w", err) + } + } + + previousID := current.ID + current.TalentLevel++ + ledgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10) + if _, exists := s.talentApplied[ledgerKey]; exists { + return 0, nil, true, fmt.Errorf("player: talent upgrade ledger already contains target %s", ledgerKey) + } + if fromCollection { + if err := s.collection.CanUpdateCharacter(ctx, previousID, current); err != nil { + return 0, nil, true, fmt.Errorf("player: validate collection talent upgrade: %w", err) + } + } + identity := "talent-upgrade:" + sessionID + ":" + cacheKey + ":character:" + strconv.FormatUint(index, 10) + ":level:" + strconv.FormatUint(rule.CurrentLevel, 10) + if gold != 0 { + if _, err := s.wallet.SpendGoldOnce(ctx, identity, gold); err != nil { + return 0, nil, true, fmt.Errorf("player: consume talent upgrade gold: %w", err) + } + } + if len(items) != 0 { + if err := s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, fmt.Errorf("player: consume talent upgrade items: %w", err) + } + } + reply := talentUpgradeReply{Digest: digest, Code: talentSkillUpgradePacketCode} + ledgerPayload, err := json.Marshal(reply) + if err != nil { + return 0, nil, true, err + } + ledgerChange := stateio.EntryMutation{Bucket: "talent_upgrades", Key: ledgerKey, Payload: ledgerPayload} + if fromCollection { + if err := s.collection.UpdateCharacter(ctx, previousID, current); err != nil { + return 0, nil, true, fmt.Errorf("player: persist collection talent upgrade: %w", err) + } + if err := s.store.SaveWithEntries(ctx.State, "characters", nil, []stateio.EntryMutation{ledgerChange}); err != nil { + return 0, nil, true, fmt.Errorf("player: persist talent upgrade replay ledger: %w", err) + } + } else { + next := append([]Character(nil), s.characters...) + next[position] = current + if err := s.persistWithChanges(ctx, next, []stateio.EntryMutation{ledgerChange}); err != nil { + return 0, nil, true, fmt.Errorf("player: persist talent upgrade: %w", err) + } + s.characters = next + } + // TalentSkillUpgradeResponse.item_info is an optional grant list. Current + // GameData defines no refund, so the correct protobuf response is empty. + s.talentApplied[ledgerKey] = reply + replies[cacheKey] = reply + return talentSkillUpgradePacketCode, nil, true, nil +} + +func (s *TalentUseService) charHealing(ctx command.Context, request []byte) (int, []byte, bool, error) { + return s.healing(ctx, request, "healing") +} + +func (s *TalentUseService) healing(ctx command.Context, request []byte, operation string) (int, []byte, bool, error) { + + fail := func(e error) (int, []byte, bool, error) { return 62, nil, true, e } + seq, ok, err := wire.Varint(request, 1) + if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || ctx.SessionID == "" { + return fail(fmt.Errorf("player: invalid healing sequence")) + } + index, _, err := wire.Varint(request, 2) + if err != nil || index > math.MaxInt64 { + return fail(fmt.Errorf("player: invalid healing caster")) + } + food, _, err := wire.Varint(request, 4) + if err != nil || food > math.MaxInt64 || (index == 0) == (food == 0) { + return fail(fmt.Errorf("player: healing requires caster or food")) + } + state, err := s.load(ctx) + if err != nil { + return fail(err) + } + key := ctx.SessionID + ":" + operation + ":" + fmt.Sprint(seq) + digest := fmt.Sprintf("%x", sha256.Sum256(request)) + if prior, ok := state.Receipts[key]; ok { + if prior.Digest != digest { + return fail(fmt.Errorf("player: changed healing replay")) + } + return 62, prior.Body, true, nil + } + var ids []uint64 + seen := map[uint64]bool{} + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 3 { + return nil + } + if f.Type != 0 && f.Type != 2 { + return fmt.Errorf("player: invalid healing targets") + } + for raw := f.Value; len(raw) > 0; { + id, n := binary.Uvarint(raw) + if n <= 0 || id == 0 || id > math.MaxInt64 || seen[id] || len(ids) >= 4096 { + return fmt.Errorf("player: invalid healing target") + } + raw = raw[n:] + seen[id] = true + ids = append(ids, id) + } + return nil + }) + if err != nil || len(ids) == 0 { + return fail(fmt.Errorf("player: missing healing targets")) + } + if s.context == nil { + return fail(fmt.Errorf("player: healing field context unavailable")) + } + pack, _, battle, err := s.context(ctx) + if err != nil { + return fail(err) + } + if battle { + return fail(fmt.Errorf("player: healing unavailable during battle")) + } + var character Character + var rule gamedata.TalentUseRule + var item assets.Item + if food > 0 { + for _, v := range s.inventory.All(ctx) { + if v.InvenIndex == food && v.Type == 5 { + item = v + break + } + } + g, ok := s.design.Foods[item.ID] + if !ok { + return fail(fmt.Errorf("player: invalid revival food")) + } + rule, ok = s.design.Rules[[2]uint64{g, 1}] + if !ok { + return fail(fmt.Errorf("player: missing revival food talent")) + } + item.Count = uint64(len(ids)) + if err = s.inventory.CanConsume(ctx, []assets.Item{item}); err != nil { + return fail(err) + } + } else { + var owned bool + character, owned = s.characters.Find(ctx, index) + meta, ok := s.design.Characters[character.ID] + if !owned || !ok || meta.BannedPacks[pack] { + return fail(fmt.Errorf("player: invalid healing caster")) + } + rule, ok = s.design.Rules[[2]uint64{meta.Group, character.TalentLevel}] + if !ok { + return fail(fmt.Errorf("player: missing recovery level")) + } + } + if rule.Class != 10 || len(rule.Values) == 0 || rule.Values[0] > 100 { + return fail(fmt.Errorf("player: invalid fatigue recovery skill")) + } + var targets []Character + for _, id := range ids { + c, owned := s.characters.Find(ctx, id) + if !owned { + return fail(fmt.Errorf("player: revival target not owned")) + } + hp, e := s.characters.CurrentHealth(ctx, id) + if e != nil { + return fail(e) + } + if hp != 0 { + return fail(fmt.Errorf("player: revival target is not fatigued")) + } + maximum, e := s.characters.MaxHealth(ctx, id) + if e != nil { + return fail(e) + } + c.HP = 1 + if rule.Values[0] > 0 { + c.HP = uint64(float64(maximum) * rule.Values[0] / 100) + if c.HP == 0 { + c.HP = 1 + } + } + targets = append(targets, c) + } + identity := "talent-healing:" + key + if food > 0 { + if err = s.inventory.Consume(ctx, []assets.Item{item}); err != nil { + return fail(err) + } + } else { + if rule.Catalyst > math.MaxUint64/uint64(len(ids)) { + return fail(fmt.Errorf("player: recovery cost overflow")) + } + if rule.Catalyst > 0 { + if rule.Catalyst > 0 { + if _, err = s.wallet.SpendCatalystOnce(ctx, identity, rule.Catalyst*uint64(len(ids))); err != nil { + return fail(err) + } + } + } + } + var out []byte + for _, c := range targets { + if err = s.characters.SetCurrentHealth(ctx, c.InvenIndex, c.HP); err != nil { + return fail(err) + } + out = wire.AppendBytes(out, 1, CharacterWire(c)) + } + if food == 0 && rule.Experience > 0 { + maximum, e := s.experienceMaximum(character) + if e != nil { + return fail(e) + } + gain := rule.Experience * uint64(len(ids)) + if character.TalentExp >= maximum { + gain = 0 + } else if gain > maximum-character.TalentExp { + gain = maximum - character.TalentExp + } + if gain > 0 { + if _, e = s.characters.AddTalentExperience(ctx, index, gain, maximum); e != nil { + return fail(e) + } + out = wire.AppendVarint(out, 2, gain) + } + } + state.Receipts[key] = talentUseReceipt{Digest: digest, Body: out} + b, err := json.Marshal(state) + if err != nil { + return fail(err) + } + if err = s.store.Save(ctx.State, "talentuse", b); err != nil { + return fail(err) + } + return 62, out, true, nil +} + +func (s *TalentDispatchService) rowWire(r talentDispatchRow) []byte { + b := wire.AppendVarint(nil, 1, r.ID) + b = wire.AppendVarint(b, 2, uint64(s.now().UnixMilli())) + return wire.AppendVarint(b, 3, uint64(r.End)) +} + +// Start returns extra TalentSkillUseResponse fields. Its caller owns talent +// cost/experience/cooldown and the encompassing account transaction. +func (s *TalentDispatchService) Start(ctx command.Context, identity string, character Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) { + + if identity == "" || rule.Class != 18 || character.InvenIndex == 0 || len(targets) == 0 { + return nil, fmt.Errorf("dispatch: invalid start") + } + st, e := s.load(ctx) + if e != nil { + return nil, e + } + if b, ok := st.Starts[identity]; ok { + return append([]byte(nil), b...), nil + } + allowed := map[uint64]bool{} + for _, v := range rule.Values { + if v > 0 { + allowed[uint64(v)] = true + } + } + prefabs := map[string]bool{} + for id, r := range st.Rows { + if !r.Claimed { + prefabs[s.design[id].Prefab] = true + } + } + var rows []talentDispatchRow + for _, id := range targets { + d, ok := s.design[id] + if !ok || !allowed[id] || prefabs[d.Prefab] { + return nil, fmt.Errorf("dispatch: unavailable dispatch %d", id) + } + prefabs[d.Prefab] = true + rs, e := d.Roll(s.draw) + if e != nil { + return nil, e + } + r := talentDispatchRow{ID: id, Start: s.now().UnixMilli(), Identity: identity} + r.End = d.EndTime(time.UnixMilli(r.Start)).UnixMilli() + for _, reward := range rs { + r.Rewards = append(r.Rewards, gamedata.Reward(reward)) + } + rows = append(rows, r) + } + var b []byte + for _, r := range rows { + st.Rows[r.ID] = r + b = wire.AppendBytes(b, 8, s.rowWire(r)) + } + st.Starts[identity] = b + return b, s.save(ctx, st) +} + +// Charm instances are field companions. They live only in the character domain +// with an expiry, never grant a permanent collection character or costume. +func (s *TalentUseService) applyCharm(ctx command.Context, _ string, _ Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) { + pack, _, _, err := s.context(ctx) + if err != nil { + return nil, err + } + npcs, err := s.design.NPCs(pack) + if err != nil { + return nil, err + } + npc := npcs[targets[0]] + if s.design.CharmCharacter == nil || len(rule.Values) < 2 { + return nil, fmt.Errorf("player: charm character design unavailable") + } + var chars []Character + expiry := uint64(s.now().UnixMilli() + int64(rule.Values[1])*1000) + for _, id := range npc.CharmCharacters { + d, e := s.design.CharmCharacter(id) + if e != nil { + return nil, e + } + if d.CharacterID == 0 || d.CostumeID == 0 || d.HP == 0 { + return nil, fmt.Errorf("player: invalid charm companion design") + } + if pack <= 0 || pack >= 65536 || d.CharacterID >= 1<<32 { + return nil, fmt.Errorf("player: charm instance namespace overflow") + } + index := CharmCharacterIndexBase | uint64(pack)<<32 | d.CharacterID + chars = append(chars, Character{InvenIndex: index, ID: d.CharacterID, Level: d.Level, HP: d.HP, CostumeID: d.CostumeID, TalentLevel: 1, ExpiryTime: expiry}) + } + if err = s.characters.ensureCharmCharacters(ctx, chars); err != nil { + return nil, err + } + var out []byte + for _, c := range chars { + out = wire.AppendBytes(out, 5, CharacterWire(c)) + } + return out, nil +} + +func OpenFoodService(ctx command.Context, store stateio.Store, design *gamedata.FoodDesign, inventory *assets.Inventory, characters *CharacterStore) (*FoodService, error) { + entries, ok := store.(stateio.ScopedEntryStore) + if !ok || design == nil || inventory == nil || characters == nil { + return nil, errors.New("player: incomplete food configuration") + } + s := &FoodService{store: entries, design: design, inventory: inventory, characters: characters} + health, err := entries.ListEntries(ctx.State, "characters", "current_hp") + if err != nil { + return nil, err + } + for key, raw := range health { + index, err := strconv.ParseUint(key, 10, 64) + var hp uint64 + if err != nil || index == 0 || json.Unmarshal(raw, &hp) != nil { + return nil, errors.New("player: malformed saved current health") + } + if _, err := characters.MaxHealth(ctx, index); err != nil { + return nil, err + } + } + replies, err := entries.ListEntries(ctx.State, "characters", "food_requests") + if err != nil { + return nil, err + } + for key, raw := range replies { + var reply foodReply + decodedKey, keyErr := hex.DecodeString(key) + if keyErr != nil || len(decodedKey) != sha256.Size || json.Unmarshal(raw, &reply) != nil || len(reply.Body) == 0 { + return nil, errors.New("player: malformed saved food replay") + } + digest, err := hex.DecodeString(reply.Digest) + if err != nil || len(digest) != sha256.Size { + return nil, errors.New("player: malformed saved food replay digest") + } + if err := wire.Walk(reply.Body, func(field wire.Field) error { return nil }); err != nil { + return nil, errors.New("player: malformed saved food replay body") + } + } + return s, nil +} + +func (s *FoodService) eatFoodAuto(ctx command.Context, request []byte) (int, []byte, bool, error) { + var seq uint64 + var targets []struct { + index uint64 + items []assets.Item + } + seenSequence := false + err := wire.Walk(request, func(field wire.Field) error { + switch field.Number { + case 1: + if field.Type != 0 || seenSequence { + return errors.New("player: invalid EatFoodAuto sequence") + } + seenSequence = true + seq, _ = binary.Uvarint(field.Value) + case 2: + if field.Type != 2 { + return errors.New("player: invalid EatFoodAuto target") + } + var index uint64 + seenIndex := false + if err := wire.Walk(field.Value, func(inner wire.Field) error { + if inner.Number == 2 { + return nil + } + if inner.Number != 1 || inner.Type != 0 || seenIndex { + return errors.New("player: invalid EatFoodAuto character") + } + seenIndex = true + index, _ = binary.Uvarint(inner.Value) + return nil + }); err != nil { + return err + } + if index == 0 || index > math.MaxInt64 { + return errors.New("player: invalid EatFoodAuto character index") + } + items, err := assets.DecodeItemRequest(field.Value, 2, "EatFoodAuto") + if err != nil { + return err + } + if len(items) == 0 { + return errors.New("player: EatFoodAuto character requires food stacks") + } + targets = append(targets, struct { + index uint64 + items []assets.Item + }{index, items}) + default: + return errors.New("player: unknown EatFoodAuto field") + } + return nil + }) + if err != nil || seq == 0 || seq > math.MaxInt32 || len(targets) == 0 { + return 0, nil, true, errors.New("player: invalid EatFoodAuto request") + } + + if ctx.SessionID == "" || s.currentPack == nil || s.battleActive == nil { + return 0, nil, true, errors.New("player: EatFoodAuto context unavailable") + } + keyDigest := sha256.Sum256([]byte(ctx.SessionID + ":EatFoodAuto:" + strconv.FormatUint(seq, 10))) + key := hex.EncodeToString(keyDigest[:]) + digest := sha256.Sum256(request) + digestString := hex.EncodeToString(digest[:]) + prior, found, err := s.store.LoadEntry(ctx.State, "characters", "food_requests", key) + if err != nil { + return 0, nil, true, err + } + if found { + var reply foodReply + if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString { + return 0, nil, true, errors.New("player: EatFoodAuto sequence reused with different request") + } + return 27, append([]byte(nil), reply.Body...), true, nil + } + pack, err := s.currentPack(ctx) + if err != nil || pack <= 0 || s.battleActive(ctx) { + return 0, nil, true, errors.New("player: EatFoodAuto unavailable during battle or outside pack") + } + seenCharacters := map[uint64]bool{} + var items []assets.Item + var body []byte + var changes []stateio.EntryMutation + for _, target := range targets { + if seenCharacters[target.index] { + return 0, nil, true, errors.New("player: duplicate EatFoodAuto character") + } + seenCharacters[target.index] = true + character, err := s.recoverCharacter(ctx, target.index, target.items) + if err != nil { + return 0, nil, true, err + } + items = append(items, target.items...) + raw, _ := json.Marshal(character.HP) + changes = append(changes, stateio.EntryMutation{Bucket: "current_hp", Key: strconv.FormatUint(target.index, 10), Payload: raw}) + info := wire.AppendVarint(nil, 1, target.index) + info = wire.AppendVarint(info, 2, character.HP) + body = wire.AppendBytes(body, 1, info) + } + // CanConsume accounts for a shared stack requested by several characters. + if err := s.inventory.CanConsume(ctx, items); err != nil { + return 0, nil, true, err + } + if err := s.inventory.Consume(ctx, items); err != nil { + return 0, nil, true, err + } + replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body}) + changes = append(changes, stateio.EntryMutation{Bucket: "food_requests", Key: key, Payload: replyRaw}) + if err := s.store.SaveWithEntries(ctx.State, "characters", nil, changes); err != nil { + return 0, nil, true, err + } + return 27, body, true, nil +} + +// AutoRecover selects a living permanent fatigue-recovery caster. The selected +// caster is preferred; another owned caster can take over when it is fatigued. +func (s *TalentUseService) AutoRecover(ctx command.Context, seq, caster uint64, targets []uint64) (AutoRecoveryResult, error) { + r := AutoRecoveryResult{Caster: caster, Catalyst: s.wallet.CatalystBalance(ctx)} + if s.context == nil { + return r, fmt.Errorf("player: automatic recovery context unavailable") + } + pack, _, battle, err := s.context(ctx) + if err != nil { + return r, err + } + if battle { + return r, fmt.Errorf("player: automatic recovery during battle") + } + all := s.characters.RawAll() + sort.SliceStable(all, func(i, j int) bool { + left, right := all[i].InvenIndex == caster, all[j].InvenIndex == caster + if left != right { + return left + } + return all[i].InvenIndex < all[j].InvenIndex + }) + var selected Character + var cost uint64 + for _, c := range all { + if IsStoryCharacter(c) || IsCharmCharacter(c) { + continue + } + meta, ok := s.design.Characters[c.ID] + if !ok || meta.BannedPacks[pack] { + continue + } + rule, ok := s.design.Rules[[2]uint64{meta.Group, c.TalentLevel}] + if !ok || rule.Class != 10 { + continue + } + hp, e := s.characters.CurrentHealth(ctx, c.InvenIndex) + if e != nil { + return r, e + } + if hp == 0 { + continue + } + selected = c + if len(targets) > 0 && rule.Catalyst > math.MaxUint64/uint64(len(targets)) { + return r, fmt.Errorf("player: automatic recovery cost overflow") + } + cost = rule.Catalyst * uint64(len(targets)) + break + } + if selected.InvenIndex == 0 { + r.Disabled = 1 + return r, nil + } + r.Caster = selected.InvenIndex + if len(targets) == 0 { + return r, nil + } + if cost > r.Catalyst { + r.Disabled = 2 + return r, nil + } + request := wire.AppendVarint(nil, 1, seq) + request = wire.AppendVarint(request, 2, r.Caster) + for _, id := range targets { + request = wire.AppendVarint(request, 3, id) + } + _, body, _, err := s.healing(ctx, request, "auto-recovery") + if err != nil { + return r, err + } + r.Experience, _, err = wire.Varint(body, 2) + if err != nil { + return r, err + } + r.Catalyst = s.wallet.CatalystBalance(ctx) + for _, id := range targets { + c, ok := s.characters.Find(ctx, id) + if !ok { + return r, fmt.Errorf("player: recovered character unavailable") + } + hp, e := s.characters.CurrentHealth(ctx, id) + if e != nil { + return r, e + } + c.HP = hp + r.Characters = append(r.Characters, c) + } + return r, nil +} diff --git a/go/internal/server/domain/roster/recruit.go b/go/internal/server/domain/roster/recruit.go new file mode 100644 index 0000000..2cd44fd --- /dev/null +++ b/go/internal/server/domain/roster/recruit.go @@ -0,0 +1,109 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "crypto/rand" + "errors" + "fmt" + "math" + "math/big" + "slices" + "strconv" + + "time" +) + +// RecruitNPCResolver must authorize the current pack/map/quest NPC and return +// its Scout interaction value. A client NPC ID alone grants no authority. +type RecruitNPCResolver func(command.Context, uint64) (uint64, error) + +// RecruitService participates in the enclosing account transaction, which +// atomically commits inventory costs, collection copies and completion/replay. +type RecruitService struct { + design *gamedata.RecruitDesign + catalog CostumeDesignSource + collection *CollectionStore + inventory *assets.Inventory + wallet *assets.Wallet + resolver RecruitNPCResolver + + now func() time.Time +} + +func NewRecruitService(design *gamedata.RecruitDesign, catalog CostumeDesignSource, collection *CollectionStore, inventory *assets.Inventory, wallet *assets.Wallet, resolver RecruitNPCResolver) (*RecruitService, error) { + if design == nil || catalog == nil || collection == nil || inventory == nil || wallet == nil || resolver == nil { + return nil, errors.New("player: incomplete recruitment service") + } + for _, r := range design.Rules { + if _, ok := catalog.Character(r.CostumeID); !ok { + return nil, fmt.Errorf("player: recruit costume %d has no design", r.CostumeID) + } + } + return &RecruitService{design: design, catalog: catalog, collection: collection, inventory: inventory, wallet: wallet, resolver: resolver, now: time.Now}, nil +} + +func recruitIdentity(id uint64) string { return "recruit:" + strconv.FormatUint(id, 10) } + +func recruitCode(path string) int { + if path == "/CharSpecialScoutBuy" { + return 149 + } + if path == "/CharSpecialScoutReset" { + return 150 + } + return 13 +} + +type specialRecruitState struct { + IDs []uint64 + Count, Next uint64 + Day string +} + +func (s *CollectionStore) saveRecruitState(ctx command.Context, body []byte) error { + + next := cloneCollection(s.data) + next.Grants["special-recruit-state"] = CollectionGrant{GachaResponse: append([]byte(nil), body...)} + return s.commit(ctx, next) +} + +// The local rotation policy uses GameData weights without replacement. Its +// distribution is a server policy; official initial/random state is unknown. +func (s *RecruitService) rollSpecial(count uint64) (specialRecruitState, error) { + state := specialRecruitState{Day: s.now().UTC().Format("2006-01-02"), Count: count, Next: uint64(s.now().Add(time.Duration(s.design.AutoResetMinute) * time.Minute).UnixMilli())} + var pool []uint64 + for id, r := range s.design.Rules { + if r.Type == 1 && r.AppearProb > 0 { + if _, done := s.collection.Grant(recruitIdentity(id)); !done { + pool = append(pool, id) + } + } + } + slices.Sort(pool) + for len(state.IDs) < int(s.design.AppearCount) && len(pool) > 0 { + var total uint64 + for _, id := range pool { + if total > math.MaxUint64-s.design.Rules[id].AppearProb { + return state, errors.New("player: scout appearance weight overflow") + } + total += s.design.Rules[id].AppearProb + } + draw, e := rand.Int(rand.Reader, new(big.Int).SetUint64(total)) + if e != nil { + return state, e + } + n := draw.Uint64() + for i, id := range pool { + w := s.design.Rules[id].AppearProb + if n < w { + state.IDs = append(state.IDs, id) + pool = append(pool[:i], pool[i+1:]...) + break + } + n -= w + } + } + return state, nil +} diff --git a/go/internal/server/domain/roster/starter.go b/go/internal/server/domain/roster/starter.go new file mode 100644 index 0000000..033964f --- /dev/null +++ b/go/internal/server/domain/roster/starter.go @@ -0,0 +1,112 @@ +// Package player owns the initial, mutable inventory/character snapshot used +// by the local account. GameData defines what each ID means; this file holds +// only the new player's ownership and progress. +package roster + +import ( + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/platform/versionconfig" + "encoding/json" + "errors" + "fmt" + "os" +) + +type Costume struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level,omitempty"` + UseChar uint64 `json:"use_char,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + PotentialIDs []uint64 `json:"-"` + DesignID uint64 `json:"design_id,omitempty"` + BurstLevel uint64 `json:"burst_level,omitempty"` + TimeValue uint64 `json:"time_value,omitempty"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` +} + +type Pictorial struct { + ID uint64 `json:"id"` + GroupID uint64 `json:"group_id"` +} + +type Character struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + HP uint64 `json:"hp,omitempty"` + Level uint64 `json:"level,omitempty"` + CostumeID uint64 `json:"costume_id,omitempty"` + Exp uint64 `json:"exp,omitempty"` + UseCostume uint64 `json:"use_costume,omitempty"` + TalentLevel uint64 `json:"talent_level,omitempty"` + TalentExp uint64 `json:"talent_exp,omitempty"` + SolidarityReward uint64 `json:"solidarity_reward,omitempty"` + ExpiryTime uint64 `json:"expiry_time,omitempty"` + ConnectPotentialCostume uint64 `json:"connect_potential_costume,omitempty"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` +} + +type Starter struct { + Version string `json:"version"` + CookingRecipes []uint64 `json:"cooking_recipes,omitempty"` + Items []assets.Item `json:"items"` + Costumes []Costume `json:"costumes"` + Characters []Character `json:"characters"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` + FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` +} + +func Load(path string) (*Starter, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("player: read starter: %w", err) + } + var starter Starter + if err := json.Unmarshal(data, &starter); err != nil { + return nil, fmt.Errorf("player: decode starter: %w", err) + } + if err := starter.Validate(); err != nil { + return nil, err + } + return &starter, nil +} + +func (s *Starter) Validate() error { + if s == nil || s.Version != versionconfig.State() { + return errors.New("player: wrong starter version") + } + seenRecipes := map[uint64]bool{} + for _, id := range s.CookingRecipes { + if id == 0 || id > 0x7fffffff || seenRecipes[id] { + return errors.New("player: invalid initial cooking recipe") + } + seenRecipes[id] = true + } + for _, item := range s.Items { + if item.ID == 0 || item.Count == 0 { + return errors.New("player: invalid item") + } + } + for _, costume := range s.Costumes { + if costume.ID == 0 || costume.InvenIndex == 0 { + return errors.New("player: invalid costume") + } + } + for _, character := range s.Characters { + if character.ID == 0 || character.InvenIndex == 0 { + return errors.New("player: invalid character") + } + } + return nil +} + +func (s *Starter) Write(path string) error { + if err := s.Validate(); err != nil { + return err + } + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} diff --git a/go/internal/server/player/story_characters.go b/go/internal/server/domain/roster/story_characters.go similarity index 71% rename from go/internal/server/player/story_characters.go rename to go/internal/server/domain/roster/story_characters.go index b8eba35..76fd916 100644 --- a/go/internal/server/player/story_characters.go +++ b/go/internal/server/domain/roster/story_characters.go @@ -1,18 +1,20 @@ -package player +package roster -import "fmt" +import ( + "bd2server/internal/server/domain/command" + "fmt" + // StoryCharacterIndexBase reserves local instances for temporary quest parties. + // These are not collection rewards and never receive a collection grant. +) -// StoryCharacterIndexBase reserves local instances for temporary quest parties. -// These are not collection rewards and never receive a collection grant. const StoryCharacterIndexBase uint64 = 1 << 60 func IsStoryCharacter(c Character) bool { return c.InvenIndex >= StoryCharacterIndexBase } // EnsureStoryCharacters saves new authored temporary characters atomically. // Existing instances retain their completed-battle health across reconnects. -func (s *CharacterStore) EnsureStoryCharacters(characters []Character) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *CharacterStore) EnsureStoryCharacters(ctx command.Context, characters []Character) error { + next := append([]Character(nil), s.characters...) seen := map[uint64]Character{} for _, c := range next { @@ -31,7 +33,7 @@ func (s *CharacterStore) EnsureStoryCharacters(characters []Character) error { next = append(next, c) seen[c.InvenIndex] = c } - if err := s.persist(next); err != nil { + if err := s.persist(ctx, next); err != nil { return err } s.characters = next diff --git a/go/internal/server/domain/roster/talent_charm.go b/go/internal/server/domain/roster/talent_charm.go new file mode 100644 index 0000000..1b160ee --- /dev/null +++ b/go/internal/server/domain/roster/talent_charm.go @@ -0,0 +1,56 @@ +package roster + +import ( + "bd2server/internal/server/domain/command" + "encoding/json" + "fmt" + "strconv" + "time" +) + +const CharmCharacterIndexBase uint64 = 1 << 59 + +func IsCharmCharacter(c Character) bool { + return c.InvenIndex >= CharmCharacterIndexBase && c.InvenIndex < StoryCharacterIndexBase +} +func CharacterExpired(c Character, now time.Time) bool { + return c.ExpiryTime > 0 && c.ExpiryTime <= uint64(now.UnixMilli()) +} + +func (s *CharacterStore) ensureCharmCharacters(ctx command.Context, chars []Character) error { + + next := append([]Character(nil), s.characters...) + for _, c := range chars { + if !IsCharmCharacter(c) || c.ExpiryTime == 0 { + return fmt.Errorf("player: invalid charm instance") + } + found := false + for i, old := range next { + if old.InvenIndex == c.InvenIndex { + if old.ID != c.ID { + return fmt.Errorf("player: charm instance collision") + } + next[i] = c + found = true + break + } + } + if !found { + next = append(next, c) + } + } + if err := s.persist(ctx, next); err != nil { + return err + } + s.characters = next + for _, c := range chars { + raw, err := json.Marshal(c.HP) + if err != nil { + return err + } + if err = s.store.PutEntry(ctx.State, "characters", "current_hp", strconv.FormatUint(c.InvenIndex, 10), raw); err != nil { + return err + } + } + return nil +} diff --git a/go/internal/server/domain/roster/talent_dispatch.go b/go/internal/server/domain/roster/talent_dispatch.go new file mode 100644 index 0000000..e4147ef --- /dev/null +++ b/go/internal/server/domain/roster/talent_dispatch.go @@ -0,0 +1,90 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + + "time" +) + +type talentDispatchRow struct { + ID uint64 `json:"id"` + Start int64 `json:"start"` + End int64 `json:"end"` + Identity string `json:"identity"` + Rewards []gamedata.Reward `json:"rewards"` + Claimed bool `json:"claimed"` + Bundle []byte `json:"bundle"` +} +type talentDispatchState struct { + Claims map[string]talentDispatchClaim `json:"claims"` + Rows map[uint64]talentDispatchRow `json:"rows"` + Starts map[string][]byte `json:"starts"` +} +type talentDispatchClaim struct { + Digest string `json:"digest"` + Body []byte `json:"body"` +} + +type TalentDispatchService struct { + store stateio.Store + design map[uint64]gamedata.TalentDispatchDesign + now func() time.Time + draw func(uint64) (uint64, error) + economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) + } +} + +func OpenTalentDispatch(store stateio.Store, d map[uint64]gamedata.TalentDispatchDesign, economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +}) (*TalentDispatchService, error) { + if store == nil || len(d) == 0 || economy == nil { + return nil, fmt.Errorf("dispatch: invalid configuration") + } + return &TalentDispatchService{store: store, design: d, economy: economy, now: time.Now, draw: func(n uint64) (uint64, error) { + if n == 0 { + return 0, fmt.Errorf("dispatch: empty pool") + } + v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) + if e != nil { + return 0, e + } + return v.Uint64(), nil + }}, nil +} +func (s *TalentDispatchService) load(ctx command.Context) (talentDispatchState, error) { + st := talentDispatchState{Rows: map[uint64]talentDispatchRow{}, Starts: map[string][]byte{}, Claims: map[string]talentDispatchClaim{}} + b, e := s.store.Load(ctx.State, "talent_dispatch") + if e != nil || b == nil { + return st, e + } + if e = stateio.RequireExactJSONObject(b, "rows", "starts", "claims"); e != nil { + return st, e + } + if e = json.Unmarshal(b, &st); e != nil { + return st, e + } + if st.Rows == nil || st.Starts == nil || st.Claims == nil { + return st, fmt.Errorf("dispatch: invalid state") + } + for id, r := range st.Rows { + if _, ok := s.design[id]; !ok || r.ID != id || r.Start <= 0 || r.End <= r.Start || r.Identity == "" { + return st, fmt.Errorf("dispatch: invalid row") + } + } + return st, nil +} + +func (s *TalentDispatchService) save(ctx command.Context, st talentDispatchState) error { + b, e := json.Marshal(st) + if e != nil { + return e + } + return s.store.Save(ctx.State, "talent_dispatch", b) +} diff --git a/go/internal/server/domain/roster/talent_upgrade.go b/go/internal/server/domain/roster/talent_upgrade.go new file mode 100644 index 0000000..ac18675 --- /dev/null +++ b/go/internal/server/domain/roster/talent_upgrade.go @@ -0,0 +1,53 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + + assets "bd2server/internal/server/domain/inventory" + "errors" + "fmt" + "math" +) + +const talentSkillUpgradePacketCode = 44 + +func validateTalentUpgradeMaterials(costs []gamedata.PromotionCost, materials []assets.Item) ([]assets.Item, uint64, error) { + want := make(map[[2]uint64]uint64, len(costs)) + for _, cost := range costs { + if cost.Type == 0 || cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type != 4 && cost.ID == 0) { + return nil, 0, errors.New("player: invalid GameData talent upgrade cost") + } + key := [2]uint64{cost.Type, cost.ID} + if want[key] > math.MaxUint64-cost.Count { + return nil, 0, errors.New("player: talent upgrade GameData cost overflow") + } + want[key] += cost.Count + } + got := make(map[[2]uint64]uint64, len(materials)) + var items []assets.Item + var gold uint64 + for _, material := range materials { + key := [2]uint64{material.Type, material.ID} + if got[key] > math.MaxUint64-material.Count { + return nil, 0, errors.New("player: talent upgrade material overflow") + } + got[key] += material.Count + if material.Type == 4 { + if gold != 0 || material.InvenIndex != 0 || material.ID != 0 { + return nil, 0, errors.New("player: invalid talent upgrade currency") + } + gold = material.Count + } else { + items = append(items, material) + } + } + if len(got) != len(want) { + return nil, 0, errors.New("player: talent upgrade material kinds mismatch") + } + for key, count := range want { + if got[key] != count { + return nil, 0, fmt.Errorf("player: talent upgrade material %d/%d=%d want=%d", key[0], key[1], got[key], count) + } + } + return items, gold, nil +} diff --git a/go/internal/server/domain/roster/talent_use.go b/go/internal/server/domain/roster/talent_use.go new file mode 100644 index 0000000..b0a5ae1 --- /dev/null +++ b/go/internal/server/domain/roster/talent_use.go @@ -0,0 +1,166 @@ +package roster + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/json" + "fmt" + + "time" +) + +type TalentUseContext func(command.Context) (pack int, mapID uint64, battle bool, err error) +type TalentUseEffect func(ctx command.Context, identity string, character Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) +type TalentUseEconomy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} +type talentUseState struct { + End int64 + Count uint64 + Day string + Level uint64 + Settled bool +} +type talentUseReceipt struct { + Digest string + Body []byte +} +type talentUseSnapshot struct { + Skills map[uint64]talentUseState + NPCs map[string]int64 + Receipts map[string]talentUseReceipt + Discounts map[string]uint64 +} +type TalentUseService struct { + design *gamedata.TalentUseDesign + store stateio.Store + characters *CharacterStore + inventory *assets.Inventory + wallet *assets.Wallet + economy TalentUseEconomy + context TalentUseContext + effects map[uint64]TalentUseEffect + + now func() time.Time +} + +func NewTalentUseService(d *gamedata.TalentUseDesign, store stateio.Store, characters *CharacterStore, inventory *assets.Inventory, wallet *assets.Wallet, economy TalentUseEconomy) (*TalentUseService, error) { + if d == nil || store == nil || characters == nil || inventory == nil || wallet == nil || economy == nil { + return nil, fmt.Errorf("player: missing talent use dependencies") + } + return &TalentUseService{design: d, store: store, characters: characters, inventory: inventory, wallet: wallet, economy: economy, effects: map[uint64]TalentUseEffect{}, now: time.Now}, nil +} +func (s *TalentUseService) AttachContext(ctx command.Context, f TalentUseContext) { s.context = f } +func (s *TalentUseService) AttachEffect(class uint64, f TalentUseEffect) { s.effects[class] = f } + +func (s *TalentUseService) load(ctx command.Context) (talentUseSnapshot, error) { + v := talentUseSnapshot{Skills: map[uint64]talentUseState{}, NPCs: map[string]int64{}, Receipts: map[string]talentUseReceipt{}, Discounts: map[string]uint64{}} + b, e := s.store.Load(ctx.State, "talentuse") + if e != nil { + return v, e + } + if b != nil { + if e = stateio.RequireExactJSONObject(b, "Skills", "NPCs", "Receipts", "Discounts"); e != nil { + return v, e + } + if e = json.Unmarshal(b, &v); e != nil || v.Skills == nil || v.NPCs == nil || v.Receipts == nil || v.Discounts == nil { + return v, fmt.Errorf("player: invalid talent use state") + } + } + return v, nil +} + +func sha256Sum(b []byte) []byte { v := sha256.Sum256(b); return v[:] } +func talentNPCClass(class uint64) bool { return class == 1 || class == 5 || class == 16 || class == 19 } +func talentHasCooldown(class uint64) bool { + return class == 2 || class == 6 || class == 12 || class == 13 || class == 15 || class == 17 +} + +func (s *TalentUseService) experienceMaximum(c Character) (uint64, error) { + if s.design.Growth == nil { + return 0, fmt.Errorf("player: talent growth design missing") + } + meta, ok := s.design.Growth.Characters[c.ID] + if !ok { + return 0, fmt.Errorf("player: missing character growth") + } + var total uint64 + for level := uint64(1); level <= c.TalentLevel; level++ { + v, ok := s.design.Growth.Levels[[2]uint64{meta.GrowthGroup, level}] + if !ok { + return 0, fmt.Errorf("player: missing talent growth level") + } + total += v.NeedExp + } + return total, nil +} + +func (s *TalentUseService) nextNPCReset(reset uint64) time.Time { + shift := 9*time.Hour - s.design.ResetSchedule.DailyReset + clock := s.now().UTC().Add(shift) + next := time.Date(clock.Year(), clock.Month(), clock.Day()+1, 0, 0, 0, 0, time.UTC) + if reset == 2 { + for next.Weekday() != s.design.ResetSchedule.WeeklyDay { + next = next.AddDate(0, 0, 1) + } + } + return next.Add(-shift) +} + +func (s *TalentUseService) ShopDiscount(ctx command.Context, pack, npc uint64) (uint64, error) { + + v, e := s.load(ctx) + if e != nil { + return 0, e + } + return v.Discounts[ctx.SessionID+":"+fmt.Sprintf("%d/%d", pack, npc)], nil +} + +// ConsumeOverwhelm authorizes the follow-up result for one successful cast. +// The one-minute handoff window is this server's request policy, not a claimed +// official skill duration; the field domain validates every actual monster. +func (s *TalentUseService) ConsumeOverwhelm(ctx command.Context, identity string, count uint64) error { + + if identity == "" || count == 0 { + return fmt.Errorf("player: invalid overwhelm settlement") + } + v, err := s.load(ctx) + if err != nil { + return err + } + receiptKey := "overwhelm:" + identity + digest := fmt.Sprint(count) + if prior, ok := v.Receipts[receiptKey]; ok { + if prior.Digest != digest { + return fmt.Errorf("player: changed overwhelm settlement") + } + return nil + } + var chosen uint64 + var latest int64 + for group, state := range v.Skills { + rule, ok := s.design.Rules[[2]uint64{group, state.Level}] + if !ok || rule.Class != 3 || state.Settled || state.Count == 0 { + continue + } + if state.End <= s.now().UnixMilli() && s.now().UnixMilli()-state.End <= 60000 && state.End > latest { + chosen = group + latest = state.End + } + } + if chosen == 0 { + return fmt.Errorf("player: overwhelm result has no pending successful cast") + } + state := v.Skills[chosen] + state.Settled = true + v.Skills[chosen] = state + v.Receipts[receiptKey] = talentUseReceipt{Digest: digest} + b, err := json.Marshal(v) + if err != nil { + return err + } + return s.store.Save(ctx.State, "talentuse", b) +} diff --git a/go/internal/server/domain/world/achievement_gameplay.go b/go/internal/server/domain/world/achievement_gameplay.go new file mode 100644 index 0000000..e0d3987 --- /dev/null +++ b/go/internal/server/domain/world/achievement_gameplay.go @@ -0,0 +1,124 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/progression/achievements" + "fmt" + "sort" + "strings" +) + +// achievements.GameplayAchievementProvider exposes only authoritative owned instances and +// persisted quest/object state. It does not manufacture past acquisition counts. +func (s *Service) GameplayAchievementProvider(ctx command.Context, design *gamedata.AchievementCounterDesign, grades gamedata.GameplayAchievementGrades) *achievements.OwnedGameplayAchievementProvider { + p := &achievements.OwnedGameplayAchievementProvider{Design: design, CharacterGrades: grades.Characters, EquipmentGrades: grades.Equipment} + observedPeriods := map[[2]int]gamedata.FieldRewardObject{} + p.TransientVersion = func() string { + var signature strings.Builder + fmt.Fprintf(&signature, "%d/%d", len(s.packs), len(s.fieldPacks)) + keys := make([][2]int, 0, len(observedPeriods)) + for key := range observedPeriods { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i][0] != keys[j][0] { + return keys[i][0] < keys[j][0] + } + return keys[i][1] < keys[j][1] + }) + for _, key := range keys { + period, err := s.fieldObjectPeriodFor(key[0], observedPeriods[key]) + fmt.Fprintf(&signature, "/%d:%d:%s:%v", key[0], key[1], period, err) + } + return signature.String() + } + if s.characters != nil { + p.Characters = s.characters + } + if s.collection != nil { + p.Costumes = s.collection + p.Gacha = s.collection + } + if s.equipment != nil { + p.Equipment = s.equipment + } + if s.inventory != nil { + p.Items = s.inventory + } + p.Conditions = func(ctx command.Context) ([]achievements.GameplayAchievementCondition, error) { + var conditions []achievements.GameplayAchievementCondition + seen := map[[2]uint64]bool{} + if s.storyCatalog == nil { + return nil, nil + } + for _, c := range design.Conditions { + if c.Type < 14 || c.Type > 16 { + continue + } + key := [2]uint64{c.Type, c.SubType} + if seen[key] { + continue + } + seen[key] = true + pack, known := s.storyCatalog.Packs[int(c.SubType)] + if !known || len(pack.MainQuestIDs) == 0 { + continue + } + complete := true + for _, qid := range pack.MainQuestIDs { + if !s.state.QuestCleared(qid, pack.ID, int(c.Type-14)) { + complete = false + break + } + } + if complete { + conditions = append(conditions, achievements.GameplayAchievementCondition{Type: c.Type, SubType: c.SubType, Value: 1}) + } + } + return conditions, nil + } + p.FieldObjects = func(ctx command.Context) (map[string]gamedata.FieldRewardObject, error) { + observedPeriods = map[[2]int]gamedata.FieldRewardObject{} + objects := map[string]gamedata.FieldRewardObject{} + openedPeriods, err := s.state.OpenedFieldRewardPeriods(ctx) + if err != nil { + return nil, err + } + // Opened IDs are persisted independently of quest difficulty. Only loaded + // packs with real opened entries require their reward design to be resolved. + packs := map[int]bool{} + for id := range s.packs { + packs[id] = true + } + for id := range s.fieldPacks { + packs[id] = true + } + for pack := range packs { + periods := openedPeriods[pack] + if len(periods) == 0 { + continue + } + d, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return nil, err + } + for id, openedPeriod := range periods { + obj, known := d.Objects[id] + if !known { + return nil, fmt.Errorf("achievement: opened field design absent") + } + observedPeriods[[2]int{pack, obj.ResetType}] = obj + period, err := s.fieldObjectPeriodFor(pack, obj) + if err != nil { + continue + } + if openedPeriod == period { + objects[fmt.Sprintf("field:%d:%d:%s", pack, id, period)] = obj + } + } + } + return objects, nil + } + return p +} diff --git a/go/internal/server/domain/world/auto_recovery.go b/go/internal/server/domain/world/auto_recovery.go new file mode 100644 index 0000000..569d348 --- /dev/null +++ b/go/internal/server/domain/world/auto_recovery.go @@ -0,0 +1,22 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "fmt" +) + +func (s *Service) AttachAutoRecoveryPolicy(policy *gamedata.PackRecoveryPolicy) error { + s.autoRecoveryPolicy = policy + return nil +} +func (s *Service) AutoRecoveryAllowed(ctx command.Context) (bool, error) { + if s.autoRecoveryPolicy == nil { + return false, fmt.Errorf("world: automatic recovery pack policy unavailable") + } + p, e := s.CurrentPackID(ctx) + if e != nil { + return false, e + } + return s.autoRecoveryPolicy.Allowed(p, s.packCompleteFor(p)), nil +} diff --git a/go/internal/server/world/cash_packages.go b/go/internal/server/domain/world/cash_packages.go similarity index 100% rename from go/internal/server/world/cash_packages.go rename to go/internal/server/domain/world/cash_packages.go diff --git a/go/internal/server/domain/world/event_field_pack.go b/go/internal/server/domain/world/event_field_pack.go new file mode 100644 index 0000000..541713d --- /dev/null +++ b/go/internal/server/domain/world/event_field_pack.go @@ -0,0 +1,46 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "fmt" +) + +// EventFieldPackSource binds installed hidden packs to the server's current +// calendars. A purchase receipt alone never authorizes an expired event. +type EventFieldPackSource interface { + ResolveEventFieldPack(ctx command.Context, _ int) (gamedata.EventFieldPack, bool, error) + ListEventFieldPacks(ctx command.Context) ([]gamedata.EventFieldPack, error) +} + +// PackAvailable exposes the same account/calendar authorization to NPC +// services without letting them infer access from the client's pack number. +func (s *Service) PackAvailable(ctx command.Context, id uint64) bool { + if id == 0 || id > uint64(^uint32(0)>>1) { + return false + } + return s.packUnlocked(ctx, int(id)) +} + +func (s *Service) AttachEventFieldPacks(source EventFieldPackSource) error { + if source == nil { + return fmt.Errorf("world: nil event field pack source") + } + s.eventFieldPacks = source + return nil +} + +func (s *Service) resolveEventFieldPack(ctx command.Context, id int) (gamedata.EventFieldPack, bool, error) { + if s.eventFieldPacks == nil { + return gamedata.EventFieldPack{}, false, nil + } + return s.eventFieldPacks.ResolveEventFieldPack(ctx, id) +} + +func (s *Service) eventPackPurchased(id int) bool { + if s.collection == nil { + return false + } + _, found := s.collection.Grant(packPurchaseIdentity(id)) + return found +} diff --git a/go/internal/server/world/field_buff_damage.go b/go/internal/server/domain/world/field_buff_damage.go similarity index 65% rename from go/internal/server/world/field_buff_damage.go rename to go/internal/server/domain/world/field_buff_damage.go index 5b83c35..993a1ba 100644 --- a/go/internal/server/world/field_buff_damage.go +++ b/go/internal/server/domain/world/field_buff_damage.go @@ -1,22 +1,19 @@ package world import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/roster" "fmt" "math" ) -func (s *Service) AttachFieldBuffRuntime(root, version string) error { - d, e := gamedata.LoadFieldBuffDesign(root, version) - if e != nil { - return e - } - s.fieldBuffs = d +func (s *Service) AttachFieldBuffRuntime(design map[uint64]gamedata.FieldBuffDesign) error { + s.fieldBuffs = design s.AttachFieldMonsterDamage(s.applyMonsterFieldDamage) return nil } -func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string) ([][]byte, error) { +func (s *Service) applyMonsterFieldDamage(ctx command.Context, pack int, id uint64, identity string) ([][]byte, error) { r, ok := s.fieldBuffs[id] if !ok || (r.Type != 4 && r.Type != 5) || r.TargetType > 2 || math.IsNaN(r.Value) || math.IsInf(r.Value, 0) || r.Value < 0 { return nil, fmt.Errorf("world: invalid damaging field buff") @@ -24,17 +21,17 @@ func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string) if s.characters == nil || s.decks == nil { return nil, fmt.Errorf("world: field health runtime unavailable") } - indices, e := s.fieldBuffTargets(pack, r.TargetType) + indices, e := s.fieldBuffTargets(ctx, pack, r.TargetType) if e != nil { return nil, e } var response [][]byte for _, index := range indices { - c, found := s.characters.Find(index) + c, found := s.characters.Find(ctx, index) if !found { return nil, fmt.Errorf("world: field character missing") } - hp, e := s.characters.CurrentHealth(c.InvenIndex) + hp, e := s.characters.CurrentHealth(ctx, c.InvenIndex) if e != nil { return nil, e } @@ -43,7 +40,7 @@ func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string) } damage := r.Value if r.Type == 5 { - max, e := s.characters.MaxHealth(c.InvenIndex) + max, e := s.characters.MaxHealth(ctx, c.InvenIndex) if e != nil { return nil, e } @@ -53,11 +50,11 @@ func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string) if damage < float64(hp) { remaining = hp - uint64(damage) } - if e = s.characters.SetCurrentHealth(c.InvenIndex, remaining); e != nil { + if e = s.characters.SetCurrentHealth(ctx, c.InvenIndex, remaining); e != nil { return nil, e } c.HP = remaining - response = append(response, player.CharacterWire(c)) + response = append(response, roster.CharacterWire(c)) if r.TargetType == 0 { break } @@ -65,14 +62,14 @@ func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string) return response, nil } -func (s *Service) fieldBuffTargets(pack int, target uint64) ([]uint64, error) { +func (s *Service) fieldBuffTargets(ctx command.Context, pack int, target uint64) ([]uint64, error) { if target > 2 || s.decks == nil { return nil, fmt.Errorf("world: invalid field buff target") } var indices []uint64 - if s.questDifficulty(pack) == 0 && s.storyRoster != nil { + if s.decks.FieldControlType() == 2 && s.questDifficulty(pack) == 0 && s.storyRoster != nil { if quest := s.firstUnclearedQuestFor(pack); quest != 0 { - party, err := s.currentBattleParty() + party, err := s.currentBattleParty(ctx) if err != nil { return nil, err } @@ -82,12 +79,12 @@ func (s *Service) fieldBuffTargets(pack int, target uint64) ([]uint64, error) { } } if len(indices) == 0 { - if s.decks.FieldControlType() == 0 || len(s.decks.CurrentFieldDeck()) == 0 { + if s.decks.FieldControlType() == 0 || len(s.decks.CurrentFieldDeck(ctx)) == 0 { for _, entry := range s.decks.CurrentDeck() { indices = append(indices, entry.CharacterInvenIndex) } } else { - for _, entry := range s.decks.CurrentFieldDeck() { + for _, entry := range s.decks.CurrentFieldDeck(ctx) { indices = append(indices, entry.CharacterInvenIndex) } } diff --git a/go/internal/server/domain/world/field_monster_damage_tick.go b/go/internal/server/domain/world/field_monster_damage_tick.go new file mode 100644 index 0000000..3fd3f97 --- /dev/null +++ b/go/internal/server/domain/world/field_monster_damage_tick.go @@ -0,0 +1,42 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "encoding/json" + "fmt" +) + +func (s *Service) takeFieldMonsterDamageTick(ctx command.Context, instance string) (bool, error) { + if s.monsterStore == nil { + return false, fmt.Errorf("world: monster damage persistence unavailable") + } + data, err := s.monsterStore.Load(ctx.State, "field_monster_damage") + if err != nil { + return false, err + } + ticks := map[string]int64{} + if data != nil { + if err := json.Unmarshal(data, &ticks); err != nil || ticks == nil { + return false, fmt.Errorf("world: invalid monster damage ticks") + } + for id, timestamp := range ticks { + if id == "" || timestamp <= 0 { + return false, fmt.Errorf("world: invalid monster damage tick") + } + } + } + now := s.monsterTime().UnixMilli() + // FieldMonsterController.IProcessHitDotDamage waits one second per hit. + if last := ticks[instance]; last != 0 && now-last < 1000 { + return false, nil + } + ticks[instance] = now + data, err = json.Marshal(ticks) + if err != nil { + return false, err + } + if err := s.monsterStore.Save(ctx.State, "field_monster_damage", data); err != nil { + return false, err + } + return true, nil +} diff --git a/go/internal/server/domain/world/field_monster_event_test.go b/go/internal/server/domain/world/field_monster_event_test.go new file mode 100644 index 0000000..47b8ac9 --- /dev/null +++ b/go/internal/server/domain/world/field_monster_event_test.go @@ -0,0 +1,275 @@ +//go:build integration + +package world + +import ( + "bytes" + "fmt" + "slices" + "testing" + "time" + + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/domain/roster/deck" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" +) + +func eventMonsterIntegration(t *testing.T, pack int) (command.Context, *Service, *assets.Inventory, func(int)) { + t.Helper() + ctx, service, source := fieldQuestIntegration(t, pack) + versions, err := versionconfig.Find() + if err != nil { + t.Fatal(err) + } + root := versions.Resolve("data/resources/GameData") + service.attachFieldMonsterDesign(source) + service.attachPackDetailDesign(source) + service.fieldReset, err = gamedata.LoadFieldResetSchedule(root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + service.monsterNow = func() time.Time { return time.Date(2026, time.October, 7, 0, 0, 0, 0, time.UTC) } + if err := service.AttachFieldMonsterState(stateio.RootStore{}); err != nil { + t.Fatal(err) + } + items, err := assets.OpenInventory(ctx, stateio.RootStore{}, nil) + if err != nil { + t.Fatal(err) + } + stacks, err := gamedata.LoadItemStackDesign(root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + if err := items.AttachItemStacks(stacks); err != nil { + t.Fatal(err) + } + service.characters, err = roster.OpenCharacterStore(ctx, stateio.RootStore{}, []roster.Character{{InvenIndex: 1, ID: 1, HP: 1000, Level: 1}}, items, root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + service.decks, err = deck.OpenStore(ctx, stateio.RootStore{}, deck.Seed{Version: versions.GameVersion, FieldCharControlDeckType: 1, FieldDeck: []deck.FieldEntry{{CharacterInvenIndex: 1, Slot: 1}}}) + if err != nil { + t.Fatal(err) + } + buffs, err := gamedata.LoadFieldBuffDesign(root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + if err := service.AttachFieldBuffRuntime(buffs); err != nil { + t.Fatal(err) + } + setMap := func(mapID int) { + t.Helper() + request := wire.AppendBytes(wire.AppendVarint(nil, 2, uint64(pack)), 3, []byte(fmt.Sprintf(`{"MapId":%d}`, mapID))) + if err := service.state.SaveUserPosition(ctx, request); err != nil { + t.Fatal(err) + } + } + return ctx, service, items, setMap +} + +func fieldMonsterEventRequest(seq, id, dash uint64) []byte { + return wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id), 3, dash) +} + +// DisableMonster sends FieldMonsterEvent(false) for an inactive ordinary +// monster; isRefresh is only local and absent from the protobuf request. +func TestOrdinaryMonsterRefreshDoesNotBecomeVictory(t *testing.T) { + ctx, service, items, setMap := eventMonsterIntegration(t, 2) + setMap(22) + request := fieldMonsterEventRequest(1, 101, 0) + _, response, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", request) + if err != nil { + t.Fatalf("inactive ordinary refresh must succeed before the new chapter's main quest is complete: %v", err) + } + if len(response) != 0 { + t.Fatal("inactive ordinary acknowledgement must not re-enter the client's RefreshRegen/DisableMonster loop") + } + // The current client re-enters RefreshRegen only for a non-null MonsterInfo + // with MonsterId > 0. All five real forest cleanup senders must terminate. + for i, id := range []uint64{101, 102, 103, 104, 105} { + cleanupRequest := fieldMonsterEventRequest(uint64(100+i), id, 0) + _, acknowledgement, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", cleanupRequest) + if err != nil || len(acknowledgement) != 0 { + t.Fatalf("forest cleanup %d would re-enter the client monster-refresh callback: %v", id, err) + } + _, repeated, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", cleanupRequest) + if err != nil || len(repeated) != 0 { + t.Fatalf("forest cleanup %d retry would restore a looping reply: %v", id, err) + } + } + state, err := service.loadMonsterState(ctx) + if err != nil || len(state.Monsters) != 0 || len(state.Claims) != 0 || len(items.All(ctx)) != 0 { + t.Fatalf("inactive refresh invented a spawn, claim or reward: %+v %v", state, err) + } + if err := service.state.ClearQuest(ctx, 43, 2, 0); err != nil { + t.Fatal(err) + } + _, response, _, err = service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", fieldMonsterEventRequest(2, 101, 0)) + if err != nil { + t.Fatalf("ordinary old-map cleanup must not need current collision-map authorization: %v", err) + } + if len(response) != 0 { + t.Fatal("ordinary cleanup acknowledgement must not refresh or count a battle monster as a reward") + } + state, err = service.loadMonsterState(ctx) + if err != nil || state.Monsters[monsterKey(2, 101)].Defeated || len(state.Claims) != 0 { + t.Fatalf("ordinary synchronization became a battle victory: %+v %v", state, err) + } + count, err := service.packMonsterDefeatedCount(ctx, 2) + if err != nil || count != 0 { + t.Fatalf("ordinary sync must not add a forest kill: %d %v", count, err) + } + +} + +// Pack 2's private crash monster 4011 uses FieldBuff203 (50 fixed damage) +// and CrashAvailableFromDash. It grants no battle reward on collision. +func TestPrivateMonsterCollisionDashDamageAndRefreshRetries(t *testing.T) { + for _, dash := range []uint64{0, 1} { + t.Run(fmt.Sprintf("dash%d", dash), func(t *testing.T) { + ctx, service, items, setMap := eventMonsterIntegration(t, 2) + now := service.monsterTime() + service.monsterNow = func() time.Time { return now } + placements, err := service.monsterMaps(2) + if err != nil { + t.Fatal(err) + } + setMap(21) + request := fieldMonsterEventRequest(1, 4011, dash) + if _, _, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", request); err == nil { + t.Fatalf("active collision from another map must retain map authorization: %v", err) + } + if hp, err := service.characters.CurrentHealth(ctx, 1); err != nil || hp != 1000 || len(items.All(ctx)) != 0 { + t.Fatalf("rejected remote collision changed health or rewards: hp=%d err=%v", hp, err) + } + setMap(placements[4011][0]) + _, response, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", request) + if err != nil { + t.Fatal(err) + } + wantHP := uint64(950) + if dash == 1 { + wantHP = 1000 + } + hp, err := service.characters.CurrentHealth(ctx, 1) + if err != nil || hp != wantHP || len(items.All(ctx)) != 0 { + t.Fatalf("private collision health=%d want=%d, no rewards permitted: %v", hp, wantHP, err) + } + setMap(21) + _, retry, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", request) + if err != nil || !bytes.Equal(response, retry) { + t.Fatalf("settled request retry must work after leaving the collision map: %v", err) + } + if _, cleanup, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", fieldMonsterEventRequest(2, 4011, 0)); err != nil || len(cleanup) != 0 { + t.Fatalf("inactive cleanup after a collision must synchronize without damaging again: %v", err) + } + hp, _ = service.characters.CurrentHealth(ctx, 1) + if hp != wantHP { + t.Fatal("collision retry or inactive refresh applied damage twice") + } + if _, _, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", fieldMonsterEventRequest(1, 4011, 1-dash)); err == nil { + t.Fatalf("changing a settled request must fail explicitly: %v", err) + } + setMap(placements[4011][0]) + now = now.Add(599 * time.Second) + if _, _, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", fieldMonsterEventRequest(3, 4011, 0)); err != nil { + t.Fatal(err) + } + if hp, err := service.characters.CurrentHealth(ctx, 1); err != nil || hp != wantHP { + t.Fatalf("cooling monster damaged the player before it respawned: hp=%d err=%v", hp, err) + } + now = now.Add(time.Second) + if _, _, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", fieldMonsterEventRequest(4, 4011, 0)); err != nil { + t.Fatal(err) + } + if hp, err := service.characters.CurrentHealth(ctx, 1); err != nil || hp != wantHP-50 { + t.Fatalf("respawned monster did not apply the next real collision: hp=%d err=%v", hp, err) + } + }) + } +} + +func TestRewardMonsterCollisionGrantsOnceAndInactiveRefreshIsSafe(t *testing.T) { + ctx, service, items, setMap := eventMonsterIntegration(t, 2) + versions, err := versionconfig.Find() + if err != nil { + t.Fatal(err) + } + graph, err := gamedata.LoadRewardGraph(versions.Resolve("data/resources/GameData"), versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + graph.SetSampler(func(uint64) (uint64, error) { return 0, nil }) + wallet, err := assets.OpenWallet(ctx, stateio.RootStore{}, assets.Currency{}) + if err != nil { + t.Fatal(err) + } + service.researchEconomy = eventInventoryRewards{items: items, wallet: wallet, graph: graph} + placements, err := service.monsterMaps(2) + if err != nil { + t.Fatal(err) + } + setMap(placements[4001][0]) + request := fieldMonsterEventRequest(1, 4001, 1) + _, response, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", request) + if err != nil { + t.Fatal(err) + } + awarded := items.All(ctx) + if wallet.Snapshot(ctx).Gold != 4200 || len(awarded) != 0 { + t.Fatal("reward collision did not open its gold box into the wallet") + } + _, retry, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", request) + if err != nil || !bytes.Equal(response, retry) || !slices.Equal(awarded, items.All(ctx)) || wallet.Snapshot(ctx).Gold != 4200 { + t.Fatalf("reward collision retry must preserve its reply: %v", err) + } + setMap(21) + if _, cleanup, _, err := service.handleFieldMonsterEvent(ctx, "/FieldMonsterEvent", fieldMonsterEventRequest(2, 4001, 0)); err != nil || len(cleanup) != 0 || !slices.Equal(awarded, items.All(ctx)) || wallet.Snapshot(ctx).Gold != 4200 { + t.Fatalf("inactive reward refresh must neither fail nor grant another item: %v", err) + } +} + +type eventInventoryRewards struct { + items *assets.Inventory + wallet *assets.Wallet + graph *gamedata.RewardGraph +} + +func (e eventInventoryRewards) Apply(ctx command.Context, identity string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) { + definitions := make([]gamedata.BattleReward, len(rewards)) + for i, reward := range rewards { + definitions[i] = gamedata.BattleReward(reward) + } + resolved, err := e.graph.Resolve(definitions) + if err != nil { + return nil, err + } + var gold []gamedata.Reward + var owned []gamedata.BattleReward + var bundle []byte + for _, reward := range resolved { + if reward.Type == 4 { + gold = append(gold, gamedata.Reward(reward)) + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(assets.Item{Type: 4, Count: reward.Count})) + } else { + owned = append(owned, reward) + } + } + if _, err := e.wallet.GrantQuestOnce(ctx, identity, gold); err != nil { + return nil, err + } + items, err := e.items.GrantOnce(ctx, identity, owned) + if err != nil { + return nil, err + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + } + return bundle, nil +} diff --git a/go/internal/server/domain/world/field_monster_events.go b/go/internal/server/domain/world/field_monster_events.go new file mode 100644 index 0000000..2112f91 --- /dev/null +++ b/go/internal/server/domain/world/field_monster_events.go @@ -0,0 +1,29 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "fmt" +) + +func (s *Service) grantFieldMonster(ctx command.Context, pack int, m gamedata.FieldMonsterDesign, identity string) ([]byte, error) { + if s.researchEconomy == nil { + return nil, fmt.Errorf("world: monster economy unavailable") + } + var rewards []gamedata.Reward + if m.Reward.Type != 0 && m.Reward.Count > 0 { + rewards = append(rewards, m.Reward) + } else if m.BattleDeck != 0 { + if s.monsterRewards == nil { + return nil, fmt.Errorf("world: monster reward loader unavailable") + } + rows, e := s.monsterRewards(pack, m.BattleDeck) + if e != nil { + return nil, e + } + for _, r := range rows { + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + } + } + return s.researchEconomy.Apply(ctx, identity, nil, rewards) +} diff --git a/go/internal/server/domain/world/field_object_routes.go b/go/internal/server/domain/world/field_object_routes.go new file mode 100644 index 0000000..8cb05b2 --- /dev/null +++ b/go/internal/server/domain/world/field_object_routes.go @@ -0,0 +1,61 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "fmt" +) + +func (s *Service) fieldObjectCurrentPack(pack int) bool { + + current := s.activePack + + if current == 0 { + current = s.state.ActivePackID() + } + return current == pack +} + +func (s *Service) validateFieldObjectMap(ctx command.Context, pack, mapID int) error { + _, saved := s.state.Position() + _, event, err := s.resolveEventFieldPack(ctx, pack) + if err != nil { + return err + } + if !saved && !event { + return nil + } + current, err := s.currentFieldMap(ctx, pack) + if err != nil || current != mapID { + return fmt.Errorf("%w: field object outside current map", ErrInvalidRequest) + } + return nil +} + +func (s *Service) rewardMonsterAvailable(ctx command.Context, pack, monster int) (bool, error) { + if s.fieldObjectLoader == nil && s.fieldObjects == nil { + return true, nil + } + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return false, err + } + linked := false + for _, obj := range design.Objects { + if obj.MonsterID != monster || obj.BuffID != 0 || len(obj.Rewards) != 0 { + continue + } + linked = true + period, e := s.fieldObjectPeriodFor(pack, obj) + if e != nil { + continue + } + opened, e := s.state.FieldRewardOpened(ctx, pack, obj.ID, period) + if e != nil { + return false, e + } + if opened { + return true, nil + } + } + return !linked, nil +} diff --git a/go/internal/server/domain/world/field_objects.go b/go/internal/server/domain/world/field_objects.go new file mode 100644 index 0000000..5cc8dae --- /dev/null +++ b/go/internal/server/domain/world/field_objects.go @@ -0,0 +1,99 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "fmt" +) + +func (s *Service) WithFieldObjects(designs map[int]gamedata.FieldObjectDesign) *Service { + s.fieldObjects = designs + return s +} +func (s *Service) AttachFieldObjectRuntime(source *gamedata.Source, schedule gamedata.FieldResetSchedule) error { + s.fieldReset = schedule + s.fieldObjectLoader = func(pack int) (gamedata.FieldObjectDesign, error) { + return source.FieldObjects(pack) + } + s.fieldObjects = map[int]gamedata.FieldObjectDesign{} + return nil +} +func (s *Service) fieldObjectDesign(ctx command.Context, pack int) (gamedata.FieldObjectDesign, error) { + if design, ok := s.fieldObjects[pack]; ok { + return design, nil + } + if s.fieldObjectLoader == nil { + return gamedata.FieldObjectDesign{}, fmt.Errorf("world: field object design unavailable") + } + if _, story := s.packs[pack]; !story { + if _, field := s.fieldPacks[pack]; !field { + if _, event, err := s.resolveEventFieldPack(ctx, pack); err != nil || !event { + return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest) + } + } + } + design, err := s.fieldObjectLoader(pack) + if err != nil { + return design, err + } + s.fieldObjects[pack] = design + return design, nil +} +func (s *Service) openedFieldObjects(ctx command.Context, pack int) ([]int, error) { + ids, err := s.state.OpenedFieldRewards(ctx, pack) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return []int{}, nil + } + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return nil, err + } + var active []int + for _, id := range ids { + obj, ok := design.Objects[id] + if !ok { + return nil, fmt.Errorf("world: saved field object absent from design") + } + period, e := s.fieldObjectPeriodFor(pack, obj) + if e != nil { + continue + } + opened, e := s.state.FieldRewardOpened(ctx, pack, id, period) + if e != nil { + return nil, e + } + if opened { + active = append(active, id) + } + } + return active, nil +} +func (s *Service) WithFieldResetSchedule(schedule gamedata.FieldResetSchedule) *Service { + s.fieldReset = schedule + return s +} +func (s *Service) fieldObjectPeriod(obj gamedata.FieldRewardObject) (string, error) { + return s.fieldReset.Period(obj.ResetType, s.monsterTime()) +} +func (s *Service) fieldObjectPeriodFor(pack int, obj gamedata.FieldRewardObject) (string, error) { + if obj.ResetType == 2 { + resolver, ok := s.eventFieldPacks.(interface { + FieldObjectEventPeriod(int) (string, int64, error) + }) + if !ok { + return "", fmt.Errorf("%w: field event calendar unavailable", ErrInvalidRequest) + } + period, _, err := resolver.FieldObjectEventPeriod(pack) + if err != nil { + return "", err + } + if period == "" { + return "", fmt.Errorf("%w: field event inactive", ErrInvalidRequest) + } + return "event:" + period, nil + } + return s.fieldObjectPeriod(obj) +} diff --git a/go/internal/server/world/field_pack.go b/go/internal/server/domain/world/field_pack.go similarity index 78% rename from go/internal/server/world/field_pack.go rename to go/internal/server/domain/world/field_pack.go index 8d803eb..d3f8b57 100644 --- a/go/internal/server/world/field_pack.go +++ b/go/internal/server/domain/world/field_pack.go @@ -3,16 +3,17 @@ package world import ( "errors" - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" ) // LastPlayedPackID prevents LoginUser from selecting a field that this server // cannot restore. A fresh account keeps the versioned login seed's pack. -func (s *Service) LastPlayedPackID() (uint64, error) { +func (s *Service) LastPlayedPackID(ctx command.Context) (uint64, error) { id := uint64(s.state.ActivePackID()) if id == 0 { var err error - id, err = s.state.LastPlayedPackID() + id, err = s.state.LastPlayedPackID(ctx) if err != nil { return id, err } @@ -20,7 +21,7 @@ func (s *Service) LastPlayedPackID() (uint64, error) { if id == 0 { id = uint64(s.startingPack()) } - if !s.packUnlocked(int(id)) { + if !s.packUnlocked(ctx, int(id)) { return 0, errors.New("world: saved login pack is unavailable") } if pack, field := s.fieldPacks[int(id)]; field { @@ -32,7 +33,7 @@ func (s *Service) LastPlayedPackID() (uint64, error) { return id, nil } -func (s *Service) AttachSquadLevel(provider func() (uint64, error)) error { +func (s *Service) AttachSquadLevel(ctx command.Context, provider func() (uint64, error)) error { if provider == nil { return errors.New("world: nil squad level provider") } @@ -40,7 +41,7 @@ func (s *Service) AttachSquadLevel(provider func() (uint64, error)) error { return nil } -func (s *Service) fieldPackUnlocked(pack gamedata.FieldPack) bool { +func (s *Service) fieldPackUnlocked(ctx command.Context, pack gamedata.FieldPack) bool { // A valid committed field position is the existing account's entry marker. // Restoration does not repeat a purchase or an unlock check. if saved, found := s.state.Position(); found && saved.PackID == int(pack.ID) && pack.MapIDs[saved.Position.MapID] { @@ -64,7 +65,7 @@ func (s *Service) fieldPackUnlocked(pack gamedata.FieldPack) bool { return false } found := false - for _, item := range s.inventory.All() { + for _, item := range s.inventory.All(ctx) { if item.Type == 19 && item.ID == pack.TicketID && item.Count != 0 { found = true break diff --git a/go/internal/server/domain/world/field_quest_difficulty_test.go b/go/internal/server/domain/world/field_quest_difficulty_test.go new file mode 100644 index 0000000..3d068e7 --- /dev/null +++ b/go/internal/server/domain/world/field_quest_difficulty_test.go @@ -0,0 +1,139 @@ +//go:build integration + +package world + +import ( + "context" + "fmt" + "path/filepath" + "testing" + + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/world/progress" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" + accountstate "bd2server/internal/server/storage/account" + "bd2server/internal/server/storage/stateio" +) + +func fieldQuestIntegration(t *testing.T, pack int) (command.Context, *Service, *gamedata.Source) { + t.Helper() + versions, err := versionconfig.Find() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gamedata.CloseDatabaseCache(); err != nil { + t.Error(err) + } + }) + root := versions.Resolve("data/resources/GameData") + catalog, err := gamedata.LoadStoryCatalog(root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + repo, err := accountstate.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }) + tx, err := repo.BeginCommand(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := tx.Rollback(); err != nil { + t.Error(err) + } + }) + ctx := command.Context{Identity: command.Identity{AccountID: t.Name(), SessionID: "authenticated"}, State: tx} + state, err := progress.OpenStore(ctx, stateio.RootStore{}) + if err != nil { + t.Fatal(err) + } + if err := state.SelectQuest(ctx, pack, progress.QuestSelection{Difficulty: 2}); err != nil { + t.Fatal(err) + } + service := &Service{seed: Seed{PackID: pack}, activePack: pack, state: state, storyCatalog: catalog} + return ctx, service, gamedata.NewSource(root, versions.GameDataVersion) +} + +// Pack 2's authored side-quest crates appear after quest 208; main-quest +// crates appear after quest 16 at the selected difficulty. Changing main +// difficulty must preserve side-crate positions while resetting main crates. +func TestAuthoredFieldActionsUseTheirQuestDifficulty(t *testing.T) { + ctx, service, source := fieldQuestIntegration(t, 2) + design, err := source.FieldObjects(2) + if err != nil { + t.Fatal(err) + } + service.WithFieldObjects(map[int]gamedata.FieldObjectDesign{2: design}) + move := func(id int) error { + obj := design.Actions[id] + userPosition := wire.AppendBytes(wire.AppendVarint(nil, 2, 2), 3, []byte(fmt.Sprintf(`{"MapId":%d}`, obj.MapID))) + if err := service.state.SaveUserPosition(ctx, userPosition); err != nil { + return err + } + request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 2), 3, uint64(obj.GroupID)), 4, uint64(id)) + request = wire.AppendVarint(request, 1, 1) + request = wire.AppendBytes(request, 5, wire.AppendVarint(nil, 1, uint64(obj.MapID))) + _, _, _, err := service.handleFieldObjectPosition(ctx, request) + return err + } + if err := service.state.SelectQuest(ctx, 2, progress.QuestSelection{Difficulty: 0}); err != nil { + t.Fatal(err) + } + for _, id := range []int{2102, 181} { + obj := design.Actions[id] + if err := service.state.ClearQuest(ctx, obj.QuestID, 2, 0); err != nil { + t.Fatal(err) + } + if err := move(id); err != nil { + t.Fatalf("completed normal quest must enable crate %d: %v", id, err) + } + } + if err := service.state.SelectQuest(ctx, 2, progress.QuestSelection{Difficulty: 2}); err != nil { + t.Fatal(err) + } + info, err := service.fieldActionInfo(ctx, 2) + if err != nil { + t.Fatal(err) + } + rows, err := nestedMessages(info, 2) + if err != nil || len(rows) != 1 { + t.Fatalf("only the side crate may retain its drop position after changing main difficulty: rows=%d err=%v", len(rows), err) + } + id, _, err := wire.Varint(rows[0], 1) + if err != nil || id != 2102 { + t.Fatalf("changing main difficulty must retain side crate 2102 and reset main crate 181: id=%d err=%v", id, err) + } + if err := move(2102); err != nil { + t.Fatalf("completed side quest 208 must enable its movable crate at main difficulty 2: %v", err) + } + if err := move(181); err == nil { + t.Fatal("main quest 16 completed only at normal must not enable its difficulty-2 crate") + } + if err := service.state.ClearQuest(ctx, 16, 2, 2); err != nil { + t.Fatal(err) + } + if err := move(181); err != nil { + t.Fatalf("difficulty-2 main completion must enable its crate: %v", err) + } + service.state, err = progress.OpenStore(ctx, stateio.RootStore{}) + if err != nil { + t.Fatal(err) + } + info, err = service.fieldActionInfo(ctx, 2) + if err != nil { + t.Fatal(err) + } + rows, err = nestedMessages(info, 2) + if err != nil || len(rows) != 2 { + t.Fatalf("reconnect must retain both positions moved after their quests completed: rows=%d err=%v", len(rows), err) + } +} diff --git a/go/internal/server/domain/world/field_traps.go b/go/internal/server/domain/world/field_traps.go new file mode 100644 index 0000000..3c54022 --- /dev/null +++ b/go/internal/server/domain/world/field_traps.go @@ -0,0 +1,282 @@ +package world + +import ( + "bytes" + "encoding/json" + "fmt" + "slices" + "sort" + + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" +) + +type fieldTrapState struct { + Pack, Map, Trap int + Enabled bool + Partial []uint64 +} +type fieldTrapRuntime struct { + States map[string]fieldTrapState + Hits map[string]int64 + Requests map[string]fieldMonsterReply +} + +func loadFieldTrapRuntime(ctx command.Context) (fieldTrapRuntime, error) { + v := fieldTrapRuntime{States: map[string]fieldTrapState{}, Hits: map[string]int64{}, Requests: map[string]fieldMonsterReply{}} + raw, err := (stateio.EntrySnapshotStore{Domain: "missions", Bucket: "gameplay"}).Load(ctx.State, "field_traps") + if err != nil || raw == nil { + return v, err + } + if err := stateio.RequireExactJSONObject(raw, "States", "Hits", "Requests"); err != nil { + return v, err + } + if err := json.Unmarshal(raw, &v); err != nil || v.States == nil || v.Hits == nil || v.Requests == nil { + return v, fmt.Errorf("world: invalid trap runtime") + } + for key, state := range v.States { + if state.Pack <= 0 || state.Map <= 0 || state.Trap <= 0 || key != trapKey(state.Pack, state.Map, state.Trap) { + return v, fmt.Errorf("world: invalid saved trap identity %q", key) + } + seen := map[uint64]bool{} + for _, id := range state.Partial { + if id == 0 || id > 0x7fffffff || seen[id] { + return v, fmt.Errorf("world: invalid saved trap switch %q", key) + } + seen[id] = true + } + } + for key, hit := range v.Hits { + var pack, mapID, id int + if n, err := fmt.Sscanf(key, "%d/%d/%d", &pack, &mapID, &id); err != nil || n != 3 || pack <= 0 || mapID <= 0 || id <= 0 || key != trapKey(pack, mapID, id) || hit <= 0 { + return v, fmt.Errorf("world: invalid saved trap hit %q", key) + } + } + for key, reply := range v.Requests { + if key == "" || len(reply.Request) == 0 { + return v, fmt.Errorf("world: invalid saved trap reply %q", key) + } + if err := wire.Walk(reply.Request, func(wire.Field) error { return nil }); err != nil { + return v, err + } + if err := wire.Walk(reply.Response, func(wire.Field) error { return nil }); err != nil { + return v, err + } + } + return v, nil +} +func saveFieldTrapRuntime(ctx command.Context, v fieldTrapRuntime) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + return (stateio.EntrySnapshotStore{Domain: "missions", Bucket: "gameplay"}).Save(ctx.State, "field_traps", raw) +} +func trapKey(pack, mapID, id int) string { return fmt.Sprintf("%d/%d/%d", pack, mapID, id) } + +func (s *Service) trapQuestEligible(pack int, quests []uint64) bool { + if len(quests) == 0 || quests[0] == 0 { + return true + } + for _, quest := range quests { + id := int(quest) + if id == s.firstUnclearedQuestFor(pack) { + return true + } + if _, active := s.state.QuestInPack(id, pack, s.questDifficultyFor(pack, id)); active && !s.state.QuestCleared(id, pack, s.questDifficultyFor(pack, id)) { + return true + } + } + return false +} + +func (s *Service) handleFieldTraps(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + code := 63 + switch path { + case "/FieldTrapInfo": + code = 171 + case "/InteractionTrigger": + code = 172 + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > 0x7fffffff { + return code, nil, true, fmt.Errorf("%w: %s invalid sequence", ErrInvalidRequest, path) + } + id, found, err := wire.Varint(request, 2) + if err != nil || !found || id == 0 || id > 0x7fffffff { + return code, nil, true, fmt.Errorf("%w: %s invalid object id", ErrInvalidRequest, path) + } + v, err := loadFieldTrapRuntime(ctx) + if err != nil { + return code, nil, true, err + } + identity := "" + if path != "/FieldTrapInfo" { + if ctx.SessionID == "" { + return code, nil, true, fmt.Errorf("%w: %s trap/trigger %d missing session", ErrInvalidRequest, path, id) + } + identity = fmt.Sprintf("%s:%s:%d", path, ctx.SessionID, seq) + if prior, found := v.Requests[identity]; found { + if !bytes.Equal(prior.Request, request) { + return code, nil, true, fmt.Errorf("%w: %s trap/trigger %d changed retry", ErrInvalidRequest, path, id) + } + return code, prior.Response, true, nil + } + } + pack, err := s.CurrentPackID(ctx) + if path == "/FieldTrapInfo" { + pack = int(id) + err = nil + } + if err != nil { + return code, nil, true, err + } + mapID := 0 + if position, found := s.state.Position(); found && position.PackID == pack { + mapID = position.Position.MapID + } + fail := func(reason string) (int, []byte, bool, error) { + return code, nil, true, fmt.Errorf("%w: %s pack %d map %d trap/trigger %d %s", ErrInvalidRequest, path, pack, mapID, id, reason) + } + if !s.packUnlocked(ctx, pack) { + return fail("pack unavailable") + } + if s.trapLoader == nil { + return fail("trap design unavailable") + } + design, err := s.trapLoader(pack) + if err != nil { + return code, nil, true, fmt.Errorf("world: %s pack %d map %d trap/trigger %d load design: %w", path, pack, mapID, id, err) + } + if path == "/FieldTrapInfo" { + requested, _, err := wire.Varint(request, 3) + if err != nil || requested > 0x7fffffff { + return fail("invalid map filter") + } + if requested != 0 && !slices.Contains(design.MapIDs, int(requested)) { + return fail(fmt.Sprintf("requested map %d outside pack", requested)) + } + var keys []string + for key, state := range v.States { + if state.Pack == pack && (requested == 0 || state.Map == int(requested)) { + keys = append(keys, key) + } + } + sort.Strings(keys) + var response []byte + for _, key := range keys { + state := v.States[key] + trap, found := design.Traps[state.Trap] + if !found || trap.ResetType != 0 || !slices.Contains(trap.Maps, state.Map) { + return fail("saved persistent trap absent from design") + } + row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(pack)), 2, uint64(state.Map)), 3, uint64(state.Trap)) + if state.Enabled { + row = wire.AppendVarint(row, 4, 1) + } + for _, id := range state.Partial { + row = wire.AppendVarint(row, 5, id) + } + response = wire.AppendBytes(response, 1, row) + } + return code, response, true, nil + } + finish := func(response []byte) (int, []byte, bool, error) { + v.Requests[identity] = fieldMonsterReply{Request: slices.Clone(request), Response: slices.Clone(response)} + if err := saveFieldTrapRuntime(ctx, v); err != nil { + return code, nil, true, err + } + return code, response, true, nil + } + if !slices.Contains(design.MapIDs, mapID) { + return fail("current position missing or outside pack") + } + if s.battleActive != nil && s.battleActive(ctx) { + return fail("active battle") + } + var response []byte + if path == "/InteractionTrigger" { + trigger, found := design.Triggers[int(id)] + if !found || !slices.Contains(trigger.Maps, mapID) { + return fail("trigger not placed in current map") + } + if trigger.Type != 1 || trigger.AutoInteraction != 0 || !s.trapQuestEligible(pack, trigger.QuestRange) { + return fail("trigger is not an available manual trap switch") + } + changed := false + for _, sw := range design.Switches { + if sw.ObjectType != 15 || !slices.Contains(sw.Objects, id) { + continue + } + var targets []gamedata.FieldTrap + for _, trap := range design.Traps { + if trap.SwitchID == sw.ID && trap.ResetType == 0 && slices.Contains(trap.Maps, mapID) { + targets = append(targets, trap) + } + } + if len(targets) == 0 { + continue + } + partial := v.States[trapKey(pack, mapID, targets[0].ID)].Partial + if sw.OrderType == 1 && (len(partial) >= len(sw.Objects) || sw.Objects[len(partial)] != id) { + return fail("switch activation order does not match design") + } + if !slices.Contains(partial, id) { + partial = append(slices.Clone(partial), id) + } + complete := true + for _, required := range sw.Objects { + complete = complete && slices.Contains(partial, required) + } + for _, trap := range targets { + key := trapKey(pack, mapID, trap.ID) + state, found := v.States[key] + if !found { + state = fieldTrapState{Pack: pack, Map: mapID, Trap: trap.ID, Enabled: trap.DefaultEnabled} + } + state.Partial = slices.Clone(partial) + if complete { + state.Enabled = !state.Enabled + state.Partial = nil + } + v.States[key] = state + } + changed = true + } + if !changed { + return fail("trigger has no persistent trap switch in current map") + } + } else { + trap, found := design.Traps[int(id)] + if !found || !slices.Contains(trap.Maps, mapID) { + return fail("trap not placed in current map") + } + if !s.trapQuestEligible(pack, trap.QuestRange) { + return fail("trap quest range inactive") + } + if state, found := v.States[trapKey(pack, mapID, int(id))]; found && trap.ResetType == 0 && !state.Enabled { + return finish(nil) + } + key := trapKey(pack, mapID, int(id)) + now := s.monsterTime().UnixMilli() + if last, found := v.Hits[key]; found && now-last < int64(trap.CoolSeconds)*1000 { + return finish(nil) + } + if trap.FieldBuff != 0 && (s.decks == nil || s.decks.FieldControlType() != 2 || !design.StoryModeImmune) { + if s.monsterDamage == nil { + return fail("field health runtime unavailable") + } + rows, err := s.monsterDamage(ctx, pack, trap.FieldBuff, identity) + if err != nil { + return code, nil, true, fmt.Errorf("world: TrapDamage pack %d map %d trap %d buff %d: %w", pack, mapID, id, trap.FieldBuff, err) + } + for _, row := range rows { + response = wire.AppendBytes(response, 1, row) + } + } + v.Hits[key] = now + } + return finish(response) +} diff --git a/go/internal/server/world/hunting.go b/go/internal/server/domain/world/hunting.go similarity index 72% rename from go/internal/server/world/hunting.go rename to go/internal/server/domain/world/hunting.go index c281e84..80ec760 100644 --- a/go/internal/server/world/hunting.go +++ b/go/internal/server/domain/world/hunting.go @@ -1,8 +1,13 @@ package world -import "fmt" +import ( + "bd2server/internal/server/domain/command" + "fmt" +) -func (s *Service) AttachHuntingGround(provider interface{ EnsureForPack(int) ([]byte, error) }) error { +func (s *Service) AttachHuntingGround(provider interface { + EnsureForPack(ctx command.Context, _ int) ([]byte, error) +}) error { if provider == nil { return fmt.Errorf("world: missing hunting ground provider") } @@ -12,8 +17,8 @@ func (s *Service) AttachHuntingGround(provider interface{ EnsureForPack(int) ([] // HuntingEligibility follows the hunting tutorial: hard/extreme grounds need // completion of the same story difficulty, independently of the last hunt boss. -func (s *Service) HuntingEligibility(pack int, difficulty uint64) error { - if difficulty > 2 || !s.packUnlocked(pack) { +func (s *Service) HuntingEligibility(ctx command.Context, pack int, difficulty uint64) error { + if difficulty > 2 || !s.packUnlocked(ctx, pack) { return fmt.Errorf("world: hunting pack or difficulty locked") } if difficulty == 0 { diff --git a/go/internal/server/domain/world/mission_unlock.go b/go/internal/server/domain/world/mission_unlock.go new file mode 100644 index 0000000..0b464db --- /dev/null +++ b/go/internal/server/domain/world/mission_unlock.go @@ -0,0 +1,21 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "math" + // MissionsUnlocked uses the same pack authorization as map entry. A quest + // condition requires a committed normal-difficulty clear in that pack. +) + +func (s *Service) MissionsUnlocked(ctx command.Context, pack, quest uint64) bool { + if pack == 0 { + return quest == 0 + } + if pack > math.MaxInt32 || quest > math.MaxInt32 { + return false + } + if quest == 0 { + return s.packUnlocked(ctx, int(pack)) + } + return s.state.QuestCleared(int(quest), int(pack), 0) +} diff --git a/go/internal/server/domain/world/monster_info.go b/go/internal/server/domain/world/monster_info.go new file mode 100644 index 0000000..5c90fea --- /dev/null +++ b/go/internal/server/domain/world/monster_info.go @@ -0,0 +1,238 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "fmt" + "slices" + "time" +) + +type fieldMonsterState struct { + Generation uint64 + Start, Respawn, LifeEnd int64 + Defeated bool + Period string +} +type fieldMonsterSnapshot struct { + Monsters map[string]fieldMonsterState + Claims map[string]bool + Requests map[string]fieldMonsterReply +} +type fieldMonsterReply struct{ Request, Response []byte } + +func (s *Service) AttachFieldMonsterDamage(f func(command.Context, int, uint64, string) ([][]byte, error)) { + s.monsterDamage = f +} + +func (s *Service) AttachFieldMonsterState(store stateio.Store) error { + if store == nil { + return fmt.Errorf("world: missing monster state store") + } + s.monsterStore = store + return nil +} +func (s *Service) monsterTime() time.Time { + if s.monsterNow != nil { + return s.monsterNow() + } + return time.Now() +} +func (s *Service) loadMonsterState(ctx command.Context) (fieldMonsterSnapshot, error) { + v := fieldMonsterSnapshot{Monsters: map[string]fieldMonsterState{}, Claims: map[string]bool{}, Requests: map[string]fieldMonsterReply{}} + if s.monsterStore == nil { + return v, nil + } + b, e := s.monsterStore.Load(ctx.State, "field_monster_runtime") + if e != nil || b == nil { + return v, e + } + if e = stateio.RequireExactJSONObject(b, "Monsters", "Claims", "Requests"); e != nil { + return v, e + } + if e = json.Unmarshal(b, &v); e != nil || v.Monsters == nil || v.Claims == nil || v.Requests == nil { + return v, fmt.Errorf("world: invalid field monster state") + } + for _, m := range v.Monsters { + if m.Generation == 0 || m.Start <= 0 || m.Respawn < 0 || m.LifeEnd < 0 { + return v, fmt.Errorf("world: invalid monster timestamps") + } + } + return v, nil +} +func (s *Service) saveMonsterState(ctx command.Context, v fieldMonsterSnapshot) error { + if s.monsterStore == nil { + return fmt.Errorf("world: monster persistence unavailable") + } + b, e := json.Marshal(v) + if e != nil { + return e + } + return s.monsterStore.Save(ctx.State, "field_monster_runtime", b) +} +func monsterKey(pack, id int) string { + return fmt.Sprintf("%d/0/%d", pack, id) +} +func (s *Service) monsterPeriod(m gamedata.FieldMonsterDesign) (string, error) { + if m.ResetType == 0 { + return "own", nil + } + reset := 0 + if m.ResetType == 2 { + reset = 3 + } + return s.fieldReset.Period(reset, s.monsterTime()) +} +func (s *Service) monsterState(v *fieldMonsterSnapshot, pack int, m gamedata.FieldMonsterDesign) (fieldMonsterState, error) { + key := monsterKey(pack, m.ID) + old, exists := v.Monsters[key] + period, e := s.monsterPeriod(m) + if e != nil { + return old, e + } + now := s.monsterTime().UnixMilli() + fresh := func(generation uint64, start int64) fieldMonsterState { + r := fieldMonsterState{Generation: generation, Start: start, Period: period} + if m.LifeSeconds > 0 { + r.LifeEnd = start + int64(m.LifeSeconds)*1000 + } + return r + } + if !exists { + old = fresh(1, now) + } else if old.Period != period { + old = fresh(old.Generation+1, now) + } else if old.Respawn > 0 && now >= old.Respawn { + old = fresh(old.Generation+1, old.Respawn) + } else if old.LifeEnd > 0 && now >= old.LifeEnd && !old.Defeated { + old.Defeated = true + old.Respawn = old.LifeEnd + int64(m.RegenSeconds)*1000 + if old.Respawn <= now { + old = fresh(old.Generation+1, now) + } + } + v.Monsters[key] = old + return old, nil +} +func (s *Service) monsterEligible(pack int, m gamedata.FieldMonsterDesign) bool { + // NormalHunt regeneration is unlocked by normal story progress, independently + // of the optional difficulty main quest that hides these field monsters. + return m.QuestID == 0 || s.state.QuestCleared(m.QuestID, pack, 0) +} + +func (s *Service) attachFieldMonsterDesign(source *gamedata.Source) { + s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) { + return source.FieldMonsters(pack) + } + s.monsterRewards = func(pack int, deck uint64) ([]gamedata.BattleReward, error) { + return source.BattleRewards(pack, deck) + } + s.monsterMaps = func(pack int) (map[int][]int, error) { return source.FieldMonsterMaps(pack) } +} +func (s *Service) authorizeMonsterMap(ctx command.Context, pack, id int) error { + available, err := s.rewardMonsterAvailable(ctx, pack, id) + if err != nil { + return err + } + if !available { + return fmt.Errorf("world: reward monster not summoned") + } + if s.monsterMaps == nil { + return nil + } + maps, e := s.monsterMaps(pack) + if e != nil { + return e + } + current, e := s.currentFieldMap(ctx, pack) + if e != nil { + return e + } + if slices.Contains(maps[id], current) { + return nil + } + return fmt.Errorf("world: monster outside current map") +} +func (s *Service) findFieldMonster(pack, id int) (gamedata.FieldMonsterDesign, bool, error) { + if s.monsterLoader == nil { + return gamedata.FieldMonsterDesign{}, false, nil + } + d, e := s.monsterLoader(pack) + if e != nil { + return gamedata.FieldMonsterDesign{}, false, e + } + for _, m := range d { + if m.ID == id { + return m, true, nil + } + } + return gamedata.FieldMonsterDesign{}, false, nil +} + +// BeginFieldMonsterBattle locks the regenerated instance for retry-safe rewards. +// Scripted monsters without regeneration return handled=false. +func (s *Service) BeginFieldMonsterBattle(ctx command.Context, pack int, id, deck uint64) (string, bool, error) { + m, found, e := s.findFieldMonster(pack, int(id)) + if e == nil && found { + available, err := s.rewardMonsterAvailable(ctx, pack, int(id)) + if err != nil { + return "", true, err + } + if !available { + return "", true, fmt.Errorf("world: reward monster not summoned") + } + } + if found && m.GroupID == 0 { + return "", false, nil + } + if e != nil || !found { + return "", found, e + } + if !s.packUnlocked(ctx, pack) || !s.monsterEligible(pack, m) { + return "", true, ErrInvalidRequest + } + deckOK := deck == m.BattleDeck + for _, allowed := range m.BattleDecks { + if deck == allowed { + deckOK = true + } + } + if m.BattleDeck != 0 && !deckOK { + return "", true, fmt.Errorf("world: field monster deck mismatch") + } + if e = s.authorizeMonsterMap(ctx, pack, int(id)); e != nil { + return "", true, e + } + v, e := s.loadMonsterState(ctx) + if e != nil { + return "", true, e + } + state, e := s.monsterState(&v, pack, m) + if e != nil { + return "", true, e + } + if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() { + return "", true, fmt.Errorf("world: monster is not spawned") + } + if e = s.saveMonsterState(ctx, v); e != nil { + return "", true, e + } + return fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, m.ID), state.Generation), true, nil +} + +func (s *Service) nextMonsterSpawn(m gamedata.FieldMonsterDesign) int64 { + now := s.monsterTime() + if m.ResetType == 0 { + return now.UnixMilli() + int64(m.RegenSeconds)*1000 + } + shift := 9*time.Hour - s.fieldReset.DailyReset + t := now.UTC().Add(shift) + day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1) + if m.ResetType == 2 { + delta := (int(s.fieldReset.WeeklyDay) - int(day.Weekday()) + 7) % 7 + day = day.AddDate(0, 0, delta) + } + return day.Add(-shift).UnixMilli() +} diff --git a/go/internal/server/world/npc_reputation.go b/go/internal/server/domain/world/npc_reputation.go similarity index 51% rename from go/internal/server/world/npc_reputation.go rename to go/internal/server/domain/world/npc_reputation.go index d31387e..c55d844 100644 --- a/go/internal/server/world/npc_reputation.go +++ b/go/internal/server/domain/world/npc_reputation.go @@ -1,12 +1,11 @@ package world import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" "encoding/json" "fmt" - "slices" "time" ) @@ -21,18 +20,18 @@ type npcReputationSnapshot struct { Claims map[string]bool } -func (s *Service) ConfigureNPCRuntime(root, version string, store stateio.Store) error { +func (s *Service) ConfigureNPCRuntime(source *gamedata.Source, store stateio.Store) error { if store == nil { return fmt.Errorf("world: missing NPC reputation store") } s.npcReputation = &npcReputationRuntime{store: store, load: func(pack int) (gamedata.NPCReputationDesign, error) { - return gamedata.LoadNPCReputation(root, version, pack) - }, inns: func(pack int) ([]gamedata.InnRule, error) { return gamedata.LoadInns(root, version, pack) }, now: time.Now} + return source.NPCReputation(pack) + }, inns: func(pack int) ([]gamedata.InnRule, error) { return source.Inns(pack) }, now: time.Now} return nil } -func (r *npcReputationRuntime) snapshot() (npcReputationSnapshot, error) { +func (r *npcReputationRuntime) snapshot(ctx command.Context) (npcReputationSnapshot, error) { v := npcReputationSnapshot{Starts: map[string]int64{}, Claims: map[string]bool{}} - b, err := r.store.Load("npcreputation") + b, err := r.store.Load(ctx.State, "npcreputation") if err != nil { return v, err } @@ -52,11 +51,11 @@ func (r *npcReputationRuntime) snapshot() (npcReputationSnapshot, error) { return v, nil } func reputationKey(pack int, group uint64) string { return fmt.Sprintf("%d/%d", pack, group) } -func (s *Service) reputationState(pack int, rule gamedata.NPCReputationRule) (uint64, uint64, error) { +func (s *Service) reputationState(ctx command.Context, pack int, rule gamedata.NPCReputationRule) (uint64, uint64, error) { if s.npcReputation == nil { return 1, 0, nil } - v, err := s.npcReputation.snapshot() + v, err := s.npcReputation.snapshot(ctx) if err != nil { return 0, 0, err } @@ -70,75 +69,10 @@ func (s *Service) reputationState(pack int, rule gamedata.NPCReputationRule) (ui } return 2, uint64(elapsed), nil } -func (s *Service) CompleteNPCReputation(identity string, pack int, group uint64) ([]byte, error) { - if s.npcReputation == nil || identity == "" || group == 0 || !s.packUnlocked(pack) { - return nil, ErrInvalidRequest - } - d, err := s.npcReputation.load(pack) - if err != nil { - return nil, err - } - rule, ok := d.Groups[group] - if !ok { - return nil, fmt.Errorf("world: unknown reputation group") - } - v, err := s.npcReputation.snapshot() - if err != nil { - return nil, err - } - claim := fmt.Sprintf("%s:%d:%d", identity, pack, group) - if !v.Claims[claim] { - v.Claims[claim] = true - v.Starts[reputationKey(pack, group)] = s.npcReputation.now().Unix() - b, e := json.Marshal(v) - if e != nil { - return nil, e - } - if e = s.npcReputation.store.Save("npcreputation", b); e != nil { - return nil, e - } - } - state, elapsed, err := s.reputationState(pack, rule) - if err != nil { - return nil, err - } - return reputationWire(group, state, elapsed), nil -} -func reputationWire(group, state, elapsed uint64) []byte { - b := wire.AppendVarint(nil, 1, group) - b = wire.AppendVarint(b, 2, state) - if elapsed > 0 { - b = wire.AppendVarint(b, 3, elapsed) - } - return b -} -func (s *Service) npcReputationRows(pack int) ([][]byte, error) { - if s.npcReputation == nil { - return nil, nil - } - d, err := s.npcReputation.load(pack) - if err != nil { - return nil, err - } - var groups []uint64 - for id := range d.Groups { - groups = append(groups, id) - } - slices.Sort(groups) - var rows [][]byte - for _, id := range groups { - state, elapsed, e := s.reputationState(pack, d.Groups[id]) - if e != nil { - return nil, e - } - rows = append(rows, reputationWire(id, state, elapsed)) - } - return rows, nil -} -func (s *Service) NPCShopReputation(pack uint64) (uint64, uint64, error) { - current, err := s.CurrentPackID() - if err != nil || pack == 0 || !s.packUnlocked(int(pack)) || s.npcReputation == nil { +func (s *Service) NPCShopReputation(ctx command.Context, pack uint64) (uint64, uint64, error) { + current, err := s.CurrentPackID(ctx) + if err != nil || pack == 0 || !s.packUnlocked(ctx, int(pack)) || s.npcReputation == nil { return 0, 0, ErrInvalidRequest } d, err := s.npcReputation.load(int(pack)) @@ -166,7 +100,7 @@ func (s *Service) NPCShopReputation(pack uint64) (uint64, uint64, error) { if !ok { return 1, 0, nil } - state, _, err := s.reputationState(int(pack), rule) + state, _, err := s.reputationState(ctx, int(pack), rule) if err != nil { return 0, 0, err } @@ -178,10 +112,10 @@ func (s *Service) NPCShopReputation(pack uint64) (uint64, uint64, error) { // InnContext validates the motel's real map, rather than trusting an NPC ID // supplied from another pack or the previously visited scene. -func (s *Service) InnContext(pack, npc uint64) (gamedata.InnRule, uint64, error) { +func (s *Service) InnContext(ctx command.Context, pack, npc uint64) (gamedata.InnRule, uint64, error) { var empty gamedata.InnRule - current, err := s.CurrentPackID() - if err != nil || s.npcReputation == nil || (pack != 0 && pack != uint64(current)) || !s.packUnlocked(current) { + current, err := s.CurrentPackID(ctx) + if err != nil || s.npcReputation == nil || (pack != 0 && pack != uint64(current)) || !s.packUnlocked(ctx, current) { return empty, 0, ErrInvalidRequest } position, ok := s.state.Position() @@ -200,7 +134,7 @@ func (s *Service) InnContext(pack, npc uint64) (gamedata.InnRule, uint64, error) if r.MapID != uint64(position.Position.MapID) || (npc != 0 && npc != r.NPCID) { continue } - state, _, e := s.reputationState(current, d.Groups[r.MapGroup]) + state, _, e := s.reputationState(ctx, current, d.Groups[r.MapGroup]) return r, state, e } return empty, 0, fmt.Errorf("world: motel NPC outside current map") diff --git a/go/internal/server/domain/world/overwhelm.go b/go/internal/server/domain/world/overwhelm.go new file mode 100644 index 0000000..2f11023 --- /dev/null +++ b/go/internal/server/domain/world/overwhelm.go @@ -0,0 +1,77 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "fmt" +) + +func (s *Service) AttachOverwhelmAuthorization(f func(ctx command.Context, _ string, _ uint64) error) { + s.overwhelmAuthorize = f +} +func (s *Service) AttachOverwhelmHunting(h interface { + ValidateBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64) error + CompleteBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64, _ string) ([]byte, [][]byte, error) +}) { + s.overwhelmHunting = h +} + +type overwhelmedMonster struct { + ID, Group, Deck, Mode uint64 + Definition gamedata.FieldMonsterDesign + Instance string + Costs []gamedata.Reward +} + +func (s *Service) AttachOverwhelmDesign(source *gamedata.Source, rows []gamedata.SkyWayOverwhelmRule) error { + s.overwhelmSky = rows + s.overwhelmQuest = func(pack, quest int) (gamedata.OverwhelmQuestRule, error) { + return source.OverwhelmQuest(pack, quest) + } + return nil +} + +func (s *Service) validateOverwhelmQuest(ctx command.Context, pack, quest int, values []uint64, targets []overwhelmedMonster) error { + if s.overwhelmQuest == nil { + return fmt.Errorf("world: overwhelm quest rules missing") + } + r, e := s.overwhelmQuest(pack, quest) + if e != nil { + return e + } + mapID, e := s.currentFieldMap(ctx, pack) + if e != nil { + return e + } + gain := uint64(0) + for _, m := range targets { + switch r.Type { + case 3: + for _, id := range r.Targets { + if id == m.ID { + gain++ + } + } + case 1: + for _, enemy := range r.Enemies[m.Deck] { + for _, id := range r.Targets { + if enemy == id { + gain++ + } + } + } + case 8: + if len(r.Targets) > 0 && r.Targets[0] == uint64(mapID) { + gain += uint64(len(r.Enemies[m.Deck])) + } + } + } + old := uint64(0) + if p, ok := s.state.QuestInPack(quest, pack, s.questDifficultyFor(pack, quest)); ok && len(p.Values) > 0 { + old = uint64(p.Values[0]) + } + if gain == 0 || len(values) != 1 || values[0] != old+gain { + return fmt.Errorf("world: quest does not match overwhelmed monsters") + } + return nil +} diff --git a/go/internal/server/domain/world/pack_detail.go b/go/internal/server/domain/world/pack_detail.go new file mode 100644 index 0000000..23dad16 --- /dev/null +++ b/go/internal/server/domain/world/pack_detail.go @@ -0,0 +1,59 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "slices" +) + +func (s *Service) attachPackDetailDesign(source *gamedata.Source) { + s.packDetailDesign = func(packID int) (gamedata.PackDetailDesign, error) { + return source.PackDetail(packID) + } +} + +func (s *Service) packRewardCounts(ctx command.Context, packID int) (once, regen, research uint64, err error) { + pack, err := s.packDetailDesign(packID) + if err != nil { + return 0, 0, 0, err + } + ids, err := s.openedFieldObjects(ctx, packID) + if err != nil { + return 0, 0, 0, err + } + mapIDs := pack.MapIDs + for _, id := range ids { + object := s.fieldObjects[packID].Objects[id] + if !slices.Contains(mapIDs, object.MapID) { + continue + } + if matchesFieldCount(object, 2) { + once++ + } + if matchesFieldCount(object, 3) { + regen++ + } + } + ids, err = s.state.ResearchObjects(ctx, packID) + if err != nil || len(ids) == 0 { + return once, regen, 0, err + } + design, err := s.researchDesign(packID) + if err != nil { + return 0, 0, 0, err + } + for _, id := range ids { + object, found := design.Objects[id] + if !found || object.Reward.Type == 0 && object.CollectionID == 0 { + continue + } + seen := map[int]bool{} + for _, mapID := range object.Maps { + if !seen[mapID] && slices.Contains(mapIDs, mapID) { + research++ + seen[mapID] = true + } + } + } + return once, regen, research, nil +} diff --git a/go/internal/server/domain/world/pack_jam.go b/go/internal/server/domain/world/pack_jam.go new file mode 100644 index 0000000..ee353de --- /dev/null +++ b/go/internal/server/domain/world/pack_jam.go @@ -0,0 +1,7 @@ +package world + +import ( + "fmt" +) + +func packJamIdentity(packID int) string { return fmt.Sprintf("pack-jam:%d", packID) } diff --git a/go/internal/server/domain/world/pack_monsters.go b/go/internal/server/domain/world/pack_monsters.go new file mode 100644 index 0000000..08d42e4 --- /dev/null +++ b/go/internal/server/domain/world/pack_monsters.go @@ -0,0 +1,44 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "fmt" +) + +func (s *Service) packMonsterRows(ctx command.Context, pack int) ([][]byte, error) { + if s.packDetailDesign == nil { + return nil, fmt.Errorf("world: missing pack detail design loader") + } + design, err := s.packDetailDesign(pack) + if err != nil { + return nil, err + } + filter := make(map[int]bool, len(design.RegenMonsterIDs)) + for _, id := range design.RegenMonsterIDs { + filter[id] = true + } + return s.monsterRows(ctx, pack, filter) +} + +func (s *Service) packMonsterDefeatedCount(ctx command.Context, pack int) (uint64, error) { + rows, err := s.packMonsterRows(ctx, pack) + if err != nil { + return 0, err + } + var count uint64 + for _, row := range rows { + active, _, err := wire.Varint(row, 6) + if err != nil { + return 0, err + } + respawn, _, err := wire.Varint(row, 3) + if err != nil { + return 0, err + } + if active == 0 || respawn > uint64(s.monsterTime().UnixMilli()) { + count++ + } + } + return count, nil +} diff --git a/go/internal/server/domain/world/pack_monsters_test.go b/go/internal/server/domain/world/pack_monsters_test.go new file mode 100644 index 0000000..9638454 --- /dev/null +++ b/go/internal/server/domain/world/pack_monsters_test.go @@ -0,0 +1,194 @@ +//go:build integration + +package world + +import ( + "bytes" + "fmt" + "slices" + "testing" + "time" + + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/battle" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/world/progress" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/storage/stateio" +) + +// LocalText 991 describes weekly field rewards. Forest map rewards count +// placed regeneration monsters, independently of the already cleared story +// monsters and optional main difficulty (FieldMonsterController.Regen). +func TestForestMonsterVictorySurvivesDifficultyChangeUntilWeeklyReset(t *testing.T) { + ctx, service, source := fieldQuestIntegration(t, 1) + versions, err := versionconfig.Find() + if err != nil { + t.Fatal(err) + } + root := versions.Resolve("data/resources/GameData") + service.attachFieldMonsterDesign(source) + service.attachPackDetailDesign(source) + service.fieldReset, err = gamedata.LoadFieldResetSchedule(root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + if err := service.AttachFieldMonsterState(stateio.RootStore{}); err != nil { + t.Fatal(err) + } + now := time.Date(2026, time.October, 7, 0, 0, 0, 0, time.UTC) + service.monsterNow = func() time.Time { return now } + if err := service.state.ClearQuest(ctx, 46, 1, 0); err != nil { + t.Fatal(err) + } + monsters, err := source.FieldMonsters(1) + if err != nil { + t.Fatal(err) + } + var forest gamedata.FieldMonsterDesign + for _, m := range monsters { + if m.ID == 101 { + forest = m + } + } + placements, err := source.FieldMonsterMaps(1) + if err != nil { + t.Fatal(err) + } + position := wire.AppendBytes(wire.AppendVarint(nil, 2, 1), 3, []byte(fmt.Sprintf(`{"MapId":%d}`, placements[forest.ID][0]))) + if err := service.state.SaveUserPosition(ctx, position); err != nil { + t.Fatal(err) + } + wallet, err := assets.OpenWallet(ctx, stateio.RootStore{}, assets.Currency{}) + if err != nil { + t.Fatal(err) + } + items, err := assets.OpenInventory(ctx, stateio.RootStore{}, nil) + if err != nil { + t.Fatal(err) + } + stacks, err := gamedata.LoadItemStackDesign(root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + if err := items.AttachItemStacks(stacks); err != nil { + t.Fatal(err) + } + graph, err := gamedata.LoadRewardGraph(root, versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + battles := battle.NewService(root, versions.GameDataVersion, service.CurrentPackID) + battles.AttachFieldMonsters(service) + battles.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) { + definitions := make([]gamedata.BattleReward, len(rewards)) + for i, reward := range rewards { + definitions[i] = gamedata.BattleReward(reward) + } + resolved, err := graph.Resolve(definitions) + if err != nil { + return nil, err + } + var walletRewards []gamedata.Reward + var inventoryRewards []gamedata.BattleReward + for _, reward := range resolved { + if reward.Type == 4 { + walletRewards = append(walletRewards, gamedata.Reward(reward)) + } else { + inventoryRewards = append(inventoryRewards, reward) + } + } + if _, err := wallet.GrantQuestOnce(ctx, identity, walletRewards); err != nil { + return nil, err + } + _, err = items.GrantOnce(ctx, identity, inventoryRewards) + return nil, err + }) + handle := func(path string, request []byte) []byte { + t.Helper() + _, response, handled, err := battles.Handle(ctx, path, request) + if err != nil || !handled { + t.Fatalf("%s: handled=%v err=%v", path, handled, err) + } + return response + } + start := func(seq uint64) { + t.Helper() + request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 3, uint64(forest.ID)), 4, forest.BattleDeck), 5, 2) + request = wire.AppendVarint(request, 2, uint64(forest.GroupID)) + handle("/BattleEnter", request) + handle("/BattleStart", wire.AppendVarint(wire.AppendVarint(nil, 1, seq+1), 2, forest.BattleDeck)) + } + assertCount := func(want uint64) { + t.Helper() + count, err := service.packMonsterDefeatedCount(ctx, 1) + if err != nil || count != want { + t.Fatalf("forest defeat count=%d want=%d err=%v", count, want, err) + } + } + assertCount(0) + start(1) + handle("/BattleEnd", wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 2)) + assertCount(0) + if wallet.Snapshot(ctx).Gold != 0 || len(items.All(ctx)) != 0 { + t.Fatal("a lost encounter granted victory rewards") + } + start(4) + if err := service.state.SelectQuest(ctx, 1, progress.QuestSelection{Difficulty: 0}); err != nil { + t.Fatal(err) + } + end := wire.AppendVarint(wire.AppendVarint(nil, 1, 6), 2, 1) + response := handle("/BattleEnd", end) + updates, err := nestedMessages(response, 4) + if err != nil || len(updates) != 1 { + t.Fatalf("victory must supply the defeated monster to the client cache: rows=%d err=%v", len(updates), err) + } + assertCount(1) + gold, awarded := wallet.Snapshot(ctx).Gold, items.All(ctx) + if gold == 0 || len(awarded) == 0 { + t.Fatal("authored forest victory rewards were not granted") + } + if retry := handle("/BattleEnd", end); !bytes.Equal(response, retry) || wallet.Snapshot(ctx).Gold != gold || !slices.Equal(awarded, items.All(ctx)) { + t.Fatal("settlement retry changed the reply or granted a second reward") + } + if err := service.state.SelectQuest(ctx, 1, progress.QuestSelection{Difficulty: 2}); err != nil { + t.Fatal(err) + } + assertCount(1) + service.state, err = progress.OpenStore(ctx, stateio.RootStore{}) + if err != nil { + t.Fatal(err) + } + assertCount(1) + packInfo, err := service.packInfoFor(ctx, 1) + if err != nil { + t.Fatal(err) + } + packMonsters, err := nestedMessages(packInfo, 6) + if err != nil { + t.Fatal(err) + } + var restored []byte + for _, row := range packMonsters { + id, _, err := wire.Varint(row, 1) + if err != nil { + t.Fatal(err) + } + if id == uint64(forest.ID) { + restored = row + } + } + if !bytes.Equal(restored, updates[0]) { + t.Fatal("pack re-entry resurrected or lost the defeated forest encounter") + } + reset := time.Date(2026, time.October, 12, 0, 0, 0, 0, time.UTC) + now = reset.Add(-time.Millisecond) + assertCount(1) + now = reset + assertCount(0) + if wallet.Snapshot(ctx).Gold != gold { + t.Fatal("weekly reset granted another reward before another victory") + } +} diff --git a/go/internal/server/domain/world/pack_purchase.go b/go/internal/server/domain/world/pack_purchase.go new file mode 100644 index 0000000..42fb87b --- /dev/null +++ b/go/internal/server/domain/world/pack_purchase.go @@ -0,0 +1,74 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "fmt" +) + +func packPurchaseIdentity(id int) string { return fmt.Sprintf("pack-purchase:%d", id) } + +func (s *Service) grantPurchaseRewards(ctx command.Context, identity string, rewards []gamedata.Reward) ([]assets.Item, error) { + var itemRewards []gamedata.BattleReward + for _, reward := range rewards { + if reward.Count == 0 && reward.Type != 11 { + return nil, fmt.Errorf("world: empty pack purchase reward") + } + if reward.Type == 19 { + if reward.ID == 0 { + return nil, fmt.Errorf("world: invalid pack ticket") + } + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 + } + } + var costumeIDs []uint64 + for _, reward := range rewards { + if reward.Type == 11 { + if s.questCostumes == nil { + return nil, fmt.Errorf("world: missing purchase costume design") + } + if _, found := s.questCostumes.Character(reward.ID); !found { + return nil, fmt.Errorf("world: missing purchase costume %d", reward.ID) + } + costumeIDs = append(costumeIDs, reward.ID) + } + } + if len(costumeIDs) > 0 { + grant, err := s.collection.GrantCostumes(ctx, identity+":costumes", costumeIDs, s.questCostumes) + if err != nil { + return nil, err + } + var exchanges []gamedata.Reward + for _, x := range grant.Exchanges { + if !purchaseCurrency(x.ExchangeItemType) || x.ExchangeItemID != 0 { + return nil, fmt.Errorf("world: unsupported purchase costume exchange") + } + exchanges = append(exchanges, gamedata.Reward{Type: x.ExchangeItemType, ID: x.ExchangeItemID, Count: x.ExchangeCount}) + } + if len(exchanges) > 0 { + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":costumes:exchange", exchanges); err != nil { + return nil, err + } + } + } + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":currency", rewards); err != nil { + return nil, err + } + if len(itemRewards) == 0 { + return nil, nil + } + items, err := s.inventory.GrantOnce(ctx, identity+":items", itemRewards) + if err == nil && len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + return items, err +} + +func purchaseCurrency(typ uint64) bool { + switch typ { + case 2, 3, 4, 12, 20: + return true + } + return false +} diff --git a/go/internal/server/domain/world/pack_reward_counts.go b/go/internal/server/domain/world/pack_reward_counts.go new file mode 100644 index 0000000..bd2258d --- /dev/null +++ b/go/internal/server/domain/world/pack_reward_counts.go @@ -0,0 +1,50 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "fmt" +) + +func (s *Service) AttachResearchRuntime(source *gamedata.Source, chars map[uint64]bool, economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +}) error { + if economy == nil { + return fmt.Errorf("world: nil research economy") + } + s.researchCharacters = chars + s.researchEconomy = economy + s.researchDesigns = map[int]gamedata.FieldResearchDesign{} + s.researchLoader = func(pack int) (gamedata.FieldResearchDesign, error) { + return source.FieldResearch(pack) + } + return nil +} +func (s *Service) researchDesign(pack int) (gamedata.FieldResearchDesign, error) { + if d, ok := s.researchDesigns[pack]; ok { + return d, nil + } + if s.researchLoader == nil { + return gamedata.FieldResearchDesign{}, fmt.Errorf("world: research design unavailable") + } + d, err := s.researchLoader(pack) + if err != nil { + return d, err + } + s.researchDesigns[pack] = d + return d, nil +} + +func matchesFieldCount(obj gamedata.FieldRewardObject, category uint64) bool { + // PackMapRewardInfo uses independent predicates; a normal or hidden box + // with a one-time reset belongs in both once and acquisition totals. + switch category { + case 2: + return obj.ResetType == 1 && obj.Type != 5 + case 3: + return obj.Type == 1 || obj.Type == 3 + case 4: + return obj.Type == 6 && obj.ResetType == 2 + } + return false +} diff --git a/go/internal/server/progress/chests.go b/go/internal/server/domain/world/progress/chests.go similarity index 68% rename from go/internal/server/progress/chests.go rename to go/internal/server/domain/world/progress/chests.go index a6a7544..e10fda8 100644 --- a/go/internal/server/progress/chests.go +++ b/go/internal/server/domain/world/progress/chests.go @@ -1,7 +1,8 @@ package progress import ( - "bd2server/internal/server/stateio" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" "fmt" "sort" "strconv" @@ -14,29 +15,27 @@ func fieldRewardKey(pack, id int) string { // FieldRewardOpened reads persisted entries on every call; request rollback // therefore does not leave an opened-object cache behind. -func (s *Store) FieldRewardOpened(pack, id int, period string) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) FieldRewardOpened(ctx command.Context, pack, id int, period string) (bool, error) { + store, err := s.fieldRewardEntries() if err != nil { return false, err } - raw, found, err := store.LoadEntry("progress", "field_rewards", fieldRewardKey(pack, id)) + raw, found, err := store.LoadEntry(ctx.State, "progress", "field_rewards", fieldRewardKey(pack, id)) return found && string(raw) == period, err } -func (s *Store) fieldRewardEntries() (stateio.EntryStore, error) { +func (s *Store) fieldRewardEntries() (stateio.ScopedEntryStore, error) { if s.storage == nil { - s.storage = stateio.NewMemory() + s.storage = stateio.RootStore{} } - store, ok := s.storage.(stateio.EntryStore) + store, ok := s.storage.(stateio.ScopedEntryStore) if !ok { return nil, fmt.Errorf("progress: field rewards require entry storage") } return store, nil } -func (s *Store) MarkFieldRewardOpened(pack, id int, period string) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) MarkFieldRewardOpened(ctx command.Context, pack, id int, period string) error { + if pack <= 0 || id <= 0 { return fmt.Errorf("progress: invalid field reward identity") } @@ -44,16 +43,15 @@ func (s *Store) MarkFieldRewardOpened(pack, id int, period string) error { if err != nil { return err } - return store.PutEntry("progress", "field_rewards", fieldRewardKey(pack, id), []byte(period)) + return store.PutEntry(ctx.State, "progress", "field_rewards", fieldRewardKey(pack, id), []byte(period)) } -func (s *Store) OpenedFieldRewards(pack int) ([]int, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) OpenedFieldRewards(ctx command.Context, pack int) ([]int, error) { + store, err := s.fieldRewardEntries() if err != nil { return nil, err } - entries, err := store.ListEntries("progress", "field_rewards") + entries, err := store.ListEntries(ctx.State, "progress", "field_rewards") if err != nil { return nil, err } @@ -78,14 +76,13 @@ func (s *Store) OpenedFieldRewards(pack int) ([]int, error) { // OpenedFieldRewardPeriods reads the complete opened-object snapshot once from // the current request transaction. The returned maps are owned by the caller; // nothing is cached across requests or transaction rollback. -func (s *Store) OpenedFieldRewardPeriods() (map[int]map[int]string, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) OpenedFieldRewardPeriods(ctx command.Context) (map[int]map[int]string, error) { + store, err := s.fieldRewardEntries() if err != nil { return nil, err } - entries, err := store.ListEntries("progress", "field_rewards") + entries, err := store.ListEntries(ctx.State, "progress", "field_rewards") if err != nil { return nil, err } diff --git a/go/internal/server/progress/field_actions.go b/go/internal/server/domain/world/progress/field_actions.go similarity index 73% rename from go/internal/server/progress/field_actions.go rename to go/internal/server/domain/world/progress/field_actions.go index 7b0f816..13efab7 100644 --- a/go/internal/server/progress/field_actions.go +++ b/go/internal/server/domain/world/progress/field_actions.go @@ -1,6 +1,7 @@ package progress import ( + "bd2server/internal/server/domain/command" "encoding/json" "fmt" "strconv" @@ -14,9 +15,8 @@ type FieldActionPosition struct { QuestCleared bool `json:"quest_cleared"` } -func (s *Store) SaveFieldActionPosition(pack, id int, position FieldActionPosition) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) SaveFieldActionPosition(ctx command.Context, pack, id int, position FieldActionPosition) error { + if pack <= 0 || id <= 0 || len(position.Position) == 0 { return fmt.Errorf("progress: invalid field action position") } @@ -28,17 +28,16 @@ func (s *Store) SaveFieldActionPosition(pack, id int, position FieldActionPositi if err != nil { return err } - return store.PutEntry("progress", "field_action_positions", fieldRewardKey(pack, id), raw) + return store.PutEntry(ctx.State, "progress", "field_action_positions", fieldRewardKey(pack, id), raw) } -func (s *Store) FieldActionPositions(pack int) (map[int]FieldActionPosition, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) FieldActionPositions(ctx command.Context, pack int) (map[int]FieldActionPosition, error) { + store, err := s.fieldRewardEntries() if err != nil { return nil, err } - entries, err := store.ListEntries("progress", "field_action_positions") + entries, err := store.ListEntries(ctx.State, "progress", "field_action_positions") if err != nil { return nil, err } diff --git a/go/internal/server/domain/world/progress/field_buffs.go b/go/internal/server/domain/world/progress/field_buffs.go new file mode 100644 index 0000000..79dcf9d --- /dev/null +++ b/go/internal/server/domain/world/progress/field_buffs.go @@ -0,0 +1,36 @@ +package progress + +import ( + "bd2server/internal/server/domain/command" + "fmt" + "strconv" +) + +func (s *Store) RemoveFieldBuff(ctx command.Context, id uint64) error { + + store, err := s.fieldRewardEntries() + if err != nil { + return err + } + _, err = store.DeleteEntry(ctx.State, "progress", "field_buffs", strconv.FormatUint(id, 10)) + return err +} + +func (s *Store) ClaimFieldBuffBattle(ctx command.Context, identity string) (bool, error) { + + if identity == "" { + return false, fmt.Errorf("progress: empty field buff battle identity") + } + store, err := s.fieldRewardEntries() + if err != nil { + return false, err + } + _, found, err := store.LoadEntry(ctx.State, "progress", "field_buff_battles", identity) + if err != nil || found { + return false, err + } + if err = store.PutEntry(ctx.State, "progress", "field_buff_battles", identity, []byte("used")); err != nil { + return false, err + } + return true, nil +} diff --git a/go/internal/server/progress/progress.go b/go/internal/server/domain/world/progress/progress.go similarity index 61% rename from go/internal/server/progress/progress.go rename to go/internal/server/domain/world/progress/progress.go index 090455a..31a013e 100644 --- a/go/internal/server/progress/progress.go +++ b/go/internal/server/domain/world/progress/progress.go @@ -3,7 +3,8 @@ package progress import ( - "encoding/binary" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/storage/stateio" "encoding/json" "errors" "fmt" @@ -11,10 +12,6 @@ import ( "sort" "strconv" "strings" - "sync" - - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" ) var ( @@ -59,12 +56,12 @@ type Store struct { activePackID int startingPackID int selections map[string]QuestSelection - mu sync.RWMutex - storage stateio.Store - position SavedPosition - tutorials map[int]struct{} - quests map[string]QuestProgress - cleared map[string]struct{} + + storage stateio.Store + position SavedPosition + tutorials map[int]struct{} + quests map[string]QuestProgress + cleared map[string]struct{} } func NewStore() *Store { @@ -105,13 +102,13 @@ func parseQuestKey(key string) (int, int, int, bool) { } // OpenStore recovers player progress from its domain snapshot. -func OpenStore(storage stateio.Store) (*Store, error) { +func OpenStore(ctx command.Context, storage stateio.Store) (*Store, error) { s := NewStore() if storage == nil { return nil, errors.New("progress: nil storage") } s.storage = storage - data, err := storage.Load("progress") + data, err := storage.Load(ctx.State, "progress") if err != nil { return nil, fmt.Errorf("progress: load state: %w", err) } @@ -174,22 +171,21 @@ func OpenStore(storage stateio.Store) (*Store, error) { return s, nil } -func (s *Store) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - data, err := s.storage.Load("progress") +func (s *Store) EnsurePersisted(ctx command.Context) error { + + data, err := s.storage.Load(ctx.State, "progress") if err != nil { return err } if data != nil { return nil } - return s.commit(s.position, s.tutorials, s.quests, s.cleared) + return s.commit(ctx, s.position, s.tutorials, s.quests, s.cleared) } // commit writes a complete snapshot before exposing the new state. Caller // holds mu; failure leaves the in-memory player state unchanged. -func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error { +func (s *Store) commit(ctx command.Context, position SavedPosition, tutorials map[int]struct{}, quests map[string]QuestProgress, cleared map[string]struct{}) error { if s.storage != nil { state := snapshot{ActivePackID: s.activePackID, StartPackID: s.startingPackID, Selections: s.selections, Version: snapshotVersion, Position: position, Quests: quests, Cleared: make(map[string]json.RawMessage, len(cleared))} for id := range tutorials { @@ -203,7 +199,7 @@ func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quest if err != nil { return err } - if err := s.storage.Save("progress", data); err != nil { + if err := s.storage.Save(ctx.State, "progress", data); err != nil { return fmt.Errorf("progress: save state: %w", err) } } @@ -211,105 +207,8 @@ func (s *Store) commit(position SavedPosition, tutorials map[int]struct{}, quest return nil } -// UpdateQuest consumes QuestUpdateRequest: field 1 seq, field 2 quest_id, -// field 3 pack_id, and repeated packed/unpacked int32 field 4 quest_value. -// It returns the quest id that the response must echo as update_quest_id. -func (s *Store) UpdateQuest(request []byte) (int, error) { - questID, found, err := wire.Varint(request, 2) - if err != nil || !found || questID == 0 || questID > uint64(^uint32(0)>>1) { - return 0, fmt.Errorf("%w: quest id", ErrInvalidQuest) - } - packID, found, err := wire.Varint(request, 3) - if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) { - return 0, fmt.Errorf("%w: pack id", ErrInvalidQuest) - } - values := make([]int, 0, 4) - if err := wire.Walk(request, func(field wire.Field) error { - if field.Number != 4 { - return nil - } - switch field.Type { - case 0: - value, count := binary.Uvarint(field.Value) - if count <= 0 || value > uint64(^uint32(0)>>1) { - return ErrInvalidQuest - } - values = append(values, int(value)) - case 2: - for remaining := field.Value; len(remaining) > 0; { - value, count := binary.Uvarint(remaining) - if count <= 0 || value > uint64(^uint32(0)>>1) { - return ErrInvalidQuest - } - values = append(values, int(value)) - remaining = remaining[count:] - } - default: - return ErrInvalidQuest - } - return nil - }); err != nil { - return 0, fmt.Errorf("%w: quest values", ErrInvalidQuest) - } - - progress := QuestProgress{QuestID: int(questID), PackID: int(packID), Values: append([]int(nil), values...)} - s.mu.Lock() - defer s.mu.Unlock() - quests := make(map[string]QuestProgress, len(s.quests)+1) - maps.Copy(quests, s.quests) - selection := s.selections[strconv.Itoa(progress.PackID)] - progress.Difficulty = selection.Difficulty - if _, acceptedNormal := s.quests[questKey(progress.PackID, progress.QuestID)]; acceptedNormal && selection.QuestID != progress.QuestID { - progress.Difficulty = 0 - } - quests[questKey(progress.PackID, progress.QuestID, progress.Difficulty)] = progress - if err := s.commit(s.position, s.tutorials, quests, s.cleared); err != nil { - return 0, err - } - return progress.QuestID, nil -} - -// SaveUserPosition consumes SaveUserPositionRequest: -// field 1 seq, field 2 pack_id, field 3 pack_position JSON. -func (s *Store) SaveUserPosition(request []byte) error { - packID, found, err := wire.Varint(request, 2) - if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) { - return fmt.Errorf("%w: pack id", ErrInvalidPosition) - } - raw, found, err := wire.Bytes(request, 3) - if err != nil || !found || len(raw) == 0 || len(raw) > 64<<10 { - return fmt.Errorf("%w: position JSON", ErrInvalidPosition) - } - var position Position - if err := json.Unmarshal(raw, &position); err != nil || position.MapID <= 0 { - return fmt.Errorf("%w: decode JSON", ErrInvalidPosition) - } - - s.mu.Lock() - defer s.mu.Unlock() - return s.commit(SavedPosition{Difficulty: s.selections[strconv.Itoa(int(packID))].Difficulty, PackID: int(packID), Position: position, RawJSON: string(raw)}, s.tutorials, s.quests, s.cleared) -} - -// ClearTutorial consumes TutorialClearRequest: field 1 seq, field 2 id. -// Repeated requests are idempotent. -func (s *Store) ClearTutorial(request []byte) error { - id, found, err := wire.Varint(request, 2) - if err != nil || !found || id == 0 || id > uint64(^uint32(0)>>1) { - return ErrInvalidTutorial - } - s.mu.Lock() - defer s.mu.Unlock() - tutorials := make(map[int]struct{}, len(s.tutorials)+1) - for cleared := range s.tutorials { - tutorials[cleared] = struct{}{} - } - tutorials[int(id)] = struct{}{} - return s.commit(s.position, tutorials, s.quests, s.cleared) -} - func (s *Store) Position() (SavedPosition, bool) { - s.mu.RLock() - defer s.mu.RUnlock() + return s.position, s.position.PackID != 0 } @@ -317,9 +216,8 @@ func (s *Store) Position() (SavedPosition, bool) { // client suppresses SaveUserPosition in hidden packs, retaining the outside // field position rather than choosing a temporary hidden scene on relogin. // Zero means no position has been committed, so LoginUser can keep its seed. -func (s *Store) LastPlayedPackID() (uint64, error) { - s.mu.RLock() - defer s.mu.RUnlock() +func (s *Store) LastPlayedPackID(ctx command.Context) (uint64, error) { + if s.position.PackID == 0 { return 0, nil } @@ -330,15 +228,13 @@ func (s *Store) LastPlayedPackID() (uint64, error) { } func (s *Store) TutorialCleared(id int) bool { - s.mu.RLock() - defer s.mu.RUnlock() + _, found := s.tutorials[id] return found } func (s *Store) Tutorials() []int { - s.mu.RLock() - defer s.mu.RUnlock() + ids := make([]int, 0, len(s.tutorials)) for id := range s.tutorials { ids = append(ids, id) @@ -347,29 +243,8 @@ func (s *Store) Tutorials() []int { return ids } -// Handle exposes the persisted tutorial set through TutorialInfoResponse. -// TutorialClear itself remains in session so both routes share this store. -func (s *Store) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/TutorialInfo" { - return 0, nil, false, nil - } - if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 { - return 0, nil, true, ErrInvalidTutorial - } - var packed []byte - for _, id := range s.Tutorials() { - packed = binary.AppendUvarint(packed, uint64(id)) - } - var response []byte - if len(packed) > 0 { - response = wire.AppendBytes(response, 1, packed) - } - return 101, response, true, nil -} - func (s *Store) Quest(id int) (QuestProgress, bool) { - s.mu.RLock() - defer s.mu.RUnlock() + var quest QuestProgress found := false for _, candidate := range s.quests { @@ -388,8 +263,7 @@ func (s *Store) Quest(id int) (QuestProgress, bool) { } func (s *Store) QuestInPack(questID, packID int, level ...int) (QuestProgress, bool) { - s.mu.RLock() - defer s.mu.RUnlock() + quest, found := s.quests[questKey(packID, questID, level...)] quest.Values = append([]int(nil), quest.Values...) return quest, found @@ -397,12 +271,11 @@ func (s *Store) QuestInPack(questID, packID int, level ...int) (QuestProgress, b // ClearQuest atomically records a completed quest. Quest identity is the // (pack, quest) pair because every story pack starts numbering from one. -func (s *Store) ClearQuest(questID, packID int, level ...int) error { +func (s *Store) ClearQuest(ctx command.Context, questID, packID int, level ...int) error { if questID <= 0 || packID <= 0 || questDifficulty(level) < 0 || questDifficulty(level) > 4 { return ErrInvalidQuest } - s.mu.Lock() - defer s.mu.Unlock() + key := questKey(packID, questID, level...) if _, ok := s.cleared[key]; ok { return nil @@ -412,12 +285,11 @@ func (s *Store) ClearQuest(questID, packID int, level ...int) error { cleared[current] = struct{}{} } cleared[key] = struct{}{} - return s.commit(s.position, s.tutorials, s.quests, cleared) + return s.commit(ctx, s.position, s.tutorials, s.quests, cleared) } func (s *Store) QuestCleared(questID, packID int, level ...int) bool { - s.mu.RLock() - defer s.mu.RUnlock() + _, found := s.cleared[questKey(packID, questID, level...)] return found } @@ -425,8 +297,7 @@ func (s *Store) QuestCleared(questID, packID int, level ...int) bool { // ClearedQuests returns sorted quest IDs for one pack. The detached slice is // safe for response construction without holding the store lock. func (s *Store) ClearedQuests(packID int, level ...int) []int { - s.mu.RLock() - defer s.mu.RUnlock() + ids := make([]int, 0, len(s.cleared)) for key := range s.cleared { currentPack, difficulty, questID, ok := parseQuestKey(key) @@ -440,39 +311,36 @@ func (s *Store) ClearedQuests(packID int, level ...int) []int { // Selection returns the committed quest selection for a pack. func (s *Store) Selection(packID int) (QuestSelection, bool) { - s.mu.RLock() - defer s.mu.RUnlock() + value, ok := s.selections[strconv.Itoa(packID)] return value, ok } -func (s *Store) SelectQuest(packID int, selection QuestSelection) error { +func (s *Store) SelectQuest(ctx command.Context, packID int, selection QuestSelection) error { if packID <= 0 || selection.QuestID < 0 || selection.Difficulty < 0 || selection.Difficulty > 4 || selection.Option < 0 { return ErrInvalidQuest } - s.mu.Lock() - defer s.mu.Unlock() + old := s.selections next := make(map[string]QuestSelection, len(old)+1) maps.Copy(next, old) next[strconv.Itoa(packID)] = selection s.selections = next - if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { + if err := s.commit(ctx, s.position, s.tutorials, s.quests, s.cleared); err != nil { s.selections = old return err } return nil } -func (s *Store) StartPackID() int { s.mu.RLock(); defer s.mu.RUnlock(); return s.startingPackID } -func (s *Store) SetStartPack(packID int) error { +func (s *Store) StartPackID() int { return s.startingPackID } +func (s *Store) SetStartPack(ctx command.Context, packID int) error { if packID <= 0 { return ErrInvalidQuest } - s.mu.Lock() - defer s.mu.Unlock() + previous := s.startingPackID s.startingPackID = packID - if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { + if err := s.commit(ctx, s.position, s.tutorials, s.quests, s.cleared); err != nil { s.startingPackID = previous return err } @@ -480,34 +348,31 @@ func (s *Store) SetStartPack(packID int) error { } // AcceptQuest commits an explicit active quest without replacing main selection. -func (s *Store) AcceptQuest(questID, packID, difficulty int) error { +func (s *Store) AcceptQuest(ctx command.Context, questID, packID, difficulty int) error { if questID <= 0 || packID <= 0 || difficulty < 0 || difficulty > 4 { return ErrInvalidQuest } - s.mu.Lock() - defer s.mu.Unlock() + quests := make(map[string]QuestProgress, len(s.quests)+1) maps.Copy(quests, s.quests) key := questKey(packID, questID, difficulty) if _, exists := quests[key]; !exists { quests[key] = QuestProgress{QuestID: questID, PackID: packID, Difficulty: difficulty} } - return s.commit(s.position, s.tutorials, quests, s.cleared) + return s.commit(ctx, s.position, s.tutorials, quests, s.cleared) } -func (s *Store) RemoveQuest(questID, packID, difficulty int) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) RemoveQuest(ctx command.Context, questID, packID, difficulty int) error { + quests := make(map[string]QuestProgress, len(s.quests)) for key, value := range s.quests { if key != questKey(packID, questID, difficulty) { quests[key] = value } } - return s.commit(s.position, s.tutorials, quests, s.cleared) + return s.commit(ctx, s.position, s.tutorials, quests, s.cleared) } func (s *Store) QuestsInPack(packID, difficulty int) []QuestProgress { - s.mu.RLock() - defer s.mu.RUnlock() + var out []QuestProgress for _, quest := range s.quests { if quest.PackID == packID && quest.Difficulty == difficulty { @@ -519,16 +384,15 @@ func (s *Store) QuestsInPack(packID, difficulty int) []QuestProgress { return out } -func (s *Store) ActivePackID() int { s.mu.RLock(); defer s.mu.RUnlock(); return s.activePackID } -func (s *Store) SetActivePackID(packID int) error { +func (s *Store) ActivePackID() int { return s.activePackID } +func (s *Store) SetActivePackID(ctx command.Context, packID int) error { if packID <= 0 { return ErrInvalidQuest } - s.mu.Lock() - defer s.mu.Unlock() + old := s.activePackID s.activePackID = packID - if err := s.commit(s.position, s.tutorials, s.quests, s.cleared); err != nil { + if err := s.commit(ctx, s.position, s.tutorials, s.quests, s.cleared); err != nil { s.activePackID = old return err } diff --git a/go/internal/server/domain/world/progress/protocol_adapter.go b/go/internal/server/domain/world/progress/protocol_adapter.go new file mode 100644 index 0000000..4420ecf --- /dev/null +++ b/go/internal/server/domain/world/progress/protocol_adapter.go @@ -0,0 +1,178 @@ +package progress + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "encoding/binary" + "encoding/json" + "fmt" + "log/slog" + "maps" + "slices" + "strconv" +) + +// UpdateQuest consumes QuestUpdateRequest: field 1 seq, field 2 quest_id, +// field 3 pack_id, and repeated packed/unpacked int32 field 4 quest_value. +// It returns the quest id that the response must echo as update_quest_id. +func (s *Store) UpdateQuest(ctx command.Context, request []byte) (int, error) { + questID, found, err := wire.Varint(request, 2) + if err != nil || !found || questID == 0 || questID > uint64(^uint32(0)>>1) { + return 0, fmt.Errorf("%w: quest id", ErrInvalidQuest) + } + packID, found, err := wire.Varint(request, 3) + if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) { + return 0, fmt.Errorf("%w: pack id", ErrInvalidQuest) + } + values := make([]int, 0, 4) + if err := wire.Walk(request, func(field wire.Field) error { + if field.Number != 4 { + return nil + } + switch field.Type { + case 0: + value, count := binary.Uvarint(field.Value) + if count <= 0 || value > uint64(^uint32(0)>>1) { + return ErrInvalidQuest + } + values = append(values, int(value)) + case 2: + for remaining := field.Value; len(remaining) > 0; { + value, count := binary.Uvarint(remaining) + if count <= 0 || value > uint64(^uint32(0)>>1) { + return ErrInvalidQuest + } + values = append(values, int(value)) + remaining = remaining[count:] + } + default: + return ErrInvalidQuest + } + return nil + }); err != nil { + return 0, fmt.Errorf("%w: quest values", ErrInvalidQuest) + } + + progress := QuestProgress{QuestID: int(questID), PackID: int(packID), Values: append([]int(nil), values...)} + + quests := make(map[string]QuestProgress, len(s.quests)+1) + maps.Copy(quests, s.quests) + selection := s.selections[strconv.Itoa(progress.PackID)] + progress.Difficulty = selection.Difficulty + if _, acceptedNormal := s.quests[questKey(progress.PackID, progress.QuestID)]; acceptedNormal && selection.QuestID != progress.QuestID { + progress.Difficulty = 0 + } + quests[questKey(progress.PackID, progress.QuestID, progress.Difficulty)] = progress + if err := s.commit(ctx, s.position, s.tutorials, quests, s.cleared); err != nil { + return 0, err + } + return progress.QuestID, nil +} + +// SaveUserPosition consumes SaveUserPositionRequest: +// field 1 seq, field 2 pack_id, field 3 pack_position JSON. +func (s *Store) SaveUserPosition(ctx command.Context, request []byte) error { + packID, found, err := wire.Varint(request, 2) + if err != nil || !found || packID == 0 || packID > uint64(^uint32(0)>>1) { + return fmt.Errorf("%w: pack id", ErrInvalidPosition) + } + raw, found, err := wire.Bytes(request, 3) + if err != nil || !found || len(raw) == 0 || len(raw) > 64<<10 { + return fmt.Errorf("%w: position JSON", ErrInvalidPosition) + } + var position Position + if err := json.Unmarshal(raw, &position); err != nil || position.MapID <= 0 { + return fmt.Errorf("%w: decode JSON", ErrInvalidPosition) + } + + return s.commit(ctx, SavedPosition{Difficulty: s.selections[strconv.Itoa(int(packID))].Difficulty, PackID: int(packID), Position: position, RawJSON: string(raw)}, s.tutorials, s.quests, s.cleared) +} + +// ClearTutorial consumes TutorialClearRequest: field 1 seq, field 2 id. +// Repeated requests are idempotent. +func (s *Store) ClearTutorial(ctx command.Context, request []byte) error { + id, found, err := wire.Varint(request, 2) + if err != nil || !found || id == 0 || id > uint64(^uint32(0)>>1) { + return ErrInvalidTutorial + } + + tutorials := make(map[int]struct{}, len(s.tutorials)+1) + for cleared := range s.tutorials { + tutorials[cleared] = struct{}{} + } + tutorials[int(id)] = struct{}{} + return s.commit(ctx, s.position, tutorials, s.quests, s.cleared) +} + +func (s *Store) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + switch path { + case "/SaveUserPosition": + if err := s.SaveUserPosition(ctx, request); err != nil { + return 0, nil, true, fmt.Errorf("%s: %w", path, err) + } + if saved, found := s.Position(); found { + slog.Info("field position saved", "pack", saved.PackID, "map", saved.Position.MapID) + } + return 7, nil, true, nil + case "/TutorialClear": + if err := s.ClearTutorial(ctx, request); err != nil { + return 0, nil, true, fmt.Errorf("%s: %w", path, err) + } + return 102, nil, true, nil + case "/TutorialInfo": + if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 { + return 0, nil, true, ErrInvalidTutorial + } + var packed []byte + for _, id := range s.Tutorials() { + packed = binary.AppendUvarint(packed, uint64(id)) + } + var response []byte + if len(packed) > 0 { + response = wire.AppendBytes(response, 1, packed) + } + return 101, response, true, nil + default: + return 0, nil, false, nil + } +} + +func (s *Store) SaveFieldBuff(ctx command.Context, id uint64, raw []byte) error { + + storedID, _, err := wire.Varint(raw, 1) + if err != nil || id == 0 || id > 0x7fffffff || storedID != id { + return fmt.Errorf("progress: invalid field buff identity") + } + store, err := s.fieldRewardEntries() + if err != nil { + return err + } + return store.PutEntry(ctx.State, "progress", "field_buffs", strconv.FormatUint(id, 10), raw) +} + +func (s *Store) FieldBuffs(ctx command.Context) ([][]byte, error) { + + store, err := s.fieldRewardEntries() + if err != nil { + return nil, err + } + entries, err := store.ListEntries(ctx.State, "progress", "field_buffs") + if err != nil { + return nil, err + } + ids := make([]uint64, 0, len(entries)) + for key, raw := range entries { + id, err := strconv.ParseUint(key, 10, 32) + storedID, _, parseErr := wire.Varint(raw, 1) + if err != nil || parseErr != nil || id == 0 || id > 0x7fffffff || id != storedID { + return nil, fmt.Errorf("progress: invalid saved field buff") + } + ids = append(ids, id) + } + slices.Sort(ids) + var out [][]byte + for _, id := range ids { + out = append(out, entries[strconv.FormatUint(id, 10)]) + } + return out, nil +} diff --git a/go/internal/server/progress/research.go b/go/internal/server/domain/world/progress/research.go similarity index 54% rename from go/internal/server/progress/research.go rename to go/internal/server/domain/world/progress/research.go index 4cf498f..8ecfbd9 100644 --- a/go/internal/server/progress/research.go +++ b/go/internal/server/domain/world/progress/research.go @@ -1,20 +1,20 @@ package progress import ( + "bd2server/internal/server/domain/command" "fmt" "sort" "strconv" "strings" ) -func (s *Store) ResearchObjects(pack int) ([]int, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) ResearchObjects(ctx command.Context, pack int) ([]int, error) { + store, err := s.fieldRewardEntries() if err != nil { return nil, err } - entries, err := store.ListEntries("progress", "field_research") + entries, err := store.ListEntries(ctx.State, "progress", "field_research") if err != nil { return nil, err } @@ -35,18 +35,16 @@ func (s *Store) ResearchObjects(pack int) ([]int, error) { sort.Ints(ids) return ids, nil } -func (s *Store) ResearchObjectReply(pack, id int) ([]byte, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) ResearchObjectReply(ctx command.Context, pack, id int) ([]byte, bool, error) { + store, err := s.fieldRewardEntries() if err != nil { return nil, false, err } - return store.LoadEntry("progress", "field_research_rewards", fieldRewardKey(pack, id)) + return store.LoadEntry(ctx.State, "progress", "field_research_rewards", fieldRewardKey(pack, id)) } -func (s *Store) MarkResearchObject(pack, id int, rewardItems []byte) error { - s.mu.Lock() - defer s.mu.Unlock() +func (s *Store) MarkResearchObject(ctx command.Context, pack, id int, rewardItems []byte) error { + if pack <= 0 || id <= 0 { return fmt.Errorf("progress: invalid research identity") } @@ -54,8 +52,8 @@ func (s *Store) MarkResearchObject(pack, id int, rewardItems []byte) error { if err != nil { return err } - if err = store.PutEntry("progress", "field_research_rewards", fieldRewardKey(pack, id), rewardItems); err != nil { + if err = store.PutEntry(ctx.State, "progress", "field_research_rewards", fieldRewardKey(pack, id), rewardItems); err != nil { return err } - return store.PutEntry("progress", "field_research", fieldRewardKey(pack, id), []byte("once")) + return store.PutEntry(ctx.State, "progress", "field_research", fieldRewardKey(pack, id), []byte("once")) } diff --git a/go/internal/server/domain/world/protocol_field_adapter.go b/go/internal/server/domain/world/protocol_field_adapter.go new file mode 100644 index 0000000..da796fc --- /dev/null +++ b/go/internal/server/domain/world/protocol_field_adapter.go @@ -0,0 +1,1330 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/domain/world/progress" + "bd2server/internal/server/protocol/wire" + "bytes" + "encoding/binary" + "fmt" + "math" + "sort" +) + +func nestedMessages(b []byte, number int) ([][]byte, error) { + var out [][]byte + e := wire.Walk(b, func(f wire.Field) error { + if f.Number == number { + if f.Type != 2 { + return ErrInvalidRequest + } + out = append(out, append([]byte(nil), f.Value...)) + } + return nil + }) + return out, e +} + +func (s *Service) handleOverwhelm(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, present, e := wire.Varint(request, 1) + if e != nil || !present || seq == 0 || seq > 0x7fffffff || ctx.SessionID == "" { + return 275, nil, true, ErrInvalidRequest + } + pack, e := s.CurrentPackID(ctx) + if e != nil { + return 275, nil, true, e + } + v, e := s.loadMonsterState(ctx) + if e != nil { + return 275, nil, true, e + } + identity := fmt.Sprintf("overwhelm:%s:%d", ctx.SessionID, seq) + if prior, ok := v.Requests[identity]; ok { + if !bytes.Equal(prior.Request, request) { + return 275, nil, true, ErrInvalidRequest + } + return 275, prior.Response, true, nil + } + raw, e := nestedMessages(request, 2) + if e != nil || len(raw) == 0 || len(raw) > 4096 { + return 275, nil, true, ErrInvalidRequest + } + var targets []overwhelmedMonster + seen := map[uint64]bool{} + for _, b := range raw { + var m overwhelmedMonster + for f, dst := range map[int]*uint64{1: &m.Group, 2: &m.ID, 3: &m.Deck, 4: &m.Mode} { + *dst, _, e = wire.Varint(b, f) + if e != nil || *dst > 0x7fffffff { + return 275, nil, true, ErrInvalidRequest + } + } + if m.ID == 0 || seen[m.ID] { + return 275, nil, true, ErrInvalidRequest + } + seen[m.ID] = true + if m.Mode == 5 { + definition, found, x := s.findFieldMonster(pack, int(m.ID)) + if x != nil || !found || definition.UseBattleSkip != 1 || definition.Type >= 2 { + return 275, nil, true, fmt.Errorf("world: hunting monster cannot be overwhelmed") + } + if s.overwhelmHunting == nil { + return 275, nil, true, fmt.Errorf("world: hunting overwhelm runtime missing") + } + if e = s.overwhelmHunting.ValidateBattle(ctx, pack, 5, m.ID, m.Deck); e != nil { + return 275, nil, true, e + } + } else { + if m.Mode != 1 && m.Mode != 2 && m.Mode != 4 && (m.Mode < 9 || m.Mode > 15) { + return 275, nil, true, fmt.Errorf("world: unavailable overwhelm battle mode %d", m.Mode) + } + definition, found, e := s.findFieldMonster(pack, int(m.ID)) + if e != nil || !found || (definition.UseBattleSkip != 1 && definition.Type != 3) { + return 275, nil, true, ErrInvalidRequest + } + m.Definition = definition + if definition.Type == 2 || definition.Type == 4 { + return 275, nil, true, fmt.Errorf("world: private field monster cannot be overwhelmed") + } + if e = s.authorizeMonsterMap(ctx, pack, int(m.ID)); e != nil { + return 275, nil, true, e + } + validDeck := definition.Type == 3 && m.Deck == 0 + for _, d := range definition.BattleDecks { + if d == m.Deck { + validDeck = true + } + } + if definition.BattleDeck == m.Deck { + validDeck = true + } + if !validDeck { + return 275, nil, true, fmt.Errorf("world: overwhelm deck mismatch") + } + if m.Mode >= 9 && m.Mode <= 15 { + mapID, e := s.currentFieldMap(ctx, pack) + if e != nil { + return 275, nil, true, e + } + found := false + for _, rule := range s.overwhelmSky { + if rule.Map != uint64(mapID) || rule.Group+8 != m.Mode { + continue + } + cost := uint64(0) + if rule.Boss == m.ID { + found = true + cost = rule.BossAP + } else { + for i, id := range rule.Monsters { + if id == m.ID { + found = true + cost = rule.AP[i] + } + } + } + if found { + typ := uint64(21) + if rule.APType == 2 { + typ = 23 + } + if cost > 0 { + m.Costs = []gamedata.Reward{{Type: typ, Count: cost}} + } + break + } + } + if !found { + return 275, nil, true, fmt.Errorf("world: skyway monster does not match current dungeon") + } + m.Instance = fmt.Sprintf("skyway:%s:%d", identity, m.ID) + } else if m.Mode == 2 { + if int(m.Group) != definition.GroupID || definition.GroupID == 0 || !s.monsterEligible(pack, definition) { + return 275, nil, true, ErrInvalidRequest + } + state, e := s.monsterState(&v, pack, definition) + if e != nil { + return 275, nil, true, e + } + if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() { + return 275, nil, true, fmt.Errorf("world: overwhelm monster not spawned") + } + m.Instance = fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, definition.ID), state.Generation) + } else { + if m.Group != 0 { + return 275, nil, true, ErrInvalidRequest + } + m.Instance = fmt.Sprintf("questmonster:%d:%d:%d", pack, s.questDifficulty(pack), m.ID) + } + } + targets = append(targets, m) + } + quests, e := nestedMessages(request, 3) + if e != nil { + return 275, nil, true, e + } + var updates [][]byte + var updated []uint64 + for _, b := range quests { + quest, _, e := wire.Varint(b, 1) + if e != nil { + return 275, nil, true, e + } + qp, _, e := wire.Varint(b, 2) + if e != nil || int(qp) != pack || quest == 0 || !s.canClear(ctx, pack, int(quest)) { + return 275, nil, true, ErrInvalidRequest + } + values, e := intsRequest(b, 3) + if e != nil || len(values) == 0 { + return 275, nil, true, ErrInvalidRequest + } + if e = s.validateOverwhelmQuest(ctx, pack, int(quest), values, targets); e != nil { + return 275, nil, true, e + } + req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, quest), 3, qp) + for _, value := range values { + if value > 0x7fffffff { + return 275, nil, true, ErrInvalidRequest + } + req = wire.AppendVarint(req, 4, value) + } + updates = append(updates, req) + updated = append(updated, quest) + } + if s.overwhelmAuthorize == nil { + return 275, nil, true, fmt.Errorf("world: overwhelm requires successful talent use") + } + if e = s.overwhelmAuthorize(ctx, identity, uint64(len(targets))); e != nil { + return 275, nil, true, e + } + var response, bundle []byte + for _, m := range targets { + if m.Mode == 5 { + reward, monsters, e := s.overwhelmHunting.CompleteBattle(ctx, pack, 5, m.ID, m.Deck, fmt.Sprintf("%s:%d", identity, m.ID)) + if e != nil { + return 275, nil, true, e + } + bundle = append(bundle, reward...) + for _, row := range monsters { + response = wire.AppendBytes(response, 1, row) + } + } else { + if !v.Claims[m.Instance] && m.Definition.Type != 3 { + definition := m.Definition + definition.BattleDeck = m.Deck + var reward []byte + var e error + if len(m.Costs) > 0 { + if s.monsterRewards == nil || s.researchEconomy == nil { + return 275, nil, true, ErrInvalidRequest + } + rs, x := s.monsterRewards(pack, m.Deck) + if x != nil { + return 275, nil, true, x + } + rewards := make([]gamedata.Reward, 0, len(rs)) + for _, r := range rs { + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + } + reward, e = s.researchEconomy.Apply(ctx, m.Instance, m.Costs, rewards) + } else { + reward, e = s.grantFieldMonster(ctx, pack, definition, m.Instance) + } + if e != nil { + return 275, nil, true, e + } + bundle = append(bundle, reward...) + } + v.Claims[m.Instance] = true + if m.Definition.GroupID > 0 { + state, e := s.monsterState(&v, pack, m.Definition) + if e != nil { + return 275, nil, true, e + } + state.Defeated = true + state.Respawn = s.nextMonsterSpawn(m.Definition) + v.Monsters[monsterKey(pack, int(m.ID))] = state + response = wire.AppendBytes(response, 1, monsterWire(m.Definition, state, true)) + } + } + } + for i, req := range updates { + if _, _, _, e = s.handleQuestUpdate(ctx, req); e != nil { + return 275, nil, true, e + } + response = wire.AppendVarint(response, 2, updated[i]) + } + response = wire.AppendBytes(response, 3, bundle) + v.Requests[identity] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: response} + if e = s.saveMonsterState(ctx, v); e != nil { + return 275, nil, true, e + } + return 275, response, true, nil +} + +func monsterWire(m gamedata.FieldMonsterDesign, state fieldMonsterState, eligible bool) []byte { + b := wire.AppendVarint(nil, 1, uint64(m.ID)) + if m.BattleDeck != 0 { + b = wire.AppendVarint(b, 2, m.BattleDeck) + } + if state.Respawn > 0 { + b = wire.AppendVarint(b, 3, uint64(state.Respawn)) + } + if state.LifeEnd > 0 { + b = wire.AppendVarint(b, 4, uint64(state.LifeEnd)) + } + b = wire.AppendVarint(b, 5, uint64(m.GroupID)) + if eligible { + b = wire.AppendVarint(b, 6, 1) + } + return b +} + +func (s *Service) monsterRows(ctx command.Context, pack int, filter map[int]bool) ([][]byte, error) { + if s.monsterLoader == nil { + return nil, fmt.Errorf("%w: missing field monster design", ErrInvalidRequest) + } + design, e := s.monsterLoader(pack) + if e != nil { + return nil, e + } + v, e := s.loadMonsterState(ctx) + if e != nil { + return nil, e + } + var rows [][]byte + changed := false + for _, m := range design { + if m.GroupID == 0 { + continue + } + if filter != nil && !filter[m.ID] { + continue + } + eligible := s.monsterEligible(pack, m) + if eligible { + available, err := s.rewardMonsterAvailable(ctx, pack, m.ID) + if err != nil { + return nil, err + } + eligible = available + } + state := fieldMonsterState{} + if eligible { + state, e = s.monsterState(&v, pack, m) + if e != nil { + return nil, e + } + changed = true + } + rows = append(rows, monsterWire(m, state, eligible)) + } + if changed && s.monsterStore != nil { + if e = s.saveMonsterState(ctx, v); e != nil { + return nil, e + } + } + return rows, nil +} + +func (s *Service) handleMonsterInfo(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, present, e := wire.Varint(request, 1) + if e != nil || !present || seq == 0 || seq > 0x7fffffff { + return 0, nil, true, ErrInvalidRequest + } + groups, e := intsRequest(request, 2) + if e != nil { + return 0, nil, true, e + } + wanted := map[int]bool{} + for _, g := range groups { + if g == 0 || g > 0x7fffffff { + return 0, nil, true, ErrInvalidRequest + } + wanted[int(g)] = true + } + pack, e := s.CurrentPackID(ctx) + if e != nil || !s.packUnlocked(ctx, pack) || s.monsterLoader == nil { + return 0, nil, true, ErrInvalidRequest + } + design, e := s.monsterLoader(pack) + if e != nil { + return 0, nil, true, e + } + filter := map[int]bool{} + for _, m := range design { + if wanted[m.GroupID] { + filter[m.ID] = true + } + } + rows, e := s.monsterRows(ctx, pack, filter) + if e != nil { + return 0, nil, true, e + } + var b []byte + for _, r := range rows { + b = wire.AppendBytes(b, 1, r) + } + return 51, b, true, nil +} + +func (s *Service) CompleteFieldMonsterBattle(ctx command.Context, pack int, id uint64, instance string) ([]byte, error) { + m, found, e := s.findFieldMonster(pack, int(id)) + if e != nil { + return nil, e + } + if !found { + return nil, ErrInvalidRequest + } + v, e := s.loadMonsterState(ctx) + if e != nil { + return nil, e + } + key := monsterKey(pack, m.ID) + state, exists := v.Monsters[key] + if !exists { + return nil, ErrInvalidRequest + } + if !v.Claims[instance] { + if instance != fmt.Sprintf("fieldmonster:%s:%d", key, state.Generation) { + return nil, fmt.Errorf("world: stale monster battle") + } + state.Defeated = true + state.Respawn = s.nextMonsterSpawn(m) + v.Monsters[key] = state + v.Claims[instance] = true + if e = s.saveMonsterState(ctx, v); e != nil { + return nil, e + } + } + return monsterWire(m, state, s.monsterEligible(pack, m)), nil +} + +func (s *Service) handleFieldMonsterRegen(ctx command.Context, request []byte) (int, []byte, bool, error) { + id, e := requestPack(request) + if e != nil { + return 0, nil, true, e + } + pack, e := s.CurrentPackID(ctx) + if e != nil || !s.packUnlocked(ctx, pack) { + return 0, nil, true, ErrInvalidRequest + } + m, found, e := s.findFieldMonster(pack, id) + if e != nil || !found { + return 0, nil, true, ErrInvalidRequest + } + v, e := s.loadMonsterState(ctx) + if e != nil { + return 0, nil, true, e + } + seq, _, _ := wire.Varint(request, 1) + identity := fmt.Sprintf("regen:%s:%d", ctx.SessionID, seq) + if ctx.SessionID == "" { + return 0, nil, true, fmt.Errorf("world: missing monster session") + } + if reply, ok := v.Requests[identity]; ok { + if !bytes.Equal(request, reply.Request) { + return 0, nil, true, ErrInvalidRequest + } + return 139, reply.Response, true, nil + } + state, e := s.monsterState(&v, pack, m) + if e != nil { + return 0, nil, true, e + } + if !state.Defeated { + state.Defeated = true + state.Respawn = s.nextMonsterSpawn(m) + v.Monsters[monsterKey(pack, m.ID)] = state + } + response := wire.AppendBytes(nil, 1, monsterWire(m, state, s.monsterEligible(pack, m))) + v.Requests[identity] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: response} + if e = s.saveMonsterState(ctx, v); e != nil { + return 0, nil, true, e + } + return 139, response, true, nil +} + +func fieldObjectInt(raw []byte, field int, required bool) (int, error) { + v, found, err := wire.Varint(raw, field) + if err != nil || required && (!found || v == 0) || v > uint64(^uint32(0)>>1) { + return 0, ErrInvalidRequest + } + return int(v), nil +} + +// The client batches LostCoin collections. Validate the complete list before +// drawing or settling any component; the dispatcher commits the whole request. +func (s *Service) handleFieldObjectRewardList(ctx command.Context, request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil { + return 260, nil, true, err + } + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return 260, nil, true, err + } + type selection struct{ group, id int } + var selections []selection + seen := map[int]bool{} + err = wire.Walk(request, func(f wire.Field) error { + if f.Number != 3 { + return nil + } + if f.Type != 2 || len(selections) >= 100 { + return ErrInvalidRequest + } + group, e := fieldObjectInt(f.Value, 3, true) + if e != nil { + return e + } + id, e := fieldObjectInt(f.Value, 4, true) + if e != nil { + return e + } + obj, ok := design.Objects[id] + if !ok || obj.GroupID != group || obj.BuffID != 0 || obj.MonsterID != 0 || len(obj.Rewards) == 0 && obj.Type != 6 { + return ErrInvalidRequest + } + if !seen[id] { + seen[id] = true + selections = append(selections, selection{group, id}) + } + return nil + }) + if err != nil || len(selections) == 0 || !s.packUnlocked(ctx, pack) || !s.fieldObjectCurrentPack(pack) { + return 260, nil, true, ErrInvalidRequest + } + var bundle []byte + for _, selection := range selections { + part, e := s.openFieldObject(ctx, pack, selection.group, selection.id) + if e != nil { + return 260, nil, true, e + } + bundle = append(bundle, part...) + } + return 260, wire.AppendBytes(nil, 1, bundle), true, nil +} + +func (s *Service) handleFieldObjectPreview(ctx command.Context, request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil || !s.packUnlocked(ctx, pack) { + return 144, nil, true, ErrInvalidRequest + } + ids, err := s.openedFieldObjects(ctx, pack) + if err != nil { + return 144, nil, true, err + } + var response []byte + for _, id := range ids { + response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) + } + research, err := s.state.ResearchObjects(ctx, pack) + if err != nil { + return 144, nil, true, err + } + for _, id := range research { + response = wire.AppendVarint(response, 2, uint64(id)) + } + return 144, response, true, nil +} + +// Respawn is a projection of the reset schedule, never an instruction to clear +// receipts. A request before the reset cannot make an object collectible again. +func (s *Service) handleFieldObjectRespawn(ctx command.Context, request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil || !s.packUnlocked(ctx, pack) { + return 30, nil, true, ErrInvalidRequest + } + group, err := fieldObjectInt(request, 3, true) + if err != nil { + return 30, nil, true, err + } + kind, err := fieldObjectInt(request, 4, false) + if err != nil || kind != 0 && kind != 6 { + return 30, nil, true, ErrInvalidRequest + } + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return 30, nil, true, err + } + var ids []int + reset := -1 + for id, obj := range design.Objects { + if obj.GroupID == group { + ids = append(ids, id) + if reset != -1 && reset != obj.ResetType { + return 30, nil, true, fmt.Errorf("world: inconsistent field reset group") + } + reset = obj.ResetType + } + } + if len(ids) == 0 { + return 30, nil, true, ErrInvalidRequest + } + sort.Ints(ids) + var response []byte + for _, id := range ids { + obj := design.Objects[id] + period, e := s.fieldObjectPeriodFor(pack, obj) + if e != nil { + return 30, nil, true, e + } + opened, e := s.state.FieldRewardOpened(ctx, pack, id, period) + if e != nil { + return 30, nil, true, e + } + if opened { + response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) + } + } + if reset == 0 || reset == 3 { //nolint:staticcheck // QF1003 + next, e := s.fieldReset.Next(reset, s.monsterTime()) + if e != nil { + return 30, nil, true, e + } + row := wire.AppendVarint(nil, 1, uint64(group)) + row = wire.AppendVarint(row, 2, uint64(next.UnixMilli())) + response = wire.AppendBytes(response, 2, row) + } else if reset == 2 { + resolver, ok := s.eventFieldPacks.(interface { + FieldObjectEventPeriod(int) (string, int64, error) + }) + if !ok { + return 30, nil, true, ErrInvalidRequest + } + _, end, err := resolver.FieldObjectEventPeriod(pack) + if err != nil { + return 30, nil, true, err + } + row := wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(group)), 2, uint64(end)) + response = wire.AppendBytes(response, 2, row) + } + return 30, response, true, nil +} + +func (s *Service) openFieldObjectResponse(ctx command.Context, pack, group, id int) ([]byte, error) { + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return nil, err + } + obj, ok := design.Objects[id] + if !ok || obj.GroupID != group || !s.packUnlocked(ctx, pack) || !s.fieldObjectCurrentPack(pack) { + return nil, ErrInvalidRequest + } + if err := s.validateFieldObjectMap(ctx, pack, obj.MapID); err != nil { + return nil, err + } + period, err := s.fieldObjectPeriodFor(pack, obj) + if err != nil { + return nil, err + } + opened, err := s.state.FieldRewardOpened(ctx, pack, id, period) + if err != nil { + return nil, err + } + if opened { + return wire.AppendBytes(nil, 1, nil), nil + } + var effects []byte + if obj.BuffID != 0 { + buff, exists := s.fieldBuffs[uint64(obj.BuffID)] + if !exists { + return nil, fmt.Errorf("world: missing field object buff %d", obj.BuffID) + } + row, chars, e := s.applyFieldObjectBuff(ctx, pack, buff) + if e != nil { + return nil, e + } + effects = wire.AppendBytes(effects, 2, row) + for _, char := range chars { + effects = wire.AppendBytes(effects, 3, char) + } + } else if obj.MonsterID != 0 && len(obj.Rewards) == 0 { + if s.monsterLoader == nil || s.monsterStore == nil { + return nil, fmt.Errorf("world: dynamic field monster runtime unavailable") + } + monsters, e := s.monsterLoader(pack) + if e != nil { + return nil, e + } + found := false + for _, monster := range monsters { + if monster.ID != obj.MonsterID { + continue + } + if !s.monsterEligible(pack, monster) { + return nil, ErrInvalidRequest + } + snapshot, e := s.loadMonsterState(ctx) + if e != nil { + return nil, e + } + state, e := s.monsterState(&snapshot, pack, monster) + if e != nil { + return nil, e + } + if e = s.saveMonsterState(ctx, snapshot); e != nil { + return nil, e + } + effects = wire.AppendBytes(effects, 4, monsterWire(monster, state, true)) + found = true + break + } + if !found { + return nil, fmt.Errorf("world: missing field object monster %d", obj.MonsterID) + } + } + if obj.BuffID != 0 { + if err := s.state.MarkFieldRewardOpened(ctx, pack, id, period); err != nil { + return nil, err + } + return append(wire.AppendBytes(nil, 1, nil), effects...), nil + } + bundle, err := s.openFieldObject(ctx, pack, group, id) + if err != nil { + return nil, err + } + return append(wire.AppendBytes(nil, 1, bundle), effects...), nil +} + +func (s *Service) applyFieldObjectBuff(ctx command.Context, pack int, buff gamedata.FieldBuffDesign) ([]byte, [][]byte, error) { + if buff.ID == 0 || buff.Type > 5 || buff.TargetType > 2 || math.IsNaN(buff.Value) || math.IsInf(buff.Value, 0) || buff.Value < 0 || math.IsNaN(buff.Time) || math.IsInf(buff.Time, 0) || buff.Time < 0 { + return nil, nil, fmt.Errorf("world: invalid field object buff") + } + row := wire.AppendVarint(nil, 1, buff.ID) + if buff.Type <= 1 { + if buff.Time <= 0 || buff.Time > float64(^uint32(0)>>1) { + return nil, nil, fmt.Errorf("world: invalid persistent field buff duration") + } + if buff.Type == 0 { + row = wire.AppendVarint(row, 2, uint64(buff.Time)) + } else { + row = wire.AppendVarint(row, 3, uint64(s.monsterTime().UnixMilli()+int64(buff.Time*1000))) + } + prior, err := s.state.FieldBuffs(ctx) + if err != nil { + return nil, nil, err + } + for _, raw := range prior { + id, _, e := wire.Varint(raw, 1) + if e != nil { + return nil, nil, e + } + old, found := s.fieldBuffs[id] + if !found { + return nil, nil, fmt.Errorf("world: saved field buff absent from design") + } + if old.Type == buff.Type { + if e := s.state.RemoveFieldBuff(ctx, id); e != nil { + return nil, nil, e + } + } + } + if err := s.state.SaveFieldBuff(ctx, buff.ID, row); err != nil { + return nil, nil, err + } + return row, nil, nil + } + if buff.Type == 4 || buff.Type == 5 { + chars, err := s.applyMonsterFieldDamage(ctx, pack, buff.ID, "") + return row, chars, err + } + if s.characters == nil || s.decks == nil { + return nil, nil, fmt.Errorf("world: field healing runtime unavailable") + } + indices, err := s.fieldBuffTargets(ctx, pack, buff.TargetType) + if err != nil { + return nil, nil, err + } + var chars [][]byte + for _, index := range indices { + c, found := s.characters.Find(ctx, index) + if !found { + return nil, nil, fmt.Errorf("world: field healing character missing") + } + hp, err := s.characters.CurrentHealth(ctx, index) + if err != nil { + return nil, nil, err + } + if hp == 0 { + continue + } + max, err := s.characters.MaxHealth(ctx, index) + if err != nil { + return nil, nil, err + } + amount := buff.Value + if buff.Type == 3 { + amount *= float64(max) + } + remaining := max + if hp < max && amount < float64(max-hp) { + remaining = hp + uint64(amount) + } + if err = s.characters.SetCurrentHealth(ctx, index, remaining); err != nil { + return nil, nil, err + } + c.HP = remaining + chars = append(chars, roster.CharacterWire(c)) + } + return row, chars, nil +} + +// ConsumeFieldBattleBuff runs inside the successful BattleEnter transaction. +// A persisted battle identity makes retries and reconnects safe. +func (s *Service) ConsumeFieldBattleBuff(ctx command.Context, identity string) error { + if identity == "" { + return ErrInvalidRequest + } + rows, err := s.state.FieldBuffs(ctx) + if err != nil { + return err + } + var active []byte + for _, row := range rows { + id, _, e := wire.Varint(row, 1) + if e != nil { + return e + } + buff, found := s.fieldBuffs[id] + if !found { + return fmt.Errorf("world: saved battle field buff absent from design") + } + if buff.Type == 0 { + active = row + break + } + } + if active == nil { + return nil + } + claimed, err := s.state.ClaimFieldBuffBattle(ctx, identity) + if err != nil || !claimed { + return err + } + id, _, err := wire.Varint(active, 1) + if err != nil { + return err + } + count, _, err := wire.Varint(active, 2) + if err != nil { + return err + } + if count <= 1 { + return s.state.RemoveFieldBuff(ctx, id) + } + updated := wire.AppendVarint(nil, 1, id) + updated = wire.AppendVarint(updated, 2, count-1) + return s.state.SaveFieldBuff(ctx, id, updated) +} + +func (s *Service) handleFieldObjectInfo(ctx command.Context, request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if !s.packUnlocked(ctx, pack) { + return 0, nil, true, fmt.Errorf("%w: unavailable field pack", ErrInvalidRequest) + } + ids, err := s.openedFieldObjects(ctx, pack) + if err != nil { + return 0, nil, true, err + } + var response []byte + for _, id := range ids { + response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) + } + actions, err := s.fieldActionInfo(ctx, pack) + if err != nil { + return 0, nil, true, err + } + response = append(response, actions...) + return 28, response, true, nil +} + +func (s *Service) handleFieldObjectReward(ctx command.Context, request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + group, _, err := wire.Varint(request, 3) + if err != nil || group == 0 || group > uint64(^uint32(0)>>1) { + return 0, nil, true, ErrInvalidRequest + } + id, _, err := wire.Varint(request, 4) + if err != nil || id == 0 || id > uint64(^uint32(0)>>1) { + return 0, nil, true, ErrInvalidRequest + } + response, err := s.openFieldObjectResponse(ctx, pack, int(group), int(id)) + if err != nil { + return 0, nil, true, err + } + return 29, response, true, nil +} + +func (s *Service) openFieldObject(ctx command.Context, pack, group, id int) ([]byte, error) { + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return nil, err + } + obj, exists := design.Objects[id] + if !exists || obj.GroupID != group || !s.packUnlocked(ctx, pack) || !s.fieldObjectCurrentPack(pack) { + return nil, fmt.Errorf("%w: unavailable field object", ErrInvalidRequest) + } + if err := s.validateFieldObjectMap(ctx, pack, obj.MapID); err != nil { + return nil, err + } + period, err := s.fieldObjectPeriodFor(pack, obj) + if err != nil { + return nil, err + } + opened, err := s.state.FieldRewardOpened(ctx, pack, id, period) + if err != nil { + return nil, err + } + if opened { + return []byte{}, nil + } + // Buff and dynamic-monster objects have no loot group. Their state change + // belongs to the same dispatcher transaction as this consumed-object marker. + if len(obj.Rewards) == 0 { + if obj.BuffID == 0 && obj.MonsterID == 0 && obj.QuestID == 0 && obj.Type != 5 && obj.Type != 6 { + return nil, fmt.Errorf("%w: empty field object", ErrInvalidRequest) + } + return nil, s.state.MarkFieldRewardOpened(ctx, pack, id, period) + } + if s.wallet == nil || s.inventory == nil { + return nil, fmt.Errorf("world: field reward stores unavailable") + } + var rewards []gamedata.Reward + var itemRewards []gamedata.BattleReward + var equipmentRewards []assets.Equipment + // Validate every branch's type and quantity before drawing or writing a + // receipt. LoadFieldObjects validates every equipment option tree, including + // branches with zero weight, before installing the catalog. + for _, r := range obj.Rewards { + if r.Count == 0 || r.Count > uint64(^uint32(0)>>1) { + return nil, fmt.Errorf("world: invalid field reward count") + } + switch r.Type { + case 2, 3, 4, 12, 20: + case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29: + if r.ID == 0 { + return nil, fmt.Errorf("world: invalid field item") + } + case 10: + if r.ID == 0 || r.Count > 100 || s.equipment == nil || design.Equipment == nil { + return nil, fmt.Errorf("world: invalid field equipment") + } + default: + return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type) + } + } + selected, err := obj.Draw() + if err != nil { + return nil, err + } + if design.RewardGraph != nil { + selected, err = design.RewardGraph.ResolveGranted(selected) + if err != nil { + return nil, err + } + } + for _, r := range selected { + if r.Count == 0 { + return nil, fmt.Errorf("world: empty field reward") + } + switch r.Type { + case 2, 3, 4, 12, 20: + rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 + case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29: + if r.ID == 0 { + return nil, fmt.Errorf("world: invalid field item") + } + itemRewards = append(itemRewards, r) + case 10: + if s.equipment == nil || design.Equipment == nil || r.Count > 100 || len(equipmentRewards)+int(r.Count) > 100 { + return nil, fmt.Errorf("world: field equipment reward unavailable") + } + for n := uint64(0); n < r.Count; n++ { + main, sub, private, e := design.Equipment.RollOptions(r.ID) + if e != nil { + return nil, e + } + entry := assets.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}} + for _, option := range main { + entry.MainOption = append(entry.MainOption, assets.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + } + for _, option := range sub { + entry.SubOption = append(entry.SubOption, assets.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) + } + if private != nil { + entry.PrivateOption = &assets.EquipmentOption{GroupID: private.GroupID, ID: private.ID} + } + equipmentRewards = append(equipmentRewards, entry) + } + default: + return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type) + } + } + identity := fmt.Sprintf("field-reward:%d:%d:%s", pack, id, period) + if _, err = s.wallet.GrantQuestOnce(ctx, identity, rewards); err != nil { + return nil, err + } + items, err := s.inventory.GrantOnce(ctx, identity, itemRewards) + if err != nil { + return nil, err + } + for i, entry := range equipmentRewards { + equipmentRewards[i], err = s.equipment.GrantGeneratedOnce(ctx, fmt.Sprintf("%s:equipment:%d", identity, i), entry) + if err != nil { + return nil, err + } + } + if err = s.state.MarkFieldRewardOpened(ctx, pack, id, period); err != nil { + return nil, err + } + var bundle []byte + for _, r := range rewards { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(assets.Item{ID: r.ID, Type: r.Type, Count: r.Count})) + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + } + for _, entry := range equipmentRewards { + bundle = wire.AppendBytes(bundle, 4, assets.EquipmentWire(entry)) + } + return bundle, nil +} + +func monsterBundleFields(bundle []byte, itemField, equipField int) ([]byte, error) { + var b []byte + err := wire.Walk(bundle, func(f wire.Field) error { + if f.Type == 2 && f.Number == 1 { + b = wire.AppendBytes(b, itemField, f.Value) + } else if f.Type == 2 && f.Number == 4 { + b = wire.AppendBytes(b, equipField, f.Value) + } + return nil + }) + return b, err +} + +func (s *Service) handleFieldMonsterEvent(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + code := 83 + if path == "/FieldMonsterDamage" { + code = 85 + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > 0x7fffffff { + return code, nil, true, fmt.Errorf("%w: %s invalid sequence", ErrInvalidRequest, path) + } + id, err := requestPack(request) + if err != nil { + return code, nil, true, fmt.Errorf("%w: %s invalid monster id", err, path) + } + dash, _, err := wire.Varint(request, 3) + if err != nil || dash > 1 { + return code, nil, true, fmt.Errorf("%w: %s monster %d invalid dash flag", ErrInvalidRequest, path, id) + } + if ctx.SessionID == "" { + return code, nil, true, fmt.Errorf("world: %s monster %d missing session", path, id) + } + v, err := s.loadMonsterState(ctx) + if err != nil { + return code, nil, true, err + } + key := fmt.Sprintf("%s:%s:%d", path, ctx.SessionID, seq) + if prior, ok := v.Requests[key]; ok { + if !bytes.Equal(prior.Request, request) { + return code, nil, true, fmt.Errorf("%w: %s monster %d changed retry", ErrInvalidRequest, path, id) + } + return code, prior.Response, true, nil + } + pack, err := s.CurrentPackID(ctx) + if err != nil { + return code, nil, true, fmt.Errorf("world: %s monster %d resolve pack: %w", path, id, err) + } + fail := func(reason string) (int, []byte, bool, error) { + return code, nil, true, fmt.Errorf("%w: %s pack %d monster %d %s", ErrInvalidRequest, path, pack, id, reason) + } + if !s.packUnlocked(ctx, pack) { + return fail("pack unavailable") + } + m, known, err := s.findFieldMonster(pack, id) + if err != nil { + return code, nil, true, fmt.Errorf("world: %s pack %d monster %d design: %w", path, pack, id, err) + } + if !known { + return fail("absent from design") + } + if m.GroupID == 0 || m.Type > 6 { + return fail(fmt.Sprintf("type %d has no supported regeneration", m.Type)) + } + if path == "/FieldMonsterDamage" && (m.Type != 4 || m.FieldBuff == 0 || m.LifeSeconds == 0) { + return fail(fmt.Sprintf("type %d has no continuous field damage", m.Type)) + } + if s.monsterMaps == nil { + return fail(fmt.Sprintf("type %d missing scene design", m.Type)) + } + maps, err := s.monsterMaps(pack) + if err != nil { + return code, nil, true, fmt.Errorf("world: %s pack %d monster %d type %d scene design: %w", path, pack, id, m.Type, err) + } + if len(maps[id]) == 0 { + return fail(fmt.Sprintf("type %d has no scene placement", m.Type)) + } + eligible := s.monsterEligible(pack, m) + state := fieldMonsterState{} + if eligible { + available, err := s.rewardMonsterAvailable(ctx, pack, id) + if err != nil { + return code, nil, true, fmt.Errorf("world: %s pack %d monster %d type %d reward availability: %w", path, pack, id, m.Type, err) + } + eligible = available + } + if eligible { + state, err = s.monsterState(&v, pack, m) + if err != nil { + return code, nil, true, err + } + } + // DisableMonster sends this same event for refreshes without a wire flag. + // Ordinary battle monsters only synchronize; their victories settle elsewhere. + collision := m.Type == 2 || m.Type == 3 + active := eligible && !state.Defeated && state.Respawn <= s.monsterTime().UnixMilli() + var response []byte + if active && (path == "/FieldMonsterDamage" || collision) { + if err := s.authorizeMonsterMap(ctx, pack, id); err != nil { + return code, nil, true, fmt.Errorf("%s pack %d monster %d type %d collision authorization: %w", path, pack, id, m.Type, err) + } + instance := fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, id), state.Generation) + if path == "/FieldMonsterEvent" && m.Type == 2 && !v.Claims[instance] { + bundle, err := s.grantFieldMonster(ctx, pack, m, instance) + if err != nil { + return code, nil, true, err + } + response, err = monsterBundleFields(bundle, 3, 4) + if err != nil { + return code, nil, true, err + } + v.Claims[instance] = true + } + damage := path == "/FieldMonsterDamage" || (m.Type == 3 && (dash != 1 || m.CrashType != 1)) + if damage && m.FieldBuff > 0 { + if path == "/FieldMonsterDamage" { + damage, err = s.takeFieldMonsterDamageTick(ctx, instance) + if err != nil { + return code, nil, true, err + } + } + if damage { + if s.monsterDamage == nil { + return code, nil, true, fmt.Errorf("world: %s pack %d monster %d type %d damage runtime unavailable", path, pack, id, m.Type) + } + rows, err := s.monsterDamage(ctx, pack, m.FieldBuff, key) + if err != nil { + return code, nil, true, err + } + for _, row := range rows { + response = wire.AppendBytes(response, 1, row) + } + } + } + if path == "/FieldMonsterEvent" { + state.Defeated = true + state.Respawn = s.nextMonsterSpawn(m) + v.Monsters[monsterKey(pack, id)] = state + } + } + // RecvFieldMonsterEvent refreshes every returned monster. Echoing an inactive + // row calls DisableMonster again and resends this event without a refresh flag. + if path == "/FieldMonsterEvent" && active && collision { + response = wire.AppendBytes(response, 2, monsterWire(m, state, eligible)) + } + v.Requests[key] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: response} + if err := s.saveMonsterState(ctx, v); err != nil { + return code, nil, true, err + } + return code, response, true, nil +} + +// Current clients receive collision rewards in FieldMonsterEventResponse; +// FieldMonsterReward has no sender in this version and no pending reward queue. +func (s *Service) handleFieldMonsterReward(request []byte) (int, []byte, bool, error) { + seq, present, e := wire.Varint(request, 1) + if e != nil || !present || seq == 0 { + return 84, nil, true, ErrInvalidRequest + } + return 84, []byte{}, true, nil +} + +func (s *Service) ApplyTalentMonsterSummon(ctx command.Context, identity string, c roster.Character, r gamedata.TalentUseRule, ids []uint64) ([]byte, error) { + if identity == "" || len(ids) == 0 || r.Class != 20 { + return nil, ErrInvalidRequest + } + pack, e := s.CurrentPackID(ctx) + if e != nil { + return nil, e + } + v, e := s.loadMonsterState(ctx) + if e != nil { + return nil, e + } + seen := map[uint64]bool{} + var b []byte + for _, id := range ids { + if seen[id] { + return nil, ErrInvalidRequest + } + seen[id] = true + m, found, e := s.findFieldMonster(pack, int(id)) + if e != nil || !found || m.Type != 2 || !s.monsterEligible(pack, m) { + return nil, ErrInvalidRequest + } + if e = s.authorizeMonsterMap(ctx, pack, int(id)); e != nil { + return nil, e + } + state, e := s.monsterState(&v, pack, m) + if e != nil { + return nil, e + } + if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() { + return nil, fmt.Errorf("world: summon target is not spawned") + } + instance := fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, m.ID), state.Generation) + if !v.Claims[instance] { + bundle, e := s.grantFieldMonster(ctx, pack, m, instance) + if e != nil { + return nil, e + } + extra, e := monsterBundleFields(bundle, 3, 4) + if e != nil { + return nil, e + } + b = append(b, extra...) + v.Claims[instance] = true + } + state.Defeated = true + state.Respawn = s.nextMonsterSpawn(m) + v.Monsters[monsterKey(pack, m.ID)] = state + b = wire.AppendBytes(b, 9, monsterWire(m, state, true)) + } + if e = s.saveMonsterState(ctx, v); e != nil { + return nil, e + } + return b, nil +} + +func (s *Service) fieldBuffInfo(ctx command.Context) ([]byte, error) { + rows, err := s.state.FieldBuffs(ctx) + if err != nil { + return nil, err + } + now := s.monsterTime().UnixMilli() + var out []byte + for _, row := range rows { + end, _, err := wire.Varint(row, 3) + if err != nil { + return nil, err + } + if end != 0 && (end > 0x7fffffffffffffff || int64(end) <= now) { + continue + } + out = wire.AppendBytes(out, 8, row) + } + return out, nil +} + +func (s *Service) handleFieldObjectPosition(ctx command.Context, request []byte) (int, []byte, bool, error) { + pack, err := requestPack(request) + if err != nil { + return 95, nil, true, err + } + group, _, err := wire.Varint(request, 3) + if err != nil || group == 0 || group > 0x7fffffff { + return 95, nil, true, ErrInvalidRequest + } + id, _, err := wire.Varint(request, 4) + if err != nil || id == 0 || id > 0x7fffffff { + return 95, nil, true, ErrInvalidRequest + } + position, found, err := wire.Bytes(request, 5) + if err != nil || !found { + return 95, nil, true, ErrInvalidRequest + } + mapID, _, err := wire.Varint(position, 1) + if err != nil || mapID == 0 || mapID > 0x7fffffff { + return 95, nil, true, ErrInvalidRequest + } + if err = wire.Walk(position, func(f wire.Field) error { + if f.Number >= 2 && f.Number <= 4 { + if f.Type != 5 { + return ErrInvalidRequest + } + value := math.Float32frombits(binary.LittleEndian.Uint32(f.Value)) + if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) { + return ErrInvalidRequest + } + } + return nil + }); err != nil { + return 95, nil, true, err + } + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return 95, nil, true, err + } + obj, exists := design.Actions[int(id)] + if !exists || obj.GroupID != int(group) || obj.Type != 1 || !s.packUnlocked(ctx, pack) || !s.fieldObjectCurrentPack(pack) { + return 95, nil, true, fmt.Errorf("%w: unavailable field action object", ErrInvalidRequest) + } + currentMap, err := s.currentFieldMap(ctx, pack) + if err != nil || currentMap != int(mapID) { + return 95, nil, true, fmt.Errorf("%w: field action outside current map", ErrInvalidRequest) + } + cleared := obj.QuestID > 0 && s.state.QuestCleared(obj.QuestID, pack, s.questDifficultyFor(pack, obj.QuestID)) + if obj.QuestID > 0 && (obj.QuestEnableType == 1 && !cleared || obj.QuestEnableType == 2 && cleared) { + return 95, nil, true, fmt.Errorf("%w: field action unavailable for quest", ErrInvalidRequest) + } + if err = s.state.SaveFieldActionPosition(ctx, pack, int(id), progress.FieldActionPosition{Position: position, QuestCleared: cleared}); err != nil { + return 95, nil, true, err + } + return 95, []byte{}, true, nil +} + +func (s *Service) fieldActionInfo(ctx command.Context, pack int) ([]byte, error) { + positions, err := s.state.FieldActionPositions(ctx, pack) + if err != nil || len(positions) == 0 { + return nil, err + } + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return nil, err + } + ids := make([]int, 0, len(positions)) + for id := range positions { + ids = append(ids, id) + } + sort.Ints(ids) + var out []byte + for _, id := range ids { + obj, found := design.Actions[id] + if !found { + return nil, fmt.Errorf("world: saved field action absent from design") + } + stored := positions[id] + // Client resets authored action positions when the related quest ends. + // Omit that stale position on reconnect so the prefab uses its origin. + if obj.QuestID > 0 && stored.QuestCleared != s.state.QuestCleared(obj.QuestID, pack, s.questDifficultyFor(pack, obj.QuestID)) { + continue + } + info := wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(id)), 2, stored.Position) + out = wire.AppendBytes(out, 2, info) + } + return out, nil +} diff --git a/go/internal/server/domain/world/protocol_npc_adapter.go b/go/internal/server/domain/world/protocol_npc_adapter.go new file mode 100644 index 0000000..bd2041b --- /dev/null +++ b/go/internal/server/domain/world/protocol_npc_adapter.go @@ -0,0 +1,101 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "encoding/json" + "fmt" + "slices" +) + +func (s *Service) CompleteNPCReputation(ctx command.Context, identity string, pack int, group uint64) ([]byte, error) { + if s.npcReputation == nil || identity == "" || group == 0 || !s.packUnlocked(ctx, pack) { + return nil, ErrInvalidRequest + } + d, err := s.npcReputation.load(pack) + if err != nil { + return nil, err + } + rule, ok := d.Groups[group] + if !ok { + return nil, fmt.Errorf("world: unknown reputation group") + } + v, err := s.npcReputation.snapshot(ctx) + if err != nil { + return nil, err + } + claim := fmt.Sprintf("%s:%d:%d", identity, pack, group) + if !v.Claims[claim] { + v.Claims[claim] = true + v.Starts[reputationKey(pack, group)] = s.npcReputation.now().Unix() + b, e := json.Marshal(v) + if e != nil { + return nil, e + } + if e = s.npcReputation.store.Save(ctx.State, "npcreputation", b); e != nil { + return nil, e + } + } + state, elapsed, err := s.reputationState(ctx, pack, rule) + if err != nil { + return nil, err + } + return reputationWire(group, state, elapsed), nil +} + +func reputationWire(group, state, elapsed uint64) []byte { + b := wire.AppendVarint(nil, 1, group) + b = wire.AppendVarint(b, 2, state) + if elapsed > 0 { + b = wire.AppendVarint(b, 3, elapsed) + } + return b +} + +func (s *Service) npcReputationRows(ctx command.Context, pack int) ([][]byte, error) { + if s.npcReputation == nil { + return nil, nil + } + d, err := s.npcReputation.load(pack) + if err != nil { + return nil, err + } + var groups []uint64 + for id := range d.Groups { + groups = append(groups, id) + } + slices.Sort(groups) + var rows [][]byte + for _, id := range groups { + state, elapsed, e := s.reputationState(ctx, pack, d.Groups[id]) + if e != nil { + return nil, e + } + rows = append(rows, reputationWire(id, state, elapsed)) + } + return rows, nil +} + +// handleQuestUpdate accepts the existing task update packet used when an NPC +// conversation finishes. Ordinary Talk is local and sends AchievementUpdate, +// not a separate NPC dialog packet. QuestUpdate has no NPC identity to validate. +func (s *Service) handleQuestUpdate(ctx command.Context, request []byte) (int, []byte, bool, error) { + quest, pack, err := requestQuest(request) + if err != nil { + return 0, nil, true, err + } + current, err := s.CurrentPackID(ctx) + if err != nil || current != pack || !s.canClear(ctx, pack, quest) { + return 0, nil, true, ErrInvalidRequest + } + if s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest)) { + // A delayed replay must not reinsert a cleared quest into active progress. + return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil + } + if _, err := s.state.UpdateQuest(ctx, request); err != nil { + return 0, nil, true, err + } + // field 2 is RewardDBInfoBundle, not QuestDBInfo. Task completion/claims + // remain in QuestClear; an update alone must not invent or duplicate rewards. + return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil +} diff --git a/go/internal/server/world/quest_selection.go b/go/internal/server/domain/world/protocol_quest_adapter.go similarity index 63% rename from go/internal/server/world/quest_selection.go rename to go/internal/server/domain/world/protocol_quest_adapter.go index 91afb3b..e36ad3f 100644 --- a/go/internal/server/world/quest_selection.go +++ b/go/internal/server/domain/world/protocol_quest_adapter.go @@ -1,37 +1,57 @@ package world import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/wire" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/world/progress" + "bd2server/internal/server/protocol/wire" "encoding/binary" "fmt" + "sort" ) -func (s *Service) questDifficultyFor(packID, questID int) int { - if s.storyCatalog != nil && s.storyCatalog.Packs[packID].Quests[questID].Type != 0 { - return 0 +// QuestPacket always passes the repeated DeckInfo to CommonPacket.RefreshDeck, +// which replaces the entire battle deck even when that list is empty. Keep +// this live snapshot outside the commission reward receipt: retrying a clear +// after the player changes formation must return the current saved deck. +func (s *Service) commissionResponseDeck(code int, response []byte) []byte { + field := 0 + switch code { + case 17: // QuestAcceptResponse.deck_info + field = 3 + case 18: // QuestClearResponse.deck_info + field = 4 + case 20: // QuestGiveUpResponse.deck_info + field = 2 } - return s.questDifficulty(packID) -} -func (s *Service) questDifficulty(packID int) int { - selection, _ := s.state.Selection(packID) - return selection.Difficulty -} -func (s *Service) CurrentQuestDifficulty() (uint64, error) { - pack, err := s.CurrentPackID() - if err != nil { - return 0, err + if field != 0 { + return s.appendCurrentBattleDeck(response, field) } - return uint64(s.questDifficulty(pack)), nil + return response } -func questRewardIdentity(packID, questID, difficulty int) string { - if difficulty == 0 { - return fmt.Sprintf("pack%d:quest%d", packID, questID) + +func (s *Service) currentBattleDeckWires() [][]byte { + if s.decks == nil { + return nil } - return fmt.Sprintf("pack%d:difficulty%d:quest%d", packID, difficulty, questID) + var entries [][]byte + for _, current := range s.decks.CurrentDeck() { + entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex) + // DeckDBInfo field 2 is the battle-grid position, including zero and -1. + entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex) + entry = wire.AppendVarint(entry, 3, current.Slot) + entries = append(entries, entry) + } + return entries } + +func (s *Service) appendCurrentBattleDeck(response []byte, field int) []byte { + for _, entry := range s.currentBattleDeckWires() { + response = wire.AppendBytes(response, field, entry) + } + return response +} + func (s *Service) questInfoWire(packID, questID int) []byte { out := wire.AppendVarint(nil, 1, uint64(questID)) selection, _ := s.state.Selection(packID) @@ -51,6 +71,7 @@ func (s *Service) questInfoWire(packID, questID int) []byte { } return wire.AppendVarint(out, 6, uint64(packID)) } + func (s *Service) questLevelInfoWire(packID, difficulty int) []byte { out := wire.AppendVarint(nil, 1, uint64(packID)) out = wire.AppendVarint(out, 2, uint64(difficulty)) @@ -74,13 +95,30 @@ func (s *Service) questLevelInfoWire(packID, difficulty int) []byte { } return out } -func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte, bool, error) { - if path != "/QuestInfo" && s.battleActive != nil && s.battleActive() { + +// Pack entry replaces the client's completed-ID list. Side quests always use +// normal progress, independently of the selected main-story difficulty. +func (s *Service) completedQuestIDs(pack int) []int { + ids := s.state.ClearedQuests(pack, s.questDifficulty(pack)) + if s.storyCatalog == nil || s.questDifficulty(pack) == 0 { + return ids + } + for _, id := range s.state.ClearedQuests(pack, 0) { + if s.storyCatalog.Packs[pack].Quests[id].Type == 1 { + ids = append(ids, id) + } + } + sort.Ints(ids) + return ids +} + +func (s *Service) handleQuestSelection(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/QuestInfo" && s.battleActive != nil && s.battleActive(ctx) { return 0, nil, true, fmt.Errorf("%w: active battle", ErrInvalidRequest) } if path == "/QuestInfo" { pack, err := requestPack(request) - if err != nil || !s.packUnlocked(pack) { + if err != nil || !s.packUnlocked(ctx, pack) { return 0, nil, true, ErrInvalidRequest } var out []byte @@ -92,7 +130,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte } var todayCleared []int if s.todayQuests != nil { - rows, ids, err := s.todayQuests.Info(pack) + rows, ids, err := s.todayQuests.Info(ctx, pack) if err != nil { return 0, nil, true, err } @@ -102,7 +140,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte } } var cleared []byte - for _, id := range s.state.ClearedQuests(pack, s.questDifficulty(pack)) { + for _, id := range s.completedQuestIDs(pack) { cleared = binary.AppendUvarint(cleared, uint64(id)) } for _, id := range todayCleared { @@ -114,10 +152,10 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte return 16, out, true, nil } quest, pack, err := requestQuest(request) - if err != nil || !s.packUnlocked(pack) { + if err != nil || !s.packUnlocked(ctx, pack) { return 0, nil, true, ErrInvalidRequest } - designs, known := s.questsFor(pack) + designs, known := s.questsFor(ctx, pack) design, exists := designs[quest] if !known || !exists { return 0, nil, true, ErrInvalidRequest @@ -127,7 +165,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte if _, ok := s.state.QuestInPack(quest, pack); !ok { return 0, nil, true, ErrInvalidRequest } - if err := s.state.RemoveQuest(quest, pack, 0); err != nil { + if err := s.state.RemoveQuest(ctx, quest, pack, 0); err != nil { return 0, nil, true, err } return 20, s.appendCurrentBattleDeck(wire.AppendVarint(nil, 1, uint64(quest)), 2), true, nil @@ -137,7 +175,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte return 0, nil, true, ErrInvalidRequest } // Abandoning a difficulty retains its independently committed clears. - if err := s.state.SelectQuest(pack, progress.QuestSelection{}); err != nil { + if err := s.state.SelectQuest(ctx, pack, progress.QuestSelection{}); err != nil { return 0, nil, true, err } return 20, s.appendCurrentBattleDeck(wire.AppendVarint(nil, 1, uint64(quest)), 2), true, nil @@ -160,17 +198,17 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte if level != 0 || (design.PriorQuestID != 0 && !s.state.QuestCleared(design.PriorQuestID, pack)) || s.state.QuestCleared(quest, pack) { return 0, nil, true, ErrInvalidRequest } - if err := s.state.AcceptQuest(quest, pack, 0); err != nil { + if err := s.state.AcceptQuest(ctx, quest, pack, 0); err != nil { return 0, nil, true, err } out := wire.AppendBytes(nil, 1, s.questInfoWire(pack, quest)) out = s.appendCurrentBattleDeck(out, 3) - items, err := s.ensureQuestItems(pack, quest) + items, err := s.ensureQuestItems(ctx, pack, quest) if err != nil { return 0, nil, true, err } for _, item := range items { - out = wire.AppendBytes(out, 4, player.ItemWire(item)) + out = wire.AppendBytes(out, 4, assets.ItemWire(item)) } return 17, out, true, nil } @@ -194,14 +232,14 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte if quest != first || first == 0 { return 0, nil, true, ErrInvalidRequest } - if err := s.state.SelectQuest(pack, progress.QuestSelection{QuestID: quest, Difficulty: int(level), Option: int(opt)}); err != nil { + if err := s.state.SelectQuest(ctx, pack, progress.QuestSelection{QuestID: quest, Difficulty: int(level), Option: int(opt)}); err != nil { return 0, nil, true, err } - if err := s.state.SetActivePackID(pack); err != nil { + if err := s.state.SetActivePackID(ctx, pack); err != nil { return 0, nil, true, err } s.setCurrentPack(pack) - chars, decks, err := s.resolveActivePartyWires(pack, quest) + chars, decks, err := s.resolveActivePartyWires(ctx, pack, quest) if err != nil { return 0, nil, true, err } @@ -212,12 +250,12 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte for _, deck := range decks { out = wire.AppendBytes(out, 3, deck) } - items, err := s.ensureQuestItems(pack, quest) + items, err := s.ensureQuestItems(ctx, pack, quest) if err != nil { return 0, nil, true, err } for _, item := range items { - out = wire.AppendBytes(out, 4, player.ItemWire(item)) + out = wire.AppendBytes(out, 4, assets.ItemWire(item)) } return 17, out, true, nil } @@ -234,42 +272,3 @@ func (s *Service) activeSideQuestWires(packID int) [][]byte { } return out } - -func (s *Service) visibleOwnedCharacters(characters []player.Character) []player.Character { - earned := s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) - out := make([]player.Character, 0, len(characters)) - for _, character := range characters { - if !player.IsStoryCharacter(character) && (earned || s.seed.RewardCharacter.InvenIndex == 0 || character.InvenIndex != s.seed.RewardCharacter.InvenIndex) { - out = append(out, character) - } - } - return out -} - -func (s *Service) ensureQuestItems(packID, questID int) ([]player.Item, error) { - ids := s.storyCatalog.Packs[packID].Quests[questID].GiveQuestItemIDs - if len(ids) == 0 { - return nil, nil - } - if s.inventory == nil { - return nil, fmt.Errorf("world: quest item inventory unavailable") - } - var rewards []gamedata.BattleReward - for _, id := range ids { - rewards = append(rewards, gamedata.BattleReward{Type: 13, ID: id, Count: 1}) - } - identity := questRewardIdentity(packID, questID, s.questDifficultyFor(packID, questID)) + ":give-items" - items, err := s.inventory.GrantOnce(identity, rewards) - if err == nil && len(items) == 0 { - items = s.inventory.GrantedItems(identity) - } - return items, err -} - -func (s *Service) AttachBattleActive(provider func() bool) error { - if provider == nil { - return fmt.Errorf("world: nil battle activity provider") - } - s.battleActive = provider - return nil -} diff --git a/go/internal/server/domain/world/protocol_story_adapter.go b/go/internal/server/domain/world/protocol_story_adapter.go new file mode 100644 index 0000000..6f0d8b8 --- /dev/null +++ b/go/internal/server/domain/world/protocol_story_adapter.go @@ -0,0 +1,1179 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/domain/roster/deck" + "bd2server/internal/server/protocol/wire" + "encoding/binary" + "errors" + "fmt" + "log/slog" + "slices" + "sort" +) + +func requestPack(request []byte) (int, error) { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, ErrInvalidRequest + } + pack, found, err := wire.Varint(request, 2) + if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) { + return 0, ErrInvalidRequest + } + return int(pack), nil +} + +func requestQuest(request []byte) (int, int, error) { + quest, err := requestPack(request) // fields 1 and 2 have the same validation. + if err != nil { + return 0, 0, err + } + pack, found, err := wire.Varint(request, 3) + if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) { + return 0, 0, ErrInvalidRequest + } + return quest, int(pack), nil +} + +// packInfo is the canonical protobuf encoding of the semantic new-account +// starter-pack state. Its response is generated from the authoritative local progress state. + +func (s *Service) packInfoFor(ctx command.Context, packID int) ([]byte, error) { + out, err := s.basePackInfoFor(ctx, packID) + if err != nil { + return out, err + } + rows, err := s.monsterRows(ctx, packID, nil) + if err != nil { + return nil, err + } + for _, row := range rows { + out = wire.AppendBytes(out, 6, row) + } + buffs, err := s.fieldBuffInfo(ctx) + if err != nil { + return nil, err + } + out = append(out, buffs...) + if s.huntingGround == nil { + return out, nil + } + ground, err := s.huntingGround.EnsureForPack(ctx, packID) + if err != nil { + return nil, err + } + if len(ground) == 0 { + return out, nil + } + out, _, err = wire.ReplaceBytes(out, 12, ground) + return out, err +} + +func (s *Service) basePackInfoFor(ctx command.Context, packID int) ([]byte, error) { + var out []byte + if active := s.firstUnclearedQuestFor(packID); active != 0 { + quest := s.questInfoWire(packID, active) + chars, _, err := s.resolveActivePartyWires(ctx, packID, active) + if err != nil { + return nil, err + } + for _, char := range chars { + out = wire.AppendBytes(out, 1, char) + } + out = wire.AppendBytes(out, 2, quest) + } + for _, quest := range s.activeSideQuestWires(packID) { + out = wire.AppendBytes(out, 2, quest) + } + cleared := s.completedQuestIDs(packID) + // CommonPacket requests TodayQuestInfo after parsing this response, before + // the waypoint callback calls PackManager.Enter. That separate response owns + // commission restoration; including commissions here lets Enter append them + // a second time when TodayQuestInfo arrives first, crashing the quest HUD. + if len(cleared) != 0 { + var packed []byte + for _, id := range cleared { + packed = binary.AppendUvarint(packed, uint64(id)) + } + out = wire.AppendBytes(out, 3, packed) + } + position := "{}" + mapID := 0 + restored := false + if saved, found := s.state.Position(); found && saved.PackID == packID && saved.Difficulty == s.questDifficulty(packID) && saved.RawJSON != "" { + if pack, arena := s.fieldPacks[packID]; arena && !pack.MapIDs[saved.Position.MapID] { + return nil, fmt.Errorf("world: saved map %d does not belong to arena pack %d", saved.Position.MapID, packID) + } + position = saved.RawJSON + mapID = saved.Position.MapID + restored = true + } + slog.Info("world: deliver field position", "pack", packID, "map", mapID, "restored", restored) + out = wire.AppendString(out, 4, position) + if s.npcReputation != nil { + rows, err := s.npcReputationRows(ctx, packID) + if err != nil { + return nil, err + } + for _, row := range rows { + out = wire.AppendBytes(out, 9, row) + } + } + // The remaining starter-only records were observed in the official + // starter-pack response. They represent reputation, hunting-ground, statue, + // and reward state, not generic defaults, so a newly entered later pack must + // not inherit them. + if packID != s.seed.PackID { + visit := wire.AppendVarint(nil, 5, uint64(packID)) + return wire.AppendBytes(out, 12, visit), nil + } + for _, state := range s.seed.InitialReputations { + if s.npcReputation != nil { + break + } + row := wire.AppendVarint(nil, 1, state.GroupID) + row = wire.AppendVarint(row, 2, state.State) + if state.ElapsedSeconds != 0 { + row = wire.AppendVarint(row, 3, state.ElapsedSeconds) + } + out = wire.AppendBytes(out, 9, row) + } + visit := wire.AppendVarint(nil, 5, uint64(packID)) + out = wire.AppendBytes(out, 12, visit) + for _, state := range s.seed.InitialRankStatues { + row := wire.AppendVarint(nil, 1, state.ID) + row = wire.AppendVarint(row, 2, state.Season) + if state.Error { + row = wire.AppendVarint(row, 3, 1) + } + out = wire.AppendBytes(out, 14, row) + } + if s.packJamDesign == nil { + return out, nil + } + if err := s.packJamDesign.ValidateReward(); err != nil { + return nil, err + } + reward := wire.AppendVarint(nil, 3, s.packJamDesign.Reward.Type) + reward = wire.AppendVarint(reward, 4, s.packJamDesign.Reward.Count) + group := wire.AppendBytes(nil, 1, reward) + group = wire.AppendBytes(group, 6, reward) + return wire.AppendBytes(out, 16, group), nil +} + +func (s *Service) clearResponse(ctx command.Context, packID, quest int, designRewards []gamedata.Reward, items []assets.Item, questEquipment *assets.Equipment, nextItems []assets.Item, nextChars [][]byte) []byte { + var rewards []byte + if s.collection != nil { + if grant, found := s.collection.Grant(questRewardIdentity(packID, quest, s.questDifficultyFor(packID, quest)) + ":costumes"); found { + rewards = append(rewards, roster.CollectionRewardBundle(s.collection, grant)...) + } + } + for _, reward := range designRewards { + if reward.Type != 2 && reward.Type != 3 && reward.Type != 4 && reward.Type != 12 && reward.Type != 20 { + continue + } + currency := wire.AppendVarint(nil, 3, reward.Type) + currency = wire.AppendVarint(currency, 4, reward.Count) + rewards = wire.AppendBytes(rewards, 1, currency) + } + for _, item := range items { + entry := assets.ItemWire(item) + if item.Type == 17 { + pictorial := wire.AppendVarint(nil, 1, 5) + pictorial = wire.AppendVarint(pictorial, 2, item.ID) + entry = wire.AppendBytes(entry, 7, pictorial) + } + rewards = wire.AppendBytes(rewards, 1, entry) + view := wire.AppendVarint(nil, 2, item.ID) + view = wire.AppendVarint(view, 3, item.Type) + view = wire.AppendVarint(view, 4, item.Count) + rewards = wire.AppendBytes(rewards, 6, view) + } + if questEquipment != nil { + // RewardDBInfoBundle field 4 is EquipDBInfo. Equipment is an instance, + // not an ItemDBInfo with a fabricated stack count. + rewards = wire.AppendBytes(rewards, 4, assets.EquipmentWire(*questEquipment)) + } + if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 { + rewardCharacter := encodeCharacter(s.seed.RewardCharacter) + rewards = wire.AppendBytes(rewards, 2, rewardCharacter) + costume := encodeCostume(s.seed.RewardCostume) + rewards = wire.AppendBytes(rewards, 3, costume) + for _, character := range s.seed.StoryCharacters { + view := wire.AppendVarint(nil, 2, character.ID) + view = wire.AppendVarint(view, 3, 6) + rewards = wire.AppendBytes(rewards, 6, view) + } + viewCostume := wire.AppendVarint(nil, 2, s.seed.RewardCostume.ID) + viewCostume = wire.AppendVarint(viewCostume, 3, 11) + rewards = wire.AppendBytes(rewards, 6, viewCostume) + viewCharacter := wire.AppendVarint(nil, 2, s.seed.RewardCharacter.ID) + viewCharacter = wire.AppendVarint(viewCharacter, 3, 6) + viewCharacter = wire.AppendVarint(viewCharacter, 4, 1) + rewards = wire.AppendBytes(rewards, 6, viewCharacter) + } + var out []byte + out = wire.AppendBytes(out, 1, rewards) + next := s.nextQuestFor(ctx, packID, quest) + if next != 0 { + out = wire.AppendBytes(out, 2, s.questInfoWire(packID, next)) + } else { + // QuestClearResponse.QuestInfo is dereferenced by the client + // even when this is the final quest of a pack. An explicitly present, + // empty QuestDBInfo gives that generated protobuf property a non-null + // object whose Id is the client-recognized zero sentinel. Omitting the + // field parses as null and makes the completion coroutine throw before + // it can mark the pack complete. A final-pack official capture has not + // yet been obtained, so this exact wire choice remains marked for parity + // verification even though its client behavior is deterministic. + out = wire.AppendBytes(out, 2, nil) + } + out = wire.AppendVarint(out, 3, uint64(quest)) + for _, item := range nextItems { + out = wire.AppendBytes(out, 6, assets.ItemWire(item)) + } + if s.storyCatalog.Packs[packID].Quests[quest].Type == 0 && next == 0 && s.packCompleteFor(packID) { + // The final normal quest unlocks PackTable.NextPackId. Without these + // PackDBInfo updates the client cannot find the next story pack and + // falls back to presenting the hard-difficulty objective. + for _, info := range s.packDBInfoRows(ctx) { + out = wire.AppendBytes(out, 11, info) + } + } + // Receiving a character is not a request to change the saved formation. + out = s.appendCurrentBattleDeck(out, 4) + for _, char := range nextChars { + out = wire.AppendBytes(out, 5, char) + } + out = wire.AppendBytes(out, 9, s.questLevelInfoWire(packID, s.questDifficultyFor(packID, quest))) + out = wire.AppendBytes(out, 12, nil) + return wire.AppendBytes(out, 13, nil) +} + +func (s *Service) packDBInfoRows(ctx command.Context) [][]byte { + if s.storyCatalog == nil { + return nil + } + return s.storyPackDBInfoRows(ctx) +} + +func (s *Service) accountPackInfo(ctx command.Context) []byte { + var out []byte + for _, info := range s.packDBInfoRows(ctx) { + out = wire.AppendBytes(out, 1, info) + } + for _, info := range s.packDBInfoRows(ctx) { + id, _, _ := wire.Varint(info, 1) + for level := 0; level <= 4; level++ { + if len(s.state.ClearedQuests(int(id), level)) > 0 || s.questDifficulty(int(id)) == level { + out = wire.AppendBytes(out, 2, s.questLevelInfoWire(int(id), level)) + } + } + } + if s.seed.SquareSceneID != 0 { + out = wire.AppendVarint(out, 5, s.seed.SquareSceneID) + } + return out +} + +// Absorb consumes the exact acquisition objects requested by the client using +// the same period receipts and reward graph as an ordinary field interaction. +func (s *Service) ApplyTalentFieldAbsorb(ctx command.Context, _ string, _ roster.Character, rule gamedata.TalentUseRule, ids []uint64) ([]byte, error) { + pack, mapID, _, err := s.TalentFieldContext(ctx) + if err != nil { + return nil, err + } + if rule.Class != 4 || len(ids) == 0 { + return nil, ErrInvalidRequest + } + design, err := s.fieldObjectDesign(ctx, pack) + if err != nil { + return nil, err + } + var out []byte + for _, id := range ids { + obj, ok := design.Objects[int(id)] + if !ok || obj.MapID != int(mapID) || (obj.Type != 1 && obj.Type != 3) { + return nil, fmt.Errorf("world: absorption target is not an acquisition object") + } + bundle, e := s.openFieldObject(ctx, pack, obj.GroupID, int(id)) + if e != nil { + return nil, e + } + e = wire.Walk(bundle, func(f wire.Field) error { + to := map[int]int{1: 3, 2: 5, 3: 6, 4: 4}[f.Number] + if to > 0 { + out = wire.AppendBytes(out, to, f.Value) + } + return nil + }) + if e != nil { + return nil, e + } + } + return out, nil +} + +// QuestClear.CharInfo is processed by AddCharDBInfoReward and the join UI, +// not as a complete roster snapshot. DeckInfo still carries the full formation; +// only joining characters or authored level/costume changes belong in CharInfo. +func storyPartyChanges(previous []roster.Character, next [][]byte) ([][]byte, error) { + type appearance struct{ id, level, costume, useCostume uint64 } + known := make(map[uint64]appearance, len(previous)) + for _, c := range previous { + known[c.InvenIndex] = appearance{c.ID, c.Level, c.CostumeID, c.UseCostume} + } + var changed [][]byte + for _, body := range next { + var index uint64 + var value appearance + for field, destination := range map[int]*uint64{1: &index, 2: &value.id, 4: &value.level, 5: &value.costume, 7: &value.useCostume} { + var err error + *destination, _, err = wire.Varint(body, field) + if err != nil { + return nil, err + } + } + if old, exists := known[index]; exists && old == value { + continue + } + changed = append(changed, body) + known[index] = value + } + return changed, nil +} + +func (s *Service) resolveActivePartyWires(ctx command.Context, packID, questID int) ([][]byte, [][]byte, error) { + party, err := s.resolveStoryCharacters(ctx, packID, questID) + if err != nil { + return nil, nil, err + } + characters := make([][]byte, 0, len(party)) + for _, c := range party { + characters = append(characters, encodeCharacter(c)) + } + return characters, s.currentBattleDeckWires(), nil +} + +func (s *Service) storyPackDBInfoRows(ctx command.Context) [][]byte { + ids := make([]int, 0, len(s.storyCatalog.Packs)) + for id := range s.storyCatalog.Packs { + if s.storyPackUnlocked(ctx, id) { + ids = append(ids, id) + } + } + sort.Ints(ids) + var rows [][]byte + for _, id := range ids { + row := wire.AppendVarint(nil, 1, uint64(id)) + selection, _ := s.state.Selection(id) + if selection.Difficulty != 0 { + row = wire.AppendVarint(row, 4, uint64(selection.Difficulty)) + } + if selection.Option != 0 { + row = wire.AppendVarint(row, 5, uint64(selection.Option)) + } + mainCleared := 0 + for _, qid := range s.storyCatalog.Packs[id].MainQuestIDs { + if s.state.QuestCleared(qid, id, s.questDifficulty(id)) { + mainCleared++ + } + } + if mainCleared > 0 { + row = wire.AppendVarint(row, 2, uint64(mainCleared)) + } + if s.packCompleteFor(id) { + row = wire.AppendVarint(row, 3, 1) + } + purchased := false + if s.collection != nil { + _, owned := s.collection.Grant(packPurchaseIdentity(id)) + purchased = purchased || owned + } + if purchased { + row = wire.AppendVarint(row, 8, 1) + } + rows = append(rows, row) + } + if saved, found := s.state.Position(); found { + if pack, arena := s.fieldPacks[saved.PackID]; arena && pack.MapIDs[saved.Position.MapID] { + rows = append(rows, wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(saved.PackID)), 8, 1)) + } + } + return rows +} + +func (s *Service) handlePackSummary(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > 0x7fffffff { + return 0, nil, true, fmt.Errorf("%w: PackSummaryInfoList missing or invalid sequence", ErrInvalidRequest) + } + var response []byte + for _, row := range s.packDBInfoRows(ctx) { + id, found, err := wire.Varint(row, 1) + if err != nil || !found { + return 0, nil, true, fmt.Errorf("world: invalid account pack row") + } + if !s.packSummaryTargets[int(id)] { + continue + } + once, regen, research, err := s.packRewardCounts(ctx, int(id)) + if err != nil { + return 0, nil, true, err + } + row := wire.AppendVarint(nil, 1, id) + if once > 0 { + row = wire.AppendVarint(row, 2, once) + } + if regen > 0 { + row = wire.AppendVarint(row, 3, regen) + } + defeated, err := s.packMonsterDefeatedCount(ctx, int(id)) + if err != nil { + return 0, nil, true, err + } + if defeated > 0 { + row = wire.AppendVarint(row, 4, defeated) + } + if research > 0 { + row = wire.AppendVarint(row, 5, research) + } + response = wire.AppendBytes(response, 1, row) + } + return 625, response, true, nil +} + +func intsRequest(raw []byte, number int) ([]uint64, error) { + var out []uint64 + err := wire.Walk(raw, func(f wire.Field) error { + if f.Number != number { + return nil + } + if f.Type == 0 { + v, n := binary.Uvarint(f.Value) + if n <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + return nil + } + if f.Type != 2 { + return ErrInvalidRequest + } + for raw := f.Value; len(raw) > 0; { + v, n := binary.Uvarint(raw) + if n <= 0 { + return wire.ErrMalformed + } + out = append(out, v) + raw = raw[n:] + } + return nil + }) + return out, err +} + +func (s *Service) handleFieldResearch(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 || seq > 0x7fffffff { + return 59, nil, true, ErrInvalidRequest + } + pack, err := requestPack(request) + if err != nil { + return 59, nil, true, err + } + id, _, err := wire.Varint(request, 3) + if err != nil || id == 0 || id > 0x7fffffff || !s.packUnlocked(ctx, pack) || !s.fieldObjectCurrentPack(pack) { + return 59, nil, true, ErrInvalidRequest + } + design, err := s.researchDesign(pack) + if err != nil { + return 59, nil, true, err + } + obj, ok := design.Objects[int(id)] + if !ok || obj.CollectionID == 0 && obj.Reward.Type == 0 { + return 59, nil, true, ErrInvalidRequest + } + position, saved := s.state.Position() + mapOK := false + for _, mapID := range obj.Maps { + if saved && position.PackID == pack && position.Position.MapID == mapID { + mapOK = true + } + } + if !mapOK { + return 59, nil, true, fmt.Errorf("%w: research outside current map", ErrInvalidRequest) + } + // Quest interactions use QuestUpdate, while FieldObjectResearch grants the + // object's collection/reward after its quest interaction is no longer active. + for _, quest := range obj.InteractionQuests { + if _, active := s.state.QuestInPack(quest, pack, s.questDifficulty(pack)); active && !s.state.QuestCleared(quest, pack, s.questDifficulty(pack)) { + return 59, nil, true, fmt.Errorf("%w: research belongs to active quest", ErrInvalidRequest) + } + } + if obj.Type == 1 { + eligible := false + // This server validates a learned research talent. Client animation and + // temporary highlight flags are presentation state rather than authority. + if s.characters != nil { + for _, c := range s.characters.All(ctx) { + if c.TalentLevel > 0 && s.researchCharacters[c.ID] { + eligible = true + break + } + } + } + if !eligible { + return 59, nil, true, fmt.Errorf("%w: research talent unavailable", ErrInvalidRequest) + } + } + prior, err := s.state.ResearchObjects(ctx, pack) + if err != nil { + return 59, nil, true, err + } + for _, v := range prior { + if v == int(id) { + _, found, e := s.state.ResearchObjectReply(ctx, pack, int(id)) + if e != nil { + return 59, nil, true, e + } + if !found { + return 59, nil, true, fmt.Errorf("world: researched object reward receipt missing") + } + // RewardItem is a delta. A fresh request for an already researched + // object must not credit that delta again on the client. + return 59, wire.AppendVarint(nil, 1, seq), true, nil + } + } + if s.researchEconomy == nil { + return 59, nil, true, fmt.Errorf("world: research economy unavailable") + } + var rewards []gamedata.Reward + if obj.Reward.Type != 0 && obj.Reward.Count > 0 { + rewards = append(rewards, obj.Reward) + } + if obj.CollectionID > 0 { + rewards = append(rewards, gamedata.Reward{Type: 17, ID: uint64(obj.CollectionID), Count: 1}) + } + bundle, err := s.researchEconomy.Apply(ctx, fmt.Sprintf("research:%d:%d", pack, id), nil, rewards) + if err != nil { + return 59, nil, true, err + } + var items []byte + err = wire.Walk(bundle, func(f wire.Field) error { + if f.Number == 1 && f.Type == 2 { + items = wire.AppendBytes(items, 2, f.Value) + } + return nil + }) + if err != nil { + return 59, nil, true, err + } + if err = s.state.MarkResearchObject(ctx, pack, int(id), items); err != nil { + return 59, nil, true, err + } + return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil +} + +func (s *Service) handlePackRewardCounts(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, present, err := wire.Varint(request, 1) + if err != nil || !present || seq == 0 { + return 226, nil, true, ErrInvalidRequest + } + packs, err := intsRequest(request, 2) + if err != nil || len(packs) == 0 { + return 226, nil, true, ErrInvalidRequest + } + types, err := intsRequest(request, 3) + if err != nil { + return 226, nil, true, err + } + if len(types) == 0 { + types = []uint64{1, 2, 3, 4} + } + var out []byte + seen := map[[2]uint64]bool{} + for _, p := range packs { + pack := int(p) + if pack <= 0 || !s.packUnlocked(ctx, pack) { + return 226, nil, true, ErrInvalidRequest + } + for _, t := range types { + if t < 1 || t > 4 { + return 226, nil, true, ErrInvalidRequest + } + key := [2]uint64{p, t} + if seen[key] { + continue + } + seen[key] = true + var count, max uint64 + if t == 1 { + d, e := s.researchDesign(pack) + if e != nil { + return 226, nil, true, e + } + ids, e := s.state.ResearchObjects(ctx, pack) + if e != nil { + return 226, nil, true, e + } + for _, o := range d.Objects { + if o.CollectionID > 0 || o.Reward.Type > 0 { + max++ + } + } + for _, id := range ids { + if o, ok := d.Objects[id]; ok && (o.CollectionID > 0 || o.Reward.Type > 0) { + count++ + } + } + } else { + d, e := s.fieldObjectDesign(ctx, pack) + if e != nil { + return 226, nil, true, e + } + for _, o := range d.Objects { + if !matchesFieldCount(o, t) { + continue + } + period, e := s.fieldObjectPeriodFor(pack, o) + if t != 4 || e == nil { + max++ + } + if e != nil { + continue + } + opened, e := s.state.FieldRewardOpened(ctx, pack, o.ID, period) + if e != nil { + return 226, nil, true, e + } + if opened { + count++ + } + } + } + info := wire.AppendVarint(nil, 1, t) + info = wire.AppendVarint(info, 2, p) + info = wire.AppendVarint(info, 3, count) + info = wire.AppendVarint(info, 4, max) + out = wire.AppendBytes(out, 1, info) + } + } + return 226, out, true, nil +} + +// EnsureInitialPackPurchase is called only by new-account initialization. It +// awards the bootstrap pack's real purchase rewards, not a recovery inference +// from a saved position or from the former seed-only unlock chain. +func (s *Service) EnsureInitialPackPurchase(ctx command.Context) error { + _, err := s.purchaseStoryPack(ctx, s.startingPack(), true) + return err +} + +func (s *Service) handlePackBuy(ctx command.Context, request []byte) (int, []byte, bool, error) { + id, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if pack, found, err := s.resolveEventFieldPack(ctx, id); err != nil { + return 0, nil, true, err + } else if found { + bundle, err := s.purchasePack(ctx, id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, false) + if err != nil { + return 0, nil, true, err + } + response := wire.AppendBytes(nil, 1, s.eventPackDBInfo(pack)) + return 6, wire.AppendBytes(response, 2, bundle), true, nil + } + if !s.packUnlocked(ctx, id) { + return 0, nil, true, fmt.Errorf("%w: unavailable purchase pack %d", ErrInvalidRequest, id) + } + bundle, err := s.purchaseStoryPack(ctx, id, false) + if err != nil { + return 0, nil, true, err + } + var info []byte + for _, row := range s.packDBInfoRows(ctx) { + value, _, _ := wire.Varint(row, 1) + if value == uint64(id) { + info = row + break + } + } + if info == nil { + return 0, nil, true, fmt.Errorf("world: purchased pack absent from account") + } + response := wire.AppendBytes(nil, 1, info) + return 6, wire.AppendBytes(response, 2, bundle), true, nil +} + +func (s *Service) purchaseStoryPack(ctx command.Context, id int, initial bool) ([]byte, error) { + if s.storyCatalog == nil { + return nil, fmt.Errorf("world: purchase services unavailable") + } + pack, exists := s.storyCatalog.Packs[id] + if !exists { + return nil, fmt.Errorf("%w: unknown purchase pack %d", ErrInvalidRequest, id) + } + return s.purchasePack(ctx, id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, initial) +} + +// purchasePack shares the same durable receipt and enclosing account transaction +// across story and calendar-authorized hidden event packs. +func (s *Service) purchasePack(ctx command.Context, id int, buyType, buyPrice uint64, buyRewards []gamedata.Reward, initial bool) ([]byte, error) { + if s.collection == nil || s.wallet == nil || s.inventory == nil { + return nil, fmt.Errorf("world: purchase services unavailable") + } + identity := packPurchaseIdentity(id) + if _, owned := s.collection.Grant(identity); owned { + return []byte{}, nil + } + for _, reward := range buyRewards { + if !purchaseCurrency(reward.Type) && reward.Type != 19 && reward.Type != 11 { + return nil, fmt.Errorf("world: unsupported pack purchase reward type %d", reward.Type) + } + } + if !initial && buyPrice != 0 { + var err error + switch buyType { + case 2: + _, err = s.wallet.SpendJewelryOnce(ctx, identity+":price", buyPrice) + case 3: + _, err = s.wallet.SpendFreeJewelryOnce(ctx, identity+":price", buyPrice) + case 4: + _, err = s.wallet.SpendGoldOnce(ctx, identity+":price", buyPrice) + case 12: + _, err = s.wallet.SpendCatalystOnce(ctx, identity+":price", buyPrice) + default: + return nil, fmt.Errorf("%w: unsupported pack purchase currency%d", ErrInvalidRequest, buyType) + } + if err != nil { + return nil, err + } + } + items, err := s.grantPurchaseRewards(ctx, identity, buyRewards) + if err != nil { + return nil, err + } + if err := s.collection.RecordGrantMarker(ctx, identity); err != nil { + return nil, err + } + var bundle []byte + if grant, found := s.collection.Grant(identity + ":costumes"); found { + bundle = append(bundle, roster.CollectionRewardBundle(s.collection, grant)...) + } + for _, reward := range buyRewards { + if purchaseCurrency(reward.Type) { + currency := wire.AppendVarint(wire.AppendVarint(nil, 3, reward.Type), 4, reward.Count) + bundle = wire.AppendBytes(bundle, 1, currency) + } + } + for _, item := range items { + bundle = wire.AppendBytes(bundle, 1, assets.ItemWire(item)) + } + return bundle, nil +} + +func (s *Service) handlePackDocking(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + packID, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if _, known := s.questsFor(ctx, packID); !known || !s.packUnlocked(ctx, packID) { + return 0, nil, true, fmt.Errorf("%w: unavailable docking pack %d", ErrInvalidRequest, packID) + } + if s.wallet == nil || s.packJamDesign == nil { + return 0, nil, true, fmt.Errorf("world: pack jam design or wallet unavailable") + } + identity := packJamIdentity(packID) + if path == "/PackPreviewInfo" { + var response []byte + if active := s.firstUnclearedQuestFor(packID); active != 0 { + quest := wire.AppendVarint(nil, 1, uint64(active)) + quest = wire.AppendVarint(quest, 6, uint64(packID)) + response = wire.AppendBytes(response, 1, quest) + } + if s.wallet.WasGranted(identity) { + response = wire.AppendVarint(response, 3, 1) + } + return 104, response, true, nil + } + // Serialize the claim check and grant so concurrent domain callers cannot + // return the animation reward twice. The wallet commits balance and ledger together. + + if s.wallet.WasGranted(identity) { + return 72, nil, true, nil + } + reward := s.packJamDesign.Reward + if s.packJamDesign.ValidateReward() != nil { + return 0, nil, true, fmt.Errorf("world: unsupported pack jam reward") + } + if _, err := s.wallet.GrantQuestOnce(ctx, identity, []gamedata.Reward{reward}); err != nil { + return 0, nil, true, fmt.Errorf("world: grant pack jam reward: %w", err) + } + item := wire.AppendVarint(nil, 3, reward.Type) + item = wire.AppendVarint(item, 4, reward.Count) + return 72, wire.AppendBytes(nil, 1, item), true, nil +} + +func (s *Service) handlePackDetail(ctx command.Context, request []byte) (int, []byte, bool, error) { + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 || seq > 0x7fffffff { + return 0, nil, true, fmt.Errorf("%w: PackDetailInfo invalid sequence", ErrInvalidRequest) + } + packID, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if !s.packUnlocked(ctx, packID) || !s.packSummaryTargets[packID] { + return 0, nil, true, fmt.Errorf("%w: PackDetailInfo unavailable pack %d", ErrInvalidRequest, packID) + } + ids, err := s.openedFieldObjects(ctx, packID) + if err != nil { + return 0, nil, true, err + } + response := []byte{} + rows, err := s.packMonsterRows(ctx, packID) + if err != nil { + return 0, nil, true, err + } + for _, row := range rows { + response = wire.AppendBytes(response, 1, row) + } + for _, id := range ids { + response = wire.AppendBytes(response, 2, wire.AppendVarint(nil, 1, uint64(id))) + } + research, e := s.state.ResearchObjects(ctx, packID) + if e != nil { + return 0, nil, true, e + } + for _, id := range research { + response = wire.AppendVarint(response, 3, uint64(id)) + } + return 627, response, true, nil +} + +func (s *Service) eventPackDBInfo(pack gamedata.EventFieldPack) []byte { + row := wire.AppendVarint(nil, 1, uint64(pack.ID)) + if s.eventPackPurchased(pack.ID) { + row = wire.AppendVarint(row, 8, 1) + } + return row +} + +func (s *Service) eventPackInfoRows(ctx command.Context) ([][]byte, error) { + if s.eventFieldPacks == nil { + return nil, nil + } + packs, err := s.eventFieldPacks.ListEventFieldPacks(ctx) + if err != nil { + return nil, err + } + var rows [][]byte + for _, pack := range packs { + // PackManager buys a hidden pack only when PackInfo has no row for it. + if s.eventPackPurchased(pack.ID) { + rows = append(rows, s.eventPackDBInfo(pack)) + } + } + return rows, nil +} + +func (s *Service) enterEventFieldPack(ctx command.Context, pack gamedata.EventFieldPack) (int, []byte, bool, error) { + if !s.eventPackPurchased(pack.ID) { + return 0, nil, true, fmt.Errorf("%w: event pack %d is not purchased", ErrInvalidRequest, pack.ID) + } + position := pack.InitialPosition + if position == "" { + return 0, nil, true, fmt.Errorf("world: missing event pack initial position") + } + if saved, found := s.state.Position(); found && saved.PackID == pack.ID { + if !slices.Contains(pack.MapIDs, saved.Position.MapID) { + return 0, nil, true, fmt.Errorf("%w: map outside event pack", ErrInvalidRequest) + } + position = saved.RawJSON + } + response := wire.AppendString(nil, 4, position) + buffs, err := s.fieldBuffInfo(ctx) + if err != nil { + return 0, nil, true, err + } + response = append(response, buffs...) + // The common callback dereferences HuntingGroundInfo even in hidden packs. + // Use the domain-generated empty/current snapshot; never borrow the outside + // map's monsters or story progress. + var hunting []byte + if s.huntingGround != nil { + var err error + hunting, err = s.huntingGround.EnsureForPack(ctx, pack.ID) + if err != nil { + return 0, nil, true, err + } + } + response = wire.AppendBytes(response, 12, hunting) + // Hidden-pack entry must retain the outside field position: the client + // intentionally suppresses SaveUserPosition while playing these packs. The + // persistent active pack also stays outside, so relogin cannot be stranded + // in a hidden scene after its calendar closes. + s.setCurrentPack(pack.ID) + return 5, response, true, nil +} + +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if s.todayQuests != nil { + if code, body, handled, err := s.todayQuests.Handle(ctx, path, request); handled { + if err == nil { + body = s.commissionResponseDeck(code, body) + } + return code, body, handled, err + } + } + switch path { + case "/MonsterInfo": + return s.handleMonsterInfo(ctx, request) + case "/FieldMonsterRegen": + return s.handleFieldMonsterRegen(ctx, request) + case "/FieldMonsterEvent", "/FieldMonsterDamage": + return s.handleFieldMonsterEvent(ctx, path, request) + case "/FieldMonsterReward": + return s.handleFieldMonsterReward(request) + case "/Overwhelm": + return s.handleOverwhelm(ctx, request) + case "/QuestUpdate": + return s.handleQuestUpdate(ctx, request) + case "/FieldObjectInfo": + return s.handleFieldObjectInfo(ctx, request) + case "/FieldObjectReward": + return s.handleFieldObjectReward(ctx, request) + case "/FieldObjectRewardList": + return s.handleFieldObjectRewardList(ctx, request) + case "/FieldObjectPreview": + return s.handleFieldObjectPreview(ctx, request) + case "/FieldObjectRespawn": + return s.handleFieldObjectRespawn(ctx, request) + case "/FieldObjecPositionUpdate", "/FieldObjectPositionUpdate": + return s.handleFieldObjectPosition(ctx, request) + case "/FieldObjectResearch": + return s.handleFieldResearch(ctx, request) + case "/TrapDamage", "/FieldTrapInfo", "/InteractionTrigger": + return s.handleFieldTraps(ctx, path, request) + case "/PackRewardObjectCount": + return s.handlePackRewardCounts(ctx, request) + case "/QuestInfo", "/QuestAccept", "/QuestGiveUp": + return s.handleQuestSelection(ctx, path, request) + case "/PackBuy": + return s.handlePackBuy(ctx, request) + case "/PackDetailInfo": + return s.handlePackDetail(ctx, request) + case "/PackSummaryInfoList": + return s.handlePackSummary(ctx, request) + case "/PackPreviewInfo", "/PackJamEvent": + return s.handlePackDocking(ctx, path, request) + case "/PackInfo": + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, errors.New("world: PackInfo missing sequence") + } + response := s.accountPackInfo(ctx) + rows, err := s.eventPackInfoRows(ctx) + if err != nil { + return 0, nil, true, err + } + for _, row := range rows { + response = wire.AppendBytes(response, 1, row) + } + return 4, response, true, nil + case "/CharInfo": + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, errors.New("world: CharInfo missing sequence") + } + var response []byte + characters := s.visibleOwnedCharacters(s.characters.All(ctx)) + if s.tutorialRosterRestricted() { + // The tutorial roster still contains only starter identities, but + // their field HP must come from persisted state rather than falling + // through to Starter.Handle's immutable new-account HP. + characters = make([]roster.Character, 0, len(s.starter.Characters)) + for _, seeded := range s.starter.Characters { + character, exists := s.characters.Find(ctx, seeded.InvenIndex) + if !exists { + return 0, nil, true, fmt.Errorf("world: missing starter character %d", seeded.InvenIndex) + } + characters = append(characters, character) + } + } + if s.decks != nil { + seen := map[uint64]bool{} + for _, char := range characters { + seen[char.InvenIndex] = true + } + deckCharacters := s.decks.CurrentDeck() + for _, entry := range s.decks.CurrentFieldDeck(ctx) { + deckCharacters = append(deckCharacters, deck.DeckEntry{CharacterInvenIndex: entry.CharacterInvenIndex}) + } + for _, entry := range deckCharacters { + if seen[entry.CharacterInvenIndex] { + continue + } + if char, ok := s.characters.Find(ctx, entry.CharacterInvenIndex); ok && roster.IsStoryCharacter(char) { + characters = append(characters, char) + seen[char.InvenIndex] = true + } + } + } + for _, character := range characters { + response = wire.AppendBytes(response, 1, encodeCharacter(character)) + } + control := s.starter.FieldCharControlDeckType + if s.decks != nil { + control = s.decks.FieldControlType() + } + response = wire.AppendVarint(response, 2, control) + return 9, response, true, nil + case "/CostumeInfo": + if s.tutorialRosterRestricted() { + return 0, nil, false, nil + } + var response []byte + costumes := s.starter.Costumes + if s.collection != nil { + costumes = s.collection.Costumes() + } + var selections map[uint64]uint64 + if s.prestigeSelections != nil { + var err error + selections, err = s.prestigeSelections(ctx) + if err != nil { + return 0, nil, true, err + } + } + for _, costume := range costumes { + if design := selections[costume.ID]; design != 0 { + costume.DesignID = design + } + response = wire.AppendBytes(response, 1, encodeCostume(costume)) + } + if s.collection == nil { + costume := s.seed.RewardCostume + if design := selections[costume.ID]; design != 0 { + costume.DesignID = design + } + response = wire.AppendBytes(response, 1, encodeCostume(costume)) + } + return 40, response, true, nil + case "/PackInGameInfo": + if s.battleActive != nil && s.battleActive(ctx) { + return 0, nil, true, fmt.Errorf("%w: active battle", ErrInvalidRequest) + } + pack, err := requestPack(request) + if err != nil { + return 0, nil, true, err + } + if eventPack, found, err := s.resolveEventFieldPack(ctx, pack); err != nil { + return 0, nil, true, err + } else if found { + code, body, handled, e := s.enterEventFieldPack(ctx, eventPack) + if e == nil && s.talentPackInfo != nil { + extra, x := s.talentPackInfo(ctx, pack) + if x != nil { + return 0, nil, true, x + } + body = append(body, extra...) + } + return code, body, handled, e + } + if !s.packUnlocked(ctx, pack) { + return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack) + } + if active := s.firstUnclearedQuestFor(pack); active != 0 { + if _, err := s.ensureQuestItems(ctx, pack, active); err != nil { + return 0, nil, true, err + } + } + response, err := s.packInfoFor(ctx, pack) + if err != nil { + return 0, nil, true, err + } + if s.talentPackInfo != nil { + extra, e := s.talentPackInfo(ctx, pack) + if e != nil { + return 0, nil, true, e + } + response = append(response, extra...) + } + if err := s.state.SetActivePackID(ctx, pack); err != nil { + return 0, nil, true, err + } + s.setCurrentPack(pack) + return 5, response, true, nil + case "/QuestClear": + quest, pack, err := requestQuest(request) + if err != nil { + return 0, nil, true, err + } + quests, unlocked := s.questsFor(ctx, pack) + design, exists := quests[quest] + if !unlocked || !s.packUnlocked(ctx, pack) || !exists { + return 0, nil, true, fmt.Errorf("%w: quest %d pack %d", ErrInvalidRequest, quest, pack) + } + if !s.canClear(ctx, pack, quest) { + return 0, nil, true, fmt.Errorf("%w: quest %d is not active", ErrInvalidRequest, quest) + } + wasCleared := s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest)) + var previousParty []roster.Character + if design.Type == 0 && s.storyRoster != nil { + previousParty, err = s.resolveStoryCharacters(ctx, pack, quest) + if err != nil { + return 0, nil, true, err + } + } + items, questEquipment, err := s.grantQuestRewards(ctx, pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)]) + if err != nil { + return 0, nil, true, err + } + if err := s.state.ClearQuest(ctx, quest, pack, s.questDifficultyFor(pack, quest)); err != nil { + return 0, nil, true, fmt.Errorf("world: clear quest: %w", err) + } + if s.collection != nil && quest == s.seed.BattleUnlockQuestID && pack == s.seed.PackID && s.questDifficulty(pack) == 0 && !wasCleared { + if err := s.collection.AttachRewardCostume(ctx, s.seed.RewardCostume); err != nil { + return 0, nil, true, fmt.Errorf("world: attach cleared quest costume: %w", err) + } + } + if selection, selected := s.state.Selection(pack); selected && design.Type == 0 && !wasCleared { + selection.QuestID = s.nextQuestFor(ctx, pack, quest) + if err := s.state.SelectQuest(ctx, pack, selection); err != nil { + return 0, nil, true, err + } + } + var nextItems []assets.Item + var nextChars [][]byte + if next := s.nextQuestFor(ctx, pack, quest); next != 0 { + if design.Type == 0 { + nextChars, _, err = s.resolveActivePartyWires(ctx, pack, next) + if err != nil { + return 0, nil, true, err + } + nextChars, err = storyPartyChanges(previousParty, nextChars) + if err != nil { + return 0, nil, true, err + } + } else if !wasCleared { + if err := s.state.AcceptQuest(ctx, next, pack, 0); err != nil { + return 0, nil, true, err + } + } + nextItems, err = s.ensureQuestItems(ctx, pack, next) + if err != nil { + return 0, nil, true, err + } + } + return 18, s.clearResponse(ctx, pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)], items, questEquipment, nextItems, nextChars), true, nil + default: + return 0, nil, false, nil + } +} diff --git a/go/internal/server/domain/world/quest_battle.go b/go/internal/server/domain/world/quest_battle.go new file mode 100644 index 0000000..eb28927 --- /dev/null +++ b/go/internal/server/domain/world/quest_battle.go @@ -0,0 +1,38 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "fmt" +) + +// ValidateQuestBattle checks ownership after GameData resolves the monster's +// quest range and battle deck. A side quest remains normal difficulty even +// when the player has selected a harder main story. +func (s *Service) ValidateQuestBattle(ctx command.Context, pack int, questIDs []uint64) error { + current, err := s.CurrentPackID(ctx) + if err != nil || current != pack || len(questIDs) == 0 { + return fmt.Errorf("%w: quest battle pack", ErrInvalidRequest) + } + quests, known := s.questsFor(ctx, pack) + if !known { + return fmt.Errorf("%w: quest battle catalog", ErrInvalidRequest) + } + for _, id := range questIDs { + if id == 0 || id > 0x7fffffff { + continue + } + quest, exists := quests[int(id)] + if !exists || s.state.QuestCleared(int(id), pack, s.questDifficultyFor(pack, int(id))) { + continue + } + if quest.Type == 0 && s.firstUnclearedQuestFor(pack) == int(id) { + return nil + } + if quest.Type == 1 { + if _, accepted := s.state.QuestInPack(int(id), pack, 0); accepted { + return nil + } + } + } + return fmt.Errorf("%w: quest battle has no active quest", ErrInvalidRequest) +} diff --git a/go/internal/server/domain/world/quest_selection.go b/go/internal/server/domain/world/quest_selection.go new file mode 100644 index 0000000..10406c2 --- /dev/null +++ b/go/internal/server/domain/world/quest_selection.go @@ -0,0 +1,72 @@ +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "fmt" +) + +func (s *Service) questDifficultyFor(packID, questID int) int { + if s.storyCatalog != nil && s.storyCatalog.Packs[packID].Quests[questID].Type != 0 { + return 0 + } + return s.questDifficulty(packID) +} +func (s *Service) questDifficulty(packID int) int { + selection, _ := s.state.Selection(packID) + return selection.Difficulty +} +func (s *Service) CurrentQuestDifficulty(ctx command.Context) (uint64, error) { + pack, err := s.CurrentPackID(ctx) + if err != nil { + return 0, err + } + return uint64(s.questDifficulty(pack)), nil +} +func questRewardIdentity(packID, questID, difficulty int) string { + if difficulty == 0 { + return fmt.Sprintf("pack%d:quest%d", packID, questID) + } + return fmt.Sprintf("pack%d:difficulty%d:quest%d", packID, difficulty, questID) +} + +func (s *Service) visibleOwnedCharacters(characters []roster.Character) []roster.Character { + earned := s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) + out := make([]roster.Character, 0, len(characters)) + for _, character := range characters { + if !roster.IsStoryCharacter(character) && (earned || s.seed.RewardCharacter.InvenIndex == 0 || character.InvenIndex != s.seed.RewardCharacter.InvenIndex) { + out = append(out, character) + } + } + return out +} + +func (s *Service) ensureQuestItems(ctx command.Context, packID, questID int) ([]assets.Item, error) { + ids := s.storyCatalog.Packs[packID].Quests[questID].GiveQuestItemIDs + if len(ids) == 0 { + return nil, nil + } + if s.inventory == nil { + return nil, fmt.Errorf("world: quest item inventory unavailable") + } + var rewards []gamedata.BattleReward + for _, id := range ids { + rewards = append(rewards, gamedata.BattleReward{Type: 13, ID: id, Count: 1}) + } + identity := questRewardIdentity(packID, questID, s.questDifficultyFor(packID, questID)) + ":give-items" + items, err := s.inventory.GrantOnce(ctx, identity, rewards) + if err == nil && len(items) == 0 { + items = s.inventory.GrantedItems(identity) + } + return items, err +} + +func (s *Service) AttachBattleActive(provider func(command.Context) bool) error { + if provider == nil { + return fmt.Errorf("world: nil battle activity provider") + } + s.battleActive = provider + return nil +} diff --git a/go/internal/server/world/recruit_npc.go b/go/internal/server/domain/world/recruit_npc.go similarity index 74% rename from go/internal/server/world/recruit_npc.go rename to go/internal/server/domain/world/recruit_npc.go index 69bbd85..4f5a31f 100644 --- a/go/internal/server/world/recruit_npc.go +++ b/go/internal/server/domain/world/recruit_npc.go @@ -1,27 +1,28 @@ package world import ( - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" "fmt" ) -func (s *Service) ResolveRecruitNPC(npcID uint64, root, version string, design *gamedata.RecruitDesign) (uint64, error) { - pack, err := s.CurrentPackID() +func (s *Service) ResolveRecruitNPC(ctx command.Context, npcID uint64, source *gamedata.Source, design *gamedata.RecruitDesign) (uint64, error) { + pack, err := s.CurrentPackID(ctx) if err != nil { return 0, err } - npc, err := gamedata.LoadRecruitNPC(root, version, pack, npcID) + npc, err := source.RecruitNPC(pack, npcID) if err != nil { return 0, err } - return s.resolveRecruitNPC(npc, design) + return s.resolveRecruitNPC(ctx, npc, design) } -func (s *Service) resolveRecruitNPC(npc gamedata.RecruitNPC, design *gamedata.RecruitDesign) (uint64, error) { +func (s *Service) resolveRecruitNPC(ctx command.Context, npc gamedata.RecruitNPC, design *gamedata.RecruitDesign) (uint64, error) { if design == nil || s.state == nil { return 0, fmt.Errorf("world: recruitment unavailable") } - pack, err := s.CurrentPackID() + pack, err := s.CurrentPackID(ctx) if err != nil { return 0, err } @@ -44,7 +45,7 @@ func (s *Service) resolveRecruitNPC(npc gamedata.RecruitNPC, design *gamedata.Re if kind == 0 && s.packCompleteFor(pack) { continue } - quests, knownPack := s.questsFor(pack) + quests, knownPack := s.questsFor(ctx, pack) if !knownPack { return 0, fmt.Errorf("world: recruit NPC quest pack unavailable") } @@ -58,7 +59,7 @@ func (s *Service) resolveRecruitNPC(npc gamedata.RecruitNPC, design *gamedata.Re } active := false if kind == 0 { - active = s.canClear(pack, id) + active = s.canClear(ctx, pack, id) } else { _, active = s.state.QuestInPack(id, pack) } diff --git a/go/internal/server/domain/world/side_quest_chain_test.go b/go/internal/server/domain/world/side_quest_chain_test.go new file mode 100644 index 0000000..a002ff8 --- /dev/null +++ b/go/internal/server/domain/world/side_quest_chain_test.go @@ -0,0 +1,253 @@ +//go:build integration + +package world + +import ( + "context" + "encoding/binary" + "errors" + "path/filepath" + "slices" + "testing" + + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/domain/world/progress" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" + accountstate "bd2server/internal/server/storage/account" + "bd2server/internal/server/storage/stateio" +) + +// Quest_NPC_01 takes one NPC acceptance and continues through forest, cave, +// and final map travel. Completed stages must survive pack entry: NPCInfo and +// the HUD offer missing completed IDs again after the active chain ends. +func TestForestSideQuestCompletionSurvivesPackEntry(t *testing.T) { + versions, err := versionconfig.Find() + if err != nil { + t.Fatal(err) + } + stacks, err := gamedata.LoadItemStackDesign(versions.Resolve("data/resources/GameData"), versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := gamedata.CloseDatabaseCache(); err != nil { + t.Error(err) + } + }) + catalog, err := gamedata.LoadStoryCatalog(versions.Resolve("data/resources/GameData"), versions.GameDataVersion) + if err != nil { + t.Fatal(err) + } + questCostumes, err := gamedata.LoadQuestCostumes(versions.Resolve("data/resources/GameData"), versions.GameDataVersion, + map[int]map[int]gamedata.QuestDesign{1: catalog.Packs[1].Quests}, catalog) + if err != nil { + t.Fatal(err) + } + repo, err := accountstate.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := repo.Close(); err != nil { + t.Error(err) + } + }) + run := func(action func(command.Context, *Service)) { + t.Helper() + tx, err := repo.BeginCommand(context.Background()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback() }() + ctx := command.Context{Identity: command.Identity{AccountID: "forest-side-quest", SessionID: "authenticated"}, State: tx} + state, err := progress.OpenStore(ctx, stateio.RootStore{}) + if err != nil { + t.Fatal(err) + } + wallet, err := assets.OpenWallet(ctx, stateio.RootStore{}, assets.Currency{}) + if err != nil { + t.Fatal(err) + } + inventory, err := assets.OpenInventory(ctx, stateio.RootStore{}, nil) + if err != nil { + t.Fatal(err) + } + if err := inventory.AttachItemStacks(stacks); err != nil { + t.Fatal(err) + } + collection, err := roster.OpenCollectionStore(ctx, stateio.RootStore{}, nil) + if err != nil { + t.Fatal(err) + } + service := &Service{seed: Seed{PackID: 1}, activePack: 1, state: state, wallet: wallet, inventory: inventory, + storyCatalog: catalog, collection: collection, questCostumes: questCostumes} + service.attachFieldMonsterDesign(gamedata.NewSource(versions.Resolve("data/resources/GameData"), versions.GameDataVersion)) + if err := service.AttachFieldMonsterState(stateio.RootStore{}); err != nil { + t.Fatal(err) + } + action(ctx, service) + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + } + request := func(id int, values ...int) []byte { + b := wire.AppendVarint(nil, 1, 1) + b = wire.AppendVarint(b, 2, uint64(id)) + b = wire.AppendVarint(b, 3, 1) + for _, value := range values { + b = wire.AppendVarint(b, 4, uint64(value)) + } + return b + } + handle := func(ctx command.Context, service *Service, path string, raw []byte) []byte { + t.Helper() + _, response, handled, err := service.Handle(ctx, path, raw) + if err != nil || !handled { + t.Fatalf("%s: handled=%v err=%v", path, handled, err) + } + return response + } + run(func(ctx command.Context, service *Service) { + if err := service.state.ClearQuest(ctx, 46, 1); err != nil { + t.Fatal(err) + } + if err := service.state.SelectQuest(ctx, 1, progress.QuestSelection{Difficulty: 2}); err != nil { + t.Fatal(err) + } + for _, id := range catalog.Packs[1].MainQuestIDs { + if err := service.state.ClearQuest(ctx, id, 1, 2); err != nil { + t.Fatal(err) + } + } + handle(ctx, service, "/QuestAccept", request(101)) + if err := service.ValidateQuestBattle(ctx, 1, []uint64{103}); err == nil { + t.Fatal("unreached cave battle must not be authorized by static quest range alone") + } + }) + for _, step := range []struct { + quest, next int + values []int + }{ + {101, 102, []int{1011}}, {102, 103, []int{1021}}, + {103, 104, []int{1}}, {104, 105, []int{1}}, {105, 106, []int{1051, 1052}}, + {106, 107, []int{1}}, {107, 108, []int{1071}}, {108, 109, []int{1}}, + {109, 110, []int{1}}, {110, 111, []int{1101}}, {111, 112, []int{1111}}, {112, 0, []int{1}}, + } { + run(func(ctx command.Context, service *Service) { + if step.quest == 103 { + if err := service.ValidateQuestBattle(ctx, 1, []uint64{103}); err != nil { + t.Fatalf("accepted cave continuation must authorize its battle: %v", err) + } + entry := handle(ctx, service, "/PackInGameInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)) + completed := questIDs(t, entry, 3) + if !slices.Contains(completed, 101) || !slices.Contains(completed, 102) { + t.Fatalf("re-entry lost the completed forest stages: %v", completed) + } + } + handle(ctx, service, "/QuestUpdate", request(step.quest, step.values...)) + response := handle(ctx, service, "/QuestClear", request(step.quest)) + next, present, err := wire.Bytes(response, 2) + if err != nil || !present { + t.Fatalf("clear %d did not provide its continuation: %v", step.quest, err) + } + id, _, err := wire.Varint(next, 1) + level, _, levelErr := wire.Varint(next, 4) + if err != nil || id != uint64(step.next) || levelErr != nil || level != 0 { + t.Fatalf("forest continuation must remain normal side quest %d: id=%d level=%d", step.next, id, level) + } + }) + run(func(ctx command.Context, service *Service) { + rows := service.activeSideQuestWires(1) + wantRows := 1 + if step.next == 0 { + wantRows = 0 + } + if len(rows) != wantRows { + t.Fatalf("reconnect should restore %d continuations, got %d", wantRows, len(rows)) + } + if len(rows) > 0 { + id, _, err := wire.Varint(rows[0], 1) + if err != nil || id != uint64(step.next) { + t.Fatalf("reconnect restored id=%d; player should continue with %d", id, step.next) + } + } + if selection, _ := service.state.Selection(1); selection.Difficulty != 2 || selection.QuestID != 0 { + t.Fatalf("side quest changed main difficulty: %+v", selection) + } + gold := service.wallet.Snapshot(ctx).Gold + items := service.inventory.All(ctx) + handle(ctx, service, "/QuestClear", request(step.quest)) + if service.wallet.Snapshot(ctx).Gold != gold || len(service.activeSideQuestWires(1)) != wantRows || !slices.Equal(service.inventory.All(ctx), items) { + t.Fatal("clear retry changed rewards or duplicated continuation") + } + }) + } + run(func(ctx command.Context, service *Service) { + if err := service.ValidateQuestBattle(ctx, 1, []uint64{103}); err == nil { + t.Fatal("cleared cave objective must not authorize another quest battle") + } + for _, path := range []string{"/PackInGameInfo", "/QuestInfo"} { + response := handle(ctx, service, path, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)) + field := 2 + if path == "/PackInGameInfo" { + field = 3 + } + completed := questIDs(t, response, field) + for id := 101; id <= 112; id++ { + // Both NPCInfo and the find-quest HUD offer a node when its prior + // is completed but this node is absent from CompleteQuestIDs. + if !slices.Contains(completed, id) { + t.Fatalf("%s lost completed side stage %d", path, id) + } + } + } + gold := service.wallet.Snapshot(ctx).Gold + items := service.inventory.All(ctx) + for _, id := range []int{101, 112} { + _, _, _, err := service.Handle(ctx, "/QuestAccept", request(id)) + if !errors.Is(err, ErrInvalidRequest) { + t.Fatalf("completed side stage %d must not be accepted again: %v", id, err) + } + } + if service.wallet.Snapshot(ctx).Gold != gold || !slices.Equal(service.inventory.All(ctx), items) { + t.Fatal("rejected acceptance changed side-quest rewards") + } + if err := service.state.ClearQuest(ctx, 1, 1, 1); err != nil { + t.Fatal(err) + } + if err := service.state.SelectQuest(ctx, 1, progress.QuestSelection{QuestID: 2, Difficulty: 1}); err != nil { + t.Fatal(err) + } + response := handle(ctx, service, "/QuestInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)) + completed := questIDs(t, response, 2) + if slices.Contains(completed, 46) || !slices.Contains(completed, 1) || !slices.Contains(completed, 112) { + t.Fatalf("hard main clears and normal side clears must retain separate progress: %v", completed) + } + }) +} + +func questIDs(t *testing.T, response []byte, field int) []int { + t.Helper() + var ids []int + if err := wire.Walk(response, func(value wire.Field) error { + if value.Number != field { + return nil + } + for raw := value.Value; len(raw) > 0; { + id, count := binary.Uvarint(raw) + if count <= 0 { + return errors.New("invalid completed quest IDs") + } + ids = append(ids, int(id)) + raw = raw[count:] + } + return nil + }); err != nil { + t.Fatal(err) + } + return ids +} diff --git a/go/internal/server/world/start_pack.go b/go/internal/server/domain/world/start_pack.go similarity index 74% rename from go/internal/server/world/start_pack.go rename to go/internal/server/domain/world/start_pack.go index 74bd509..fdea19e 100644 --- a/go/internal/server/world/start_pack.go +++ b/go/internal/server/domain/world/start_pack.go @@ -3,7 +3,8 @@ package world import ( "fmt" - "bd2server/internal/server/progress" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/world/progress" ) func (s *Service) startingPack() int { @@ -18,19 +19,19 @@ func (s *Service) startingPack() int { // ConfigureStartPack keeps server policy separate from the versioned tutorial // seed. The entry chapter is committed once when an account is initialized. -func (s *Service) ConfigureStartPack(packID int, initialize bool) error { +func (s *Service) ConfigureStartPack(ctx command.Context, packID int, initialize bool) error { if initialize { pack, ok := s.storyCatalog.Packs[packID] if !ok || len(pack.MainQuestIDs) == 0 { return fmt.Errorf("world: starting chapter %d has no main quest", packID) } - if err := s.state.SetStartPack(packID); err != nil { + if err := s.state.SetStartPack(ctx, packID); err != nil { return err } - if err := s.state.SetActivePackID(packID); err != nil { + if err := s.state.SetActivePackID(ctx, packID); err != nil { return err } - if err := s.state.SelectQuest(packID, progress.QuestSelection{QuestID: pack.MainQuestIDs[0]}); err != nil { + if err := s.state.SelectQuest(ctx, packID, progress.QuestSelection{QuestID: pack.MainQuestIDs[0]}); err != nil { return err } } diff --git a/go/internal/server/domain/world/story_catalog.go b/go/internal/server/domain/world/story_catalog.go new file mode 100644 index 0000000..b99d4c2 --- /dev/null +++ b/go/internal/server/domain/world/story_catalog.go @@ -0,0 +1,41 @@ +package world + +import "bd2server/internal/server/domain/command" + +func (s *Service) storyPackUnlocked(ctx command.Context, id int) bool { + pack, exists := s.storyCatalog.Packs[id] + if !exists { + return false + } + // A server-selected entry chapter is explicitly available to this account. + // This does not invent tickets for the remaining chapter catalog. + if id == s.startingPack() { + return true + } + // ContentOpen.TutorialID triggers a tutorial; the client does not use it + // as an authorization requirement. Story NextPackID is navigation only. + if pack.Open == nil { + return true + } + if pack.Open.SquadLevel != 0 { + if s.squadLevel == nil { + return false + } + level, err := s.squadLevel() + if err != nil || level < pack.Open.SquadLevel { + return false + } + } + if pack.Open.TicketID != 0 { + if s.inventory == nil { + return false + } + for _, item := range s.inventory.All(ctx) { + if item.Type == 19 && item.ID == pack.Open.TicketID && item.Count > 0 { + return true + } + } + return false + } + return true +} diff --git a/go/internal/server/world/story_characters.go b/go/internal/server/domain/world/story_characters.go similarity index 67% rename from go/internal/server/world/story_characters.go rename to go/internal/server/domain/world/story_characters.go index e31cf0b..7a26a1e 100644 --- a/go/internal/server/world/story_characters.go +++ b/go/internal/server/domain/world/story_characters.go @@ -1,23 +1,24 @@ package world import ( - "bd2server/internal/server/player" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/roster" "fmt" ) // currentBattleParty reads the player's chosen battle party without changing // membership or positions. Ordinary story quests do not force their temporary // guests into battle; fixed-character modes own their separate battle decks. -func (s *Service) currentBattleParty() ([]player.Character, error) { +func (s *Service) currentBattleParty(ctx command.Context) ([]roster.Character, error) { if s.decks == nil { return nil, nil } - var party []player.Character + var party []roster.Character for _, entry := range s.decks.CurrentDeck() { if s.characters == nil { return nil, fmt.Errorf("world: battle character store unavailable") } - c, found := s.characters.Find(entry.CharacterInvenIndex) + c, found := s.characters.Find(ctx, entry.CharacterInvenIndex) if !found { return nil, fmt.Errorf("world: saved battle character %d unavailable", entry.CharacterInvenIndex) } @@ -29,7 +30,7 @@ func (s *Service) currentBattleParty() ([]player.Character, error) { // resolveStoryCharacters provisions authored temporary guests for the pack's // character and talent UI. Availability is independent of battle selection; // StoryCharGroup supplies cosmetic field actors, not inventory identities. -func (s *Service) resolveStoryCharacters(packID, questID int) ([]player.Character, error) { +func (s *Service) resolveStoryCharacters(ctx command.Context, packID, questID int) ([]roster.Character, error) { if s.storyRoster == nil { return nil, nil } @@ -44,11 +45,11 @@ func (s *Service) resolveStoryCharacters(packID, questID int) ([]player.Characte if err != nil { return nil, err } - temporary := make([]player.Character, 0, len(designs)) - reused := make([]player.Character, 0, len(designs)) + temporary := make([]roster.Character, 0, len(designs)) + reused := make([]roster.Character, 0, len(designs)) var authoredOrder []uint64 for _, d := range designs { - var existing player.Character + var existing roster.Character for _, c := range s.characters.RawAll() { if d.TemporaryPack != 0 && c.ID == d.CharacterID && c.Level == d.Level && (c.CostumeID == 0 || c.CostumeID == d.CostumeID) { existing = c @@ -63,43 +64,31 @@ func (s *Service) resolveStoryCharacters(packID, questID int) ([]player.Characte if packID <= 0 || packID >= 65536 || d.CharacterID == 0 || d.CharacterID >= 1<<32 || d.Level == 0 || d.Level >= 256 { return nil, fmt.Errorf("world: story instance namespace overflow") } - index := player.StoryCharacterIndexBase | uint64(packID)<<40 | d.CharacterID<<8 | d.Level + index := roster.StoryCharacterIndexBase | uint64(packID)<<40 | d.CharacterID<<8 | d.Level authoredOrder = append(authoredOrder, index) - temporary = append(temporary, player.Character{InvenIndex: index, ID: d.CharacterID, HP: d.HP, Level: d.Level, CostumeID: d.CostumeID, TalentLevel: d.InitialTalentLevel}) + temporary = append(temporary, roster.Character{InvenIndex: index, ID: d.CharacterID, HP: d.HP, Level: d.Level, CostumeID: d.CostumeID, TalentLevel: d.InitialTalentLevel}) } - if err := s.characters.EnsureStoryCharacters(temporary); err != nil { + if err := s.characters.EnsureStoryCharacters(ctx, temporary); err != nil { return nil, err } - authored := make(map[uint64]player.Character, len(designs)) + authored := make(map[uint64]roster.Character, len(designs)) for _, c := range reused { - current, ok := s.characters.Find(c.InvenIndex) + current, ok := s.characters.Find(ctx, c.InvenIndex) if !ok { return nil, fmt.Errorf("world: saved story instance unavailable") } authored[current.InvenIndex] = current } for _, c := range temporary { - current, ok := s.characters.Find(c.InvenIndex) + current, ok := s.characters.Find(ctx, c.InvenIndex) if !ok { return nil, fmt.Errorf("world: story character was not saved") } authored[current.InvenIndex] = current } - result := make([]player.Character, 0, len(authoredOrder)) + result := make([]roster.Character, 0, len(authoredOrder)) for _, index := range authoredOrder { result = append(result, authored[index]) } return result, nil } - -func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]byte, error) { - party, err := s.resolveStoryCharacters(packID, questID) - if err != nil { - return nil, nil, err - } - characters := make([][]byte, 0, len(party)) - for _, c := range party { - characters = append(characters, encodeCharacter(c)) - } - return characters, s.currentBattleDeckWires(), nil -} diff --git a/go/internal/server/domain/world/talent_effects.go b/go/internal/server/domain/world/talent_effects.go new file mode 100644 index 0000000..f45bfc0 --- /dev/null +++ b/go/internal/server/domain/world/talent_effects.go @@ -0,0 +1,42 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "fmt" +) + +func (s *Service) AttachTalentPackInfo(source func(ctx command.Context, _ int) ([]byte, error)) { + s.talentPackInfo = source +} + +func (s *Service) TalentFieldContext(ctx command.Context) (int, uint64, bool, error) { + pack, err := s.CurrentPackID(ctx) + if err != nil { + return 0, 0, false, err + } + mapID, err := s.currentFieldMap(ctx, pack) + if err != nil { + return 0, 0, false, err + } + battle := s.battleActive != nil && s.battleActive(ctx) + return pack, uint64(mapID), battle, nil +} + +// Hidden packs deliberately suppress SaveUserPosition, so a calendar-authored +// initial map is authoritative until the client supplies a valid saved map. +// Ordinary pack starts live in GateSpotData assets, not QuestTable.MapId; +// guessing a quest target map would authorize interactions in another scene. +func (s *Service) currentFieldMap(ctx command.Context, pack int) (int, error) { + if !s.packUnlocked(ctx, pack) { + return 0, ErrInvalidRequest + } + if saved, ok := s.state.Position(); ok && saved.PackID == pack && saved.Difficulty == s.questDifficulty(pack) && saved.Position.MapID > 0 { + return saved.Position.MapID, nil + } + if event, found, e := s.resolveEventFieldPack(ctx, pack); e != nil { + return 0, e + } else if found && event.InitialMapID > 0 { + return int(event.InitialMapID), nil + } + return 0, fmt.Errorf("world: current scene map is not yet known") +} diff --git a/go/internal/server/domain/world/today_quests.go b/go/internal/server/domain/world/today_quests.go new file mode 100644 index 0000000..ccb2aa7 --- /dev/null +++ b/go/internal/server/domain/world/today_quests.go @@ -0,0 +1,39 @@ +package world + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/world/todayquest" + "fmt" +) + +func (s *Service) AttachTodayQuests(service *todayquest.Service) error { + if service == nil { + return fmt.Errorf("world: nil commission service") + } + s.todayQuests = service + return nil +} + +// NPCController checks whether any owned pack has completed its main story, +// rather than requiring completion of the board's own pack. +func (s *Service) CommissionPackUnlocked(ctx command.Context, pack int) bool { + if !s.packUnlocked(ctx, pack) || s.storyCatalog == nil { + return false + } + for id, design := range s.storyCatalog.Packs { + if !s.packUnlocked(ctx, id) || len(design.MainQuestIDs) == 0 { + continue + } + complete := true + for _, quest := range design.MainQuestIDs { + if !s.state.QuestCleared(quest, id, 0) { + complete = false + break + } + } + if complete { + return true + } + } + return false +} diff --git a/go/internal/server/domain/world/todayquest/protocol_adapter.go b/go/internal/server/domain/world/todayquest/protocol_adapter.go new file mode 100644 index 0000000..12e3d4b --- /dev/null +++ b/go/internal/server/domain/world/todayquest/protocol_adapter.go @@ -0,0 +1,271 @@ +package todayquest + +import ( + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/protocol/wire" + "encoding/binary" + "fmt" + "sort" +) + +func (s *Service) questWire(a Active) []byte { + q := s.design.Quests[a.ID] + b := wire.AppendVarint(nil, 1, uint64(a.ID)) + b = wire.AppendVarint(b, 2, uint64(a.Value)) + for _, id := range a.Objects { + b = wire.AppendVarint(b, 3, id) + } + return wire.AppendVarint(b, 6, uint64(q.PackID)) +} + +func (s *Service) Info(ctx command.Context, pack int) ([][]byte, []int, error) { + + st, e := s.load(ctx) + if e != nil { + return nil, nil, e + } + if e = s.save(ctx, st); e != nil { + return nil, nil, e + } + var rows [][]byte + var cleared []int + ids := []int{} + for id := range st.Active { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + if s.design.Quests[id].PackID == pack { + rows = append(rows, s.questWire(st.Active[id])) + } + } + for id := range st.Cleared { + if s.design.Quests[id].PackID == pack { + cleared = append(cleared, id) + } + } + sort.Ints(cleared) + return rows, cleared, nil +} + +func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + if path != "/TodayQuestInfo" && path != "/QuestAccept" && path != "/QuestUpdate" && path != "/QuestClear" && path != "/QuestGiveUp" { + return 0, nil, false, nil + } + id := uint64(0) + var e error + if path != "/TodayQuestInfo" { + id, _, e = wire.Varint(request, 2) + if e != nil { + return 0, nil, true, e + } + if _, ok := s.design.Quests[int(id)]; !ok { + return 0, nil, false, nil + } + } + + seq, found, e := wire.Varint(request, 1) + if e != nil || !found || seq == 0 { + return 0, nil, true, fmt.Errorf("todayquest: missing sequence") + } + st, e := s.load(ctx) + if e != nil { + return 0, nil, true, e + } + if path == "/TodayQuestInfo" { + var b []byte + ids := []int{} + for id := range st.Active { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + b = wire.AppendBytes(b, 1, s.questWire(st.Active[id])) + } + ids = nil + for id := range st.Cleared { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + b = wire.AppendVarint(b, 2, uint64(id)) + } + b = wire.AppendVarint(b, 3, s.secondsLeft(st)) + for _, id := range st.Offered { + b = wire.AppendVarint(b, 4, uint64(id)) + } + return 64, b, true, s.save(ctx, st) + } + q := s.design.Quests[int(id)] + pack, _, e := wire.Varint(request, 3) + if e != nil || int(pack) != q.PackID || !s.unlocked(ctx, q.PackID) { + return 0, nil, true, fmt.Errorf("todayquest: unavailable pack") + } + a, active := st.Active[q.ID] + switch path { + case "/QuestAccept": + level, _, e := wire.Varint(request, 4) + opt, _, optErr := wire.Varint(request, 5) + if e != nil || level != 0 || optErr != nil || opt != 0 { + return 0, nil, true, fmt.Errorf("todayquest: invalid difficulty") + } + if !active { + offered := false + for _, x := range st.Offered { + if x == q.ID { + offered = true + } + } + completed := 0 + for x := range st.Cleared { + if s.design.Quests[x].NextID == 0 { + completed++ + } + } + if q.PriorID != 0 || !offered || st.Cleared[q.ID] || completed+len(st.Active) >= s.design.Limit { + return 0, nil, true, fmt.Errorf("todayquest: root not available or limit reached") + } + a = Active{ID: q.ID} + st.Active[q.ID] = a + } + items, e := s.give(ctx, st, q.ID) + if e != nil { + return 0, nil, true, e + } + b := wire.AppendBytes(nil, 1, s.questWire(a)) + for _, it := range items { + b = wire.AppendBytes(b, 4, assets.ItemWire(it)) + } + return 17, b, true, s.save(ctx, st) + case "/QuestGiveUp": + if !active { + return 0, nil, true, fmt.Errorf("todayquest: quest is not active") + } + delete(st.Active, q.ID) + root := s.root(q.ID) + for x := range st.Cleared { + if s.root(x) == root { + delete(st.Cleared, x) + } + } + return 20, wire.AppendVarint(nil, 1, id), true, s.save(ctx, st) + case "/QuestUpdate": + if !active { + return 0, nil, true, fmt.Errorf("todayquest: quest is not active") + } + vs, e := values(request, 4) + if e != nil || len(vs) == 0 { + return 0, nil, true, fmt.Errorf("todayquest: missing progress") + } + if q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18 { + for _, v := range vs { + allowed := false + for _, x := range q.MagicValues { + if x == v { + allowed = true + } + } + if !allowed { + return 0, nil, true, fmt.Errorf("todayquest: foreign quest object") + } + seen := false + for _, x := range a.Objects { + if x == v { + seen = true + } + } + if !seen { + a.Objects = append(a.Objects, v) + } + } + } else { + if len(vs) != 1 || vs[0] > uint64(q.ConditionCount) { + return 0, nil, true, fmt.Errorf("todayquest: invalid progress") + } + if int(vs[0]) > a.Value { + a.Value = int(vs[0]) + } + } + st.Active[q.ID] = a + return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, id), 2, nil), true, s.save(ctx, st) + case "/QuestClear": + if b, ok := st.Responses[q.ID]; ok && st.Cleared[q.ID] { + return 18, b, true, nil + } + if !active || (q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && len(a.Objects) < q.ConditionCount || !(q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && a.Value < q.ConditionCount { //nolint:staticcheck // QF1001 + return 0, nil, true, fmt.Errorf("todayquest: incomplete quest") + } + if q.ReputationCompleteID != 0 && s.CompleteReputation == nil { + return 0, nil, true, fmt.Errorf("todayquest: reputation provider unavailable") + } + if q.NextID == 0 && s.CompleteAchievement == nil { + return 0, nil, true, fmt.Errorf("todayquest: achievement provider unavailable") + } + bundle, e := s.economy.Apply(ctx, s.identity(st, q.ID, "reward"), nil, q.Rewards) + if e != nil { + return 0, nil, true, e + } + b := wire.AppendBytes(nil, 1, bundle) + b = wire.AppendVarint(b, 3, id) + delete(st.Active, q.ID) + st.Cleared[q.ID] = true + if q.ReputationCompleteID != 0 { + rep, e := s.CompleteReputation(ctx, s.identity(st, q.ID, "reputation"), q.PackID, uint64(q.ReputationCompleteID)) + if e != nil { + return 0, nil, true, e + } + b = wire.AppendBytes(b, 8, rep) + } + if q.NextID != 0 { + next := Active{ID: q.NextID} + st.Active[next.ID] = next + b = wire.AppendBytes(b, 2, s.questWire(next)) + items, e := s.give(ctx, st, next.ID) + if e != nil { + return 0, nil, true, e + } + for _, it := range items { + b = wire.AppendBytes(b, 6, assets.ItemWire(it)) + } + } else { + b = wire.AppendBytes(b, 2, nil) + if e = s.CompleteAchievement(ctx, s.identity(st, q.ID, "achievement")); e != nil { + return 0, nil, true, e + } + key := s.identity(st, q.ID, "score") + if _, ok := st.ScoreAwards[key]; !ok { + st.ScoreAwards[key] = uint64(s.design.AchievementScore) + } + } + st.Responses[q.ID] = b + return 18, b, true, s.save(ctx, st) + } + return 0, nil, false, nil +} + +func values(raw []byte, number int) ([]uint64, error) { + var out []uint64 + err := wire.Walk(raw, func(f wire.Field) error { + if f.Number != number { + return nil + } + if f.Type != 0 && f.Type != 2 { + return fmt.Errorf("todayquest: invalid progress type") + } + b := f.Value + for len(b) > 0 { + n, k := binary.Uvarint(b) + if k <= 0 { + return fmt.Errorf("todayquest: invalid packed progress") + } + out = append(out, n) + b = b[k:] + if f.Type == 0 && len(b) > 0 { + return fmt.Errorf("todayquest: invalid scalar progress") + } + } + return nil + }) + return out, err +} diff --git a/go/internal/server/domain/world/todayquest/todayquest.go b/go/internal/server/domain/world/todayquest/todayquest.go new file mode 100644 index 0000000..a8f6bfb --- /dev/null +++ b/go/internal/server/domain/world/todayquest/todayquest.go @@ -0,0 +1,208 @@ +// Package todayquest implements the NPC commission board from TodayQuestTable. +package todayquest + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + + "time" +) + +type Economy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) +} +type Inventory interface { + GrantOnce(ctx command.Context, _ string, _ []gamedata.BattleReward) ([]assets.Item, error) + GrantedItems(string) []assets.Item +} +type Active struct { + ID int `json:"id"` + Value int `json:"value"` + Objects []uint64 `json:"objects"` +} +type snapshot struct { + Period string `json:"period"` + Offered []int `json:"offered"` + Active map[int]Active `json:"active"` + Cleared map[int]bool `json:"cleared"` + Responses map[int][]byte `json:"responses"` + ScoreAwards map[string]uint64 `json:"score_awards"` +} +type Service struct { + store stateio.Store + design *gamedata.TodayQuestCatalog + economy Economy + inventory Inventory + now func() time.Time + unlocked func(ctx command.Context, _ int) bool + CompleteReputation func(ctx command.Context, _ string, _ int, _ uint64) ([]byte, error) + CompleteAchievement func(command.Context, string) error +} + +// Request dispatch owns the account transaction; economy/inventory writes and +// this snapshot are committed together by the explicit command capability. +func Open(store stateio.Store, design *gamedata.TodayQuestCatalog, economy Economy, inventory Inventory, unlocked func(ctx command.Context, _ int) bool) (*Service, error) { + if store == nil || design == nil || len(design.Quests) == 0 || design.Limit <= 0 || design.PostCount <= 0 || economy == nil || inventory == nil || unlocked == nil { + return nil, fmt.Errorf("todayquest: invalid configuration") + } + return &Service{store: store, design: design, economy: economy, inventory: inventory, unlocked: unlocked, now: time.Now}, nil +} +func (s *Service) load(ctx command.Context) (snapshot, error) { + p, e := s.design.Reset.Period(3, s.now()) + if e != nil { + return snapshot{}, e + } + raw, e := s.store.Load(ctx.State, "today_quests") + if e != nil { + return snapshot{}, e + } + st := snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: map[string]uint64{}} + if raw != nil { + if e = stateio.RequireExactJSONObject(raw, "period", "offered", "active", "cleared", "responses", "score_awards"); e != nil { + return st, e + } + if e = json.Unmarshal(raw, &st); e != nil { + return st, e + } + if st.Period == "" || st.Active == nil || st.Cleared == nil || st.Responses == nil || st.ScoreAwards == nil { + return st, fmt.Errorf("todayquest: invalid saved state") + } + } + if st.Period != p { + st = snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: st.ScoreAwards} + } + for id, a := range st.Active { + q, ok := s.design.Quests[id] + if !ok || a.ID != id || st.Cleared[id] || a.Value < 0 || a.Value > q.ConditionCount || len(a.Objects) > q.ConditionCount { + return st, fmt.Errorf("todayquest: invalid active progress") + } + seen := map[uint64]bool{} + for _, object := range a.Objects { + allowed := false + for _, v := range q.MagicValues { + if object == v { + allowed = true + } + } + if !allowed || seen[object] { + return st, fmt.Errorf("todayquest: invalid saved object") + } + seen[object] = true + } + if q.PriorID != 0 && !st.Cleared[q.PriorID] { + return st, fmt.Errorf("todayquest: missing prior clear") + } + } + for id, cleared := range st.Cleared { + if _, ok := s.design.Quests[id]; !ok || !cleared { + return st, fmt.Errorf("todayquest: invalid cleared node") + } + } + for id, b := range st.Responses { + if _, ok := s.design.Quests[id]; !ok || len(b) == 0 { + return st, fmt.Errorf("todayquest: invalid clear receipt") + } + } + // Deterministic sampling is a local server policy. Persisted offers are stable + // across restarts, with PostCount independent roots for every unlocked pack. + byPack := map[int][]int{} + for id, q := range s.design.Quests { + if q.PriorID == 0 && s.unlocked(ctx, q.PackID) { + byPack[q.PackID] = append(byPack[q.PackID], id) + } + } + existing := map[int]bool{} + for _, id := range st.Offered { + q, ok := s.design.Quests[id] + if !ok || q.PriorID != 0 { + return st, fmt.Errorf("todayquest: invalid offered root") + } + existing[q.PackID] = true + } + packs := []int{} + for p := range byPack { + packs = append(packs, p) + } + sort.Ints(packs) + for _, pack := range packs { + if existing[pack] { + continue + } + ids := byPack[pack] + sort.Slice(ids, func(i, j int) bool { + a := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[i]))) + b := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[j]))) + return string(a[:]) < string(b[:]) + }) + n := min(s.design.PostCount, len(ids)) + st.Offered = append(st.Offered, ids[:n]...) + } + sort.Ints(st.Offered) + return st, nil +} +func (s *Service) save(ctx command.Context, st snapshot) error { + b, e := json.Marshal(st) + if e != nil { + return e + } + return s.store.Save(ctx.State, "today_quests", b) +} +func (s *Service) identity(st snapshot, id int, part string) string { + return fmt.Sprintf("todayquest:%s:%d:%s", st.Period, id, part) +} +func (s *Service) root(id int) int { + for s.design.Quests[id].PriorID != 0 { + id = s.design.Quests[id].PriorID + } + return id +} + +func (s *Service) give(ctx command.Context, st snapshot, id int) ([]assets.Item, error) { + var rs []gamedata.BattleReward + for _, item := range s.design.Quests[id].GiveItemIDs { + rs = append(rs, gamedata.BattleReward{Type: 13, ID: item, Count: 1}) + } + if len(rs) == 0 { + return nil, nil + } + key := s.identity(st, id, "give") + items, e := s.inventory.GrantOnce(ctx, key, rs) + if e == nil && len(items) == 0 { + items = s.inventory.GrantedItems(key) + } + return items, e +} +func (s *Service) secondsLeft(st snapshot) uint64 { + base, _ := time.Parse("2006-01-02", st.Period) + end := base.AddDate(0, 0, 7).Add(s.design.Reset.DailyReset - 9*time.Hour) + n := int64(end.Sub(s.now()).Seconds()) + if n < 0 { + return 0 + } + return uint64(n) +} + +// Score projects durable commission points. These are deliberately separate +// from user experience: no current QuestClear/Notify protocol supports an Exp +// update, and the board's score label alone does not establish that meaning. +func (s *Service) Score(ctx command.Context) (uint64, error) { + + st, e := s.load(ctx) + if e != nil { + return 0, e + } + var total uint64 + for _, n := range st.ScoreAwards { + if n == 0 || n > 2147483647 || total > 2147483647-n { + return 0, fmt.Errorf("todayquest: invalid score awards") + } + total += n + } + return total, nil +} diff --git a/go/internal/server/world/waypoint.go b/go/internal/server/domain/world/waypoint.go similarity index 53% rename from go/internal/server/world/waypoint.go rename to go/internal/server/domain/world/waypoint.go index e71c588..6d10b15 100644 --- a/go/internal/server/world/waypoint.go +++ b/go/internal/server/domain/world/waypoint.go @@ -1,31 +1,32 @@ package world import ( - "bd2server/internal/server/gamedata" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" "fmt" ) // AttachWaypointRuntime must follow AttachDecks; Save permits the client's // automatic activation of all safe-area waypoints when it enters a pack. -func (s *Service) AttachWaypointRuntime(root, version string) error { +func (s *Service) AttachWaypointRuntime(ctx command.Context, source *gamedata.Source) error { if s.decks == nil { return fmt.Errorf("world: waypoint deck unavailable") } - return s.decks.ConfigureWaypoints(func(pack uint64) (gamedata.WaypointPack, error) { - return gamedata.LoadWaypointPack(root, version, pack) - }, func(pack uint64, use bool) error { - if !s.packUnlocked(int(pack)) { + return s.decks.ConfigureWaypoints(ctx, func(pack uint64) (gamedata.WaypointPack, error) { + return source.Waypoint(pack) + }, func(ctx command.Context, pack uint64, use bool) error { + if !s.packUnlocked(ctx, int(pack)) { return fmt.Errorf("world: waypoint pack%d locked", pack) } if use { - current, err := s.CurrentPackID() + current, err := s.CurrentPackID(ctx) if err != nil { return err } if uint64(current) != pack { return fmt.Errorf("world: waypoint pack is not current") } - if s.battleActive != nil && s.battleActive() { + if s.battleActive != nil && s.battleActive(ctx) { return fmt.Errorf("world: waypoint travel during battle") } } diff --git a/go/internal/server/domain/world/world.go b/go/internal/server/domain/world/world.go new file mode 100644 index 0000000..5fd48d9 --- /dev/null +++ b/go/internal/server/domain/world/world.go @@ -0,0 +1,472 @@ +// Package world implements the local map/quest state that is not part of a +// player's assets. It deliberately stores semantic seed values, never a +// captured response payload. +package world + +import ( + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/roster" + "bd2server/internal/server/domain/roster/deck" + "bd2server/internal/server/domain/world/progress" + "bd2server/internal/server/domain/world/todayquest" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/storage/stateio" + "encoding/json" + "errors" + "fmt" + "os" + + "time" +) + +var ErrInvalidRequest = errors.New("world: invalid request") + +type InitialReputation struct { + GroupID uint64 `json:"group_id"` + State uint64 `json:"state"` + ElapsedSeconds uint64 `json:"elapsed_seconds,omitempty"` +} +type InitialRankStatue struct { + ID uint64 `json:"id"` + Season uint64 `json:"season"` + Error bool `json:"error"` +} + +type Seed struct { + SquareSceneID uint64 `json:"square_scene_id,omitempty"` + InitialReputations []InitialReputation `json:"initial_reputations,omitempty"` + InitialRankStatues []InitialRankStatue `json:"initial_rank_statues,omitempty"` + // Versioned story slot placeholder; confirmed current CostumeTable row, slot semantics await current capture. + PlaceholderCostumeID uint64 `json:"placeholder_costume_id,omitempty"` + Version string `json:"version"` + PackID int `json:"pack_id"` + StartQuestID int `json:"start_quest_id"` + BattleUnlockQuestID int `json:"battle_unlock_quest_id"` + RewardCharacter roster.Character `json:"reward_character"` + RewardCostume roster.Costume `json:"reward_costume"` + StoryCharacters []roster.Character `json:"story_characters"` +} + +func LoadSeed(path string) (Seed, error) { + b, err := os.ReadFile(path) + if err != nil { + return Seed{}, fmt.Errorf("world: read seed: %w", err) + } + var seed Seed + if err := json.Unmarshal(b, &seed); err != nil { + return Seed{}, fmt.Errorf("world: decode seed: %w", err) + } + if seed.Version != versionconfig.State() || seed.PackID <= 0 || seed.StartQuestID <= 0 || seed.BattleUnlockQuestID <= 0 || seed.RewardCharacter.ID == 0 || seed.RewardCostume.ID == 0 || len(seed.StoryCharacters) == 0 { + return Seed{}, errors.New("world: invalid seed") + } + return seed, nil +} + +func New(ctx command.Context, seed Seed, rules *gamedata.WorldRules, source *gamedata.Source, storage stateio.Store, state *progress.Store, starter *roster.Starter, equipment *assets.EquipmentInventory, inventory *assets.Inventory, wallet *assets.Wallet) (*Service, error) { + if rules == nil || source == nil || state == nil || starter == nil || equipment == nil || inventory == nil || wallet == nil { + return nil, errors.New("world: incomplete player configuration") + } + ownedCharacters := append([]roster.Character(nil), starter.Characters...) + ownedCharacters = append(ownedCharacters, seed.RewardCharacter) + ownedCharacters = append(ownedCharacters, seed.StoryCharacters...) + characters, err := roster.OpenCharacterStore(ctx, storage, ownedCharacters, inventory, source.Root(), source.Version()) + if err != nil { + return nil, err + } + quests := rules.Packs[seed.PackID] + if _, ok := quests[seed.StartQuestID]; !ok { + return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID) + } + activePack := seed.PackID + if id := state.ActivePackID(); id != 0 { + activePack = id + } + if saved, found := state.Position(); found && state.ActivePackID() == 0 { + _, storyKnown := rules.Packs[saved.PackID] + _, fieldKnown := rules.FieldPacks[saved.PackID] + if storyKnown || fieldKnown { + activePack = saved.PackID + } + } + service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, + quests: quests, transition: rules.Transitions[seed.PackID], packs: rules.Packs, transitions: rules.Transitions, activePack: activePack, fieldPacks: rules.FieldPacks, + questDifficulties: rules.Difficulties, packJamDesign: rules.Jam, packSummaryTargets: rules.SummaryTargets, storyCatalog: rules.Story, questCostumes: rules.QuestCostumes, storyRoster: rules.Characters} + service.attachPackDetailDesign(source) + service.attachFieldMonsterDesign(source) + service.trapLoader = source.FieldTraps + return service, nil +} + +func (s *Service) CharacterService() *roster.CharacterStore { return s.characters } + +func (s *Service) EarnedQuestCostume() (roster.Costume, bool) { + return s.seed.RewardCostume, s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) +} + +// CurrentPackID returns the story pack selected by the latest successful +// PackInGameInfo request. BattleEnter does not carry a pack field, so battle +// sessions lock this value when they begin. On restart, Load seeds it from the +// persisted position and finally falls back to the versioned starter pack. +func (s *Service) CurrentPackID(ctx command.Context) (int, error) { + + packID := s.activePack + + if packID == 0 { + packID = s.seed.PackID + } + if _, known := s.questsFor(ctx, packID); !known || !s.packUnlocked(ctx, packID) { + return 0, fmt.Errorf("world: current pack %d is unavailable", packID) + } + return packID, nil +} + +func (s *Service) setCurrentPack(packID int) { + + if s.activePack != packID { + s.transientVersion++ + } + s.activePack = packID + +} + +func (s *Service) TransientVersion() uint64 { + + return s.transientVersion +} + +type Service struct { + autoRecoveryPolicy *gamedata.PackRecoveryPolicy + todayQuests *todayquest.Service + huntingGround interface { + EnsureForPack(ctx command.Context, _ int) ([]byte, error) + } + battleActive func(command.Context) bool + questDifficulties map[int]map[int]bool + startingPackID int + storyRoster *gamedata.StoryCharacterCatalog + storyCatalog *gamedata.StoryCatalog + questCostumes roster.CostumeDesignSource + packDetailDesign func(int) (gamedata.PackDetailDesign, error) + packSummaryTargets map[int]bool + packJamDesign *gamedata.PackJamDesign + + fieldPacks map[int]gamedata.FieldPack + eventFieldPacks EventFieldPackSource + fieldObjects map[int]gamedata.FieldObjectDesign + fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error) + monsterLoader func(int) ([]gamedata.FieldMonsterDesign, error) + monsterStore stateio.Store + monsterNow func() time.Time + + monsterRewards func(int, uint64) ([]gamedata.BattleReward, error) + monsterMaps func(int) (map[int][]int, error) + monsterDamage func(command.Context, int, uint64, string) ([][]byte, error) + trapLoader func(int) (gamedata.FieldTrapDesign, error) + fieldBuffs map[uint64]gamedata.FieldBuffDesign + talentPackInfo func(ctx command.Context, _ int) ([]byte, error) + overwhelmAuthorize func(ctx command.Context, _ string, _ uint64) error + overwhelmSky []gamedata.SkyWayOverwhelmRule + overwhelmQuest func(int, int) (gamedata.OverwhelmQuestRule, error) + overwhelmHunting interface { + ValidateBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64) error + CompleteBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64, _ string) ([]byte, [][]byte, error) + } + npcReputation *npcReputationRuntime + fieldReset gamedata.FieldResetSchedule + researchDesigns map[int]gamedata.FieldResearchDesign + researchLoader func(int) (gamedata.FieldResearchDesign, error) + researchCharacters map[uint64]bool + researchEconomy interface { + Apply(ctx command.Context, _ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) + } + squadLevel func() (uint64, error) + seed Seed + state *progress.Store + starter *roster.Starter + equipment *assets.EquipmentInventory + inventory *assets.Inventory + wallet *assets.Wallet + characters *roster.CharacterStore + collection *roster.CollectionStore + decks *deck.Store + quests map[int]gamedata.QuestDesign + transition gamedata.PackTransition + packs map[int]map[int]gamedata.QuestDesign + transitions map[int]gamedata.PackTransition + + activePack int + transientVersion uint64 + prestigeSelections func(ctx command.Context) (map[uint64]uint64, error) +} + +func (s *Service) AttachCollection(ctx command.Context, collection *roster.CollectionStore) error { + if collection == nil { + return errors.New("world: nil collection store") + } + s.collection = collection + return s.characters.AttachCollection(ctx, collection) +} + +// AttachPrestigeSelections projects the durable skin choice into CostumeInfo +// responses without changing the frozen collection/deck schemas. +func (s *Service) AttachPrestigeSelections(ctx command.Context, provider func(ctx command.Context) (map[uint64]uint64, error)) error { + if provider == nil { + return errors.New("world: nil prestige selection provider") + } + s.prestigeSelections = provider + return nil +} + +func (s *Service) AttachDecks(ctx command.Context, decks *deck.Store) error { + if decks == nil { + return errors.New("world: nil deck store") + } + s.decks = decks + return nil +} + +func (s *Service) questsFor(ctx command.Context, packID int) (map[int]gamedata.QuestDesign, bool) { + if _, found, err := s.resolveEventFieldPack(ctx, packID); err == nil && found { + return map[int]gamedata.QuestDesign{}, true + } + if _, exists := s.fieldPacks[packID]; exists { + return map[int]gamedata.QuestDesign{}, true + } + if s.storyCatalog != nil { + pack, found := s.storyCatalog.Packs[packID] + return pack.Quests, found + } + return nil, false +} + +// packUnlocked uses installed ContentOpen rules and real account tickets. +func (s *Service) packUnlocked(ctx command.Context, packID int) bool { + if pack, found, err := s.resolveEventFieldPack(ctx, packID); err != nil { + return false + } else if found { + return s.eventPackPurchased(pack.ID) + } + if pack, exists := s.fieldPacks[packID]; exists { + return s.fieldPackUnlocked(ctx, pack) + } + return s.storyCatalog != nil && s.storyPackUnlocked(ctx, packID) +} + +func (s *Service) canClear(ctx command.Context, packID, quest int) bool { + if s.state.QuestCleared(quest, packID, s.questDifficultyFor(packID, quest)) { + return true + } + quests, found := s.questsFor(ctx, packID) + if !found || s.storyCatalog == nil { + return false + } + design, found := quests[quest] + if !found { + return false + } + if design.PriorQuestID != 0 && !s.state.QuestCleared(design.PriorQuestID, packID, s.questDifficultyFor(packID, quest)) { + return false + } + if design.Type == 0 { + return quest == s.firstUnclearedQuestFor(packID) + } + _, active := s.state.QuestInPack(quest, packID, s.questDifficultyFor(packID, quest)) + return active +} + +func (s *Service) grantQuestRewards(ctx command.Context, packID, quest int, designRewards []gamedata.Reward) ([]assets.Item, *assets.Equipment, error) { + identity := questRewardIdentity(packID, quest, s.questDifficultyFor(packID, quest)) + if s.wallet != nil { + if _, err := s.wallet.GrantQuestOnce(ctx, identity, designRewards); err != nil { + return nil, nil, fmt.Errorf("world: grant quest currency: %w", err) + } + } + var itemRewards []gamedata.BattleReward + var equipmentReward *gamedata.Reward + for i := range designRewards { + reward := designRewards[i] + switch reward.Type { + case 2, 3, 4, 12, 20: + if reward.Count == 0 { + return nil, nil, errors.New("world: zero currency reward") + } + case 10: + if reward.ID == 0 || equipmentReward != nil { + return nil, nil, errors.New("world: invalid equipment reward") + } + equipmentReward = &reward + case 11: + // Quest 26's character/costume instances come from the versioned + // story seed and are encoded below; they are not stackable items. + if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID { + continue + } + if s.collection == nil || s.questCostumes == nil { + return nil, nil, fmt.Errorf("world: costume reward service unavailable") + } + if _, ok := s.questCostumes.Character(reward.ID); !ok { + return nil, nil, fmt.Errorf("world: missing quest costume %d", reward.ID) + } + default: + if reward.ID == 0 || reward.Count == 0 { + return nil, nil, fmt.Errorf("world: invalid item reward type=%d id=%d count=%d", reward.Type, reward.ID, reward.Count) + } + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 + } + } + var costumeIDs []uint64 + for _, reward := range designRewards { + if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID) { //nolint:staticcheck // QF1001 + costumeIDs = append(costumeIDs, reward.ID) + } + } + if len(costumeIDs) != 0 { + grant, err := s.collection.GrantCostumes(ctx, identity+":costumes", costumeIDs, s.questCostumes) + if err != nil { + return nil, nil, fmt.Errorf("world: grant quest costume: %w", err) + } + var exchanges []gamedata.Reward + for _, exchange := range grant.Exchanges { + if exchange.ExchangeItemType != 20 { + return nil, nil, fmt.Errorf("world: unsupported quest costume exchange type %d", exchange.ExchangeItemType) + } + exchanges = append(exchanges, gamedata.Reward{Type: exchange.ExchangeItemType, ID: exchange.ExchangeItemID, Count: exchange.ExchangeCount}) + } + if len(exchanges) > 0 { + if s.wallet == nil { + return nil, nil, fmt.Errorf("world: quest exchange wallet unavailable") + } + if _, err := s.wallet.GrantQuestOnce(ctx, identity+":costumes:exchange", exchanges); err != nil { + return nil, nil, err + } + } + } + if s.questDifficultyFor(packID, quest) == 0 { + quests, known := s.questsFor(ctx, packID) + if !known { + return nil, nil, fmt.Errorf("world: unknown collection reward pack%d", packID) + } + for _, reward := range quests[quest].CollectionRewards { + itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 + } + } + var items []assets.Item + if len(itemRewards) != 0 { + if s.inventory == nil { + return nil, nil, errors.New("world: inventory unavailable") + } + var err error + items, err = s.inventory.GrantOnce(ctx, identity+":items", itemRewards) + if err != nil { + return nil, nil, fmt.Errorf("world: grant quest items: %w", err) + } + if len(items) == 0 { + items = s.inventory.GrantedItems(identity + ":items") + } + } + var equipment *assets.Equipment + if equipmentReward != nil { + if s.equipment == nil { + return nil, nil, errors.New("world: equipment inventory unavailable") + } + entry, err := s.equipment.GrantOnce(ctx, fmt.Sprintf("%s:equip%d", identity, equipmentReward.ID), equipmentReward.ID) + if err != nil { + return nil, nil, fmt.Errorf("world: grant quest equipment: %w", err) + } + equipment = &entry + } + return items, equipment, nil +} + +func (s *Service) firstUnclearedQuestFor(packID int) int { + if s.storyCatalog == nil { + return 0 + } + for _, id := range s.storyCatalog.Packs[packID].MainQuestIDs { + if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) { + return id + } + } + return 0 +} + +func (s *Service) packCompleteFor(packID int) bool { + if s.storyCatalog == nil { + return false + } + ids := s.storyCatalog.Packs[packID].MainQuestIDs + if len(ids) == 0 { + return false + } + for _, id := range ids { + if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) { + return false + } + } + return true +} + +// PictorialCharacters hides quest-26 rewards until they are earned, even +// though their instances already exist in the versioned world seed. +func (s *Service) PictorialCharacters() []roster.Character { + if !s.tutorialRosterRestricted() && s.characters != nil { + var permanent []roster.Character + for _, c := range s.visibleOwnedCharacters(s.characters.RawAll()) { + if !roster.IsCharmCharacter(c) { + permanent = append(permanent, c) + } + } + return permanent + } + return append([]roster.Character(nil), s.starter.Characters...) +} + +func (s *Service) PictorialCostumes() []roster.Costume { + result := append([]roster.Costume(nil), s.starter.Costumes...) + if s.collection != nil { + result = s.collection.Costumes() + } + if s.collection == nil && !s.tutorialRosterRestricted() && s.startingPack() == s.seed.PackID { + result = append(result, s.seed.RewardCostume) + } + return result +} + +func (s *Service) PictorialItems(ctx command.Context) []assets.Item { + var result []assets.Item + if s.inventory != nil { + result = s.inventory.All(ctx) + } else { + result = append(result, s.starter.Items...) + } + return result +} + +func (s *Service) PictorialEquipment(ctx command.Context) []assets.Equipment { + if s.equipment == nil { + return nil + } + return s.equipment.All(ctx) +} + +func (s *Service) PictorialDiscovered() []roster.Pictorial { + return append([]roster.Pictorial(nil), s.starter.Pictorialbook...) +} + +func encodeCostume(c roster.Costume) []byte { + return roster.CostumeWire(c) +} + +func (s *Service) nextQuestFor(ctx command.Context, packID, current int) int { + quests, found := s.questsFor(ctx, packID) + if !found { + return 0 + } + return quests[current].NextQuestID +} + +func encodeCharacter(c roster.Character) []byte { + return roster.CharacterWire(c) +} diff --git a/go/internal/server/eventactions/battle.go b/go/internal/server/eventactions/battle.go deleted file mode 100644 index 7d7c63a..0000000 --- a/go/internal/server/eventactions/battle.go +++ /dev/null @@ -1,170 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/wire" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" -) - -// Tactics uses the client's custom battle stage fields 8/9 and has fixed -// tutorial decks. Character health and ordinary pack rewards do not apply. -func (s *Service) HandlesBattle(mode uint64) bool { return mode == 29 } -func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - digest := battleDigest(req) - if r, ok := s.state.Receipts[s.session+":enter:"+receipt]; ok { - if r.Digest != digest { - return nil, errors.New("eventactions: changed battle enter replay") - } - return r.Reply, nil - } - before, _ := json.Marshal(s.state) - if val(req, 5) != 29 { - return nil, errors.New("eventactions: wrong tactics battle mode") - } - group, stage, deck := val(req, 8), val(req, 9), val(req, 4) - row, ok := s.row("TacticsBingoTable", 4, 5, group, stage) - if !ok || row.V(2) != deck { - return nil, errors.New("eventactions: invalid tactics stage/deck") - } - var uid uint64 - for _, v := range s.registry.List() { - if v.Type != 20 || !s.active(v) { - continue - } - g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) - if ok && g.V(1) == group { - uid = v.UID - break - } - } - if uid == 0 { - return nil, errors.New("eventactions: tactics event inactive") - } - if _, err := s.tacticsBlueParty(row.V(1)); err != nil { - return nil, err - } - // The client sends BattleEnter before the new scene's DeckSave. Its - // battle reset clears the prior stage's virtual party; do the same here. - s.state.Deck = nil - s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = uid, stage, deck - s.state.Receipts[s.session+":enter:"+receipt] = receiptRecord(nil, digest) - if e := s.save(); e != nil { - _ = json.Unmarshal(before, &s.state) - return nil, e - } - return nil, nil -} -func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - if r, ok := s.state.Receipts[s.session+":battle:"+receipt]; ok { - if r.Digest != battleDigest(req) { - return nil, errors.New("eventactions: changed battle completion replay") - } - return r.Reply, nil - } - before, _ := json.Marshal(s.state) - uid, stage := s.state.BattleUID, s.state.BattleStage - if uid == 0 || stage == 0 { - return nil, errors.New("eventactions: tactics battle not entered") - } - if _, e := s.resolve(uid, 20); e != nil { - return nil, e - } - if val(req, 2) == 1 && !contains(s.state.Tactics[uid], stage) { - priorLines := s.tacticsLines(uid) - s.state.Tactics[uid] = append(s.state.Tactics[uid], stage) - if s.progress != nil { - if e := s.progress(346, stage, 1); e != nil { - _ = json.Unmarshal(before, &s.state) - return nil, e - } - lines := s.tacticsLines(uid) - if lines > priorLines { - if e := s.progress(347, 0, lines-priorLines); e != nil { - _ = json.Unmarshal(before, &s.state) - return nil, e - } - } - v, _ := s.registry.Resolve(uid) - g, _ := s.design.Row("TacticsBingoGroupTable", 3, v.ID) - n := uint64(5) - g.V(4) - if uint64(len(s.state.Tactics[uid])) == n*n { - if e := s.progress(348, 0, 1); e != nil { - _ = json.Unmarshal(before, &s.state) - return nil, e - } - } - } - } - var out []byte - for _, id := range s.state.Tactics[uid] { - out = wire.AppendVarint(out, 29, id) - } - s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = 0, 0, 0 - s.state.Receipts[s.session+":battle:"+receipt] = receiptRecord(out, battleDigest(req)) - if e := s.save(); e != nil { - _ = json.Unmarshal(before, &s.state) - return nil, e - } - return out, nil -} - -func (s *Service) tacticsLines(uid uint64) uint64 { - v, _ := s.registry.Resolve(uid) - g, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) - if !ok || g.V(4) > 2 { - return 0 - } - n := uint64(5) - g.V(4) - clear := s.state.Tactics[uid] - var lines uint64 - for i := range n { - row, col := true, true - for j := range n { - row = row && contains(clear, i*n+j+1) - col = col && contains(clear, j*n+i+1) - } - if row { - lines++ - } - if col { - lines++ - } - } - a, b := true, true - for i := range n { - a = a && contains(clear, i*n+i+1) - b = b && contains(clear, i*n+(n-i)) - } - if a { - lines++ - } - if b { - lines++ - } - return lines -} - -func (s *Service) AssociatedMissionGroup(v events.Schedule) uint64 { - if v.Type == 20 { - r, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) - if ok { - return r.V(2) - } - } - if v.Type == 23 { - r, ok := s.design.Row("VotingEventTable", 6, v.ID) - if ok { - return r.V(12) - } - } - return 0 -} -func receiptRecord(b []byte, digest string) receipt { return receipt{Digest: digest, Reply: b} } -func battleDigest(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) } diff --git a/go/internal/server/eventactions/cafeteria.go b/go/internal/server/eventactions/cafeteria.go deleted file mode 100644 index 266161c..0000000 --- a/go/internal/server/eventactions/cafeteria.go +++ /dev/null @@ -1,53 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "errors" -) - -func (s *Service) cafeteriaReward(req []byte, identity string) ([]byte, error) { - group, id := val(req, 2), val(req, 3) - row, ok := s.row("CafeteriaEventTable", 5, 6, group, id) - if !ok || row.V(13) == 0 || row.V(11) == 0 { - return nil, errors.New("eventactions: cafeteria interaction absent") - } - var uid uint64 - for _, v := range s.registry.List() { - if v.Type == 25 && s.active(v) && (v.SubID == group || v.SubID == 0 && v.ID == group) { - uid = v.UID - break - } - } - if uid == 0 { - return nil, errors.New("eventactions: cafeteria event inactive") - } - if len(s.design.Tables["CafeteriaDefaultTable"]) != 1 { - return nil, errors.New("eventactions: cafeteria default missing") - } - defaults := s.design.Tables["CafeteriaDefaultTable"][0] - cap := defaults.V(8) - if cap == 0 || s.state.CafeteriaCurrency >= cap { - return nil, errors.New("eventactions: cafeteria daily currency limit") - } - term := defaults.V(3) - if row.V(4) != 1 { - term = defaults.V(28) - } - receiptKey := key(uid, group, id) - now := s.now().UnixMilli() - if last := s.state.CafeteriaLast[receiptKey]; last > 0 && now-last < int64(term)*1000 { - return nil, errors.New("eventactions: cafeteria interaction cooldown") - } - count := min(row.V(11), cap-s.state.CafeteriaCurrency) - rewards := []gamedata.Reward{{Type: row.V(13), ID: row.V(12), Count: count}} - bundle, err := s.economy.Apply(identity, nil, rewards) - if err != nil { - return nil, err - } - s.state.CafeteriaCurrency += count - s.state.CafeteriaLast[receiptKey] = now - out := wire.AppendBytes(nil, 1, bundle) - out = wire.AppendVarint(out, 2, s.state.CafeteriaCurrency) - return out, nil -} diff --git a/go/internal/server/eventactions/cafeteria_test.go b/go/internal/server/eventactions/cafeteria_test.go deleted file mode 100644 index cdb7973..0000000 --- a/go/internal/server/eventactions/cafeteria_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "testing" - "time" -) - -func TestCafeteriaActiveScheduleDailyCapCooldownAndReplay(t *testing.T) { - s, e, store := setup(t) - now := s.now() - s.now = func() time.Time { return now } - s.design.Tables["CafeteriaDefaultTable"] = []gamedata.EventActionRow{row(map[int]uint64{8: 5, 3: 2, 28: 2})} - s.design.Tables["CafeteriaEventTable"] = []gamedata.EventActionRow{row(map[int]uint64{5: 1, 6: 1, 4: 1, 11: 3, 13: 44})} - b := wire.AppendVarint(req(1), 2, 1) - b = wire.AppendVarint(b, 3, 1) - if _, _, _, err := s.Handle("/CafeteriaEventNpcInteractionReward", b); err == nil { - t.Fatal("inactive cafeteria accepted") - } - r := events.NewRegistry() - if err := r.Replace([]events.Schedule{{UID: 77, Type: 25, ID: 3007, SubID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()}}); err != nil { - t.Fatal(err) - } - s.registry = r - code, reply, _, err := s.Handle("/CafeteriaEventNpcInteractionReward", b) - if err != nil || code != 414 || e.calls != 1 { - t.Fatalf("cafeteria%d err%v", code, err) - } - n, err := Open(store, s.design, r, e) - if err != nil { - t.Fatal(err) - } - n.now = s.now - n.BeginSession("test") - _, again, _, err := n.Handle("/CafeteriaEventNpcInteractionReward", b) - if err != nil || !bytes.Equal(reply, again) || e.calls != 1 { - t.Fatal("replay grants twice") - } - b2 := wire.AppendVarint(req(2), 2, 1) - b2 = wire.AppendVarint(b2, 3, 1) - if _, _, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b2); err == nil { - t.Fatal("cooldown bypass") - } - now = now.Add(2 * time.Second) - _, reply, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b2) - if err != nil { - t.Fatal(err) - } - count, _, _ := wire.Varint(reply, 2) - if count != 5 || e.rewards[len(e.rewards)-1].Count != 2 { - t.Fatal("daily cap not clamped") - } - b3 := wire.AppendVarint(req(3), 2, 1) - b3 = wire.AppendVarint(b3, 3, 1) - now = now.Add(24 * time.Hour) - if _, _, _, err = n.Handle("/CafeteriaEventNpcInteractionReward", b3); err != nil || n.state.CafeteriaCurrency != 3 { - t.Fatal("daily limit failed to reset") - } -} diff --git a/go/internal/server/eventactions/mini_content.go b/go/internal/server/eventactions/mini_content.go deleted file mode 100644 index 217f203..0000000 --- a/go/internal/server/eventactions/mini_content.go +++ /dev/null @@ -1,129 +0,0 @@ -package eventactions - -import ( - "errors" - "fmt" - "slices" - "strconv" - "strings" - - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -type MiniContentResolver interface { - ResolveMiniContentUID(uint64) (uint64, uint64, int64, int64, error) - ListMiniContentRoutes() ([]gamedata.MiniContentRoute, error) -} - -func (s *Service) AttachMiniContent(resolver MiniContentResolver, design *gamedata.MiniContentDesign) error { - if resolver == nil || design == nil { - return errors.New("eventactions: mini content dependencies missing") - } - routes, err := resolver.ListMiniContentRoutes() - if err != nil { - return err - } - for _, route := range routes { - if route.ContentType == 14 { - ids := design.Groups[route.ContentID] - if len(ids) == 0 { - return fmt.Errorf("eventactions: published mini story group %d missing", route.ContentID) - } - for _, id := range ids { - if _, exists := design.Stories[id]; !exists { - return fmt.Errorf("eventactions: published daily story %d missing", id) - } - } - } - if route.ContentType == 13 { - found := false - for _, row := range s.design.Tables["NpcQuizTable"] { - if row.V(1) == route.ContentID { - found = true - break - } - } - if !found { - return fmt.Errorf("eventactions: published NPC quiz group %d missing", route.ContentID) - } - } - } - s.miniContent, s.miniDesign = resolver, design - return nil -} - -func (s *Service) resolveQuiz(uid uint64) (events.Schedule, error) { - if s.miniContent != nil { - typ, id, start, end, err := s.miniContent.ResolveMiniContentUID(uid) - if err == nil { - if typ != 13 { - return events.Schedule{}, errors.New("eventactions: content UID is not a quiz") - } - return events.Schedule{UID: uid, ID: id, Start: start, End: end}, nil - } - } - return s.registry.Resolve(uid) -} - -func (s *Service) dailyStory(path string, b []byte, identity string) ([]byte, error) { - if path == "/DailyStoryInfo" { - var ids []uint64 - for k, claimed := range s.state.Claims { - if claimed && strings.HasPrefix(k, "daily-story:") { - id, err := strconv.ParseUint(strings.TrimPrefix(k, "daily-story:"), 10, 64) - if err == nil { - ids = append(ids, id) - } - } - } - slices.Sort(ids) - var out []byte - for _, id := range ids { - out = wire.AppendVarint(out, 1, id) - } - return out, nil - } - id := val(b, 2) - if id == 0 || s.miniContent == nil || s.miniDesign == nil { - return nil, errors.New("eventactions: daily story unavailable") - } - reward, ok := s.miniDesign.Stories[id] - if !ok { - return nil, errors.New("eventactions: unknown daily story") - } - ck := fmt.Sprintf("daily-story:%d", id) - if s.state.Claims[ck] { - return wire.AppendVarint(wire.AppendBytes(nil, 1, nil), 2, id), nil - } - routes, err := s.miniContent.ListMiniContentRoutes() - if err != nil { - return nil, err - } - available := false - now := s.now().UnixMilli() - for _, route := range routes { - if route.ContentType != 14 || now < route.Start || now > route.End { - continue - } - for _, story := range s.miniDesign.Groups[route.ContentID] { - if story == id { - available = true - } - } - } - if !available { - return nil, errors.New("eventactions: daily story event inactive") - } - var rewards []gamedata.Reward - if reward.Count > 0 { - rewards = append(rewards, reward) - } - bundle, err := s.economy.Apply(identity, nil, rewards) - if err != nil { - return nil, err - } - s.state.Claims[ck] = true - return wire.AppendVarint(wire.AppendBytes(nil, 1, bundle), 2, id), nil -} diff --git a/go/internal/server/eventactions/mini_content_test.go b/go/internal/server/eventactions/mini_content_test.go deleted file mode 100644 index a48a26a..0000000 --- a/go/internal/server/eventactions/mini_content_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "errors" - "testing" -) - -type miniRoutes []gamedata.MiniContentRoute - -func (r miniRoutes) ListMiniContentRoutes() ([]gamedata.MiniContentRoute, error) { return r, nil } -func (r miniRoutes) ResolveMiniContentUID(uid uint64) (uint64, uint64, int64, int64, error) { - for _, v := range r { - if v.UID == uid { - return v.ContentType, v.ContentID, v.Start, v.End, nil - } - } - return 0, 0, 0, 0, errors.New("unknown content UID") -} - -func TestMiniContentStoryAndQuizCompleteReplayAndWindows(t *testing.T) { - s, e, store := setup(t) - now := s.now().UnixMilli() - routes := miniRoutes{{UID: 10000031, ContentType: 14, ContentID: 3, Start: now - 86400000, End: now + 86400000}, {UID: 10000032, ContentType: 13, ContentID: 3, Start: now - 86400000, End: now + 86400000}} - d := &gamedata.MiniContentDesign{Stories: map[uint64]gamedata.Reward{99: {Type: 4, Count: 10}, 100: {Type: 4, Count: 20}}, Groups: map[uint64][]uint64{3: {99}}} - s.design.Tables["NpcQuizTable"] = []gamedata.EventActionRow{{Values: map[int]uint64{1: 3, 2: 1, 8: 0}, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}} - if err := s.AttachMiniContent(routes, d); err != nil { - t.Fatal(err) - } - s.design.Tables["NpcQuizTable"] = []gamedata.EventActionRow{{Values: map[int]uint64{1: 3, 2: 1, 8: 0}, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}} - story := wire.AppendVarint(req(1), 2, 99) - code, reply, handled, err := s.Handle("/DailyStoryClear", story) - if err != nil || code != 539 || !handled || val(reply, 2) != 99 { - t.Fatal("story clear", code, err) - } - if e.calls != 1 { - t.Fatal("story reward omitted") - } - if _, _, _, err := s.Handle("/DailyStoryClear", wire.AppendVarint(req(2), 2, 100)); err == nil { - t.Fatal("unpublished story claimed") - } - if _, _, _, err := s.Handle("/DailyStoryClear", wire.AppendVarint(req(3), 2, 99)); err != nil || e.calls != 1 { - t.Fatal("story repeated reward", err) - } - _, info, _, err := s.Handle("/DailyStoryInfo", req(4)) - if err != nil || val(info, 1) != 99 { - t.Fatal("story completion info", err) - } - quiz := wire.AppendVarint(req(5), 2, 10000032) - quiz = wire.AppendVarint(quiz, 3, 3) - quiz = wire.AppendVarint(quiz, 4, 1) - _, quizReply, _, err := s.Handle("/NpcQuizClear", quiz) - if err != nil || e.calls != 2 { - t.Fatal("dedicated quiz clear", err) - } - _, quizInfo, _, err := s.Handle("/NpcQuizInfo", wire.AppendVarint(req(6), 2, 10000032)) - if err != nil || len(quizInfo) == 0 { - t.Fatal("quiz completion info", err) - } - reopened, err := Open(store, s.design, s.registry, e) - if err != nil { - t.Fatal(err) - } - reopened.now = s.now - reopened.BeginSession("test") - if err := reopened.AttachMiniContent(routes, d); err != nil { - t.Fatal(err) - } - _, replay, _, err := reopened.Handle("/NpcQuizClear", quiz) - if err != nil || !bytes.Equal(replay, quizReply) || e.calls != 2 { - t.Fatal("quiz restart replay", err) - } - _, replay, _, err = reopened.Handle("/DailyStoryClear", story) - if err != nil || !bytes.Equal(replay, reply) || e.calls != 2 { - t.Fatal("story restart replay", err) - } - reopened.state.Claims = map[string]bool{} - routes[0].End = now - 1 - routes[1].End = now - 1 - if _, _, _, err := reopened.Handle("/DailyStoryClear", wire.AppendVarint(req(7), 2, 99)); err == nil { - t.Fatal("expired story claimed") - } - quiz = wire.AppendVarint(req(8), 2, 10000032) - quiz = wire.AppendVarint(quiz, 3, 3) - quiz = wire.AppendVarint(quiz, 4, 1) - if _, _, _, err := reopened.Handle("/NpcQuizClear", quiz); err == nil { - t.Fatal("expired quiz claimed") - } - if e.calls != 2 { - t.Fatal("inactive content changed reward ledger") - } -} diff --git a/go/internal/server/eventactions/service.go b/go/internal/server/eventactions/service.go deleted file mode 100644 index f3aada3..0000000 --- a/go/internal/server/eventactions/service.go +++ /dev/null @@ -1,554 +0,0 @@ -// Package eventactions owns repeatable event interaction state. It uses the -// injected calendar and shared transactional reward service. -package eventactions - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - "sync" - "time" -) - -type Economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} -type receipt struct { - Digest string - Reply []byte -} -type spawn struct { - UID, Group, ID uint64 - Start int64 - Caught map[string]bool -} -type vote struct{ Round, Candidate, Normal, Additional uint64 } -type snapshot struct { - Version string - BattleUID, BattleStage, BattleDeck uint64 - Claims map[string]bool - Favorites map[uint64]bool - Votes map[string]*vote - NormalVoted map[string]bool - VoteRewards map[string]bool - Spawns map[uint64]*spawn - DailyNormal, DailySpecial uint64 - CafeteriaCurrency uint64 - CafeteriaLast map[string]int64 - Day string - Tactics map[uint64][]uint64 - Deck []byte - Receipts map[string]receipt -} -type Service struct { - mu sync.Mutex - store stateio.Store - design *gamedata.EventActionsDesign - registry events.Resolver - economy Economy - state snapshot - now func() time.Time - session string - friendship func(uint64) uint64 - chargeInfo func() ([]byte, error) - progress func(uint64, uint64, uint64) error - voteTotals func(uint64, uint64) (map[uint64]uint64, error) - miniContent MiniContentResolver - miniDesign *gamedata.MiniContentDesign -} - -func Open(store stateio.Store, d *gamedata.EventActionsDesign, r events.Resolver, e Economy) (*Service, error) { - if store == nil || d == nil || r == nil || e == nil { - return nil, errors.New("eventactions: missing dependency") - } - s := &Service{store: store, design: d, registry: r, economy: e, now: time.Now} - b, err := store.Load("eventactions") - if err != nil { - return nil, err - } - if b != nil { - if err = stateio.RequireExactJSONObject(b, "Version", "BattleUID", "BattleStage", "BattleDeck", "Claims", "Favorites", "Votes", "NormalVoted", "VoteRewards", "Spawns", "DailyNormal", "DailySpecial", "CafeteriaCurrency", "CafeteriaLast", "Day", "Tactics", "Deck", "Receipts"); err != nil { - return nil, err - } - if err = json.Unmarshal(b, &s.state); err != nil { - return nil, err - } - if s.state.Version != versionconfig.State() || s.state.Claims == nil || s.state.Favorites == nil || s.state.Votes == nil || s.state.NormalVoted == nil || s.state.VoteRewards == nil || s.state.Spawns == nil || s.state.Tactics == nil || s.state.Receipts == nil || s.state.CafeteriaLast == nil { - return nil, errors.New("eventactions: invalid state layout") - } - for _, v := range s.state.Votes { - if v == nil || v.Candidate == 0 || v.Round == 0 { - return nil, errors.New("eventactions: invalid saved vote") - } - } - for _, p := range s.state.Spawns { - if p == nil || p.Caught == nil || p.ID == 0 { - return nil, errors.New("eventactions: invalid saved spawn") - } - } - } else { - s.state.Version = versionconfig.State() - } - s.init() - return s, nil -} -func (s *Service) init() { - if s.state.CafeteriaLast == nil { - s.state.CafeteriaLast = map[string]int64{} - } - if s.state.Claims == nil { - s.state.Claims = map[string]bool{} - } - if s.state.Favorites == nil { - s.state.Favorites = map[uint64]bool{} - } - if s.state.Votes == nil { - s.state.Votes = map[string]*vote{} - } - if s.state.NormalVoted == nil { - s.state.NormalVoted = map[string]bool{} - } - if s.state.VoteRewards == nil { - s.state.VoteRewards = map[string]bool{} - } - if s.state.Spawns == nil { - s.state.Spawns = map[uint64]*spawn{} - } - if s.state.Tactics == nil { - s.state.Tactics = map[uint64][]uint64{} - } - if s.state.Receipts == nil { - s.state.Receipts = map[string]receipt{} - } -} -func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } -func (s *Service) AttachFriendshipLevel(f func(uint64) uint64) { s.friendship = f } -func (s *Service) AttachChargeInfo(f func() ([]byte, error)) { s.chargeInfo = f } -func (s *Service) AttachProgress(f func(uint64, uint64, uint64) error) { s.progress = f } - -// The current server owns one account save. Its real votes are the default -// leaderboard; a future account aggregator can supply broader server totals. -func (s *Service) AttachVoteTotals(f func(uint64, uint64) (map[uint64]uint64, error)) { - s.voteTotals = f -} -func (s *Service) save() error { - b, e := json.Marshal(s.state) - if e != nil { - return e - } - return s.store.Save("eventactions", b) -} -func val(p []byte, n int) uint64 { v, _, _ := wire.Varint(p, n); return v } -func key(v ...uint64) string { - r := []string{} - for _, n := range v { - r = append(r, fmt.Sprint(n)) - } - return strings.Join(r, "/") -} -func (s *Service) active(v events.Schedule) bool { - return s.now().UnixMilli() >= v.Start && s.now().UnixMilli() < v.End -} -func (s *Service) resolve(uid, t uint64) (events.Schedule, error) { - v, e := s.registry.Resolve(uid) - if e != nil { - return v, e - } - if v.Type != t || !s.active(v) { - return v, errors.New("eventactions: event inactive or wrong type") - } - return v, nil -} -func (s *Service) find(t uint64) (events.Schedule, bool) { - for _, v := range s.registry.List() { - if v.Type == t && s.active(v) { - return v, true - } - } - return events.Schedule{}, false -} -func (s *Service) roll() { - day := s.now().UTC().Format("2006-01-02") - if s.state.Day != day { - s.state.Day = day - s.state.DailyNormal = 0 - s.state.DailySpecial = 0 - s.state.Spawns = map[uint64]*spawn{} - s.state.CafeteriaCurrency = 0 - s.state.CafeteriaLast = map[string]int64{} - s.state.NormalVoted = map[string]bool{} - } -} -func (s *Service) Handle(path string, b []byte) (int, []byte, bool, error) { - codes := map[string]int{"/FieldEventSpawnInfo": 551, "/FieldEventSpawnStart": 552, "/FieldEventSpawnReward": 553, "/FireWorksInfo": 554, "/FireWorksReward": 555, "/CharVoteInfo": 581, "/CharVoteSave": 582, "/CharVoteRanking": 583, "/CharVoteSeasonRanking": 584, "/CharVoteFavoriteAdd": 585, "/CharVoteFavoriteDelete": 586, "/CharVoteTotalRanking": 587, "/FriendshipSpecialEpisodeInfo": 623, "/FriendshipSpecialEpisodeClear": 624, "/ChargeCostInfo": 123, "/NpcQuizInfo": 535, "/NpcQuizClear": 536, "/TacticsBingoInfo": 546, "/TacticsBingoDeckSave": 550} - code, ok := codes[path] - if path == "/CafeteriaEventNpcInteractionReward" { - code, ok = 414, true - } - if path == "/DailyStoryInfo" { - code, ok = 538, true - } - if path == "/DailyStoryClear" { - code, ok = 539, true - } - if !ok { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - seq, found, e := wire.Varint(b, 1) - if e != nil || !found || seq == 0 { - return code, nil, true, errors.New("eventactions: invalid request") - } - if e = wire.Walk(b, func(wire.Field) error { return nil }); e != nil { - return code, nil, true, e - } - sum := sha256.Sum256(append([]byte(path), b...)) - digest := hex.EncodeToString(sum[:]) - rk := s.session + ":" + path + ":" + key(seq) - if r, ok := s.state.Receipts[rk]; ok { - if r.Digest != digest { - return code, nil, true, errors.New("eventactions: replay changed") - } - return code, r.Reply, true, nil - } - before, _ := json.Marshal(s.state) - s.roll() - out, e := s.handle(path, b, rk) - if e != nil { - _ = json.Unmarshal(before, &s.state) - return code, nil, true, e - } - s.state.Receipts[rk] = receipt{digest, out} - if e = s.save(); e != nil { - _ = json.Unmarshal(before, &s.state) - return code, nil, true, e - } - return code, out, true, nil -} -func (s *Service) row(table string, groupField, idField int, g, id uint64) (gamedata.EventActionRow, bool) { - for _, r := range s.design.Tables[table] { - if r.V(groupField) == g && r.V(idField) == id { - return r, true - } - } - return gamedata.EventActionRow{}, false -} -func (s *Service) spawnWire(p *spawn) []byte { - if p == nil { - return nil - } - b := wire.AppendVarint(nil, 1, uint64(p.Start)) - b = wire.AppendVarint(b, 2, p.UID) - b = wire.AppendVarint(b, 3, p.ID) - b = wire.AppendVarint(b, 4, p.Group) - var ids []string - for k := range p.Caught { - ids = append(ids, k) - } - sort.Strings(ids) - for _, k := range ids { - var g, id uint64 - _, _ = fmt.Sscanf(k, "%d/%d", &g, &id) - x := wire.AppendVarint(nil, 1, p.ID) - x = wire.AppendVarint(x, 2, g) - x = wire.AppendVarint(x, 3, id) - b = wire.AppendBytes(b, 5, x) - } - return b -} -func (s *Service) handle(path string, b []byte, identity string) ([]byte, error) { - switch path { - case "/DailyStoryInfo", "/DailyStoryClear": - return s.dailyStory(path, b, identity) - case "/CafeteriaEventNpcInteractionReward": - return s.cafeteriaReward(b, identity) - case "/ChargeCostInfo": - if s.chargeInfo == nil { - return nil, errors.New("eventactions: charge state source unavailable") - } - return s.chargeInfo() - case "/FieldEventSpawnInfo": - uid := val(b, 2) - if _, e := s.resolve(uid, 21); e != nil { - return nil, e - } - var out []byte - if p := s.state.Spawns[uid]; p != nil { - out = wire.AppendBytes(out, 1, s.spawnWire(p)) - } - out = wire.AppendVarint(out, 2, s.state.DailyNormal) - out = wire.AppendVarint(out, 3, s.state.DailySpecial) - return out, nil - case "/FieldEventSpawnStart": - v, e := s.resolve(val(b, 2), 21) - if e != nil { - return nil, e - } - g, id := val(b, 3), val(b, 4) - r, ok := s.row("FieldSpawnEventTable", 4, 5, g, id) - if !ok || g != v.ID { - return nil, errors.New("eventactions: invalid spawn event") - } - if err := s.spawnWindow(r); err != nil { - return nil, err - } - if p := s.state.Spawns[v.UID]; p != nil { - defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) - expired := defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000 - if expired && (p.Group != g || p.ID != id) { - delete(s.state.Spawns, v.UID) - } else { - if p.Group != g || p.ID != id { - return nil, errors.New("eventactions: spawn already started") - } - return wire.AppendBytes(nil, 1, s.spawnWire(p)), nil - } - } - _ = r - s.state.Spawns[v.UID] = &spawn{v.UID, g, id, s.now().UnixMilli(), map[string]bool{}} - return wire.AppendBytes(nil, 1, s.spawnWire(s.state.Spawns[v.UID])), nil - case "/FieldEventSpawnReward": - v, e := s.resolve(val(b, 6), 21) - if e != nil { - return nil, e - } - p := s.state.Spawns[v.UID] - if p == nil || p.ID != val(b, 2) || p.Group != val(b, 4) { - return nil, errors.New("eventactions: spawn not started") - } - spawnRow, ok := s.row("FieldSpawnEventTable", 4, 5, p.Group, p.ID) - if !ok { - return nil, errors.New("eventactions: spawn definition absent") - } - g, id := val(b, 5), val(b, 3) - r, ok := s.row("FieldEventMonsterTable", 3, 4, g, id) - if !ok || g != spawnRow.V(2) { - return nil, errors.New("eventactions: invalid spawn monster") - } - ck := key(g, id) - if p.Caught[ck] { - // A new transport sequence must not turn a confirmed catch into - // either another grant or an error/recovery loop. - return wire.AppendBytes(nil, 1, nil), nil - } - defaults, _ := s.design.Row("FieldEventDefaultTable", 9, 0) - if defaults.V(12) > 0 && s.now().UnixMilli()-p.Start >= int64(defaults.V(12))*1000 { - return nil, errors.New("eventactions: spawn time expired") - } - special := r.V(2) != 0 - limited := special && s.state.DailySpecial >= defaults.V(5) || !special && s.state.DailyNormal >= defaults.V(4) - if limited { - // Participation remains available after the daily reward quota. - // Completing the capture without a grant also prevents client retries. - p.Caught[ck] = true - return wire.AppendBytes(nil, 1, nil), nil - } - reward, ok := s.design.SpawnRewards[[2]uint64{r.V(5), r.V(1)}] - if !ok || reward.Count == 0 { - return nil, errors.New("eventactions: spawn reward missing") - } - bundle, e := s.economy.Apply(identity, nil, []gamedata.Reward{reward}) - if e != nil { - return nil, e - } - p.Caught[ck] = true - if special { - s.state.DailySpecial++ - } else { - s.state.DailyNormal++ - } - if s.progress != nil { - if e = s.progress(349, r.V(2), 1); e != nil { - return nil, e - } - } - return wire.AppendBytes(nil, 1, bundle), nil - case "/FireWorksInfo": - var out []byte - for k := range s.state.Claims { - var uid, g uint64 - if _, e := fmt.Sscanf(k, "fire:%d/%d", &uid, &g); e == nil { - out = wire.AppendVarint(out, 1, g) - } - } - return out, nil - case "/FireWorksReward": - v, e := s.resolve(val(b, 2), 22) - if e != nil { - return nil, e - } - g := val(b, 3) - if g != v.ID { - return nil, errors.New("eventactions: wrong fireworks group") - } - ck := "fire:" + key(v.UID, g) - if s.state.Claims[ck] { - return nil, errors.New("eventactions: fireworks already received") - } - var rs []gamedata.Reward - for _, r := range s.design.Tables["FireworksTable"] { - if r.V(4) == g { - rs = append(rs, r.Rewards...) - break - } - } - if len(rs) == 0 { - return nil, errors.New("eventactions: fireworks design missing") - } - bundle, e := s.economy.Apply(identity, nil, rs) - if e != nil { - return nil, e - } - s.state.Claims[ck] = true - return wire.AppendBytes(nil, 1, bundle), nil - case "/FriendshipSpecialEpisodeInfo": - var out []byte - for k := range s.state.Claims { - var g, id uint64 - if _, e := fmt.Sscanf(k, "friend:%d/%d", &g, &id); e == nil { - x := wire.AppendVarint(nil, 1, g) - x = wire.AppendVarint(x, 2, id) - out = wire.AppendBytes(out, 1, x) - } - } - return out, nil - case "/FriendshipSpecialEpisodeClear": - g, id := val(b, 2), val(b, 3) - r, ok := s.row("FriendshipSpecialEpisodeTable", 5, 6, g, id) - if !ok { - return nil, errors.New("eventactions: unknown episode") - } - if s.friendship == nil || s.friendship(g) < r.V(12) { - return nil, errors.New("eventactions: friendship level insufficient") - } - if id > 1 && !s.state.Claims["friend:"+key(g, id-1)] { - return nil, errors.New("eventactions: previous episode incomplete") - } - ck := "friend:" + key(g, id) - if s.state.Claims[ck] { - return nil, errors.New("eventactions: episode already cleared") - } - rs := append([]gamedata.Reward(nil), r.Rewards...) - for _, v := range s.registry.List() { - if v.Type == 24 && v.ID == g && s.active(v) { - rs = append(rs, r.EventRewards...) - break - } - } - bundle, e := s.economy.Apply(identity, nil, rs) - if e != nil { - return nil, e - } - s.state.Claims[ck] = true - x := wire.AppendVarint(nil, 1, g) - x = wire.AppendVarint(x, 2, id) - out := wire.AppendBytes(nil, 1, bundle) - out = wire.AppendBytes(out, 2, x) - return out, nil - case "/NpcQuizInfo": - uid := val(b, 2) - v, e := s.resolveQuiz(uid) - if e != nil { - return nil, e - } - var out []byte - for _, r := range s.design.Tables["NpcQuizTable"] { - if r.V(1) != v.ID { - continue - } - if s.state.Claims["quiz:"+key(uid, r.V(1), r.V(2))] { - x := wire.AppendVarint(nil, 1, uid) - x = wire.AppendVarint(x, 2, r.V(1)) - x = wire.AppendVarint(x, 3, r.V(2)) - out = wire.AppendBytes(out, 1, x) - } - } - return out, nil - case "/NpcQuizClear": - uid, g, id := val(b, 2), val(b, 3), val(b, 4) - v, e := s.resolveQuiz(uid) - if e != nil { - return nil, e - } - if !s.active(v) { - return nil, errors.New("eventactions: quiz event inactive") - } - r, ok := s.row("NpcQuizTable", 1, 2, g, id) - if !ok || g != v.ID || s.now().UnixMilli() < v.Start+int64(r.V(8))*86400000 { - return nil, errors.New("eventactions: quiz locked") - } - ck := "quiz:" + key(uid, g, id) - if s.state.Claims[ck] { - return nil, errors.New("eventactions: quiz already cleared") - } - bundle, e := s.economy.Apply(identity, nil, r.Rewards) - if e != nil { - return nil, e - } - s.state.Claims[ck] = true - x := wire.AppendVarint(nil, 1, uid) - x = wire.AppendVarint(x, 2, g) - x = wire.AppendVarint(x, 3, id) - out := wire.AppendBytes(nil, 1, bundle) - return wire.AppendBytes(out, 2, x), nil - case "/TacticsBingoInfo": - uid := val(b, 2) - v, e := s.registry.Resolve(uid) - if e != nil { - return nil, e - } - if v.Type != 20 { - return nil, errors.New("eventactions: wrong tactics event") - } - out := wire.AppendVarint(nil, 1, uid) - out = wire.AppendVarint(out, 2, v.ID) - for _, id := range s.state.Tactics[uid] { - out = wire.AppendVarint(out, 3, id) - } - if !s.active(v) { - out = wire.AppendVarint(out, 4, 1) - } - return out, nil - case "/TacticsBingoDeckSave": - if err := s.validateTacticsDeck(b); err != nil { - return nil, err - } - s.state.Deck = append([]byte(nil), b...) - return nil, nil - default: - return s.voteHandle(path, b, identity) - } -} - -func (s *Service) RecordTacticsClear(uid, stage uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.resolve(uid, 20) - if e != nil { - return e - } - group, ok := s.design.Row("TacticsBingoGroupTable", 3, v.ID) - if !ok { - return errors.New("eventactions: tactics group missing") - } - if _, ok = s.row("TacticsBingoTable", 4, 5, group.V(1), stage); !ok { - return errors.New("eventactions: invalid tactics stage") - } - if slices.Contains(s.state.Tactics[uid], stage) { - return nil - } - s.state.Tactics[uid] = append(s.state.Tactics[uid], stage) - slices.Sort(s.state.Tactics[uid]) - return s.save() -} diff --git a/go/internal/server/eventactions/service_test.go b/go/internal/server/eventactions/service_test.go deleted file mode 100644 index 44a3730..0000000 --- a/go/internal/server/eventactions/service_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "testing" - "time" -) - -type economyStub struct { - calls int - costs, rewards []gamedata.Reward -} - -func (e *economyStub) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { - e.calls++ - e.costs = append(e.costs, c...) - e.rewards = append(e.rewards, r...) - return []byte{10, 0}, nil -} -func row(v map[int]uint64) gamedata.EventActionRow { return gamedata.EventActionRow{Values: v} } -func setup(t *testing.T) (*Service, *economyStub, stateio.Store) { - t.Helper() - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - r := events.NewRegistry() - if e := r.Replace([]events.Schedule{{UID: 1, Type: 23, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {UID: 2, Type: 20, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {UID: 3, Type: 21, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}}); e != nil { - t.Fatal(e) - } - d := &gamedata.EventActionsDesign{Tables: map[string][]gamedata.EventActionRow{"VotingEventTable": {row(map[int]uint64{6: 1, 10: 1, 14: 1, 7: 1, 8: 6001, 9: 8, 1: 1, 2: 6002, 3: 8})}, "VotingCandidateTable": {row(map[int]uint64{1: 1, 2: 9})}, "VotingRoundTable": {row(map[int]uint64{2: 1, 3: 1})}, "TacticsBingoGroupTable": {row(map[int]uint64{3: 1, 1: 5, 4: 2})}, "TacticsBingoTable": {row(map[int]uint64{4: 5, 5: 1, 2: 99})}, "FieldSpawnEventTable": {row(map[int]uint64{4: 1, 5: 1, 2: 1})}, "FieldEventMonsterTable": {row(map[int]uint64{3: 1, 4: 1, 1: 3001, 5: 3009})}, "FieldEventDefaultTable": {row(map[int]uint64{4: 10, 5: 2, 12: 40})}}, SpawnRewards: map[[2]uint64]gamedata.Reward{{3009, 3001}: {Type: 4, Count: 10000}}} - economy := &economyStub{} - d.Tables["TacticsBingoTable"][0].Values[1] = 50 - d.Tables["CharGroupTable"] = []gamedata.EventActionRow{row(map[int]uint64{1: 30011, 2: 50, 3: 1, 5: 1}), row(map[int]uint64{1: 30022, 2: 50, 3: 2, 5: 1})} - d.Tables["CharTable"] = []gamedata.EventActionRow{row(map[int]uint64{12: 30011, 5: 30101}), row(map[int]uint64{12: 30022, 5: 30201})} - d.Tables["FieldSpawnEventTable"][0].Text = map[int]string{7: "12:00:00", 1: "12:10:00"} - store := stateio.NewMemory() - s, e := Open(store, d, r, economy) - if e != nil { - t.Fatal(e) - } - s.now = func() time.Time { return now } - s.BeginSession("test") - return s, economy, store -} -func req(n uint64) []byte { return wire.AppendVarint(nil, 1, n) } -func TestVoteConsumesDesignCostRejectsRepeatAndExactReplay(t *testing.T) { - s, e, store := setup(t) - b := req(1) - b = wire.AppendVarint(b, 2, 9) - b = wire.AppendVarint(b, 4, 1) - _, reply, _, err := s.Handle("/CharVoteSave", b) - if err != nil { - t.Fatal(err) - } - if e.calls != 1 || e.costs[0].ID != 6001 { - t.Fatal("vote cost not applied") - } - next, err := Open(store, s.design, s.registry, e) - if err != nil { - t.Fatal(err) - } - next.now = s.now - next.BeginSession("test") - _, replay, _, err := next.Handle("/CharVoteSave", b) - if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 { - t.Fatal("replay duplicated vote") - } - changed := wire.AppendVarint(b, 5, 1) - if _, _, _, err = next.Handle("/CharVoteSave", changed); err == nil { - t.Fatal("changed replay accepted") - } - b = wire.AppendVarint(req(2), 2, 9) - b = wire.AppendVarint(b, 4, 1) - if _, _, _, err = next.Handle("/CharVoteSave", b); err == nil { - t.Fatal("second normal vote accepted") - } -} -func TestTacticsBattleRequiresStageAndBindsWinReceipt(t *testing.T) { - s, _, _ := setup(t) - enter := req(1) - enter = wire.AppendVarint(enter, 4, 99) - enter = wire.AppendVarint(enter, 5, 29) - enter = wire.AppendVarint(enter, 8, 5) - enter = wire.AppendVarint(enter, 9, 1) - if _, err := s.EnterBattle(enter, "enter1"); err != nil { - t.Fatal(err) - } - end := wire.AppendVarint(req(2), 2, 1) - reply, err := s.CompleteBattle(end, "end2") - if err != nil || val(reply, 29) != 1 { - t.Fatal("stage not cleared") - } - replay, err := s.CompleteBattle(end, "end2") - if err != nil || !bytes.Equal(reply, replay) { - t.Fatal("battle retry lost") - } - loss := wire.AppendVarint(req(2), 2, 2) - if _, err = s.CompleteBattle(loss, "end2"); err == nil { - t.Fatal("changed battle result accepted") - } -} -func TestSpawnNeedsStartAndRejectsRecatch(t *testing.T) { - s, e, _ := setup(t) - reward := req(1) - for f, v := range map[int]uint64{2: 1, 3: 1, 4: 1, 5: 1, 6: 3} { - reward = wire.AppendVarint(reward, f, v) - } - if _, _, _, err := s.Handle("/FieldEventSpawnReward", reward); err == nil { - t.Fatal("unstarted catch accepted") - } - start := wire.AppendVarint(req(2), 2, 3) - start = wire.AppendVarint(start, 3, 1) - start = wire.AppendVarint(start, 4, 1) - if _, _, _, err := s.Handle("/FieldEventSpawnStart", start); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/FieldEventSpawnReward", reward); err != nil { - t.Fatal(err) - } - if e.calls != 1 || s.state.DailyNormal != 1 { - t.Fatal("catch not settled") - } -} - -func TestVotingNextRoundAdvancesRealTopCandidates(t *testing.T) { - s, _, _ := setup(t) - s.design.Tables["VotingCandidateTable"] = append(s.design.Tables["VotingCandidateTable"], row(map[int]uint64{1: 1, 2: 10})) - s.design.Tables["VotingRoundTable"] = []gamedata.EventActionRow{row(map[int]uint64{2: 1, 3: 1, 1: 1}), row(map[int]uint64{2: 1, 3: 2, 4: 1})} - s.state.Votes[key(1, 1, 9)] = &vote{Round: 1, Candidate: 9, Normal: 1} - s.state.Votes[key(1, 1, 10)] = &vote{Round: 1, Candidate: 10, Additional: 2} - v, r, _ := s.voteEvent() - advanced := s.advancedCandidates(v, r, 2, s.candidates(r)) - if len(advanced) != 1 || advanced[0] != 10 { - t.Fatalf("wrong advanced list %v", advanced) - } - s.state.Votes[key(1, 1, 9)].Additional = 1 - rows := s.rankRows(1, 1, s.candidates(r)) - if val(rows[0], 3) != 1 || val(rows[1], 3) != 1 { - t.Fatal("equal votes did not share rank") - } -} diff --git a/go/internal/server/eventactions/spawn_test.go b/go/internal/server/eventactions/spawn_test.go deleted file mode 100644 index 7facc13..0000000 --- a/go/internal/server/eventactions/spawn_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "errors" - "path/filepath" - "testing" - "time" -) - -type trackerTransactionEconomy struct{ store stateio.Store } - -func (e trackerTransactionEconomy) Apply(_ string, _ []gamedata.Reward, _ []gamedata.Reward) ([]byte, error) { - return nil, e.store.Save("tracker-reward", []byte(`{"Granted":true}`)) -} - -func TestTrackerRewardAndProgressFailureRollbackTogether(t *testing.T) { - s, _, _ := setup(t) - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s.store = repo - s.economy = trackerTransactionEconomy{repo} - if _, _, _, err = s.Handle("/FieldEventSpawnStart", spawnStart(1, 1)); err != nil { - t.Fatal(err) - } - s.AttachProgress(func(uint64, uint64, uint64) error { return errors.New("mission write failed") }) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/FieldEventSpawnReward", spawnCatch(2)); err == nil { - t.Fatal("mission failure was ignored") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - b, err := repo.Load("tracker-reward") - if err != nil || b != nil { - t.Fatal("reward survived transaction rollback") - } - next, err := Open(repo, s.design, s.registry, trackerTransactionEconomy{repo}) - if err != nil { - t.Fatal(err) - } - next.now = s.now - next.BeginSession("retry") - if next.state.DailyNormal != 0 || len(next.state.Spawns[3].Caught) != 0 { - t.Fatal("catch survived failed mission transaction") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = next.Handle("/FieldEventSpawnReward", spawnCatch(3)); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - b, err = repo.Load("tracker-reward") - if err != nil || b == nil || next.state.DailyNormal != 1 { - t.Fatal("retry failed to commit reward and catch together") - } -} - -func spawnStart(seq, id uint64) []byte { - b := wire.AppendVarint(req(seq), 2, 3) - b = wire.AppendVarint(b, 3, 1) - return wire.AppendVarint(b, 4, id) -} -func spawnCatch(seq uint64) []byte { - b := req(seq) - for _, f := range [][2]uint64{{2, 1}, {3, 1}, {4, 1}, {5, 1}, {6, 3}} { - b = wire.AppendVarint(b, int(f[0]), f[1]) - } - return b -} - -func TestTrackerPrepareCatchResumeAndCrossSequenceRetry(t *testing.T) { - s, e, store := setup(t) - info := wire.AppendVarint(req(1), 2, 3) - _, b, _, err := s.Handle("/FieldEventSpawnInfo", info) - if err != nil { - t.Fatal(err) - } - if _, exists, _ := wire.Bytes(b, 1); exists { - t.Fatal("new player received fabricated non-null progress") - } - if _, _, _, err = s.Handle("/FieldEventSpawnStart", spawnStart(2, 1)); err != nil { - t.Fatal(err) - } - progressCalls := 0 - s.AttachProgress(func(kind, target, count uint64) error { - if kind != 349 || target != 0 || count != 1 { - t.Fatal("wrong catch mission progress") - } - progressCalls++ - return nil - }) - if _, _, _, err = s.Handle("/FieldEventSpawnReward", spawnCatch(3)); err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/FieldEventSpawnReward", spawnCatch(4)); err != nil { - t.Fatal(err) - } - if e.calls != 1 || progressCalls != 1 || s.state.DailyNormal != 1 { - t.Fatal("repeat catch duplicated settlement") - } - next, err := Open(store, s.design, s.registry, e) - if err != nil { - t.Fatal(err) - } - next.now = s.now - next.BeginSession("restart") - _, b, _, err = next.Handle("/FieldEventSpawnInfo", wire.AppendVarint(req(5), 2, 3)) - if err != nil { - t.Fatal(err) - } - p, exists, _ := wire.Bytes(b, 1) - if !exists || val(p, 3) != 1 || val(b, 2) != 1 { - t.Fatal("reconnect lost durable catch progress") - } - caught := 0 - if err := wire.Walk(p, func(f wire.Field) error { - if f.Number == 5 { - caught++ - } - return nil - }); err != nil { - t.Fatal(err) - } - if caught != 1 { - t.Fatal("resume lost caught monster") - } - if _, _, _, err = next.Handle("/FieldEventSpawnReward", spawnCatch(6)); err != nil || e.calls != 1 { - t.Fatal("restart catch retry duplicated reward") - } -} - -func TestTrackerSlotsExpiryAndDailyReset(t *testing.T) { - s, _, _ := setup(t) - base := s.now() - now := base - s.now = func() time.Time { return now } - s.design.Tables["FieldSpawnEventTable"] = append(s.design.Tables["FieldSpawnEventTable"], gamedata.EventActionRow{Values: map[int]uint64{4: 1, 5: 2, 2: 1}, Text: map[int]string{7: "13:00:00", 1: "13:10:00"}}) - r := events.NewRegistry() - if err := r.Replace([]events.Schedule{{UID: 3, Type: 21, ID: 1, Start: base.Add(-time.Hour).UnixMilli(), End: base.Add(48 * time.Hour).UnixMilli()}}); err != nil { - t.Fatal(err) - } - s.registry = r - if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(1, 1)); err != nil { - t.Fatal(err) - } - start := s.state.Spawns[3].Start - now = base.Add(40 * time.Second) - if _, _, _, err := s.Handle("/FieldEventSpawnReward", spawnCatch(2)); err == nil { - t.Fatal("accepted catch at time limit") - } - if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(3, 1)); err != nil || s.state.Spawns[3].Start != start { - t.Fatal("expired slot was renewed") - } - if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(4, 2)); err == nil { - t.Fatal("future slot started early") - } - now = base.Add(time.Hour) - if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(5, 2)); err != nil || s.state.Spawns[3].ID != 2 { - t.Fatal("next slot did not replace expired progress") - } - s.state.DailyNormal = 10 - s.state.DailySpecial = 2 - now = base.Add(24 * time.Hour) - _, b, _, err := s.Handle("/FieldEventSpawnInfo", wire.AppendVarint(req(6), 2, 3)) - if err != nil { - t.Fatal(err) - } - if _, ok, _ := wire.Bytes(b, 1); ok || val(b, 2) != 0 || val(b, 3) != 0 { - t.Fatal("daily reset kept yesterday's progress or quotas") - } -} - -func TestTrackerQuotaStopsRewardsWithoutRejectingCapture(t *testing.T) { - s, e, _ := setup(t) - if _, _, _, err := s.Handle("/FieldEventSpawnStart", spawnStart(1, 1)); err != nil { - t.Fatal(err) - } - s.state.DailyNormal = 10 - if _, _, _, err := s.Handle("/FieldEventSpawnReward", spawnCatch(2)); err != nil { - t.Fatal("daily reward quota rejected gameplay capture", err) - } - if e.calls != 0 || s.state.DailyNormal != 10 || !s.state.Spawns[3].Caught[key(1, 1)] { - t.Fatal("quota either awarded again or failed to finish capture") - } -} diff --git a/go/internal/server/eventactions/tactics_deck.go b/go/internal/server/eventactions/tactics_deck.go deleted file mode 100644 index 51dc5bd..0000000 --- a/go/internal/server/eventactions/tactics_deck.go +++ /dev/null @@ -1,156 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "errors" - "math" - "sort" -) - -// BattlePlayLibrary gives TacticsBingo the standard 3 by 4 grid and five -// characters. Positions are zero-based; DeckSave sequences start at one. -const tacticsGridSize = 3 * 4 -const tacticsPartySize = 5 - -type tacticsDeckEntry struct { - index, character, costume, position, sequence uint64 -} - -func decodeTacticsDeck(request []byte) ([]tacticsDeckEntry, error) { - var entries []tacticsDeckEntry - indices, characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{} - err := wire.Walk(request, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 2 { - return errors.New("eventactions: malformed tactics deck entry") - } - seen := map[int]bool{} - if err := wire.Walk(f.Value, func(v wire.Field) error { - if v.Number >= 1 && v.Number <= 5 { - if v.Type != 0 || seen[v.Number] { - return errors.New("eventactions: malformed tactics deck field") - } - seen[v.Number] = true - } - return nil - }); err != nil { - return err - } - e := tacticsDeckEntry{val(f.Value, 1), val(f.Value, 2), val(f.Value, 3), val(f.Value, 4), val(f.Value, 5)} - // These indices are client-generated virtual identities, not owned - // inventory. Still enforce their signed protocol range and uniqueness. - if e.index == 0 || e.index > math.MaxInt64 || e.character == 0 || e.character > math.MaxInt32 || e.costume == 0 || e.costume > math.MaxInt32 || e.position >= tacticsGridSize || e.sequence == 0 || e.sequence > tacticsPartySize || indices[e.index] || characters[e.character] || positions[e.position] || sequences[e.sequence] { - return errors.New("eventactions: invalid tactics deck") - } - indices[e.index], characters[e.character], positions[e.position], sequences[e.sequence] = true, true, true, true - entries = append(entries, e) - return nil - }) - if err != nil { - return nil, err - } - if len(entries) == 0 || len(entries) > tacticsPartySize { - return nil, errors.New("eventactions: invalid tactics deck size") - } - for sequence := uint64(1); sequence <= uint64(len(entries)); sequence++ { - if !sequences[sequence] { - return nil, errors.New("eventactions: invalid tactics deck sequence") - } - } - return entries, nil -} - -// SetPlayDataTarosTactics builds the first five authored CharGroup rows with -// their CharTable default costumes; player ownership never enters this path. -func (s *Service) tacticsBlueParty(group uint64) (map[uint64]uint64, error) { - var rows []gamedata.EventActionRow - for _, row := range s.design.Tables["CharGroupTable"] { - if group != 0 && row.V(2) == group { - rows = append(rows, row) - } - } - sort.Slice(rows, func(i, j int) bool { return rows[i].V(3) < rows[j].V(3) }) - if len(rows) > tacticsPartySize { - rows = rows[:tacticsPartySize] - } - if len(rows) == 0 { - return nil, errors.New("eventactions: tactics blue party missing") - } - party := map[uint64]uint64{} - for _, row := range rows { - id := row.V(1) - character, ok := s.design.Row("CharTable", 12, id) - if id == 0 || party[id] != 0 || row.V(5) == 0 || !ok || character.V(5) == 0 { - return nil, errors.New("eventactions: invalid tactics blue party design") - } - party[id] = character.V(5) - } - return party, nil -} - -func matchesTacticsParty(entries []tacticsDeckEntry, party map[uint64]uint64) bool { - if len(entries) != len(party) { - return false - } - for _, e := range entries { - if party[e.character] != e.costume { - return false - } - } - return true -} - -func (s *Service) validateTacticsDeck(request []byte) error { - entries, err := decodeTacticsDeck(request) - if err != nil { - return err - } - // The request only carries seq and deck_info. The client normally enters - // the stage before saving; bind the saved party to that server context. - if s.state.BattleUID != 0 { - event, err := s.resolve(s.state.BattleUID, 20) - if err != nil { - return err - } - group, ok := s.design.Row("TacticsBingoGroupTable", 3, event.ID) - stage, found := s.row("TacticsBingoTable", 4, 5, group.V(1), s.state.BattleStage) - if !ok || !found || stage.V(2) != s.state.BattleDeck { - return errors.New("eventactions: tactics battle context missing") - } - party, err := s.tacticsBlueParty(stage.V(1)) - if err != nil { - return err - } - if matchesTacticsParty(entries, party) { - return nil - } - return errors.New("eventactions: tactics deck does not match entered stage") - } - // Without a bound stage, accept only a complete authored party belonging - // to a currently active tactics schedule, never an arbitrary mixed roster. - for _, event := range s.registry.List() { - if event.Type != 20 || !s.active(event) { - continue - } - group, ok := s.design.Row("TacticsBingoGroupTable", 3, event.ID) - if !ok { - continue - } - for _, stage := range s.design.Tables["TacticsBingoTable"] { - if stage.V(4) != group.V(1) { - continue - } - party, err := s.tacticsBlueParty(stage.V(1)) - if err != nil { - return err - } - if matchesTacticsParty(entries, party) { - return nil - } - } - } - return errors.New("eventactions: tactics deck has no active authored party") -} diff --git a/go/internal/server/eventactions/tactics_deck_test.go b/go/internal/server/eventactions/tactics_deck_test.go deleted file mode 100644 index 29b6ea0..0000000 --- a/go/internal/server/eventactions/tactics_deck_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "math" - "os" - "testing" - "time" -) - -func TestInstalledTacticsBlueParties23510(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - design, err := gamedata.LoadEventActionsDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - s := &Service{design: design} - for _, stage := range design.Tables["TacticsBingoTable"] { - party, err := s.tacticsBlueParty(stage.V(1)) - if err != nil { - t.Fatalf("group %d stage %d: %v", stage.V(4), stage.V(5), err) - } - entries := make([]tacticsDeckEntry, 0, len(party)) - for id, costume := range party { - order := uint64(len(entries) + 1) - entries = append(entries, tacticsDeckEntry{1000000 + order, id, costume, order - 1, order}) - } - decoded, err := decodeTacticsDeck(tacticsSave(1, entries...)) - if err != nil || !matchesTacticsParty(decoded, party) { - t.Fatalf("group %d stage %d could not save its authored virtual party: %v", stage.V(4), stage.V(5), err) - } - } - // GameData query: group 1 stage 1 has three Lv1 companions and these - // CharTable defaults, distinct from ordinary account costume identities. - party, err := s.tacticsBlueParty(2006301) - if err != nil || len(party) != 3 || party[30011] != 300110 || party[30022] != 300220 || party[30141] != 301410 { - t.Fatalf("installed fixed tutorial party mismatch: %v, %v", party, err) - } -} - -func tacticsSave(seq uint64, entries ...tacticsDeckEntry) []byte { - b := req(seq) - for _, e := range entries { - var body []byte - for i, value := range []uint64{e.index, e.character, e.costume, e.position, e.sequence} { - body = wire.AppendVarint(body, i+1, value) - } - b = wire.AppendBytes(b, 2, body) - } - return b -} - -func tacticsEnter(seq, stage, deck uint64) []byte { - b := req(seq) - for i, value := range map[int]uint64{4: deck, 5: 29, 8: 5, 9: stage} { - b = wire.AppendVarint(b, i, value) - } - return b -} - -func testTacticsEntries() []tacticsDeckEntry { - return []tacticsDeckEntry{{900001, 30011, 30101, 0, 1}, {900002, 30022, 30201, 11, 2}} -} - -func TestTacticsVirtualPartySavePersistsAndBindsEnteredStage(t *testing.T) { - s, economy, store := setup(t) - // A second valid authored party proves that matching any individual - // available character is insufficient once a specific stage is entered. - s.design.Tables["TacticsBingoTable"] = append(s.design.Tables["TacticsBingoTable"], row(map[int]uint64{1: 51, 2: 100, 4: 5, 5: 2})) - s.design.Tables["CharGroupTable"] = append(s.design.Tables["CharGroupTable"], row(map[int]uint64{1: 30033, 2: 51, 3: 1, 5: 1})) - s.design.Tables["CharTable"] = append(s.design.Tables["CharTable"], row(map[int]uint64{12: 30033, 5: 30301})) - first := tacticsSave(1, testTacticsEntries()...) - if _, _, _, err := s.Handle("/TacticsBingoDeckSave", first); err != nil { - t.Fatalf("virtual system party rejected without player ownership: %v", err) - } - next, err := Open(store, s.design, s.registry, economy) - if err != nil { - t.Fatal(err) - } - next.now = s.now - next.BeginSession("test") - if !bytes.Equal(next.state.Deck, first) { - t.Fatal("special deck was not persisted") - } - if _, _, _, err := next.Handle("/TacticsBingoDeckSave", first); err != nil { - t.Fatalf("exact save replay rejected: %v", err) - } - if _, err := next.EnterBattle(tacticsEnter(2, 2, 100), "enter2"); err != nil { - t.Fatalf("previous stage deck blocked normal stage entry: %v", err) - } - if len(next.state.Deck) != 0 { - t.Fatal("previous scene virtual deck survived new entry") - } - wrongStage := tacticsSave(3, testTacticsEntries()...) - if _, _, _, err := next.Handle("/TacticsBingoDeckSave", wrongStage); err == nil { - t.Fatal("another stage's otherwise legal party accepted") - } - second := tacticsSave(4, tacticsDeckEntry{900003, 30033, 30301, 4, 1}) - if _, _, _, err := next.Handle("/TacticsBingoDeckSave", second); err != nil { - t.Fatalf("bound stage's system party rejected: %v", err) - } - end := wire.AppendVarint(req(5), 2, 1) - if _, err := next.CompleteBattle(end, "end5"); err != nil { - t.Fatal(err) - } - if economy.calls != 0 || len(next.state.Tactics[2]) != 1 || next.state.Tactics[2][0] != 2 { - t.Fatal("special deck changed ordinary economy or stage settlement") - } -} - -func TestTacticsDeckRejectsForgedOrIncompleteSystemParties(t *testing.T) { - valid := testTacticsEntries() - tests := []struct { - name string - change func([]tacticsDeckEntry) []tacticsDeckEntry - }{ - {"empty", func(e []tacticsDeckEntry) []tacticsDeckEntry { return nil }}, - {"missing companion", func(e []tacticsDeckEntry) []tacticsDeckEntry { return e[:1] }}, - {"invented character", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].character = 55555; return e }}, - {"wrong costume", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].costume++; return e }}, - {"duplicate character", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].character = e[0].character; return e }}, - {"duplicate virtual index", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].index = e[0].index; return e }}, - {"negative virtual index", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].index = math.MaxUint64; return e }}, - {"overlapping positions", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].position = e[0].position; return e }}, - {"unassigned position", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].position = math.MaxUint64; return e }}, - {"outside standard grid", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].position = 12; return e }}, - {"duplicate order", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].sequence = e[0].sequence; return e }}, - {"missing order", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].sequence = 0; return e }}, - {"order gap", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].sequence = 3; return e }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - s, _, _ := setup(t) - accepted := tacticsSave(1, valid...) - if _, _, _, err := s.Handle("/TacticsBingoDeckSave", accepted); err != nil { - t.Fatal(err) - } - bad := tacticsSave(2, tt.change(append([]tacticsDeckEntry(nil), valid...))...) - if _, _, _, err := s.Handle("/TacticsBingoDeckSave", bad); err == nil { - t.Fatal("forged deck accepted") - } - if !bytes.Equal(s.state.Deck, accepted) { - t.Fatal("failed validation changed saved special deck") - } - }) - } -} - -func TestTacticsDeckRejectsInactiveEventAndMixedAuthoredGroups(t *testing.T) { - s, _, _ := setup(t) - s.design.Tables["TacticsBingoTable"] = append(s.design.Tables["TacticsBingoTable"], row(map[int]uint64{1: 51, 2: 100, 4: 5, 5: 2})) - s.design.Tables["CharGroupTable"] = append(s.design.Tables["CharGroupTable"], row(map[int]uint64{1: 30033, 2: 51, 3: 1, 5: 1}), row(map[int]uint64{1: 30044, 2: 51, 3: 2, 5: 1})) - s.design.Tables["CharTable"] = append(s.design.Tables["CharTable"], row(map[int]uint64{12: 30033, 5: 30301}), row(map[int]uint64{12: 30044, 5: 30401})) - mixed := testTacticsEntries() - mixed[1].character, mixed[1].costume = 30033, 30301 - if _, _, _, err := s.Handle("/TacticsBingoDeckSave", tacticsSave(1, mixed...)); err == nil { - t.Fatal("mixed characters from different authored parties accepted") - } - s.now = func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) } - if _, _, _, err := s.Handle("/TacticsBingoDeckSave", tacticsSave(2, testTacticsEntries()...)); err == nil { - t.Fatal("expired event system party accepted") - } - if len(s.state.Deck) != 0 { - t.Fatal("invalid saves changed deck") - } -} diff --git a/go/internal/server/eventactions/voting.go b/go/internal/server/eventactions/voting.go deleted file mode 100644 index 293da4b..0000000 --- a/go/internal/server/eventactions/voting.go +++ /dev/null @@ -1,317 +0,0 @@ -package eventactions - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "errors" - "fmt" - "slices" - "sort" - "time" -) - -func (s *Service) voteEvent() (events.Schedule, gamedata.EventActionRow, bool) { - v, ok := s.find(23) - if !ok { - return v, gamedata.EventActionRow{}, false - } - r, ok := s.design.Row("VotingEventTable", 6, v.ID) - return v, r, ok -} -func (s *Service) rounds(v events.Schedule, r gamedata.EventActionRow) (uint64, []gamedata.EventActionRow) { - var rows []gamedata.EventActionRow - current := uint64(0) - for _, x := range s.design.Tables["VotingRoundTable"] { - if x.V(2) == r.V(14) { - rows = append(rows, x) - } - } - sort.Slice(rows, func(i, j int) bool { return rows[i].V(3) < rows[j].V(3) }) - for _, x := range rows { - if s.now().UnixMilli() >= v.Start+int64(x.V(4))*86400000 { - current = x.V(3) - } - } - return current, rows -} -func (s *Service) candidates(r gamedata.EventActionRow) []uint64 { - var ids []uint64 - for _, x := range s.design.Tables["VotingCandidateTable"] { - if x.V(1) == r.V(10) { - ids = append(ids, x.V(2)) - } - } - slices.Sort(ids) - return ids -} -func contains(ids []uint64, id uint64) bool { - return slices.Contains(ids, id) -} -func voteWire(v *vote) []byte { - b := wire.AppendVarint(nil, 1, v.Round) - b = wire.AppendVarint(b, 2, v.Candidate) - b = wire.AppendVarint(b, 3, v.Normal+v.Additional) - b = wire.AppendVarint(b, 4, v.Normal) - return wire.AppendVarint(b, 5, v.Additional) -} -func (s *Service) voteHandle(path string, b []byte, identity string) ([]byte, error) { - v, r, ok := s.voteEvent() - if !ok { - if path == "/CharVoteInfo" || path == "/CharVoteSeasonRanking" { - return nil, nil - } - if path == "/CharVoteTotalRanking" { - return s.totalRanking(val(b, 2)), nil - } - return nil, errors.New("eventactions: voting event inactive") - } - round, rounds := s.rounds(v, r) - ids := s.candidates(r) - ids = s.advancedCandidates(v, r, round, ids) - switch path { - case "/CharVoteInfo": - out := wire.AppendVarint(nil, 1, round) - for _, id := range ids { - out = wire.AppendVarint(out, 2, id) - } - for k, x := range s.state.Votes { - var uid, ro, c uint64 - if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID { - out = wire.AppendBytes(out, 3, voteWire(x)) - } - } - for k := range s.state.VoteRewards { - var uid, id uint64 - if _, e := fmt.Sscanf(k, "%d/%d", &uid, &id); e == nil && uid == v.UID { - x := wire.AppendVarint(nil, 1, v.ID) - x = wire.AppendVarint(x, 2, id) - out = wire.AppendBytes(out, 4, x) - } - } - for _, id := range ids { - if s.state.Favorites[id] { - out = wire.AppendVarint(out, 5, id) - } - if s.state.NormalVoted[key(v.UID, id)] { - out = wire.AppendVarint(out, 6, id) - } - } - out = wire.AppendVarint(out, 7, uint64(s.now().UTC().Truncate(24*time.Hour).Add(24*time.Hour).UnixMilli())) - for i, x := range rounds { - start := v.Start + int64(x.V(4))*86400000 - end := v.End - if i+1 < len(rounds) { - end = v.Start + int64(rounds[i+1].V(4))*86400000 - } - a := wire.AppendVarint(nil, 1, x.V(3)) - a = wire.AppendVarint(a, 2, uint64(start)) - a = wire.AppendVarint(a, 3, uint64(end)) - out = wire.AppendBytes(out, 9, a) - } - return out, nil - case "/CharVoteFavoriteAdd", "/CharVoteFavoriteDelete": - id := val(b, 2) - if !contains(ids, id) { - return nil, errors.New("eventactions: unknown candidate") - } - if path == "/CharVoteFavoriteAdd" { - s.state.Favorites[id] = true - } else { - delete(s.state.Favorites, id) - } - return nil, nil - case "/CharVoteSave": - id, count, typ := val(b, 2), val(b, 4), val(b, 5) - if !contains(ids, id) || count == 0 || count > 2147483647 || typ > 1 || round == 0 { - return nil, errors.New("eventactions: invalid vote") - } - normalKey := key(v.UID, id) - if typ == 0 && (s.state.NormalVoted[normalKey] || count != 1) { - return nil, errors.New("eventactions: daily normal vote exhausted") - } - cost := gamedata.Reward{Type: r.V(9), ID: r.V(8), Count: r.V(7) * count} - if typ == 1 { - cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count} - } - // A first vote may use the additional ticket when the normal ticket - // is unavailable; the client still sends vote_type=0. - if typ == 0 { - var submittedID uint64 - _ = wire.Walk(b, func(f wire.Field) error { - if f.Number == 3 && f.Type == 2 { - submittedID = val(f.Value, 2) - } - return nil - }) - if submittedID == r.V(2) { - cost = gamedata.Reward{Type: r.V(3), ID: r.V(2), Count: r.V(1) * count} - } - } - if cost.Type == 0 || cost.Count == 0 { - return nil, errors.New("eventactions: missing vote cost") - } - vk := key(v.UID, round, id) - current := s.state.Votes[vk] - if current == nil { - current = &vote{Round: round, Candidate: id} - } - var total uint64 - for k, x := range s.state.Votes { - var uid, ro, c uint64 - if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == v.UID { - total += x.Normal + x.Additional - } - } - var rewards []gamedata.Reward - var newIDs []uint64 - for _, rr := range s.design.Tables["VotingCountRewardTable"] { - if rr.V(1) == r.V(11) && rr.V(6) <= total+count && !s.state.VoteRewards[key(v.UID, rr.V(2))] { - rewards = append(rewards, rr.Rewards...) - newIDs = append(newIDs, rr.V(2)) - } - } - bundle, e := s.economy.Apply(identity, []gamedata.Reward{cost}, rewards) - if e != nil { - return nil, e - } - s.state.Votes[vk] = current - if typ == 0 { - current.Normal += count - s.state.NormalVoted[normalKey] = true - } else { - current.Additional += count - } - out := wire.AppendBytes(nil, 1, bundle) - for _, id := range newIDs { - s.state.VoteRewards[key(v.UID, id)] = true - x := wire.AppendVarint(nil, 1, v.ID) - x = wire.AppendVarint(x, 2, id) - out = wire.AppendBytes(out, 2, x) - } - out = wire.AppendBytes(out, 4, voteWire(current)) - return out, nil - case "/CharVoteRanking": - wanted := val(b, 2) - if wanted == 0 { - wanted = round - } - out := wire.AppendVarint(nil, 1, v.ID) - out = wire.AppendVarint(out, 2, wanted) - for _, x := range s.rankRows(v.UID, wanted, ids) { - out = wire.AppendBytes(out, 3, x) - } - return out, nil - case "/CharVoteTotalRanking": - return s.totalRanking(val(b, 2)), nil - case "/CharVoteSeasonRanking": - var out []byte - for _, schedule := range s.registry.List() { - if schedule.Type != 23 { - continue - } - for _, id := range ids { - var total uint64 - for k, x := range s.state.Votes { - var uid, ro, c uint64 - if _, e := fmt.Sscanf(k, "%d/%d/%d", &uid, &ro, &c); e == nil && uid == schedule.UID && c == id { - total += x.Normal + x.Additional - } - } - if total == 0 { - continue - } - x := wire.AppendVarint(nil, 1, schedule.ID) - x = wire.AppendVarint(x, 2, id) - x = wire.AppendVarint(x, 3, total) - x = wire.AppendVarint(x, 4, total) - out = wire.AppendBytes(out, 1, x) - } - } - return out, nil - } - return nil, errors.New("eventactions: unknown voting operation") -} -func (s *Service) rankRows(uid, round uint64, ids []uint64) [][]byte { - type result struct{ id, count, last uint64 } - var rows []result - var totals map[uint64]uint64 - if s.voteTotals != nil { - totals, _ = s.voteTotals(uid, round) - } - for _, id := range ids { - r := result{id: id} - for k, x := range s.state.Votes { - var u, ro, c uint64 - if _, e := fmt.Sscanf(k, "%d/%d/%d", &u, &ro, &c); e == nil && u == uid && c == id && (round == 0 || round == ro) { - r.count += x.Normal + x.Additional - if ro > r.last { - r.last = ro - } - } - } - if totals != nil { - r.count = totals[id] - } - rows = append(rows, r) - } - sort.Slice(rows, func(i, j int) bool { - if rows[i].count == rows[j].count { - return rows[i].id < rows[j].id - } - return rows[i].count > rows[j].count - }) - var out [][]byte - var lastCount, rank uint64 - for i, r := range rows { - if i == 0 || r.count != lastCount { - rank = uint64(i + 1) - } - lastCount = r.count - x := wire.AppendVarint(nil, 1, r.id) - x = wire.AppendVarint(x, 2, r.count) - x = wire.AppendVarint(x, 3, rank) - x = wire.AppendVarint(x, 4, r.last) - out = append(out, x) - } - return out -} - -func (s *Service) advancedCandidates(v events.Schedule, event gamedata.EventActionRow, round uint64, ids []uint64) []uint64 { - for previous := uint64(1); previous < round; previous++ { - var count uint64 - for _, r := range s.design.Tables["VotingRoundTable"] { - if r.V(2) == event.V(14) && r.V(3) == previous { - count = r.V(1) - } - } - if count == 0 { - continue - } - ranked := s.rankRows(v.UID, previous, ids) - var next []uint64 - for _, b := range ranked { - if val(b, 3) <= count { - next = append(next, val(b, 1)) - } - } - ids = next - } - return ids -} -func (s *Service) totalRanking(event uint64) []byte { - out := wire.AppendVarint(nil, 1, event) - for _, v := range s.registry.List() { - if v.Type != 23 || v.ID != event { - continue - } - r, ok := s.design.Row("VotingEventTable", 6, event) - if !ok { - continue - } - for _, row := range s.rankRows(v.UID, 0, s.candidates(r)) { - out = wire.AppendBytes(out, 2, row) - } - } - return out -} diff --git a/go/internal/server/eventexchange/service_test.go b/go/internal/server/eventexchange/service_test.go deleted file mode 100644 index 71dfdd8..0000000 --- a/go/internal/server/eventexchange/service_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package eventexchange - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "fmt" - "testing" - "time" -) - -type fakeRuntime struct { - paid, grants uint64 - fail bool - uses []player.Item -} - -func (r *fakeRuntime) Apply(_ string, costs, rewards []gamedata.Reward) ([]byte, error) { - if r.fail { - return nil, fmt.Errorf("failed") - } - for _, c := range costs { - r.paid += c.Count - } - r.grants += uint64(len(rewards)) - return []byte{10, 0}, nil -} -func (r *fakeRuntime) ConsumeAndGrant(_ string, uses []player.Item, rs []gamedata.BattleReward) ([]byte, error) { - if r.fail { - return nil, fmt.Errorf("failed") - } - r.uses = uses - r.grants += uint64(len(rs)) - return []byte{10, 0}, nil -} -func exchangeFixture(t *testing.T) (*Service, *fakeRuntime) { - t.Helper() - reg := events.NewRegistry() - if e := reg.Replace([]events.Schedule{{UID: 99, Type: 7, ID: 1, Start: 1, End: 9999999999999}}); e != nil { - t.Fatal(e) - } - g := gamedata.EventExchangeGroup{ID: 1, StartPage: 1, EndPage: 2, Repeat: true, Cost: gamedata.Reward{Type: 8, ID: 77, Count: 2}, FreeCount: 1, FreeType: 2, Entries: []gamedata.EventExchangeEntry{{ID: 1, Page: 1, KeyType: 1, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 4, Count: 100}}, {ID: 2, Page: 1, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 1}}, {ID: 3, Page: 2, Ratio: 1, SetCount: 1, Reward: gamedata.Reward{Type: 4, Count: 5}}}} - r := &fakeRuntime{} - s, e := Open(stateio.NewMemory(), &gamedata.EventExchangeCatalog{Groups: map[uint64]gamedata.EventExchangeGroup{1: g}}, reg, r) - if e != nil { - t.Fatal(e) - } - s.now = func() time.Time { return time.UnixMilli(10000) } - s.draw = func(uint64) (uint64, error) { return 0, nil } - return s, r -} -func exchangeRequest(seq, count uint64, paid bool) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 2, 99) - b = wire.AppendVarint(b, 3, count) - if paid { - item := wire.AppendVarint(nil, 1, 123) - item = wire.AppendVarint(item, 2, 77) - item = wire.AppendVarint(item, 3, 8) - item = wire.AppendVarint(item, 4, count*2) - b = wire.AppendBytes(b, 4, item) - } - return b -} -func TestFreePaidReplayAndKeyAdvance(t *testing.T) { - s, r := exchangeFixture(t) - req := exchangeRequest(1, 1, false) - _, a, _, e := s.HandleSession("/EventExchangeReward", req, "session") - if e != nil { - t.Fatal(e) - } - _, b, _, e := s.HandleSession("/EventExchangeReward", req, "session") - if e != nil || !bytes.Equal(a, b) || r.grants != 1 { - t.Fatalf("replay %v %d", e, r.grants) - } - if _, _, _, e = s.HandleSession("/EventExchangeReward", exchangeRequest(2, 1, false), "session"); e == nil { - t.Fatal("free reused") - } - next := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 99), 3, 1) - if _, _, _, e = s.HandleSession("/EventExchangeNextPageOpen", next, "session"); e != nil { - t.Fatal(e) - } - if _, _, _, e = s.HandleSession("/EventExchangeReward", exchangeRequest(4, 1, true), "session"); e != nil { - t.Fatal(e) - } - st, _ := s.load() - if st.Progress["99"].Page != 3 || len(r.uses) != 1 || r.uses[0].InvenIndex != 123 { - t.Fatalf("advance %+v uses %+v", st, r.uses) - } -} -func TestInvalidCostAndFailedGrantKeepPool(t *testing.T) { - s, r := exchangeFixture(t) - bad := exchangeRequest(1, 1, true) - bad = wire.AppendBytes(bad, 4, wire.AppendVarint(nil, 3, 8)) - if _, _, _, e := s.HandleSession("/EventExchangeReward", bad, "session"); e == nil { - t.Fatal("invalid cost accepted") - } - r.fail = true - if _, _, _, e := s.HandleSession("/EventExchangeReward", exchangeRequest(2, 1, false), "session"); e == nil { - t.Fatal("failed grant accepted") - } - st, _ := s.load() - if len(st.Progress) != 0 || len(st.Receipts) != 0 { - t.Fatal("failed draw saved progress") - } -} -func TestLuckyPrizeLockedUntilUnlockDraw(t *testing.T) { - s, _ := exchangeFixture(t) - g := s.design.Groups[1] - g.UnlockRatio = 3 - g.Entries[0].LimitedRatio = 0 - g.Entries[1].LimitedRatio = 1 - g.Entries[1].SetCount = 4 - s.design.Groups[1] = g - for i := uint64(1); i <= 3; i++ { - if _, _, _, e := s.HandleSession("/EventExchangeReward", exchangeRequest(i, 1, true), "session"); e != nil { - t.Fatal(e) - } - st, _ := s.load() - count := st.Progress["99"].Counts["1"] - if i < 3 && count != 0 { - t.Fatal("prize unlocked early") - } - if i == 3 && count != 1 { - t.Fatal("prize not unlocked") - } - } -} diff --git a/go/internal/server/eventgames/games.go b/go/internal/server/eventgames/games.go deleted file mode 100644 index 80179aa..0000000 --- a/go/internal/server/eventgames/games.go +++ /dev/null @@ -1,398 +0,0 @@ -// Package eventgames implements this server's persistent seasonal minigames. -package eventgames - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "crypto/rand" - "encoding/binary" - "encoding/json" - "fmt" - "math" - "math/big" - "slices" - "strconv" - "strings" - "sync" - "time" -) - -type Resolver interface { - Resolve(uint64) (events.Schedule, error) -} -type Rewards interface { - ConsumeAndGrant(string, []player.Item, []gamedata.BattleReward) ([]byte, error) -} -type GameState struct { - UID, Type, ID, Position, Clear, Free, Reset, Tries, SinceSpecial uint64 - Board, Opened, Lines []uint64 - Special bool -} -type reply struct{ Request, Body []byte } -type snapshot struct { - Version int `json:"version"` - Games map[string]GameState `json:"games"` - Replies map[string]reply `json:"replies"` -} -type Service struct { - mu sync.Mutex - store stateio.Store - resolver Resolver - rewards Rewards - state snapshot - load func(uint64, uint64) (*gamedata.EventGame, error) - sample func(uint64) (uint64, error) - now func() time.Time -} - -func Open(store stateio.Store, root, version string, resolver Resolver, rewards Rewards) (*Service, error) { - if store == nil || resolver == nil || rewards == nil { - return nil, fmt.Errorf("eventgames: incomplete runtime") - } - s := &Service{store: store, resolver: resolver, rewards: rewards, now: time.Now, state: snapshot{1, map[string]GameState{}, map[string]reply{}}} - cache := map[string]*gamedata.EventGame{} - s.load = func(kind, id uint64) (*gamedata.EventGame, error) { - k := fmt.Sprintf("%d:%d", kind, id) - if d := cache[k]; d != nil { - return d, nil - } - d, e := gamedata.LoadEventGame(root, version, kind, id) - if e == nil { - cache[k] = d - } - return d, e - } - s.sample = func(max uint64) (uint64, error) { - if max == 0 { - return 0, fmt.Errorf("eventgames: empty sample") - } - n, e := rand.Int(rand.Reader, new(big.Int).SetUint64(max)) - if e != nil { - return 0, e - } - return n.Uint64(), nil - } - raw, e := store.Load("eventgames") - if e != nil { - return nil, e - } - if raw != nil { - if e = stateio.RequireExactJSONObject(raw, "version", "games", "replies"); e != nil { - return nil, e - } - if e = json.Unmarshal(raw, &s.state); e != nil { - return nil, e - } - if s.state.Version != 1 || s.state.Games == nil || s.state.Replies == nil { - return nil, fmt.Errorf("eventgames: incompatible state") - } - } - for key, g := range s.state.Games { - d, e := s.load(g.Type, g.ID) - if e != nil { - return nil, e - } - if e = validateSaved(key, g, d); e != nil { - return nil, e - } - } - return s, nil -} -func nums(b []byte, n int) ([]uint64, error) { - var out []uint64 - e := wire.Walk(b, func(f wire.Field) error { - if f.Number != n { - return nil - } - if f.Type == 0 { - v, _ := binary.Uvarint(f.Value) - out = append(out, v) - return nil - } - if f.Type != 2 { - return wire.ErrMalformed - } - for p := f.Value; len(p) > 0; { - v, k := binary.Uvarint(p) - if k <= 0 { - return wire.ErrMalformed - } - out = append(out, v) - p = p[k:] - } - return nil - }) - return out, e -} -func scalar(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } -func contains(a []uint64, v uint64) bool { - return slices.Contains(a, v) -} -func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { - return s.HandleSession(path, req, "local") -} - -var routes = map[string]struct { - Code int - Kind uint64 -}{"/MiniGameBoardInfo": {223, 12}, "/MiniGameBoardPlay": {224, 12}, "/MiniGameBingoInfo": {256, 13}, "/MiniGameBingoPlay": {257, 13}, "/MiniPuzzleInfo": {343, 17}, "/MiniPuzzleOpen": {344, 17}, "/MiniPuzzleAllOpen": {345, 17}, "/MiniPuzzleRenew": {346, 17}, "/MiniGameRouletteInfo": {415, 19}, "/MiniGameRouletteDraw": {416, 19}} - -func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { - r, ok := routes[path] - if !ok { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - fail := func(e error) (int, []byte, bool, error) { return r.Code, nil, true, e } - if e := wire.Walk(req, func(f wire.Field) error { - if f.Type == 0 && f.Number != 3 { - v, _ := binary.Uvarint(f.Value) - if v > math.MaxInt32 { - return wire.ErrMalformed - } - } - return nil - }); e != nil { - return fail(e) - } - seq := scalar(req, 1) - if seq == 0 || seq > math.MaxInt32 { - return fail(fmt.Errorf("eventgames: invalid sequence")) - } - key := fmt.Sprintf("%s:%s:%d", session, path, seq) - if v, ok := s.state.Replies[key]; ok { - if !bytes.Equal(v.Request, req) { - return fail(fmt.Errorf("eventgames: changed request retry")) - } - return r.Code, v.Body, true, nil - } - raw, _ := json.Marshal(s.state) - var next snapshot - _ = json.Unmarshal(raw, &next) - uids, e := nums(req, 2) - if e != nil || len(uids) == 0 && !strings.HasSuffix(path, "Info") { - return fail(fmt.Errorf("eventgames: missing schedule")) - } - if len(uids) == 0 { - return r.Code, nil, true, nil - } - if !strings.HasSuffix(path, "Info") && len(uids) != 1 { - return fail(fmt.Errorf("eventgames: operation requires one schedule")) - } - var out []byte - seen := map[uint64]bool{} - for _, uid := range uids { - if uid == 0 || seen[uid] { - return fail(fmt.Errorf("eventgames: invalid duplicate schedule")) - } - seen[uid] = true - c, e := s.resolver.Resolve(uid) - if e != nil { - return fail(e) - } - if c.UID != uid || c.Type != r.Kind { - return fail(fmt.Errorf("eventgames: schedule/game mismatch")) - } - d, e := s.load(c.Type, c.ID) - if e != nil { - return fail(e) - } - k := strconv.FormatUint(uid, 10) - g, exists := next.Games[k] - if !exists { - g = GameState{UID: uid, Type: c.Type, ID: c.ID, Free: d.Free} - if e = s.initialize(&g, d); e != nil { - return fail(e) - } - } - if g.Type != c.Type || g.ID != c.ID { - return fail(fmt.Errorf("eventgames: saved game identity changed")) - } - now := uint64(s.now().UnixMilli()) - if c.Type == 19 && g.Reset <= now { - g.Free = d.Free - g.Reset = (now/86400000 + 1) * 86400000 - } - if strings.HasSuffix(path, "Info") { - out = wire.AppendBytes(out, 1, stateWire(g, d)) - next.Games[k] = g - continue - } - if int64(now) < c.Start || int64(now) >= c.End { - return fail(fmt.Errorf("eventgames: event is closed")) - } - before := stateWire(g, d) - body, cost, rewards, e := s.play(path, req, &g, d) - if e != nil { - return fail(e) - } - consumeField := 3 - if path == "/MiniPuzzleOpen" { - consumeField = 4 - } - if path == "/MiniGameRouletteDraw" { - consumeField = 5 - } - items, e := consume(req, consumeField, d, cost) - if e != nil { - return fail(e) - } - bundles := map[int][]byte{} - fields := []int{3, 4, 5, 6, 7} - if c.Type == 19 { - fields = []int{2, 3} - } - for _, field := range fields { - rr := rewards[field] - if len(rr) == 0 { - continue - } - consumed := []player.Item(nil) - if len(items) > 0 { - consumed = items - items = nil - } - b, e := s.rewards.ConsumeAndGrant(fmt.Sprintf("eventgames:%s:%d", key, field), consumed, rr) - if e != nil { - return fail(e) - } - bundles[field] = b - } - if len(items) > 0 { - if _, e = s.rewards.ConsumeAndGrant("eventgames:"+key+":cost", items, nil); e != nil { - return fail(e) - } - } - switch c.Type { - case 12: - body = wire.AppendBytes(body, 1, stateWire(g, d)) - case 13, 17: - body = wire.AppendBytes(body, 1, before) - body = wire.AppendBytes(body, 2, stateWire(g, d)) - case 19: - body = wire.AppendBytes(body, 1, stateWire(g, d)) - } - for _, field := range fields { - if b := bundles[field]; len(b) > 0 { - body = wire.AppendBytes(body, field, b) - } - } - out = body - next.Games[k] = g - } - next.Replies[key] = reply{append([]byte(nil), req...), out} - payload, e := json.Marshal(next) - if e != nil { - return fail(e) - } - if e = s.store.Save("eventgames", payload); e != nil { - return fail(e) - } - s.state = next - return r.Code, out, true, nil -} -func consume(req []byte, n int, d *gamedata.EventGame, count uint64) ([]player.Item, error) { - var out []player.Item - total := uint64(0) - seen := map[uint64]bool{} - e := wire.Walk(req, func(f wire.Field) error { - if f.Number != n { - return nil - } - if f.Type != 2 { - return wire.ErrMalformed - } - id, typ, qty, index := scalar(f.Value, 2), scalar(f.Value, 3), scalar(f.Value, 4), scalar(f.Value, 1) - if count == 0 || id != d.CostID || typ != d.CostType || qty == 0 || index == 0 || seen[index] || qty > math.MaxInt32 || total > math.MaxUint64-qty { - return fmt.Errorf("eventgames: invalid submitted cost") - } - seen[index] = true - total += qty - out = append(out, player.Item{InvenIndex: index, ID: id, Type: typ, Count: qty}) - return nil - }) - if e != nil { - return nil, e - } - if d.Cost > 0 && count > math.MaxUint64/d.Cost || total != count*d.Cost { - return nil, fmt.Errorf("eventgames: cost does not match design") - } - return out, nil -} -func stateWire(g GameState, d *gamedata.EventGame) []byte { - b := wire.AppendVarint(nil, 1, g.UID) - switch g.Type { - case 12: - b = wire.AppendVarint(b, 2, d.ScaffoldGroup) - b = wire.AppendVarint(b, 3, d.Cells[g.Position].ID) - b = wire.AppendVarint(b, 4, g.Clear) - case 13, 17: - b = wire.AppendVarint(b, 2, g.Clear) - for _, v := range g.Board { - b = wire.AppendVarint(b, 3, v) - } - for _, v := range g.Opened { - b = wire.AppendVarint(b, 4, v) - } - case 19: - b = wire.AppendVarint(b, 2, g.Free) - b = wire.AppendVarint(b, 3, g.Reset) - if g.Special { - b = wire.AppendVarint(b, 4, 1) - } - b = wire.AppendVarint(b, 5, g.Tries) - } - return b -} -func (s *Service) initialize(g *GameState, d *gamedata.EventGame) error { - if d.Type != 13 && d.Type != 17 { - return nil - } - count := g.Clear - if d.Type == 17 && count == 0 { - count = 1 - g.Clear = 1 - } - var cells []gamedata.EventGameReward - max := uint64(0) - for _, r := range d.Cells { - if r.Count > max { - max = r.Count - } - } - if count > max { - count = max - } - for _, r := range d.Cells { - if r.Count == count { - cells = append(cells, r) - } - } - if len(cells) == 0 { - return fmt.Errorf("eventgames: board rewards missing") - } - g.Board = nil - g.Opened = nil - g.Lines = nil - for _, r := range cells { - g.Board = append(g.Board, r.ID) - } - if d.Type == 13 { - for i := len(g.Board) - 1; i > 0; i-- { - j, e := s.sample(uint64(i + 1)) - if e != nil { - return e - } - if j >= uint64(i+1) { - return fmt.Errorf("eventgames: sampler out of range") - } - g.Board[i], g.Board[j] = g.Board[j], g.Board[i] - } - } - return nil -} diff --git a/go/internal/server/eventgames/games_test.go b/go/internal/server/eventgames/games_test.go deleted file mode 100644 index 2a9b4ad..0000000 --- a/go/internal/server/eventgames/games_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package eventgames - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "fmt" - "testing" - "time" -) - -type resolve struct{ kind uint64 } - -func (r resolve) Resolve(id uint64) (events.Schedule, error) { - if id != 7 { - return events.Schedule{}, fmt.Errorf("unknown") - } - return events.Schedule{UID: 7, Type: r.kind, ID: 1, Start: 1, End: 9999999999999}, nil -} - -type economy struct{ spent, granted uint64 } - -func (e *economy) ConsumeAndGrant(_ string, c []player.Item, r []gamedata.BattleReward) ([]byte, error) { - for _, x := range c { - e.spent += x.Count - } - for _, x := range r { - e.granted += x.Count - } - return wire.AppendVarint(nil, 1, 1), nil -} -func fixture(t *testing.T, d *gamedata.EventGame) (*Service, *economy) { - t.Helper() - e := &economy{} - s, err := Open(stateio.NewMemory(), "", "", resolve{d.Type}, e) - if err != nil { - t.Fatal(err) - } - s.load = func(uint64, uint64) (*gamedata.EventGame, error) { return d, nil } - s.sample = func(n uint64) (uint64, error) { - if n == 0 { - return 0, fmt.Errorf("empty") - } - return 0, nil - } - s.now = func() time.Time { return time.UnixMilli(1000) } - return s, e -} -func request(seq, n, cost uint64) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 2, 7) - item := wire.AppendVarint(nil, 1, 1) - item = wire.AppendVarint(item, 2, 5) - item = wire.AppendVarint(item, 3, 8) - item = wire.AppendVarint(item, 4, cost) - return wire.AppendBytes(b, int(n), item) -} -func TestBoardCostStateAndWholeReplyRetry(t *testing.T) { - d := &gamedata.EventGame{ID: 1, Type: 12, Cost: 1, CostID: 5, CostType: 8, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2, Rewards: []gamedata.BattleReward{{Type: 8, ID: 9, Count: 3}}}}, Moves: []struct{ ID, Min, Max uint64 }{{1, 1, 1}}} - s, e := fixture(t, d) - req := request(1, 3, 1) - _, a, _, err := s.HandleSession("/MiniGameBoardPlay", req, "sid") - if err != nil { - t.Fatal(err) - } - _, b, _, err := s.HandleSession("/MiniGameBoardPlay", req, "sid") - if err != nil || !bytes.Equal(a, b) || e.spent != 1 || e.granted != 3 { - t.Fatalf("retry error=%v spent=%d granted=%d", err, e.spent, e.granted) - } - bad := wire.AppendVarint(req, 4, 1) - if _, _, _, err = s.HandleSession("/MiniGameBoardPlay", bad, "sid"); err == nil { - t.Fatal("changed retry accepted") - } - if s.state.Games["7"].Position != 1 { - t.Fatal("move lost") - } -} -func TestPuzzleWordRenewAndNoDuplicateOpen(t *testing.T) { - d := &gamedata.EventGame{ID: 1, Type: 17, Cost: 1, CostID: 5, CostType: 8, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1, Count: 1, Slot: 12}, {ID: 2, Count: 1}, {ID: 1, Count: 2}}, Complete: []gamedata.EventGameReward{{ID: 1, Count: 1, Members: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 4, Count: 5}}}}} - s, e := fixture(t, d) - req := request(1, 4, 1) - req = wire.AppendVarint(req, 3, 1) - if _, _, _, err := s.HandleSession("/MiniPuzzleOpen", req, "sid"); err != nil { - t.Fatal(err) - } - if e.spent != 1 || e.granted != 5 { - t.Fatal("word award missing") - } - bad := request(2, 4, 1) - bad = wire.AppendVarint(bad, 3, 1) - if _, _, _, err := s.HandleSession("/MiniPuzzleOpen", bad, "sid"); err == nil { - t.Fatal("duplicate tile accepted") - } - renew := wire.AppendVarint(nil, 1, 3) - renew = wire.AppendVarint(renew, 2, 7) - if _, _, _, err := s.HandleSession("/MiniPuzzleRenew", renew, "sid"); err != nil { - t.Fatal(err) - } - if s.state.Games["7"].Clear != 2 || e.spent != 1 { - t.Fatal("renew cost/stage wrong") - } -} -func TestRouletteFreeCountAndMilestone(t *testing.T) { - d := &gamedata.EventGame{ID: 1, Type: 19, Cost: 1, CostID: 5, CostType: 8, Free: 1, Cells: []gamedata.EventGameReward{{ID: 1, Weight: 1, Rewards: []gamedata.BattleReward{{Count: 2}}}}, Complete: []gamedata.EventGameReward{{ID: 1, Count: 1, Rewards: []gamedata.BattleReward{{Count: 3}}}}} - s, e := fixture(t, d) - req := wire.AppendVarint(nil, 1, 1) - req = wire.AppendVarint(req, 2, 7) - req = wire.AppendVarint(req, 4, 1) - if _, _, _, err := s.HandleSession("/MiniGameRouletteDraw", req, "sid"); err != nil { - t.Fatal(err) - } - if e.spent != 0 || e.granted != 5 { - t.Fatal("free rewards wrong") - } - req, _, _ = wire.ReplaceVarint(req, 1, 2) - if _, _, _, err := s.HandleSession("/MiniGameRouletteDraw", req, "sid"); err == nil { - t.Fatal("extra free draw accepted") - } -} -func TestBingoLinesUseProtocolEnums(t *testing.T) { - g := GameState{Board: make([]uint64, 9), Opened: []uint64{0, 4, 8}} - if !completeLine(&g, 3, 0, 0) || completeLine(&g, 3, 1, 0) { - t.Fatal("diagonal/row enum wrong") - } - g.Opened = []uint64{0, 1, 2} - if !completeLine(&g, 3, 1, 0) { - t.Fatal("row wrong") - } - g.Opened = []uint64{0, 3, 6} - if !completeLine(&g, 3, 2, 0) { - t.Fatal("column wrong") - } -} - -func TestBingoLineRewardExactlyOnceAndBeforeAfterWire(t *testing.T) { - d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}, Lines: []gamedata.EventGameReward{{Count: 0, LineType: 1, LineIndex: 0, Rewards: []gamedata.BattleReward{{Count: 7}}}}} - s, _ := fixture(t, d) - g := GameState{UID: 7, Type: 13, ID: 1, Board: []uint64{1, 2, 3, 4}, Opened: []uint64{0}} - body, _, rs, err := s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d) - if err != nil || len(rs[6]) != 1 { - t.Fatalf("line reward %v %+v", err, rs) - } - line, found, e := wire.Bytes(body, 4) - if e != nil || !found || scalar(line, 1) != 1 || scalar(line, 2) != 0 { - t.Fatalf("line wire %x", body) - } - _, _, rs, err = s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d) - if err != nil || len(rs[6]) != 0 { - t.Fatal("same line granted twice") - } -} -func TestMiniGameSamplerOutOfRangeRejected(t *testing.T) { - d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}} - s, _ := fixture(t, d) - s.sample = func(n uint64) (uint64, error) { return n, nil } - g := GameState{} - if err := s.initialize(&g, d); err == nil { - t.Fatal("shuffle accepted bad RNG") - } - g.Board = []uint64{1, 2, 3, 4} - if _, _, _, err := s.play("/MiniGameBingoPlay", wire.AppendVarint(nil, 4, 1), &g, d); err == nil { - t.Fatal("draw accepted bad RNG") - } -} - -func TestSavedMiniGameBoardAndCompletedLineRejected(t *testing.T) { - d := &gamedata.EventGame{ID: 1, Type: 13, Columns: 2, Cells: []gamedata.EventGameReward{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}, Lines: []gamedata.EventGameReward{{LineType: 1, LineIndex: 0}}} - g := GameState{UID: 7, Type: 13, ID: 1, Board: []uint64{1, 2, 3}} - if e := validateSaved("7", g, d); e == nil { - t.Fatal("truncated board accepted") - } - g.Board = append(g.Board, 4) - g.Lines = []uint64{1000} - if e := validateSaved("7", g, d); e == nil { - t.Fatal("uncompleted line claimed") - } - g.Opened = []uint64{0, 1} - if e := validateSaved("7", g, d); e != nil { - t.Fatal(e) - } -} - -func TestRoulettePityAndAccumulatedWire(t *testing.T) { - d := &gamedata.EventGame{ID: 1, Type: 19, RewardGroup: 77, Pity: 3, Cells: []gamedata.EventGameReward{{ID: 1, Weight: 100, Weight2: 100}, {ID: 2, Slot: 1, Weight: 1, Weight2: 1}}, Complete: []gamedata.EventGameReward{{Count: 3, Rewards: []gamedata.BattleReward{{Count: 5}}}}} - s, _ := fixture(t, d) - g := GameState{UID: 7, Type: 19, ID: 1, Tries: 2, SinceSpecial: 2} - req := wire.AppendVarint(wire.AppendVarint(nil, 3, 1), 4, 1) - body, _, rewards, e := s.play("/MiniGameRouletteDraw", req, &g, d) - if e != nil || !g.Special || g.SinceSpecial != 0 || g.Tries != 3 || len(rewards[3]) != 1 { - t.Fatalf("pity state %+v rewards%v err%v", g, rewards, e) - } - info, found, _ := wire.Bytes(body, 4) - group, _, _ := wire.Varint(info, 1) - id, _, _ := wire.Varint(info, 2) - if !found || group != 77 || id != 2 { - t.Fatalf("roulette field4 %x", body) - } -} diff --git a/go/internal/server/eventplay/battle.go b/go/internal/server/eventplay/battle.go deleted file mode 100644 index be693f6..0000000 --- a/go/internal/server/eventplay/battle.go +++ /dev/null @@ -1,175 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "encoding/json" - "fmt" - "strings" -) - -func (s *Service) HandlesBattle(mode uint64) bool { return mode == 17 } -func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - if num(req, 5) != 17 || receipt == "" { - return nil, fmt.Errorf("eventplay: invalid event battle") - } - group, id, deck := num(req, 8), num(req, 9), num(req, 4) - var row []byte - for _, r := range s.design.Rows("PackEventBattleTable", 4, group) { - if num(r, 5) == id && num(r, 1) == deck { - row = r - break - } - } - if row == nil { - return nil, fmt.Errorf("eventplay: event battle deck/stage mismatch") - } - var uid uint64 - for _, c := range s.registry.List() { - if c.ID == group && (c.Type == 9 || c.Type == 8) && s.now().UnixMilli() >= c.Start && s.now().UnixMilli() < c.End { - uid = c.UID - break - } - } - if uid == 0 { - return nil, fmt.Errorf("eventplay: event battle schedule unavailable") - } - previous := uint64(0) - for _, r := range s.design.Rows("PackEventBattleTable", 4, group) { - stage := num(r, 5) - if stage < id && stage > previous { - previous = stage - } - } - if previous != 0 && !s.state.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, group, previous)] { - return nil, fmt.Errorf("eventplay: previous event battle not cleared") - } - if validator, ok := s.economy.(interface{ CanApply([]gamedata.Reward) error }); ok { - cost := num(row, 3) - if cost > 0 { - if e := validator.CanApply([]gamedata.Reward{{Type: 30, Count: cost}}); e != nil { - return nil, e - } - } - } - payload, _ := json.Marshal(s.state) - var next snapshot - _ = json.Unmarshal(payload, &next) - next.Runs["battle:"+receipt] = Run{UID: uid, Game: group, Stage: id, Mode: 17, Started: uint64(s.now().UnixMilli()), Family: "Battle", Session: strings.Split(receipt, ":")[0]} - payload, _ = json.Marshal(next) - if e := s.store.Save("eventplay", payload); e != nil { - return nil, e - } - s.state = next - return nil, nil -} -func (s *Service) CompleteBattle(req []byte, receipt string) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - key := "eventbattle:" + receipt - if r, ok := s.state.Replies[key]; ok { - if !bytes.Equal(r.Request, req) { - return nil, fmt.Errorf("eventplay: changed battle retry") - } - return r.Body, nil - } - a, ok := s.state.Runs["battle:"+receipt] - if !ok { - return nil, fmt.Errorf("eventplay: no entered event battle") - } - var row []byte - for _, r := range s.design.Rows("PackEventBattleTable", 4, a.Game) { - if num(r, 5) == a.Stage { - row = r - break - } - } - if row == nil { - return nil, fmt.Errorf("eventplay: saved battle design missing") - } - raw, _ := json.Marshal(s.state) - var next snapshot - _ = json.Unmarshal(raw, &next) - var out []byte - if num(req, 2) == 1 { - rs, e := gamedata.EventPlayRewards(row, 9, 10, 8) - if e != nil { - return nil, e - } - definitions := s.battleChallenges[num(row, 1)] - claims, e := gamedata.VerifySubmittedChallenges(req, definitions) - if e != nil { - return nil, e - } - for _, index := range claims { - marker := fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, index) - if !next.Stories[marker] { - rs = append(rs, definitions[index].Reward) - next.Stories[marker] = true - } - } - rewards := []gamedata.Reward{} - for _, r := range rs { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - } - cost := num(row, 3) - var costs []gamedata.Reward - if cost > 0 { - costs = []gamedata.Reward{{Type: 30, Count: cost}} - } - bundle, e := s.economy.Apply(key, costs, rewards) - if e != nil { - return nil, e - } - out = wire.AppendBytes(out, 5, bundle) - info := wire.AppendVarint(nil, 1, a.UID) - info = wire.AppendVarint(info, 2, a.Game) - info = wire.AppendVarint(info, 3, a.Stage) - for i := range definitions { - if next.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", a.UID, a.Game, a.Stage, i)] { - info = wire.AppendVarint(info, 4, uint64(i)) - } - } - out = wire.AppendBytes(out, 16, info) - next.Stories[fmt.Sprintf("battle:%d:%d:%d", a.UID, a.Game, a.Stage)] = true - } - delete(next.Runs, "battle:"+receipt) - next.Replies[key] = reply{append([]byte(nil), req...), out} - raw, _ = json.Marshal(next) - if e := s.store.Save("eventplay", raw); e != nil { - return nil, e - } - s.state = next - return out, nil -} - -func (s *Service) AttachBattleChallenges(d gamedata.EventBattleChallenges) { - s.mu.Lock() - defer s.mu.Unlock() - s.battleChallenges = d -} - -func (s *Service) BattleChallengeIndexes(uid, group, stage uint64) []uint64 { - s.mu.Lock() - defer s.mu.Unlock() - return s.battleChallengeIndexesLocked(uid, group, stage) -} -func (s *Service) battleChallengeIndexesLocked(uid, group, stage uint64) []uint64 { - var deck uint64 - for _, row := range s.design.Rows("PackEventBattleTable", 4, group) { - if num(row, 5) == stage { - deck = num(row, 1) - break - } - } - var out []uint64 - for i := range s.battleChallenges[deck] { - if s.state.Stories[fmt.Sprintf("challenge:%d:%d:%d:%d", uid, group, stage, i)] { - out = append(out, uint64(i)) - } - } - return out -} diff --git a/go/internal/server/eventplay/field_object_period.go b/go/internal/server/eventplay/field_object_period.go deleted file mode 100644 index c8b4926..0000000 --- a/go/internal/server/eventplay/field_object_period.go +++ /dev/null @@ -1,42 +0,0 @@ -package eventplay - -import "fmt" - -// FieldObjectEventPeriod follows EventLostCoinInfo: the exact public calendar -// must be active, and the installed event must explicitly contain this pack. -func (s *Service) FieldObjectEventPeriod(pack int) (string, int64, error) { - s.mu.Lock() - defer s.mu.Unlock() - now := s.now().UnixMilli() - period := "" - var end int64 - for _, schedule := range s.registry.List() { - if schedule.Type != 14 || now < schedule.Start || now >= schedule.End { - continue - } - row, err := s.design.Row("EventLostCoinTable", 4, schedule.ID) - if err != nil { - return "", 0, err - } - packs, err := list(row, 1) - if err != nil { - return "", 0, err - } - matches := false - for _, id := range packs { - matches = matches || id == uint64(pack) - } - if !matches { - continue - } - if period != "" { - return "", 0, fmt.Errorf("eventplay: ambiguous active lost coin calendar for pack %d", pack) - } - period = fmt.Sprintf("event:%d:%d:%d:%d", schedule.UID, schedule.ID, schedule.Start, schedule.End) - end = schedule.End - } - if period == "" { - return "", 0, fmt.Errorf("eventplay: no active lost coin calendar for pack %d", pack) - } - return period, end, nil -} diff --git a/go/internal/server/eventplay/field_object_period_test.go b/go/internal/server/eventplay/field_object_period_test.go deleted file mode 100644 index b6545fb..0000000 --- a/go/internal/server/eventplay/field_object_period_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "testing" - "time" -) - -func TestLostCoinPeriodFollowsCalendarIdentityPackAndWindow(t *testing.T) { - registry := events.NewRegistry() - row := wire.AppendVarint(wire.AppendVarint(nil, 4, 9), 1, 21) - s := &Service{registry: registry, design: &gamedata.EventPlayCatalog{Tables: map[string][][]byte{"EventLostCoinTable": {row}}}, now: func() time.Time { return time.UnixMilli(150) }} - if err := registry.Replace([]events.Schedule{{UID: 1, Type: 14, ID: 9, Start: 100, End: 200}}); err != nil { - t.Fatal(err) - } - first, end, err := s.FieldObjectEventPeriod(21) - if err != nil || end != 200 || first == "" { - t.Fatal(first, end, err) - } - if _, _, err = s.FieldObjectEventPeriod(22); err == nil { - t.Fatal("event applied to unlisted pack") - } - s.now = func() time.Time { return time.UnixMilli(200) } - if _, _, err = s.FieldObjectEventPeriod(21); err == nil { - t.Fatal("event remained open at end boundary") - } - if err = registry.Replace([]events.Schedule{{UID: 2, Type: 14, ID: 9, Start: 200, End: 300}}); err != nil { - t.Fatal(err) - } - next, _, err := s.FieldObjectEventPeriod(21) - if err != nil || first == next { - t.Fatal("new calendar reused old claim period", err) - } -} diff --git a/go/internal/server/eventplay/field_pack.go b/go/internal/server/eventplay/field_pack.go deleted file mode 100644 index f39d25a..0000000 --- a/go/internal/server/eventplay/field_pack.go +++ /dev/null @@ -1,155 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "fmt" - "sort" -) - -// ListEventFieldPacks authorizes hidden fields through installed pack rules -// and the live project calendar. A hub field and a minigame field are separate -// routes: some public hubs have no PackEventMiniGameTable row at all. -func (s *Service) ListEventFieldPacks() ([]gamedata.EventFieldPack, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.eventFieldPacks() -} - -func (s *Service) ResolveEventFieldPack(id int) (gamedata.EventFieldPack, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - packs, err := s.eventFieldPacks() - if err != nil { - return gamedata.EventFieldPack{}, false, err - } - for _, p := range packs { - if p.ID == id { - return p, true, nil - } - } - return gamedata.EventFieldPack{}, false, nil -} - -func (s *Service) eventFieldPacks() ([]gamedata.EventFieldPack, error) { - now := s.now().UnixMilli() - result := map[int]gamedata.EventFieldPack{} - // Bound minigames use the hub play window even when their global schedule - // has a longer archive window. Zero means a known, currently closed binding. - boundEnds := map[uint64]int64{} - add := func(id int, uid, game, hub, mapID, point uint64, end int64) error { - base, ok := s.design.FieldPacks[id] - if !ok { - return nil - } - if len(base.MapIDs) == 0 { - return fmt.Errorf("eventplay: hidden pack %d has no installed map", id) - } - if mapID != 0 { - found := false - for _, m := range base.MapIDs { - if uint64(m) == mapID { - found = true - } - } - if !found { - return fmt.Errorf("eventplay: pack %d calendar map %d mismatch", id, mapID) - } - } - if old, exists := result[id]; exists && old.End >= end { - return nil - } - base.MapIDs = append([]int(nil), base.MapIDs...) - base.BuyRewards = append([]gamedata.Reward(nil), base.BuyRewards...) - base.ScheduleUID, base.GameID, base.HubID, base.End = uid, game, hub, end - base.InitialMapID, base.PointPositionID = mapID, point - if base.InitialMapID == 0 { - base.InitialMapID = uint64(base.MapIDs[0]) - } - result[id] = base - return nil - } - if s.hubCalendars != nil { - _, body, handled, err := s.hubCalendars.Handle("/EventHubInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - return nil, err - } - if handled { - err = wire.Walk(body, func(f wire.Field) error { - if f.Number != 1 || f.Type != 2 { - return nil - } - start, end := int64(num(f.Value, 3)), int64(num(f.Value, 4)) - if err := wire.Walk(f.Value, func(setting wire.Field) error { - if setting.Number != 6 || setting.Type != 2 || num(setting.Value, 2) != 6 { - return nil - } - uids, err := list(setting.Value, 3) - if err != nil { - return err - } - for _, uid := range uids { - if _, exists := boundEnds[uid]; !exists { - boundEnds[uid] = 0 - } - if now >= start && now < end && boundEnds[uid] < end { - boundEnds[uid] = end - } - } - return nil - }); err != nil { - return err - } - if now < start || now >= end { - return nil - } - hubID := num(f.Value, 2) - hub, e := s.design.Row("PackEventHubTable", 14, hubID) - if e != nil { - return e - } - return add(int(num(hub, 20)), num(f.Value, 1), 0, hubID, 0, 0, end) - }) - if err != nil { - return nil, err - } - } - } - for _, c := range s.registry.List() { - for _, binding := range s.fieldBindings { - if c.Type == binding.EventType && now >= c.Start && now < c.End { - if err := add(binding.PackID, c.UID, c.ID, 0, 0, 0, c.End); err != nil { - return nil, err - } - p := result[binding.PackID] - p.ContentOpenType = binding.ContentOpenType - result[binding.PackID] = p - } - } - if c.Type != 11 || now < c.Start || now >= c.End { - continue - } - end := c.End - if hubEnd, bound := boundEnds[c.UID]; bound { - if hubEnd == 0 { - continue - } - if hubEnd < end { - end = hubEnd - } - } - game, err := s.design.Row("PackEventMiniGameTable", 8, c.ID) - if err != nil { - return nil, err - } - if err = add(int(num(game, 12)), c.UID, c.ID, 0, num(game, 9), num(game, 13), end); err != nil { - return nil, err - } - } - var packs []gamedata.EventFieldPack - for _, p := range result { - packs = append(packs, p) - } - sort.Slice(packs, func(i, j int) bool { return packs[i].ID < packs[j].ID }) - return packs, nil -} diff --git a/go/internal/server/eventplay/field_pack_test.go b/go/internal/server/eventplay/field_pack_test.go deleted file mode 100644 index a30db6a..0000000 --- a/go/internal/server/eventplay/field_pack_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/readonly" - "bd2server/internal/server/wire" - "os" - "testing" - "time" -) - -func TestClientConstantFieldBindingRequiresActiveMatchingEvent(t *testing.T) { - s, _ := makeService(t) - s.design.FieldPacks = map[int]gamedata.EventFieldPack{734: {ID: 734, MapIDs: []int{735}, InitialPosition: "{}"}} - s.fieldBindings = []FieldBinding{{PackID: 734, EventType: 20, ContentOpenType: 20}} - reg := events.NewRegistry() - if err := reg.Replace([]events.Schedule{{UID: 32, Type: 20, ID: 81, Start: 100, End: 200}}); err != nil { - t.Fatal(err) - } - s.registry = reg - p, ok, err := s.ResolveEventFieldPack(734) - if err != nil || !ok || p.ScheduleUID != 32 || p.ContentOpenType != 20 { - t.Fatalf("%+v %v %v", p, ok, err) - } - s.now = func() time.Time { return time.UnixMilli(200) } - if _, ok, err := s.ResolveEventFieldPack(734); err != nil || ok { - t.Fatalf("expired tactics %v %v", ok, err) - } -} - -func TestInstalledTacticsFieldBinding(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - reg := events.NewRegistry() - if err := reg.Replace([]events.Schedule{{UID: 81, Type: 20, ID: 8, Start: 1, End: 9999999999999}}); err != nil { - t.Fatal(err) - } - // Use the real catalog but an in-memory account and controlled clock. - d, err := gamedata.LoadEventPlayCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - svc, _ := makeService(t) - svc.design = d - svc.registry = reg - if err = svc.AttachFieldBindingsFile("../../../seed/v2_35_10/event_field_bindings.json"); err != nil { - t.Fatal(err) - } - p, ok, err := svc.ResolveEventFieldPack(20000) - if err != nil || !ok || p.ContentOpenType != 20 || len(p.MapIDs) != 1 || p.MapIDs[0] != 200001 || p.BuyPrice != 0 { - t.Fatalf("%+v %v %v", p, ok, err) - } -} - -func TestHiddenHubPackUsesCalendarPlayWindowAndInstalledMaps(t *testing.T) { - s, _ := makeService(t) - s.registry = events.NewRegistry() - s.design.FieldPacks = map[int]gamedata.EventFieldPack{912: gamedata.EventFieldPack{ID: 912, MapIDs: []int{991, 992}, InitialPosition: "{}", BuyType: 4, BuyPrice: 20}} - s.design.Tables["PackEventHubTable"] = [][]byte{wire.AppendVarint(wire.AppendVarint(nil, 14, 55), 20, 912)} - scalar := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Varint: v} } - s.AttachHubCalendars(&readonly.Seed{Responses: map[string]readonly.Response{"/EventHubInfo": {PacketCode: 222, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{scalar(1, 77), scalar(2, 55), scalar(3, 100), scalar(4, 200), scalar(5, 300)}}}}}}) - p, ok, err := s.ResolveEventFieldPack(912) - if err != nil || !ok || p.ScheduleUID != 77 || p.HubID != 55 || p.End != 200 || p.BuyPrice != 20 || p.InitialMapID != 991 || p.InitialPosition != "{}" { - t.Fatalf("pack=%+v ok=%v err=%v", p, ok, err) - } - p.MapIDs[0] = 0 - again, _, _ := s.ResolveEventFieldPack(912) - if again.MapIDs[0] != 991 { - t.Fatal("caller changed design maps") - } - s.now = func() time.Time { return time.UnixMilli(200) } - if _, ok, err := s.ResolveEventFieldPack(912); err != nil || ok { - t.Fatalf("post-play purchase ok=%v err=%v", ok, err) - } -} - -func TestHiddenMiniGamePackRejectsForeignMapAndInactiveCalendar(t *testing.T) { - s, _ := makeService(t) - s.design.FieldPacks = map[int]gamedata.EventFieldPack{812: {ID: 812, MapIDs: []int{811}}} - s.design.Tables["PackEventMiniGameTable"] = [][]byte{row(map[int]uint64{8: 1, 12: 812, 9: 810, 13: 101})} - if _, _, err := s.ResolveEventFieldPack(812); err == nil { - t.Fatal("foreign map accepted") - } - s.design.Tables["PackEventMiniGameTable"] = [][]byte{row(map[int]uint64{8: 1, 12: 812, 9: 811, 13: 101})} - p, ok, err := s.ResolveEventFieldPack(812) - if err != nil || !ok || p.GameID != 1 || p.PointPositionID != 101 { - t.Fatalf("%+v %v %v", p, ok, err) - } - s.now = func() time.Time { return time.UnixMilli(9999999) } - if _, ok, err := s.ResolveEventFieldPack(812); err != nil || ok { - t.Fatalf("expired game available %v %v", ok, err) - } -} diff --git a/go/internal/server/eventplay/hub_calendar_test.go b/go/internal/server/eventplay/hub_calendar_test.go deleted file mode 100644 index c7a846f..0000000 --- a/go/internal/server/eventplay/hub_calendar_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/events" - "bytes" - "testing" - - "bd2server/internal/server/readonly" - "bd2server/internal/server/wire" -) - -func TestProjectHubCalendarRetainsPlayEndAndMultipleUIDs(t *testing.T) { - s, _ := makeService(t) - s.design.Tables["PackEventHubTable"] = [][]byte{wire.AppendVarint(nil, 14, 57)} - scalar := func(n int, v uint64) readonly.Field { - return readonly.Field{Number: n, Type: 0, Varint: v} - } - hub := readonly.Field{Number: 1, Type: 2, Fields: []readonly.Field{ - scalar(1, 74), scalar(2, 57), scalar(3, 100), scalar(4, 200), scalar(5, 300), - {Number: 6, Type: 2, Fields: []readonly.Field{ - scalar(1, 1), scalar(2, 3), scalar(3, 77), scalar(3, 78), - }}, - }} - seed := &readonly.Seed{Responses: map[string]readonly.Response{ - "/EventHubInfo": {PacketCode: 222, Fields: []readonly.Field{hub}}, - "/MiniGameHubInfo": {PacketCode: 390, Fields: []readonly.Field{ - {Number: 1, Type: 2, Fields: []readonly.Field{scalar(1, 4), scalar(2, 9), scalar(3, 0)}}, - }}, - }} - s.AttachHubCalendars(seed) - for _, path := range []string{"/EventHubInfo", "/MiniGameHubInfo"} { - req := wire.AppendVarint(nil, 1, 1) - wantCode, want, _, err := seed.Handle(path, req) - if err != nil { - t.Fatal(err) - } - code, got, handled, err := s.HandleSession(path, req, "session") - if err != nil || !handled || code != wantCode || !bytes.Equal(got, want) { - t.Fatalf("path=%s code=%d handled=%v err=%v body=%x want=%x", path, code, handled, err, got, want) - } - if path == "/EventHubInfo" { - raw, _, _ := wire.Bytes(got, 1) - playEnd, _, _ := wire.Varint(raw, 4) - end, _, _ := wire.Varint(raw, 5) - setting, _, _ := wire.Bytes(raw, 6) - ids, err := list(setting, 3) - if err != nil || playEnd != 200 || end != 300 || len(ids) != 2 || ids[0] != 77 || ids[1] != 78 { - t.Fatalf("hub play_end=%d end=%d refs=%v err=%v", playEnd, end, ids, err) - } - } - } - if len(s.state.Replies) != 0 { - t.Fatal("public calendars persisted as player replies") - } -} - -func TestMiniEventHubExcludesOrdinaryPacksAndGameSchedules(t *testing.T) { - s, _ := makeService(t) - registry := events.NewRegistry() - if err := registry.Replace([]events.Schedule{ - {UID: 1, Type: 8, ID: 20, Start: 1, End: 9999999}, - {UID: 2, Type: 11, ID: 41, Start: 1, End: 9999999}, - {UID: 3, Type: 8, ID: 1003, Start: 1, End: 9999999}, - }); err != nil { - t.Fatal(err) - } - s.registry = registry - normal := wire.AppendVarint(nil, 14, 20) - normal = wire.AppendVarint(normal, 20, 20) - mini := wire.AppendVarint(nil, 14, 1003) - mini = wire.AppendVarint(mini, 20, 1003) - mini = wire.AppendVarint(mini, 13, 1) - s.design.Tables["PackEventHubTable"] = [][]byte{normal, mini} - s.AttachHubCalendars(&readonly.Seed{Responses: map[string]readonly.Response{"/EventHubInfo": {Fields: []readonly.Field{ - {Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 1}, {Number: 2, Type: 0, Varint: 20}, {Number: 3, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: 9999999}, {Number: 5, Type: 0, Varint: 9999999}}}, - {Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 3}, {Number: 2, Type: 0, Varint: 1003}, {Number: 3, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: 9999999}, {Number: 5, Type: 0, Varint: 9999999}}}, - }}}}) - - code, response, handled, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session") - if err != nil || !handled || code != 534 { - t.Fatalf("mini hub response code=%d handled=%t err=%v", code, handled, err) - } - count := 0 - if err := wire.Walk(response, func(f wire.Field) error { - if f.Number == 1 { - count++ - if num(f.Value, 1) != 3 || num(f.Value, 2) != 1003 { - t.Fatalf("ordinary pack or game exposed as MiniEventMainUI: %x", f.Value) - } - } - return nil - }); err != nil || count != 1 { - t.Fatalf("mini hubs=%d err=%v", count, err) - } -} diff --git a/go/internal/server/eventplay/installed_hubs_test.go b/go/internal/server/eventplay/installed_hubs_test.go deleted file mode 100644 index f524e4d..0000000 --- a/go/internal/server/eventplay/installed_hubs_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package eventplay - -import ( - "os" - "testing" - - "bd2server/internal/server/calendar" - "bd2server/internal/server/events" - "bd2server/internal/server/readonly" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestInstalledHubCalendarsNeverRequestTheWrongPrefabKind(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - set, err := calendar.LoadDirectory("../../../../schedules", "2.35.10", "20260923193640") - if err != nil { - t.Fatal(err) - } - if err := set.ValidateDesign(root, "20260923193640"); err != nil { - t.Fatal(err) - } - registry := events.NewRegistry() - if err := registry.Replace(set.Events); err != nil { - t.Fatal(err) - } - store := stateio.NewMemory() - s, err := Open(store, root, "20260923193640", registry, &econ{}) - if err != nil { - t.Fatal(err) - } - seed, err := set.ApplyReadonly(&readonly.Seed{Version: "2.35.10", Responses: map[string]readonly.Response{}}) - if err != nil { - t.Fatal(err) - } - s.AttachHubCalendars(seed) - for _, route := range []struct { - path string - kind uint64 - }{{"/EventHubInfo", 0}, {"/MiniEventHubInfo", 1}} { - _, body, _, err := s.HandleSession(route.path, wire.AppendVarint(nil, 1, 1), "session") - if err != nil { - t.Fatal(route.path, err) - } - got := map[uint64]bool{} - if err := wire.Walk(body, func(f wire.Field) error { - if f.Number != 1 || f.Type != 2 { - return nil - } - hubID := num(f.Value, 2) - row, err := s.design.Row("PackEventHubTable", 14, hubID) - if err != nil { - return err - } - if num(row, 13) != route.kind { - t.Fatalf("%s exposes incompatible HubType for hub %d", route.path, hubID) - } - got[num(f.Value, 1)] = true - return nil - }); err != nil { - t.Fatal(err) - } - for _, hub := range set.EventHubs { - row, err := s.design.Row("PackEventHubTable", 14, hub.HubID) - if err != nil { - t.Fatal(err) - } - if got[hub.UID] != (num(row, 13) == route.kind) { - t.Fatal("published hub lost or appeared in both routes", route.path, hub.HubID) - } - } - } - if saved, err := store.Load("eventplay"); err != nil || len(saved) != 0 { - t.Fatal("public calendars persisted player state", err) - } -} diff --git a/go/internal/server/eventplay/mini_event_hubs.go b/go/internal/server/eventplay/mini_event_hubs.go deleted file mode 100644 index e9ed8e2..0000000 --- a/go/internal/server/eventplay/mini_event_hubs.go +++ /dev/null @@ -1,307 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "fmt" - "sort" -) - -// miniHubSchedule is a project calendar identity; HubID is distinct from the -// pack ID used by EventPack schedules. -type miniHubBinding struct { - Slot, ContentType uint64 - UIDs []uint64 -} - -type miniHubSchedule struct { - UID, HubID uint64 - Start, PlayEnd, End int64 - Bindings []miniHubBinding -} - -// ListMiniContentRoutes exposes the same validated hub-scoped calendar used -// for presentation, including routes whose play period has already ended. -func (s *Service) ListMiniContentRoutes() ([]gamedata.MiniContentRoute, error) { - body, err := s.miniEventHubs(wire.AppendVarint(nil, 1, 1)) - if err != nil { - return nil, err - } - var routes []gamedata.MiniContentRoute - seen := map[uint64]bool{} - err = wire.Walk(body, func(hub wire.Field) error { - if hub.Number != 1 || hub.Type != 2 { - return nil - } - return wire.Walk(hub.Value, func(slot wire.Field) error { - if slot.Number != 6 || slot.Type != 2 { - return nil - } - typ := num(slot.Value, 2) - if typ != 13 && typ != 14 { - return nil - } - uid := num(slot.Value, 4) - if seen[uid] { - return fmt.Errorf("eventplay: ambiguous mini content UID %d", uid) - } - seen[uid] = true - routes = append(routes, gamedata.MiniContentRoute{UID: uid, ContentType: typ, ContentID: num(slot.Value, 3), Start: int64(num(slot.Value, 5)), End: int64(num(slot.Value, 6))}) - return nil - }) - }) - if err != nil { - return nil, err - } - sort.Slice(routes, func(i, j int) bool { return routes[i].UID < routes[j].UID }) - return routes, nil -} - -// ResolveMiniContentUID resolves hub-scoped story/quiz identities without -// manufacturing EventScheduleInfo rows. Callers enforce the returned window. -func (s *Service) ResolveMiniContentUID(uid uint64) (contentType, contentID uint64, start, end int64, err error) { - if uid == 0 { - return 0, 0, 0, 0, fmt.Errorf("eventplay: missing mini content UID") - } - body, err := s.miniEventHubs(wire.AppendVarint(nil, 1, 1)) - if err != nil { - return 0, 0, 0, 0, err - } - found := false - err = wire.Walk(body, func(hub wire.Field) error { - if hub.Number != 1 || hub.Type != 2 { - return nil - } - return wire.Walk(hub.Value, func(slot wire.Field) error { - if slot.Number != 6 || slot.Type != 2 || num(slot.Value, 4) != uid { - return nil - } - typ := num(slot.Value, 2) - if typ != 13 && typ != 14 { - return nil - } - if found { - return fmt.Errorf("eventplay: ambiguous mini content UID %d", uid) - } - found = true - contentType, contentID = typ, num(slot.Value, 3) - start, end = int64(num(slot.Value, 5)), int64(num(slot.Value, 6)) - return nil - }) - }) - if err != nil { - return 0, 0, 0, 0, err - } - if !found { - return 0, 0, 0, 0, fmt.Errorf("eventplay: mini content UID %d unavailable", uid) - } - return contentType, contentID, start, end, nil -} - -func (s *Service) miniEventHubs(req []byte) ([]byte, error) { - var hubs []miniHubSchedule - if s.hubCalendars == nil { - return nil, nil - } - _, body, handled, err := s.hubCalendars.Handle("/EventHubInfo", req) - if err != nil { - return nil, err - } - if handled { - err = wire.Walk(body, func(f wire.Field) error { - if f.Number != 1 || f.Type != 2 { - return nil - } - hub := miniHubSchedule{UID: num(f.Value, 1), HubID: num(f.Value, 2), Start: int64(num(f.Value, 3)), PlayEnd: int64(num(f.Value, 4)), End: int64(num(f.Value, 5))} - if err := wire.Walk(f.Value, func(setting wire.Field) error { - if setting.Number != 6 || setting.Type != 2 { - return nil - } - uids, err := list(setting.Value, 3) - if err != nil { - return err - } - hub.Bindings = append(hub.Bindings, miniHubBinding{num(setting.Value, 1), num(setting.Value, 2), uids}) - return nil - }); err != nil { - return err - } - hubs = append(hubs, hub) - return nil - }) - if err != nil { - return nil, err - } - } - sort.Slice(hubs, func(i, j int) bool { return hubs[i].UID < hubs[j].UID }) - rows := s.registry.List() - var out []byte - for _, hub := range hubs { - table, err := s.design.Row("PackEventHubTable", 14, hub.HubID) - if err != nil || num(table, 13) != 1 { - continue - } - v := wire.AppendVarint(nil, 1, hub.UID) - v = wire.AppendVarint(v, 2, hub.HubID) - v = wire.AppendVarint(v, 3, uint64(hub.Start)) - v = wire.AppendVarint(v, 4, uint64(hub.PlayEnd)) - v = wire.AppendVarint(v, 5, uint64(hub.End)) - slots := s.design.Rows("PackEventListTable", 6, hub.HubID) - sort.Slice(slots, func(i, j int) bool { return num(slots[i], 10) < num(slots[j], 10) }) - for _, binding := range hub.Bindings { - found := false - for _, slot := range slots { - if binding.Slot == num(slot, 11) { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("eventplay: mini hub %d static slot index %d missing", hub.UID, binding.Slot) - } - } - for _, slot := range slots { - contentType, contentID := num(slot, 9), num(slot, 7) - bound := map[uint64]bool{} - for _, binding := range hub.Bindings { - if binding.Slot == num(slot, 11) { - if binding.ContentType != contentType { - return nil, fmt.Errorf("eventplay: mini hub %d slot %d content type mismatch", hub.UID, num(slot, 10)) - } - for _, uid := range binding.UIDs { - bound[uid] = true - } - } - } - if len(bound) == 0 { - continue - } - // Mini hub stories and NPC quizzes have hub-scoped UIDs, rather - // than entries in EventScheduleInfo. Their explicit binding and - // static EndDateType supply the calendar; never invent a global - // event type for these separate client protocols. - if contentType == 13 || contentType == 14 { - if len(bound) != 1 || bound[0] { - return nil, fmt.Errorf("eventplay: mini hub %d slot %d requires one nonzero hub-scoped UID", hub.UID, num(slot, 10)) - } - var uid uint64 - for id := range bound { - uid = id - } - for _, row := range rows { - if row.UID == uid { - return nil, fmt.Errorf("eventplay: mini hub %d slot %d hub-scoped UID collides with global schedule", hub.UID, num(slot, 10)) - } - } - end := hub.End - if num(slot, 4) == 0 { - end = hub.PlayEnd - } - if end <= hub.Start { - continue - } - b := wire.AppendVarint(nil, 1, num(slot, 10)) - b = wire.AppendVarint(b, 2, contentType) - b = wire.AppendVarint(b, 3, contentID) - b = wire.AppendVarint(b, 4, uid) - b = wire.AppendVarint(b, 5, uint64(hub.Start)) - b = wire.AppendVarint(b, 6, uint64(end)) - v = wire.AppendBytes(v, 6, b) - continue - } - eventType, subID, known := s.miniSlotSchedule(contentType, contentID) - if !known { - return nil, fmt.Errorf("eventplay: mini hub %d slot %d content type %d unsupported", hub.UID, num(slot, 10), contentType) - } - for uid := range bound { - found := false - for _, row := range rows { - if row.UID == uid && row.Type == eventType && row.ID == contentID && row.SubID == subID { - found = true - break - } - } - if !found { - return nil, fmt.Errorf("eventplay: mini hub %d slot %d schedule %d identity mismatch", hub.UID, num(slot, 10), uid) - } - } - var matches []events.Schedule - for _, row := range rows { - if bound[row.UID] && row.Type == eventType && row.ID == contentID && row.SubID == subID && row.UID != 0 && row.Start < hub.End && hub.Start < row.End { - matches = append(matches, row) - } - } - if len(matches) == 0 { - continue - } - if len(matches) > 1 { - return nil, fmt.Errorf("eventplay: mini hub %d slot %d has ambiguous schedules", hub.UID, num(slot, 10)) - } - child := matches[0] - start, end := child.Start, child.End - if start < hub.Start { - start = hub.Start - } - if end > hub.End { - end = hub.End - } - // Project policy follows static EndDateType: play content closes at - // PlayEnd; exchange content can remain through the declared End. - if num(slot, 4) == 0 && end > hub.PlayEnd { - end = hub.PlayEnd - } - if end <= start { - continue - } - b := wire.AppendVarint(nil, 1, num(slot, 10)) - b = wire.AppendVarint(b, 2, contentType) - b = wire.AppendVarint(b, 3, contentID) - b = wire.AppendVarint(b, 4, child.UID) - b = wire.AppendVarint(b, 5, uint64(start)) - b = wire.AppendVarint(b, 6, uint64(end)) - v = wire.AppendBytes(v, 6, b) - } - out = wire.AppendBytes(out, 1, v) - } - return out, nil -} - -// Hub content and EventType are separate client enums. MiniGame schedules -// are design identities; calendar SubID is zero for the supported slot routes. -func (s *Service) miniSlotSchedule(contentType, id uint64) (uint64, uint64, bool) { - eventType, known := gamedata.MiniHubEventType(contentType) - if !known { - return 0, 0, false - } - if contentType == 6 { - if _, err := s.design.Row("PackEventMiniGameTable", 8, id); err != nil { - return 0, 0, false - } - } - return eventType, 0, true -} - -// Public event packs retain their declared wire settings and both end windows. -// Mini hub prefabs are served exclusively by MiniEventHubInfo. -func (s *Service) publicEventHubs(req []byte) ([]byte, error) { - _, body, _, err := s.hubCalendars.Handle("/EventHubInfo", req) - if err != nil { - return nil, err - } - var out []byte - err = wire.Walk(body, func(f wire.Field) error { - if f.Number != 1 || f.Type != 2 { - return nil - } - row, err := s.design.Row("PackEventHubTable", 14, num(f.Value, 2)) - if err != nil { - return nil - } - if num(row, 13) == 0 { - out = wire.AppendBytes(out, 1, f.Value) - } - return nil - }) - return out, err -} diff --git a/go/internal/server/eventplay/mini_event_hubs_test.go b/go/internal/server/eventplay/mini_event_hubs_test.go deleted file mode 100644 index 2ac09c8..0000000 --- a/go/internal/server/eventplay/mini_event_hubs_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/readonly" - "bd2server/internal/server/wire" - "fmt" - "testing" - "time" -) - -func hubField(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} } -func miniCalendar(uid, hub, start, playEnd, end uint64, refs ...uint64) readonly.Field { - settings := []readonly.Field{hubField(1, 5), hubField(2, 5)} - for _, uid := range refs { - settings = append(settings, hubField(3, uid)) - } - return readonly.Field{Number: 1, Type: 2, Fields: []readonly.Field{hubField(1, uid), hubField(2, hub), hubField(3, start), hubField(4, playEnd), hubField(5, end), {Number: 6, Type: 2, Fields: settings}}} -} -func attachMiniCalendar(s *Service, rows ...readonly.Field) { - s.AttachHubCalendars(&readonly.Seed{Responses: map[string]readonly.Response{"/EventHubInfo": {Fields: rows}}}) -} -func miniSlot(group, id, index, typ, content, endType uint64) []byte { - var out []byte - for _, x := range [][2]uint64{{6, group}, {10, id}, {11, index}, {9, typ}, {7, content}, {4, endType}} { - out = wire.AppendVarint(out, int(x[0]), x[1]) - } - return out -} -func TestMiniHubStaticSlotAndExplicitScheduleIdentity(t *testing.T) { - s, _ := makeService(t) - hub := wire.AppendVarint(nil, 14, 1003) - hub = wire.AppendVarint(hub, 13, 1) - s.design.Tables["PackEventHubTable"] = [][]byte{hub} - s.design.Tables["PackEventListTable"] = [][]byte{miniSlot(1003, 4, 5, 5, 17, 1), miniSlot(1003, 2, 11, 13, 3, 0)} - registry := events.NewRegistry() - if err := registry.Replace([]events.Schedule{ - {UID: 40, Type: 12, ID: 17, Start: 100, End: 300}, - {UID: 41, Type: 12, ID: 17, Start: 400, End: 600}, - {UID: 42, Type: 13, ID: 17, Start: 100, End: 300}, - {UID: 43, Type: 12, ID: 17, SubID: 1003, Start: 100, End: 300}, - }); err != nil { - t.Fatal(err) - } - s.registry = registry - attachMiniCalendar(s, miniCalendar(1, 1003, 100, 200, 300, 40, 41), miniCalendar(2, 1003, 400, 500, 600, 40, 41)) - for _, now := range []int64{99, 100, 200, 300, 601} { - s.now = func() time.Time { return time.UnixMilli(now) } - _, body, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session") - if err != nil { - t.Fatal(err) - } - count := 0 - _ = wire.Walk(body, func(f wire.Field) error { - if f.Number != 1 { - return nil - } - count++ - slot, ok, err := wire.Bytes(f.Value, 6) - if err != nil || !ok || num(slot, 1) != 4 || num(slot, 2) != 5 || num(slot, 3) != 17 { - t.Fatalf("bad static mapping %x", slot) - } - want := uint64(40) - if num(f.Value, 1) == 2 { - want = 41 - } - if num(slot, 4) != want || num(slot, 6) != num(f.Value, 5) { - t.Fatalf("bad UID/window %x", slot) - } - if num(f.Value, 4) == num(f.Value, 5) { - t.Fatal("play/end window lost") - } - return nil - }) - if count != 2 || len(s.state.Replies) != 0 { - t.Fatalf("calendar count=%d cached=%d", count, len(s.state.Replies)) - } - } - // Same sequence must reflect an updated published calendar immediately. - attachMiniCalendar(s, miniCalendar(2, 1003, 400, 500, 600, 41)) - _, body, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session") - if err != nil { - t.Fatal(err) - } - raw, _, _ := wire.Bytes(body, 1) - if num(raw, 1) != 2 { - t.Fatal("stale account reply returned") - } -} -func TestMiniHubRequiresExplicitCalendarAndRejectsAmbiguousSlot(t *testing.T) { - s, _ := makeService(t) - hub := wire.AppendVarint(nil, 14, 1003) - hub = wire.AppendVarint(hub, 13, 1) - s.design.Tables["PackEventHubTable"] = [][]byte{hub} - s.design.Tables["PackEventListTable"] = [][]byte{miniSlot(1003, 4, 5, 5, 17, 1)} - registry := events.NewRegistry() - _ = registry.Replace([]events.Schedule{{UID: 1, Type: 8, ID: 2010, Start: 100, End: 300}, {UID: 40, Type: 12, ID: 17, Start: 100, End: 300}, {UID: 41, Type: 12, ID: 17, Start: 100, End: 300}}) - s.registry = registry - _, body, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session") - if err != nil || len(body) != 0 { - t.Fatal("hub guessed from pack schedule", err) - } - attachMiniCalendar(s, miniCalendar(1, 1003, 100, 200, 300, 40, 41)) - if _, _, _, err = s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session"); err == nil { - t.Fatal("ambiguous slot silently selected") - } - if len(s.state.Replies) != 0 { - t.Fatal("failed public query persisted") - } -} -func TestMiniHubExplicitInvalidBindingIsRejected(t *testing.T) { - for _, tc := range []struct { - name string - typ, sub, progress uint64 - }{{"wrong event type", 13, 0, 5}, {"wrong sub id", 12, 1003, 5}, {"wrong content type", 12, 0, 3}} { - t.Run(tc.name, func(t *testing.T) { - s, _ := makeService(t) - hub := wire.AppendVarint(nil, 14, 1003) - hub = wire.AppendVarint(hub, 13, 1) - s.design.Tables["PackEventHubTable"] = [][]byte{hub} - s.design.Tables["PackEventListTable"] = [][]byte{miniSlot(1003, 4, 5, 5, 17, 1)} - registry := events.NewRegistry() - _ = registry.Replace([]events.Schedule{{UID: 40, Type: tc.typ, ID: 17, SubID: tc.sub, Start: 100, End: 300}}) - s.registry = registry - field := miniCalendar(1, 1003, 100, 200, 300, 40) - field.Fields[5].Fields[1] = hubField(2, tc.progress) - attachMiniCalendar(s, field) - if _, _, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session"); err == nil { - t.Fatal("invalid explicit binding accepted") - } - }) - } -} -func TestNormalAndMiniHubCalendarsUseSeparatePrefabs(t *testing.T) { - s, _ := makeService(t) - normal := wire.AppendVarint(nil, 14, 58) - mini := wire.AppendVarint(nil, 14, 1003) - mini = wire.AppendVarint(mini, 13, 1) - s.design.Tables["PackEventHubTable"] = [][]byte{normal, mini} - // No settings here: the calendars still expose their complete windows. - a := miniCalendar(1, 58, 100, 200, 300) - a.Fields = a.Fields[:5] - b := miniCalendar(2, 1003, 400, 500, 600) - b.Fields = b.Fields[:5] - attachMiniCalendar(s, a, b) - for _, tc := range []struct { - path string - uid uint64 - }{{"/EventHubInfo", 1}, {"/MiniEventHubInfo", 2}} { - _, body, _, err := s.HandleSession(tc.path, wire.AppendVarint(nil, 1, 1), "session") - if err != nil { - t.Fatal(err) - } - count := 0 - _ = wire.Walk(body, func(f wire.Field) error { - if f.Number == 1 { - count++ - if num(f.Value, 1) != tc.uid || num(f.Value, 4) == num(f.Value, 5) { - t.Fatalf("bad calendar %s %x", tc.path, f.Value) - } - } - return nil - }) - if count != 1 { - t.Fatal("hub appeared in both prefab routes", tc.path, count) - } - } -} -func TestMiniHubExplicitQuizUsesHubScopedCalendar(t *testing.T) { - s, _ := makeService(t) - hub := wire.AppendVarint(nil, 14, 1003) - hub = wire.AppendVarint(hub, 13, 1) - s.design.Tables["PackEventHubTable"] = [][]byte{hub} - s.design.Tables["PackEventListTable"] = [][]byte{miniSlot(1003, 2, 11, 13, 3, 0)} - field := miniCalendar(1, 1003, 100, 200, 300, 40) - field.Fields[5].Fields[0] = hubField(1, 11) - field.Fields[5].Fields[1] = hubField(2, 13) - attachMiniCalendar(s, field) - if _, _, _, err := s.HandleSession("/MiniEventHubInfo", wire.AppendVarint(nil, 1, 1), "session"); err != nil { - t.Fatal(err) - } - typ, id, start, end, err := s.ResolveMiniContentUID(40) - if err != nil || typ != 13 || id != 3 || start != 100 || end != 200 { - t.Fatalf("wrong quiz calendar: %d %d %d %d %v", typ, id, start, end, err) - } - if _, _, _, _, err := s.ResolveMiniContentUID(41); err == nil { - t.Fatal("unbound UID accepted") - } -} - -func TestMiniHubStoryAndQuizRequireUniqueNonGlobalBindings(t *testing.T) { - for _, typ := range []uint64{13, 14} { - t.Run(fmt.Sprint(typ), func(t *testing.T) { - s, _ := makeService(t) - hub := wire.AppendVarint(wire.AppendVarint(nil, 14, 1003), 13, 1) - s.design.Tables["PackEventHubTable"] = [][]byte{hub} - s.design.Tables["PackEventListTable"] = [][]byte{miniSlot(1003, 2, 11, typ, 3, 0)} - field := miniCalendar(1, 1003, 100, 200, 300, 40) - field.Fields[5].Fields[0], field.Fields[5].Fields[1] = hubField(1, 11), hubField(2, typ) - attachMiniCalendar(s, field) - if _, _, _, _, err := s.ResolveMiniContentUID(40); err != nil { - t.Fatal(err) - } - routes, err := s.ListMiniContentRoutes() - if err != nil || len(routes) != 1 || routes[0].UID != 40 || routes[0].ContentType != typ || routes[0].ContentID != 3 || routes[0].Start != 100 || routes[0].End != 200 { - t.Fatalf("route list differs from hub projection: %+v %v", routes, err) - } - registry := events.NewRegistry() - if err := registry.Replace([]events.Schedule{{UID: 40, Type: 1, ID: 3, Start: 100, End: 300}}); err != nil { - t.Fatal(err) - } - s.registry = registry - if _, _, _, _, err := s.ResolveMiniContentUID(40); err == nil { - t.Fatal("global UID collision accepted") - } - }) - } -} -func TestPublicHubsWithoutProjectCalendarDoNotGuessSchedules(t *testing.T) { - s, _ := makeService(t) - registry := events.NewRegistry() - if err := registry.Replace([]events.Schedule{{UID: 1, Type: 8, ID: 2010, Start: 100, End: 300}, {UID: 2, Type: 11, ID: 41, Start: 100, End: 300}}); err != nil { - t.Fatal(err) - } - s.registry = registry - for _, path := range []string{"/EventHubInfo", "/MiniEventHubInfo", "/MiniGameHubInfo"} { - code, body, handled, err := s.HandleSession(path, wire.AppendVarint(nil, 1, 1), "session") - if err != nil || !handled || code != codes[path] || len(body) != 0 { - t.Fatalf("path=%s code=%d handled=%t body=%x err=%v", path, code, handled, body, err) - } - } - if len(s.state.Replies) != 0 { - t.Fatal("public calendar absence persisted") - } -} diff --git a/go/internal/server/eventplay/play.go b/go/internal/server/eventplay/play.go deleted file mode 100644 index c765621..0000000 --- a/go/internal/server/eventplay/play.go +++ /dev/null @@ -1,721 +0,0 @@ -// Package eventplay owns event stories and client-simulated minigame runs. -package eventplay - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/readonly" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/binary" - "encoding/json" - "fmt" - "math" - "slices" - "strings" - "sync" - "time" -) - -type Rewards interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} -type Run struct { - UID, Game, Stage, Mode, Started, Score, HP uint64 - Family, Session string - Objects, Skills []uint64 - Coin, Exp, Char, MapGroup uint64 - SkillLevels map[uint64]uint64 - MaxHP, Rerolls, Level, SkillCredits uint64 - Offers []uint64 - LevelExp uint64 - Killed, Picked, Available map[uint64]uint64 -} -type record struct { - UID, Game, Stage, Mode, Best, Paid, Date uint64 - Family string - Clears map[string]bool - PendingBundle []byte -} -type reply struct{ Request, Body []byte } -type snapshot struct { - Version int `json:"version"` - Records map[string]record `json:"records"` - Runs map[string]Run `json:"runs"` - Replies map[string]reply `json:"replies"` - Stories map[string]bool `json:"stories"` - Upgrades map[string]uint64 `json:"upgrades"` -} -type Service struct { - battleChallenges gamedata.EventBattleChallenges - mu sync.Mutex - store stateio.Store - registry events.Resolver - economy Rewards - design *gamedata.EventPlayCatalog - state snapshot - now func() time.Time - rooms RoomRuntime - onProgress func(uint64, uint64, uint64) error - hubCalendars *readonly.Seed - fieldBindings []FieldBinding -} - -func Open(store stateio.Store, root, version string, registry events.Resolver, economy Rewards) (*Service, error) { - if store == nil || registry == nil || economy == nil { - return nil, fmt.Errorf("eventplay: incomplete configuration") - } - d, e := gamedata.LoadEventPlayCatalog(root, version) - if e != nil { - return nil, e - } - s := &Service{store: store, registry: registry, economy: economy, design: d, now: time.Now, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}} - raw, e := store.Load("eventplay") - if e != nil { - return nil, e - } - if raw != nil { - if e = stateio.RequireExactJSONObject(raw, "version", "records", "runs", "replies", "stories", "upgrades"); e != nil { - return nil, e - } - if e = json.Unmarshal(raw, &s.state); e != nil { - return nil, e - } - if s.state.Version != 1 || s.state.Records == nil || s.state.Runs == nil || s.state.Replies == nil || s.state.Stories == nil || s.state.Upgrades == nil { - return nil, fmt.Errorf("eventplay: incompatible save") - } - } - for _, r := range s.state.Records { - if r.UID == 0 || r.Game == 0 || r.Best > math.MaxInt32 || r.Clears == nil { - return nil, fmt.Errorf("eventplay: invalid saved record") - } - if _, e = s.design.Row("PackEventMiniGameTable", 8, r.Game); e != nil { - return nil, e - } - } - return s, nil -} -func num(b []byte, n int) uint64 { v, _, _ := wire.Varint(b, n); return v } - -// AttachHubCalendars installs project-maintained hub layouts, including the -// separate play/end windows and slots referencing multiple domain identities. -func (s *Service) AttachHubCalendars(seed *readonly.Seed) { - s.hubCalendars = seed -} -func list(b []byte, n int) ([]uint64, error) { - var out []uint64 - e := wire.Walk(b, func(f wire.Field) error { - if f.Number != n { - return nil - } - if f.Type == 0 { - v, _ := binary.Uvarint(f.Value) - out = append(out, v) - return nil - } - if f.Type != 2 { - return wire.ErrMalformed - } - for p := f.Value; len(p) > 0; { - v, k := binary.Uvarint(p) - if k <= 0 { - return wire.ErrMalformed - } - out = append(out, v) - p = p[k:] - } - return nil - }) - return out, e -} -func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { - return s.HandleSession(path, req, "local") -} -func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { - code, ok := codes[path] - if !ok { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - fail := func(e error) (int, []byte, bool, error) { return code, nil, true, e } - seq := num(req, 1) - if seq == 0 || seq > math.MaxInt32 { - return fail(fmt.Errorf("eventplay: invalid sequence")) - } - if e := wire.Walk(req, func(f wire.Field) error { - if f.Type == 0 { - v, _ := binary.Uvarint(f.Value) - if v > math.MaxInt64 { - return wire.ErrMalformed - } - } - return nil - }); e != nil { - return fail(e) - } - // Public hub calendars are derived afresh, never stored as account replies. - if path == "/EventHubInfo" || path == "/MiniEventHubInfo" || path == "/MiniGameHubInfo" { - if s.hubCalendars != nil && path == "/MiniGameHubInfo" { - return s.hubCalendars.Handle(path, req) - } - if s.hubCalendars != nil && path == "/EventHubInfo" { - out, err := s.publicEventHubs(req) - return code, out, true, err - } - if path == "/MiniEventHubInfo" { - out, err := s.miniEventHubs(req) - return code, out, true, err - } - return code, nil, true, nil - } - key := fmt.Sprintf("%s:%s:%d", session, path, seq) - if r, ok := s.state.Replies[key]; ok { - if !bytes.Equal(r.Request, req) { - return fail(fmt.Errorf("eventplay: changed request retry")) - } - return code, r.Body, true, nil - } - raw, _ := json.Marshal(s.state) - var next snapshot - _ = json.Unmarshal(raw, &next) - var out []byte - var e error - switch path { - case "/MiniGameUserRecordInfo", "/MiniGameRanking": - out = summaryWire(&next, path == "/MiniGameUserRecordInfo") - case "/MiniGameRelayServerInfo": - return fail(fmt.Errorf("eventplay: native relay channels are not configured")) - - case "/PackEventStoryInfo", "/PackEventBattleInfo": - uids, err := list(req, 2) - if err != nil { - return fail(err) - } - for _, uid := range uids { - c, err := s.registry.Resolve(uid) - if err != nil { - return fail(err) - } - if path == "/PackEventStoryInfo" { - for _, r := range s.design.Rows("PackEventStoryTable", 1, c.ID) { - id := num(r, 2) - if next.Stories[fmt.Sprintf("%d:%d:%d", uid, c.ID, id)] { - v := wire.AppendVarint(nil, 1, uid) - v = wire.AppendVarint(v, 2, c.ID) - v = wire.AppendVarint(v, 3, id) - out = wire.AppendBytes(out, 1, v) - } - } - } else { - for _, r := range s.design.Rows("PackEventBattleTable", 4, c.ID) { - id := num(r, 5) - if !next.Stories[fmt.Sprintf("battle:%d:%d:%d", uid, c.ID, id)] { - continue - } - v := wire.AppendVarint(nil, 1, uid) - v = wire.AppendVarint(v, 2, c.ID) - v = wire.AppendVarint(v, 3, id) - for _, idx := range s.battleChallengeIndexesLocked(uid, c.ID, id) { - v = wire.AppendVarint(v, 4, idx) - } - out = wire.AppendBytes(out, 1, v) - } - } - } - case "/PackEventStoryClear", "/PackEventStoryReplayClear": - uid, group, id := num(req, 2), num(req, 3), num(req, 4) - if path == "/PackEventStoryReplayClear" { - group, id = num(req, 2), num(req, 3) - uid = 0 - } - if uid != 0 { - c, err := s.registry.Resolve(uid) - if err != nil { - return fail(err) - } - if c.ID != group || s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End { - return fail(fmt.Errorf("eventplay: story schedule unavailable")) - } - } - r, err := s.design.Story(group, id) - if err != nil { - return fail(err) - } - storyKey := fmt.Sprintf("%d:%d:%d", uid, group, id) - if !next.Stories[storyKey] { - rs, err := gamedata.EventPlayRewards(r, 11, 10, 9) - if err != nil { - return fail(err) - } - out, e = s.grant("eventplay:story:"+storyKey, rs) - if e != nil { - return fail(e) - } - out = wire.AppendBytes(nil, 1, out) - next.Stories[storyKey] = true - } - if path == "/PackEventStoryClear" { - v := wire.AppendVarint(nil, 1, uid) - v = wire.AppendVarint(v, 2, group) - v = wire.AppendVarint(v, 3, id) - out = wire.AppendBytes(out, 2, v) - } - default: - out, e = s.game(path, req, session, &next, key) - } - if e != nil { - return fail(e) - } - next.Replies[key] = reply{append([]byte(nil), req...), out} - b, e := json.Marshal(next) - if e != nil { - return fail(e) - } - if e = s.store.Save("eventplay", b); e != nil { - return fail(e) - } - s.state = next - return code, out, true, nil -} -func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) { - rs := make([]gamedata.Reward, 0, len(rewards)) - for _, r := range rewards { - rs = append(rs, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - } - return s.economy.Apply(identity, nil, rs) -} -func recordWire(r record) []byte { - b := wire.AppendVarint(nil, 1, r.UID) - b = wire.AppendVarint(b, 2, r.Paid) - b = wire.AppendVarint(b, 3, r.Best) - if r.Best > 0 && r.Paid < r.Best { - b = wire.AppendVarint(b, 4, 1) - } - return b -} -func family(path string) string { - p := strings.TrimPrefix(path, "/MiniGame") - for _, f := range []string{"Hopscotch", "Survival", "Defense", "Rhythm", "Sichuan", "Action", "Field", "Run"} { - if strings.HasPrefix(p, f) { - return f - } - } - return "" -} -func recKey(uid, stage, mode uint64, f string) string { - return fmt.Sprintf("%d:%s:%d:%d", uid, f, stage, mode) -} -func (s *Service) calendar(uid uint64, f string) (events.Schedule, error) { - if uid == 0 { - for _, c := range s.registry.List() { - if c.Type == 11 { - d, e := s.design.Row("PackEventMiniGameTable", 8, c.ID) - if e != nil { - continue - } - kind := num(d, 3) - expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f] - if f == "Run" && (kind == 2 || kind == 4) { - expected = kind - } - if kind != expected { - continue - } - uid = c.UID - break - } - } - } - c, e := s.registry.Resolve(uid) - if e != nil { - return c, e - } - if c.Type != 11 { - return c, fmt.Errorf("eventplay: minigame schedule mismatch") - } - if _, e = s.design.Row("PackEventMiniGameTable", 8, c.ID); e != nil { - return c, e - } - d, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID) - kind := num(d, 3) - expected := map[string]uint64{"Field": 1, "Run": 3, "Survival": 5, "Sichuan": 6, "Defense": 7, "Action": 8, "Rhythm": 9, "Hopscotch": 12}[f] - if f == "Run" && (kind == 2 || kind == 4) { - expected = kind - } - if kind != expected { - return c, fmt.Errorf("eventplay: configured minigame family mismatch") - } - return c, nil -} -func (s *Service) game(path string, req []byte, session string, next *snapshot, key string) ([]byte, error) { - f := family(path) - if f == "" { - return nil, fmt.Errorf("eventplay: unknown family") - } - start := strings.HasSuffix(path, "Start") - end := strings.HasSuffix(path, "End") - runKey := session + ":" + f - uid := num(req, 2) - if end || path == "/MiniGameFieldScore" || path == "/MiniGameSurvivalPlay" || strings.Contains(path, "Skill") { - if a, ok := next.Runs[runKey]; ok { - uid = a.UID - } else { - return nil, fmt.Errorf("eventplay: no active run") - } - } - c, e := s.calendar(uid, f) - if e != nil { - return nil, e - } - uid = c.UID - stage, mode := num(req, 3), num(req, 4) - if f == "Rhythm" { - mode = num(req, 5) - } - if f == "Survival" { - stage = num(req, 5) - } - if f == "Sichuan" { - stage = num(req, 4) - } - rk := recKey(uid, stage, mode, f) - r := next.Records[rk] - if r.Clears == nil { - r = record{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Family: f, Clears: map[string]bool{}} - } - if path == "/MiniGameSurvivalCharUpgrade" || path == "/MiniGameSurvivalCharUpgradeReset" { - return s.upgrade(path, req, uid, next, key) - } - if path == "/MiniGameDefenseMatching" { - matching := num(req, 3) - if matching > 2 { - return nil, fmt.Errorf("eventplay: unknown matching mode") - } - next.Runs[runKey+":matching"] = Run{UID: uid, Game: c.ID, Family: f, Session: session} - return nil, nil - } - if start { - if s.now().UnixMilli() < c.Start || s.now().UnixMilli() >= c.End { - return nil, fmt.Errorf("eventplay: season closed") - } - if _, active := next.Runs[runKey]; active { - return nil, fmt.Errorf("eventplay: run already active") - } - if e = s.validateStage(f, stage, mode); e != nil { - return nil, e - } - a := Run{UID: uid, Game: c.ID, Stage: stage, Mode: mode, Started: uint64(s.now().UnixMilli()), Family: f, Session: session, HP: 100} - if f == "Action" { - game, _ := s.design.Row("PackEventMiniGameTable", 8, c.ID) - group := num(game, 4) - rows := s.design.Rows("ActionGameStageTable", 3, group) - if len(rows) == 0 { - return nil, fmt.Errorf("eventplay: action stage group missing") - } - a.Stage = num(rows[0], 4) - a.Mode = group - a.Char = num(rows[0], 1) - } - if f == "Action" || f == "Defense" { - if locked, ok := next.Runs[runKey+":native"]; ok { - a.Stage, a.Mode, a.Char = locked.Stage, locked.Mode, locked.Char - a.Session = locked.Session - if s.rooms != nil { - if e = s.rooms.ValidateRoom(session, a.Session, uid); e != nil { - return nil, e - } - } - } - } - var startReply []byte - if f == "Field" { - d, e := s.design.Field(c.ID) - if e != nil { - return nil, e - } - a.HP = d.HP - ids := make([]uint64, 0, len(d.Objects)) - for id := range d.Objects { - ids = append(ids, id) - } - slices.Sort(ids) - for _, id := range ids { - startReply = wire.AppendVarint(startReply, 1, id) - } - } - if f == "Survival" { - a.Char = num(req, 3) - a.MapGroup = num(req, 4) - char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char) - if e != nil { - return nil, e - } - a.HP = num(char, 20) - a.MaxHP = a.HP - a.Rerolls = num(char, 19) - a.Level = 1 - a.SkillCredits = 0 - a.Killed = map[uint64]uint64{} - a.Picked = map[uint64]uint64{} - a.Available = map[uint64]uint64{} - skillGroup := num(char, 3) - skills := s.design.Rows("FieldMiniGameSkillTable", 7, skillGroup) - if len(skills) == 0 { - return nil, fmt.Errorf("eventplay: character starting skill missing") - } - a.Skills = []uint64{num(skills[0], 8)} - a.SkillLevels = map[uint64]uint64{skillGroup: 1} - found := false - for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { - if num(m, 3) == a.Stage { - found = true - } - } - if !found { - return nil, fmt.Errorf("eventplay: survival map missing") - } - } - next.Runs[runKey] = a - if s.onProgress != nil { - if e = s.onProgress(343, c.ID, 1); e != nil { - return nil, e - } - if e = s.onProgress(315, c.ID, 1); e != nil { - return nil, e - } - } - next.Records[rk] = r - if f == "Survival" { - v := wire.AppendVarint(nil, 1, a.Skills[0]) - v = wire.AppendVarint(v, 2, 1) - out := wire.AppendBytes(nil, 1, v) - return wire.AppendVarint(out, 2, seqSeed(a.Started)), nil - } - return startReply, nil - } - if end { - a := next.Runs[runKey] - if a.UID != uid { - return nil, fmt.Errorf("eventplay: run schedule changed") - } - if f == "Action" && (num(req, 3) != a.Mode || num(req, 4) != a.Stage || num(req, 6) != a.Char) { - return nil, fmt.Errorf("eventplay: action submitted stage/monster differs from configured start") - } - if f == "Action" || f == "Defense" { - guid, _, err := wire.Bytes(req, 2) - if err != nil { - return nil, err - } - if a.Session != session && !bytes.Equal(guid, []byte(a.Session)) { - return nil, fmt.Errorf("eventplay: native room guid mismatch") - } - if s.rooms != nil { - if e = s.rooms.ValidateRoom(session, string(guid), uid); e != nil { - return nil, e - } - } - } - score, e := s.endScore(f, req, a) - if e != nil { - return nil, e - } - if s.onProgress != nil { - switch f { - case "Survival": - for condition, count := range map[uint64]uint64{316: a.Level, 317: score} { - if e = s.onProgress(condition, c.ID, count); e != nil { - return nil, e - } - } - if num(req, 2) == 0 { - if e = s.onProgress(320, a.Stage, 1); e != nil { - return nil, e - } - } - case "Defense": - if e = s.onProgress(322, c.ID, score); e != nil { - return nil, e - } - case "Action": - if e = s.onProgress(323, a.Char, 1); e != nil { - return nil, e - } - for condition, field := range map[uint64]int{326: 9, 327: 10, 329: 12, 330: 13, 333: 14, 334: 15, 335: 16, 336: 17} { - if count := num(req, field); count > 0 { - if e = s.onProgress(condition, a.Char, count); e != nil { - return nil, e - } - } - } - } - } - rk = recKey(uid, a.Stage, a.Mode, f) - r = next.Records[rk] - if r.Clears == nil { - r.Clears = map[string]bool{} - } - if score > r.Best { - r.Best = score - } - var out []byte - point, rewards, e := s.scoreRewards(c.ID, score) - if e != nil { - return nil, e - } - if f == "Defense" { - for _, row := range s.design.Tables["MGDRewardTable"] { - id, wave := num(row, 1), num(row, 6) - ck := fmt.Sprintf("wave:%d", id) - if wave <= score && !r.Clears[ck] { - rr, err := gamedata.EventPlayRewards(row, 5, 3, 2) - if err != nil { - return nil, err - } - b, err := s.grant("eventplay:"+key+":"+ck, rr) - if err != nil { - return nil, err - } - out = wire.AppendBytes(out, 1, b) - out = wire.AppendVarint(out, 2, id) - r.Clears[ck] = true - } - } - } - if f == "Survival" { - if num(req, 5) > a.Coin || num(req, 6) > a.Exp { - return nil, fmt.Errorf("eventplay: end coin/exp exceeds committed play progress") - } - out = wire.AppendVarint(out, 2, a.Coin) - out = wire.AppendVarint(out, 3, a.Exp) - if num(req, 2) == 0 { - r.Clears[fmt.Sprintf("map:%d:%d", a.MapGroup, a.Stage)] = true - for _, m := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { - if num(m, 3) != a.Stage { - continue - } - rr, err := gamedata.EventPlayRewards(m, 8, 7, 6) - if err != nil { - return nil, err - } - if len(rr) > 0 { - b, err := s.grant(fmt.Sprintf("eventplay:map:%d:%d:%d", uid, a.MapGroup, a.Stage), rr) - if err != nil { - return nil, err - } - out = wire.AppendBytes(out, 1, b) - } - } - } - if a.Coin > 0 { - b, err := s.grant("eventplay:"+key+":coin", []gamedata.BattleReward{{Type: 43, Count: a.Coin}}) - if err != nil { - return nil, err - } - out = wire.AppendBytes(out, 1, b) - } - } - day := uint64(s.now().UnixMilli()) / 86400000 - if r.Date != day { - r.Paid = 0 - } - if point > r.Paid { - _, previous, e := s.scoreRewards(c.ID, r.Paid) - if e != nil { - return nil, e - } - bundle, e := s.grant("eventplay:"+key, rewardDifference(previous, rewards)) - if e != nil { - return nil, e - } - field := 1 - if f == "Run" { //nolint:staticcheck // QF1003 - field = 3 - } else if f == "Field" { - field = 5 - } else if f == "Action" { - field = 3 - } else if f == "Hopscotch" { - field = 4 - } - if f == "Rhythm" { - r.PendingBundle = append(r.PendingBundle, bundle...) - } else { - out = wire.AppendBytes(out, field, bundle) - } - r.Paid = point - r.Date = day - } - switch f { - case "Run": - out = wire.AppendVarint(out, 1, r.Best) - out = wire.AppendVarint(out, 2, r.Paid) - case "Field": - out = wire.AppendVarint(out, 1, uid) - out = wire.AppendVarint(out, 2, score) - out = wire.AppendVarint(out, 3, r.Best) - out = wire.AppendVarint(out, 4, r.Paid) - case "Sichuan": - out = wire.AppendDouble(out, 4, float64(r.Best)) - case "Rhythm": - v := wire.AppendVarint(nil, 1, a.Stage) - v = wire.AppendVarint(v, 2, a.Mode) - v = wire.AppendVarint(v, 3, r.Best) - v = wire.AppendVarint(v, 4, num(req, 2)) - v = wire.AppendVarint(v, 5, num(req, 6)) - out = wire.AppendBytes(out, 1, v) - case "Action": - out = wire.AppendVarint(out, 1, r.Best) - case "Hopscotch": - v := wire.AppendVarint(nil, 1, a.Stage) - if score > 0 { - v = wire.AppendVarint(v, 2, 1) - } - out = wire.AppendBytes(out, 2, v) - } - next.Records[rk] = r - delete(next.Runs, runKey) - if s.rooms != nil && (f == "Action" || f == "Defense") { - if e = s.rooms.CompleteRoom(session, a.Session, score); e != nil { - return nil, e - } - } - return out, nil - } - if strings.Contains(path, "Ranking") || strings.Contains(path, "RecordInfo") { - return rankingWire(next, uid, f, strings.Contains(path, "RecordInfo")), nil - } - if strings.HasSuffix(path, "Info") { - var out []byte - if f == "Rhythm" { - for k, v := range next.Records { - if v.UID == uid && v.Family == f && len(v.PendingBundle) > 0 { - out = wire.AppendBytes(out, 2, v.PendingBundle) - v.PendingBundle = nil - next.Records[k] = v - } - } - } - if f != "Run" && f != "Field" { - if _, exists := next.Records[rk]; !exists { - next.Records[rk] = r - } - return append(out, specializedInfo(next, uid, f)...), nil - } - for _, v := range next.Records { - if v.UID == uid && v.Family == f { - out = wire.AppendBytes(out, 1, recordWire(v)) - } - } - if len(out) == 0 { - next.Records[rk] = r - out = wire.AppendBytes(out, 1, recordWire(r)) - } - return out, nil - } - if strings.Contains(path, "QuickReward") || path == "/MiniGameFieldReward" { - return s.claimScore(path, uid, f, next, key) - } - return s.progress(path, req, runKey, next, key) -} diff --git a/go/internal/server/eventplay/play_test.go b/go/internal/server/eventplay/play_test.go deleted file mode 100644 index 7c6727d..0000000 --- a/go/internal/server/eventplay/play_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/binary" - "testing" - "time" -) - -type econ struct{ cost, granted uint64 } - -func (e *econ) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { - for _, x := range c { - e.cost += x.Count - } - for _, x := range r { - e.granted += x.Count - } - return wire.AppendVarint(nil, 1, 99), nil -} -func makeService(t *testing.T) (*Service, *econ) { - t.Helper() - reg := events.NewRegistry() - if e := reg.Replace([]events.Schedule{{UID: 7, Type: 9, ID: 1, Start: 1, End: 9999999}, {UID: 8, Type: 10, ID: 1, Start: 1, End: 9999999}, {UID: 9, Type: 11, ID: 1, Start: 1, End: 9999999}}); e != nil { - t.Fatal(e) - } - e := &econ{} - s := &Service{store: stateio.NewMemory(), registry: reg, economy: e, now: func() time.Time { return time.UnixMilli(100) }, design: &gamedata.EventPlayCatalog{Tables: map[string][][]byte{}}, state: snapshot{1, map[string]record{}, map[string]Run{}, map[string]reply{}, map[string]bool{}, map[string]uint64{}}} - return s, e -} -func TestBattleRewardFieldFiveClearInfoSixteenAndRetry(t *testing.T) { - s, e := makeService(t) - row := wire.AppendVarint(nil, 4, 1) - row = wire.AppendVarint(row, 5, 1) - row = wire.AppendVarint(row, 1, 88) - row = wire.AppendVarint(row, 3, 1) - row = wire.AppendVarint(row, 9, 4) - row = wire.AppendVarint(row, 8, 50) - s.design.Tables["PackEventBattleTable"] = [][]byte{row} - req := wire.AppendVarint(nil, 5, 17) - req = wire.AppendVarint(req, 8, 1) - req = wire.AppendVarint(req, 9, 1) - req = wire.AppendVarint(req, 4, 88) - if _, err := s.EnterBattle(req, "sid:1"); err != nil { - t.Fatal(err) - } - end := wire.AppendVarint(nil, 2, 1) - a, err := s.CompleteBattle(end, "sid:1") - if err != nil { - t.Fatal(err) - } - if _, ok, _ := wire.Bytes(a, 5); !ok { - t.Fatal("reward_bundle absent field5") - } - if _, ok, _ := wire.Bytes(a, 3); ok { - t.Fatal("bundle polluted CharInfo field3") - } - if _, ok, _ := wire.Bytes(a, 16); !ok { - t.Fatal("clear info absent") - } - b, err := s.CompleteBattle(end, "sid:1") - if err != nil || !bytes.Equal(a, b) || e.cost != 1 || e.granted != 50 { - t.Fatalf("retry %v cost%d grant%d", err, e.cost, e.granted) - } -} -func TestStoryClearPersistentOnceAndChangedRetry(t *testing.T) { - s, e := makeService(t) - r := wire.AppendVarint(nil, 1, 1) - r = wire.AppendVarint(r, 2, 1) - r = wire.AppendVarint(r, 11, 4) - r = wire.AppendVarint(r, 9, 10) - s.design.Tables["PackEventStoryTable"] = [][]byte{r} - req := wire.AppendVarint(nil, 1, 1) - req = wire.AppendVarint(req, 2, 8) - req = wire.AppendVarint(req, 3, 1) - req = wire.AppendVarint(req, 4, 1) - if _, _, _, err := s.HandleSession("/PackEventStoryClear", req, "sid"); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.HandleSession("/PackEventStoryClear", req, "sid"); err != nil { - t.Fatal(err) - } - if e.granted != 10 { - t.Fatal("duplicate story award") - } - bad := wire.AppendVarint(req, 9, 1) - if _, _, _, err := s.HandleSession("/PackEventStoryClear", bad, "sid"); err == nil { - t.Fatal("changed retry accepted") - } -} -func TestScoreRewardUpgradeDifference(t *testing.T) { - r := rewardDifference([]gamedata.BattleReward{{Type: 4, Count: 10}}, []gamedata.BattleReward{{Type: 4, Count: 15}}) - if len(r) != 1 || r[0].Count != 5 { - t.Fatal(r) - } -} - -func TestEndScoreWireFieldsAcrossFamilies(t *testing.T) { - s, _ := makeService(t) - for _, tc := range []struct { - family string - field int - value uint64 - }{{"Run", 3, 20}, {"Action", 7, 30}, {"Sichuan", 4, 40}, {"Hopscotch", 2, 50}, {"Defense", 3, 60}} { - got, e := s.endScore(tc.family, wire.AppendVarint(nil, tc.field, tc.value), Run{}) - if e != nil || got != tc.value { - t.Fatalf("%s score=%d err%v", tc.family, got, e) - } - } - if _, e := s.endScore("Hopscotch", wire.AppendVarint(nil, 2, 10001), Run{}); e == nil { - t.Fatal("captured area accepted beyond100percent") - } -} -func TestRhythmEndScoreRejectsStaticScoreAndJudgmentOverflow(t *testing.T) { - s, _ := makeService(t) - row := wire.AppendVarint(nil, 5, 1) - row = wire.AppendVarint(row, 8, 1000) - row = wire.AppendVarint(row, 10, 3) - s.design.Tables["RhythmGameMusicTable"] = [][]byte{row} - if _, e := s.endScore("Rhythm", wire.AppendVarint(nil, 4, 1001), Run{Stage: 1}); e == nil { - t.Fatal("score above staticmax accepted") - } - judgment := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 4) - req := wire.AppendVarint(nil, 4, 500) - req = wire.AppendBytes(req, 3, judgment) - if _, e := s.endScore("Rhythm", req, Run{Stage: 1}); e == nil { - t.Fatal("more judgments than designed notes accepted") - } -} - -func TestEventBattleChallengesPersistAndAwardOnce(t *testing.T) { - s, e := makeService(t) - row := wire.AppendVarint(nil, 4, 1) - row = wire.AppendVarint(row, 5, 1) - row = wire.AppendVarint(row, 1, 88) - row = wire.AppendVarint(row, 3, 1) - row = wire.AppendVarint(row, 9, 4) - row = wire.AppendVarint(row, 8, 5) - s.design.Tables["PackEventBattleTable"] = [][]byte{row} - s.AttachBattleChallenges(gamedata.EventBattleChallenges{88: {{Type: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}, {Type: 2, Value1: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}}}) - start := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 5, 17), 8, 1), 9, 1), 4, 88) - if _, err := s.EnterBattle(start, "sid:first"); err != nil { - t.Fatal(err) - } - end := wire.AppendVarint(nil, 2, 1) - end = wire.AppendVarint(end, 5, 0) - body, err := s.CompleteBattle(end, "sid:first") - if err != nil { - t.Fatal(err) - } - info, _, _ := wire.Bytes(body, 16) - indexes, err := challengeTestIndexes(info) - if err != nil || len(indexes) != 1 || indexes[0] != 0 || e.granted != 30 { - t.Fatalf("challenge field16=%x reward%d err%v", info, e.granted, err) - } - if _, err = s.EnterBattle(start, "sid:second"); err != nil { - t.Fatal(err) - } - end = wire.AppendVarint(end, 5, 1) - body, err = s.CompleteBattle(end, "sid:second") - if err != nil { - t.Fatal(err) - } - info, _, _ = wire.Bytes(body, 16) - indexes, _ = challengeTestIndexes(info) - if len(indexes) != 2 || e.granted != 60 { - t.Fatalf("cumulative indexes %+v grant%d", indexes, e.granted) - } - if _, err = s.EnterBattle(start, "sid:third"); err != nil { - t.Fatal(err) - } - if _, err = s.CompleteBattle(end, "sid:third"); err != nil { - t.Fatal(err) - } - if e.granted != 65 { - t.Fatal("challenge reward paid twice") - } -} -func TestEventBattleChallengeUnknownAndDuplicateRejectedBeforeCost(t *testing.T) { - s, e := makeService(t) - row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 4, 1), 5, 1), 1, 88) - s.design.Tables["PackEventBattleTable"] = [][]byte{row} - s.AttachBattleChallenges(gamedata.EventBattleChallenges{88: {{Type: 8, Reward: gamedata.BattleReward{Type: 3, Count: 25}}}}) - start := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 5, 17), 8, 1), 9, 1), 4, 88) - if _, err := s.EnterBattle(start, "sid:x"); err != nil { - t.Fatal(err) - } - bad := wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 5, 1) - if _, err := s.CompleteBattle(bad, "sid:x"); err == nil { - t.Fatal("unknownchallenge") - } - bad = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 5, 0), 5, 0) - if _, err := s.CompleteBattle(bad, "sid:x"); err == nil { - t.Fatal("duplicatechallenge") - } - if e.cost != 0 || e.granted != 0 { - t.Fatal("badchallenge mutated economic state") - } -} - -func challengeTestIndexes(b []byte) ([]uint64, error) { - var out []uint64 - err := wire.Walk(b, func(f wire.Field) error { - if f.Number == 4 { - v, _, e := wire.Varint(wire.AppendVarint(nil, 4, numFieldTest(f)), 4) - if e != nil { - return e - } - out = append(out, v) - } - return nil - }) - return out, err -} -func numFieldTest(f wire.Field) uint64 { v, _ := binary.Uvarint(f.Value); return v } diff --git a/go/internal/server/eventplay/progress.go b/go/internal/server/eventplay/progress.go deleted file mode 100644 index f1b42a2..0000000 --- a/go/internal/server/eventplay/progress.go +++ /dev/null @@ -1,35 +0,0 @@ -package eventplay - -import "bd2server/internal/server/events" - -func (s *Service) AttachProgress(fn func(condition, sub, count uint64) error) { s.onProgress = fn } -func (s *Service) AssociatedMissionGroup(c events.Schedule) (uint64, error) { - if c.Type != 11 { - return 0, nil - } - game, e := s.design.Row("PackEventMiniGameTable", 8, c.ID) - if e != nil { - return 0, e - } - switch num(game, 3) { - case 5: - row, e := s.design.Row("FieldMiniGameSurvivalTable", 9, num(game, 4)) - if e != nil { - return 0, e - } - return num(row, 8), nil - case 7: - if len(s.design.Tables["MGDDefaultTable"]) > 0 { - return num(s.design.Tables["MGDDefaultTable"][0], 5), nil - } - case 8: - if len(s.design.Tables["ActionGameDefaultTable"]) > 0 { - return num(s.design.Tables["ActionGameDefaultTable"][0], 8), nil - } - case 12: - if len(s.design.Tables["HopscotchDefaultTable"]) > 0 { - return num(s.design.Tables["HopscotchDefaultTable"][0], 20), nil - } - } - return 0, nil -} diff --git a/go/internal/server/eventplay/projection.go b/go/internal/server/eventplay/projection.go deleted file mode 100644 index c34b430..0000000 --- a/go/internal/server/eventplay/projection.go +++ /dev/null @@ -1,162 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/wire" - "fmt" - "sort" - "strings" -) - -func specializedInfo(st *snapshot, uid uint64, f string) []byte { - var out []byte - if f == "Action" || f == "Survival" || f == "Defense" { - out = wire.AppendVarint(out, 1, uid) - } - for _, r := range st.Records { - if r.UID != uid || r.Family != f { - continue - } - switch f { - case "Sichuan": - v := wire.AppendVarint(nil, 1, uid) - v = wire.AppendVarint(v, 6, r.Best) - out = wire.AppendBytes(out, 1, v) - case "Rhythm": - v := wire.AppendVarint(nil, 1, r.Stage) - v = wire.AppendVarint(v, 2, r.Mode) - v = wire.AppendVarint(v, 3, r.Best) - out = wire.AppendBytes(out, 1, v) - case "Action": - v := wire.AppendVarint(nil, 1, r.Stage) - v = wire.AppendVarint(v, 2, r.Best) - out = wire.AppendBytes(out, 2, v) - case "Hopscotch": - v := wire.AppendVarint(nil, 1, r.Stage) - if r.Best > 0 { - v = wire.AppendVarint(v, 2, 1) - } - out = wire.AppendBytes(out, 2, v) - case "Survival": - out = wire.AppendVarint(out, 4, r.Best) - for stage, clear := range r.Clears { - if !clear { - continue - } - var group, id uint64 - if _, e := fmt.Sscanf(stage, "map:%d:%d", &group, &id); e == nil { - v := wire.AppendVarint(nil, 1, group) - v = wire.AppendVarint(v, 2, id) - out = wire.AppendBytes(out, 8, v) - } - } - } - } - if f == "Survival" { - prefix := fmt.Sprintf("%d:", uid) - for key, level := range st.Upgrades { - if !strings.HasPrefix(key, prefix) { - continue - } - var id uint64 - _, _ = fmt.Sscanf(key[len(prefix):], "%d", &id) - v := wire.AppendVarint(nil, 1, id) - v = wire.AppendVarint(v, 2, level) - out = wire.AppendBytes(out, 10, v) - } - } - return out -} -func rankingWire(st *snapshot, uid uint64, f string, record bool) []byte { - keys := []string{} - for k, r := range st.Records { - if r.UID == uid && r.Family == f && r.Best > 0 { - keys = append(keys, k) - } - } - sort.Strings(keys) - var out []byte - for _, k := range keys { - r := st.Records[k] - var b []byte - switch f { - case "Survival": - b = wire.AppendVarint(nil, 1, 1) - b = wire.AppendVarint(b, 4, r.Best) - if record { - b = wire.AppendVarint(nil, 1, r.Best) - b = wire.AppendDouble(b, 2, 100) - } - case "Hopscotch": - b = wire.AppendVarint(nil, 1, 1) - b = wire.AppendVarint(b, 4, r.Best) - if record { - b = wire.AppendVarint(nil, 1, r.Stage) - b = wire.AppendVarint(b, 2, r.Best) - b = wire.AppendDouble(b, 4, 100) - } - case "Action": - b = wire.AppendVarint(nil, 3, 1) - b = wire.AppendVarint(b, 4, r.Best) - if record { - b = wire.AppendVarint(nil, 1, r.Stage) - b = wire.AppendVarint(b, 2, r.Best) - b = wire.AppendDouble(b, 3, 100) - } - case "Sichuan": - b = wire.AppendVarint(nil, 7, 1) - b = wire.AppendVarint(b, 8, r.Best) - if record { - b = wire.AppendVarint(nil, 1, r.Best) - b = wire.AppendDouble(b, 2, 100) - } - case "Rhythm": - b = wire.AppendVarint(nil, 1, 1) - b = wire.AppendVarint(b, 4, r.Best) - if record { - b = wire.AppendVarint(nil, 1, r.Best) - b = wire.AppendVarint(b, 2, r.Stage) - b = wire.AppendDouble(b, 3, 100) - } - default: - b = wire.AppendVarint(nil, 1, 1) - b = wire.AppendVarint(b, 4, r.Best) - } - out = wire.AppendBytes(out, 1, b) - } - return out -} -func (s *Service) claimScore(path string, uid uint64, f string, next *snapshot, key string) ([]byte, error) { - for k, r := range next.Records { - if r.UID != uid || r.Family != f || r.Best == 0 { - continue - } - point, rewards, e := s.scoreRewards(r.Game, r.Best) - if e != nil { - return nil, e - } - day := uint64(s.now().UnixMilli()) / 86400000 - if r.Date != day { - r.Paid = 0 - } - if point <= r.Paid { - return nil, fmt.Errorf("eventplay: daily best reward already paid") - } - _, old, e := s.scoreRewards(r.Game, r.Paid) - if e != nil { - return nil, e - } - bundle, e := s.grant("eventplay:"+key, rewardDifference(old, rewards)) - if e != nil { - return nil, e - } - r.Paid = point - r.Date = day - next.Records[k] = r - if strings.HasSuffix(path, "QuickReward") { - return wire.AppendBytes(nil, 1, bundle), nil - } - out := wire.AppendVarint(nil, 1, point) - return wire.AppendBytes(out, 2, bundle), nil - } - return nil, fmt.Errorf("eventplay: no completed score to reward") -} diff --git a/go/internal/server/eventplay/room.go b/go/internal/server/eventplay/room.go deleted file mode 100644 index b15e8fc..0000000 --- a/go/internal/server/eventplay/room.go +++ /dev/null @@ -1,39 +0,0 @@ -package eventplay - -import ( - "encoding/json" - "fmt" -) - -// RoomRuntime is implemented by the native matching/relay service. HTTP -// gameplay settles only runs whose native room is authenticated. -type RoomRuntime interface { - ValidateRoom(session, guid string, uid uint64) error - CompleteRoom(session, guid string, score uint64) error -} - -func (s *Service) AttachRooms(r RoomRuntime) { s.rooms = r } - -// LockRoomRun installs the stage selected by native room matching before the -// client's HTTP Start. It does not award rewards or trust HTTP stage changes. -func (s *Service) LockRoomRun(session, family, guid string, uid, group, stage, monster uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - if family != "Defense" && family != "Action" { - return fmt.Errorf("eventplay: invalid room family") - } - c, e := s.calendar(uid, family) - if e != nil { - return e - } - raw, _ := json.Marshal(s.state) - var next snapshot - _ = json.Unmarshal(raw, &next) - next.Runs[session+":"+family+":native"] = Run{UID: uid, Game: c.ID, Stage: stage, Mode: group, Char: monster, Family: family, Session: guid} - raw, _ = json.Marshal(next) - if e = s.store.Save("eventplay", raw); e != nil { - return e - } - s.state = next - return nil -} diff --git a/go/internal/server/eventplay/runtime.go b/go/internal/server/eventplay/runtime.go deleted file mode 100644 index 3824895..0000000 --- a/go/internal/server/eventplay/runtime.go +++ /dev/null @@ -1,233 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "fmt" - "math" - "slices" - "strings" -) - -func (s *Service) validateStage(f string, id, mode uint64) error { - switch f { - case "Rhythm": - r, e := s.design.Row("RhythmGameMusicTable", 5, id) - if e != nil { - return e - } - if mode > 2 { - return fmt.Errorf("eventplay: unknown rhythm mode") - } - _ = r - case "Hopscotch": - _, e := s.design.Row("HopscotchStageTable", 6, id) - return e - case "Sichuan": - _, e := s.design.Row("SichuanStageTable", 2, id) - return e - case "Survival": - if len(s.design.Tables["FieldMiniGameSurvivalTable"]) == 0 { - return fmt.Errorf("eventplay: survival design missing") - } - case "Action": - if len(s.design.Tables["ActionGameDefaultTable"]) == 0 { - return fmt.Errorf("eventplay: action design missing") - } - } - return nil -} -func (s *Service) endScore(f string, req []byte, a Run) (uint64, error) { - var score uint64 - switch f { - case "Run": - score = num(req, 3) - case "Field": - score = a.Score - case "Action": - score = num(req, 7) - case "Rhythm": - score = num(req, 4) - r, e := s.design.Row("RhythmGameMusicTable", 5, a.Stage) - if e != nil { - return 0, e - } - maxField := 8 - if a.Mode == 1 { //nolint:staticcheck // QF1003 - maxField = 2 - } else if a.Mode == 2 { - maxField = 1 - } - if score > num(r, maxField) { - return 0, fmt.Errorf("eventplay: rhythm score exceeds static maximum") - } - judgments := uint64(0) - err := wire.Walk(req, func(f wire.Field) error { - if f.Number == 3 { - if f.Type != 2 { - return wire.ErrMalformed - } - typ, count := num(f.Value, 1), num(f.Value, 2) - if typ > 10 || count > math.MaxInt32 { - return fmt.Errorf("eventplay: invalid note judgment") - } - judgments += count - } - return nil - }) - if err != nil { - return 0, err - } - noteField := 10 - if a.Mode > 0 { - noteField = 4 - } - if judgments > num(r, noteField) { - return 0, fmt.Errorf("eventplay: judgment count exceeds designed notes") - } - case "Sichuan": - score = num(req, 4) - case "Hopscotch": - score = num(req, 2) - if score > 10000 { - return 0, fmt.Errorf("eventplay: captured area exceeds 100 percent") - } - case "Survival": - score = num(req, 3) - d := s.design.Tables["FieldMiniGameSurvivalTable"][0] - if score > num(d, 24) { - return 0, fmt.Errorf("eventplay: survival time exceeds design") - } - case "Defense": - score = num(req, 3) - } - if score > math.MaxInt32 { - return 0, fmt.Errorf("eventplay: submitted score exceeds protocol range") - } - return score, nil -} -func (s *Service) scoreRewards(game, score uint64) (uint64, []gamedata.BattleReward, error) { - d, e := s.design.Row("PackEventMiniGameTable", 8, game) - if e != nil { - return 0, nil, e - } - return s.design.ScoreRewards(num(d, 10), score) -} -func (s *Service) progress(path string, req []byte, rk string, next *snapshot, key string) ([]byte, error) { - a, ok := next.Runs[rk] - if !ok { - return nil, fmt.Errorf("eventplay: no active run") - } - var out []byte - switch path { - case "/MiniGameFieldScore": - d, e := s.design.Field(a.Game) - if e != nil { - return nil, e - } - ids, e := list(req, 3) - if e != nil || len(ids) == 0 { - return nil, fmt.Errorf("eventplay: missing field objects") - } - for _, id := range ids { - v, ok := d.Objects[id] - if !ok || has(a.Objects, id) { - return nil, fmt.Errorf("eventplay: object invalid or already scored") - } - a.Objects = append(a.Objects, id) - if v.Type == 1 { //nolint:staticcheck // QF1003 - a.Score += v.Point - } else if v.Type == 2 { - a.HP += v.Point - } - } - out = wire.AppendVarint(nil, 1, a.Score) - out = wire.AppendVarint(out, 2, a.HP) - case "/MiniGameSurvivalPlay": - exp, coin, e := s.survivalProgress(req, &a) - if e != nil { - return nil, e - } - if s.onProgress != nil { - total := uint64(0) - for id, count := range a.Killed { - previous := next.Runs[rk].Killed[id] - if count > previous { - total += count - previous - } - } - if total > 0 { - if e = s.onProgress(319, a.Game, total); e != nil { - return nil, e - } - } - } - out = wire.AppendVarint(nil, 1, coin) - out = wire.AppendVarint(out, 2, exp) - case "/MiniGameSurvivalSkillSelectList": - var e error - out, e = s.survivalOffers(&a) - if e != nil { - return nil, e - } - case "/MiniGameSurvivalSkillUp": - id := num(req, 2) - if a.SkillCredits == 0 || !has(a.Offers, id) { - return nil, fmt.Errorf("eventplay: skill not earned or offered") - } - group, err := s.design.Row("FieldMiniGameSkillGroupTable", 1, id) - if err != nil { - return nil, fmt.Errorf("eventplay: invalid skill") - } - if item := num(req, 3); item != 0 { - if _, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, item); e != nil { - return nil, e - } - } - if a.SkillLevels == nil { - a.SkillLevels = map[uint64]uint64{} - } - level := a.SkillLevels[id] + 1 - if level > num(group, 2) { - return nil, fmt.Errorf("eventplay: survival skill at maximum") - } - capacity := uint64(0) - for _, r := range s.design.Tables["FieldMiniGameSurvivalTable"] { - if num(r, 21) > capacity { - capacity = num(r, 21) - } - } - if !has(a.Skills, id) && uint64(len(a.SkillLevels)) >= capacity { - return nil, fmt.Errorf("eventplay: survival skill capacity reached") - } - a.SkillLevels[id] = level - a.SkillCredits-- - a.Offers = nil - if level == num(group, 2) && s.onProgress != nil { - if e := s.onProgress(318, a.Game, 1); e != nil { - return nil, e - } - if e := s.onProgress(321, id, 1); e != nil { - return nil, e - } - } - if !has(a.Skills, id) { - a.Skills = append(a.Skills, id) - } - v := wire.AppendVarint(nil, 1, id) - v = wire.AppendVarint(v, 2, level) - out = wire.AppendBytes(nil, 1, v) - default: - if strings.Contains(path, "QuickReward") || strings.Contains(path, "Reward") { - return nil, fmt.Errorf("eventplay: reward requires a completed unpaid score record") - } - return nil, fmt.Errorf("eventplay: unsupported run transition") - } - next.Runs[rk] = a - _ = key - return out, nil -} -func has(a []uint64, id uint64) bool { - return slices.Contains(a, id) -} -func seqSeed(n uint64) uint64 { return n & 2147483647 } diff --git a/go/internal/server/eventplay/summary.go b/go/internal/server/eventplay/summary.go deleted file mode 100644 index c24ace9..0000000 --- a/go/internal/server/eventplay/summary.go +++ /dev/null @@ -1,64 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/wire" - "sort" -) - -func summaryWire(st *snapshot, record bool) []byte { - keys := []string{} - for k, v := range st.Records { - if v.Best > 0 { - keys = append(keys, k) - } - } - sort.Strings(keys) - var out []byte - for _, k := range keys { - v := st.Records[k] - field := map[string]int{"Survival": 1, "Sichuan": 2, "Action": 3, "Rhythm": 5, "Hopscotch": 6}[v.Family] - if field == 0 { - continue - } - var b []byte - switch v.Family { - case "Survival": - b = wire.AppendVarint(nil, 2, v.Best) - case "Sichuan": - b = wire.AppendDouble(nil, 2, float64(v.Best)) - case "Action": - b = wire.AppendVarint(nil, 2, v.Best) - b = wire.AppendVarint(b, 3, v.Stage) - case "Rhythm": - b = wire.AppendVarint(nil, 2, v.Best) - b = wire.AppendVarint(b, 3, v.Stage) - case "Hopscotch": - b = wire.AppendVarint(nil, 1, v.Stage) - b = wire.AppendVarint(b, 3, v.Best) - } - if record { - switch v.Family { - case "Survival": - b = wire.AppendVarint(nil, 1, v.Best) - b = wire.AppendDouble(b, 2, 100) - case "Sichuan": - b = wire.AppendDouble(nil, 1, float64(v.Best)) - b = wire.AppendDouble(b, 2, 100) - case "Action": - b = wire.AppendVarint(nil, 1, v.Stage) - b = wire.AppendVarint(b, 2, v.Best) - b = wire.AppendDouble(b, 3, 100) - case "Rhythm": - b = wire.AppendVarint(nil, 1, v.Best) - b = wire.AppendVarint(b, 2, v.Stage) - b = wire.AppendDouble(b, 3, 100) - case "Hopscotch": - b = wire.AppendVarint(nil, 1, v.Stage) - b = wire.AppendVarint(b, 2, v.Best) - b = wire.AppendDouble(b, 4, 100) - } - } - out = wire.AppendBytes(out, field, b) - } - return out -} diff --git a/go/internal/server/eventplay/survival.go b/go/internal/server/eventplay/survival.go deleted file mode 100644 index 8702775..0000000 --- a/go/internal/server/eventplay/survival.go +++ /dev/null @@ -1,219 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/wire" - "encoding/binary" - "fmt" - "math" - "sort" -) - -func (s *Service) survivalProgress(req []byte, a *Run) (uint64, uint64, error) { - char, e := s.design.Row("FieldMiniGameCharTable", 13, a.Char) - if e != nil { - return 0, 0, e - } - var mapRow []byte - for _, r := range s.design.Rows("FieldMiniGameMapTable", 2, a.MapGroup) { - if num(r, 3) == a.Stage { - mapRow = r - } - } - if mapRow == nil { - return 0, 0, fmt.Errorf("eventplay: survival map missing") - } - monsters := s.design.Rows("FieldMiniGameMonsterTable", 2, num(mapRow, 4)) - if a.Killed == nil { - a.Killed = map[uint64]uint64{} - } - if a.Picked == nil { - a.Picked = map[uint64]uint64{} - } - if a.Available == nil { - a.Available = map[uint64]uint64{} - } - elapsed := uint64(0) - if uint64(s.now().UnixMilli()) > a.Started { - elapsed = (uint64(s.now().UnixMilli()) - a.Started) / 1000 - } - e = wire.Walk(req, func(f wire.Field) error { - if f.Number != 6 { - return nil - } - if f.Type != 2 { - return wire.ErrMalformed - } - id, count := num(f.Value, 1), num(f.Value, 2) - if count == 0 { - return fmt.Errorf("eventplay: empty kill count") - } - var row []byte - for _, r := range monsters { - if num(r, 5) == id { - row = r - break - } - } - if row == nil { - return fmt.Errorf("eventplay: monster outside survival map") - } - max := num(row, 12) - repeats := num(row, 11) - start := num(row, 16) - interval := num(row, 13) - if elapsed < start { - return fmt.Errorf("eventplay: monster not spawned yet") - } - waves := uint64(1) - if interval > 0 { - waves += (elapsed - start) / interval - } - if repeats > 0 && waves > repeats { - waves = repeats - } - if max == 0 || a.Killed[id] > max*waves || count > max*waves-a.Killed[id] { - return fmt.Errorf("eventplay: kill count exceeds designed spawns") - } - a.Killed[id] += count - box := num(row, 6) - if box != 0 { - items, err := s.boxItems(box) - if err != nil { - return err - } - for _, item := range items { - a.Available[item] += count - } - } - return nil - }) - if e != nil { - return 0, 0, e - } - exp, coin := uint64(0), uint64(0) - healing := uint64(0) - e = wire.Walk(req, func(f wire.Field) error { - if f.Number != 5 { - return nil - } - if f.Type != 2 { - return wire.ErrMalformed - } - id, count := num(f.Value, 1), num(f.Value, 2) - if count == 0 || count > a.Available[id] { - return fmt.Errorf("eventplay: item count exceeds committed drops") - } - row, e := s.design.Row("FieldMiniGameSurvivalItemTable", 1, id) - if e != nil { - return e - } - v := num(row, 4) - if count > math.MaxInt32 || v > math.MaxInt32/count { - return fmt.Errorf("eventplay: item value overflow") - } - a.Available[id] -= count - a.Picked[id] += count - switch num(row, 3) { - case 0: - healing += v * count - case 3: - exp += uint64(math.Floor(float64(v*count) * doubleField(char, 8, 1))) - case 4: - coin += uint64(math.Floor(float64(v*count) * doubleField(char, 10, 1))) - case 5: - a.SkillCredits += count - } - return nil - }) - if e != nil { - return 0, 0, e - } - boxes, e := list(req, 7) - if e != nil { - return 0, 0, e - } - for _, id := range boxes { - if _, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id); e != nil { - return 0, 0, e - } - } - hp := num(req, 2) - if hp > a.MaxHP || hp > a.HP+healing { - return 0, 0, fmt.Errorf("eventplay: invalid survival HP") - } - a.HP = hp - a.Exp += exp - a.Coin += coin - a.LevelExp += exp - if a.Exp > math.MaxInt32 || a.Coin > math.MaxInt32 { - return 0, 0, fmt.Errorf("eventplay: survival progress exceeds protocol range") - } - growth := num(char, 2) - for { - var current, next []byte - for _, r := range s.design.Rows("FieldMiniGameCharLevelTable", 1, growth) { - if num(r, 2) == a.Level { - current = r - } - if num(r, 2) == a.Level+1 { - next = r - } - } - if current == nil || next == nil || num(current, 3) == 0 || a.LevelExp < num(current, 3) { - break - } - a.LevelExp -= num(current, 3) - a.Level++ - a.SkillCredits++ - } - return exp, coin, nil -} -func doubleField(b []byte, n int, def float64) float64 { - v := def - _ = wire.Walk(b, func(f wire.Field) error { - if f.Number == n && f.Type == 1 { - v = math.Float64frombits(binary.LittleEndian.Uint64(f.Value)) - } - return nil - }) - return v -} -func (s *Service) boxItems(id uint64) ([]uint64, error) { - r, e := s.design.Row("FieldMiniGameSurvivalBoxTable", 2, id) - if e != nil { - return nil, e - } - values, e := list(r, 3) - if e != nil { - return nil, e - } - return values, nil -} -func (s *Service) survivalOffers(a *Run) ([]byte, error) { - if a.SkillCredits == 0 { - return nil, fmt.Errorf("eventplay: no earned skill selection") - } - if len(a.Offers) > 0 { - if a.Rerolls == 0 { - return nil, fmt.Errorf("eventplay: no remaining rerolls") - } - a.Rerolls-- - } - a.Offers = nil - rows := append([][]byte(nil), s.design.Tables["FieldMiniGameSkillGroupTable"]...) - sort.Slice(rows, func(i, j int) bool { return num(rows[i], 1) < num(rows[j], 1) }) - for _, r := range rows { - id := num(r, 1) - if a.SkillLevels[id] < num(r, 2) { - a.Offers = append(a.Offers, id) - if len(a.Offers) == 3 { - break - } - } - } - out := wire.AppendVarint(nil, 1, a.Rerolls) - for _, id := range a.Offers { - out = wire.AppendVarint(out, 2, id) - } - return out, nil -} diff --git a/go/internal/server/eventplay/survival_test.go b/go/internal/server/eventplay/survival_test.go deleted file mode 100644 index 275aaf4..0000000 --- a/go/internal/server/eventplay/survival_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/wire" - "testing" -) - -func row(fields map[int]uint64) []byte { - var b []byte - for n, v := range fields { - b = wire.AppendVarint(b, n, v) - } - return b -} -func TestSurvivalProgressDerivesLootAndRejectsUnspawnedKills(t *testing.T) { - s, _ := makeService(t) - s.design.Tables["FieldMiniGameCharTable"] = [][]byte{row(map[int]uint64{13: 1, 2: 1})} - s.design.Tables["FieldMiniGameMapTable"] = [][]byte{row(map[int]uint64{2: 1, 3: 1, 4: 1})} - s.design.Tables["FieldMiniGameMonsterTable"] = [][]byte{row(map[int]uint64{2: 1, 5: 1, 12: 1, 6: 1})} - box := row(map[int]uint64{2: 1}) - box = wire.AppendVarint(box, 3, 3) - s.design.Tables["FieldMiniGameSurvivalBoxTable"] = [][]byte{box} - s.design.Tables["FieldMiniGameSurvivalItemTable"] = [][]byte{row(map[int]uint64{1: 3, 3: 3, 4: 10})} - s.design.Tables["FieldMiniGameCharLevelTable"] = [][]byte{row(map[int]uint64{1: 1, 2: 1, 3: 10}), row(map[int]uint64{1: 1, 2: 2, 3: 20})} - a := Run{Char: 1, MapGroup: 1, Stage: 1, HP: 10, MaxHP: 10, Started: 1, Killed: map[uint64]uint64{}, Available: map[uint64]uint64{}, Picked: map[uint64]uint64{}, SkillLevels: map[uint64]uint64{}, Level: 1} - req := wire.AppendVarint(nil, 2, 10) - kill := row(map[int]uint64{1: 1, 2: 1}) - req = wire.AppendBytes(req, 6, kill) - item := row(map[int]uint64{1: 3, 2: 1}) - req = wire.AppendBytes(req, 5, item) - req = wire.AppendVarint(req, 3, 999) - exp, coin, e := s.survivalProgress(req, &a) - if e != nil || exp != 10 || coin != 0 || a.SkillCredits != 1 { - t.Fatalf("exp%d coin%d credit%d err%v", exp, coin, a.SkillCredits, e) - } - if _, _, e = s.survivalProgress(req, &a); e == nil { - t.Fatal("kill exceeded spawn accepted") - } -} -func TestSurvivalSkillsRequireEarnedOfferAndRerolls(t *testing.T) { - s, _ := makeService(t) - s.design.Tables["FieldMiniGameSkillGroupTable"] = [][]byte{row(map[int]uint64{1: 1, 2: 3}), row(map[int]uint64{1: 2, 2: 3})} - a := Run{SkillCredits: 1, Rerolls: 1, SkillLevels: map[uint64]uint64{}} - if _, e := s.survivalOffers(&a); e != nil { - t.Fatal(e) - } - if len(a.Offers) != 2 { - t.Fatal("offer missing") - } - if _, e := s.survivalOffers(&a); e != nil || a.Rerolls != 0 { - t.Fatal("reroll not consumed") - } - if _, e := s.survivalOffers(&a); e == nil { - t.Fatal("infinite reroll") - } - a.SkillCredits = 0 - if _, e := s.survivalOffers(&a); e == nil { - t.Fatal("unearned skill selection") - } -} diff --git a/go/internal/server/eventplay/upgrade.go b/go/internal/server/eventplay/upgrade.go deleted file mode 100644 index c2bc17b..0000000 --- a/go/internal/server/eventplay/upgrade.go +++ /dev/null @@ -1,66 +0,0 @@ -package eventplay - -import ( - "bd2server/internal/server/gamedata" - "fmt" - "strconv" -) - -func (s *Service) upgrade(path string, req []byte, uid uint64, next *snapshot, key string) ([]byte, error) { - prefix := fmt.Sprintf("%d:", uid) - var costs, rewards []gamedata.Reward - if path == "/MiniGameSurvivalCharUpgrade" { - id, target := num(req, 2), num(req, 3) - k := prefix + strconv.FormatUint(id, 10) - if target == 0 || target != next.Upgrades[k]+1 { - return nil, fmt.Errorf("eventplay: upgrade level is not next") - } - var row []byte - for _, r := range s.design.Rows("FieldMiniGameUpgradeTable", 1, id) { - if num(r, 2) == target { - row = r - break - } - } - if row == nil { - return nil, fmt.Errorf("eventplay: upgrade design missing") - } - costs = []gamedata.Reward{{Type: 43, Count: num(row, 3)}} - if _, e := s.economy.Apply("eventplay:"+key, costs, nil); e != nil { - return nil, e - } - next.Upgrades[k] = target - return nil, nil - } - for _, r := range s.design.Tables["FieldMiniGameUpgradeTable"] { - id, level := num(r, 1), num(r, 2) - if next.Upgrades[prefix+strconv.FormatUint(id, 10)] >= level { - rewards = append(rewards, gamedata.Reward{Type: 43, Count: num(r, 3)}) - } - } - bundle, e := s.economy.Apply("eventplay:"+key, nil, rewards) - if e != nil { - return nil, e - } - for k := range next.Upgrades { - if len(k) >= len(prefix) && k[:len(prefix)] == prefix { - delete(next.Upgrades, k) - } - } - return bundle, nil -} -func rewardDifference(old, next []gamedata.BattleReward) []gamedata.BattleReward { - paid := map[[2]uint64]uint64{} - for _, r := range old { - paid[[2]uint64{r.Type, r.ID}] += r.Count - } - var out []gamedata.BattleReward - for _, r := range next { - n := paid[[2]uint64{r.Type, r.ID}] - if r.Count > n { - r.Count -= n - out = append(out, r) - } - } - return out -} diff --git a/go/internal/server/events/additional_rewards_test.go b/go/internal/server/events/additional_rewards_test.go deleted file mode 100644 index a006275..0000000 --- a/go/internal/server/events/additional_rewards_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package events - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "path/filepath" - "testing" -) - -func TestAdditionalRewardCurrenciesAndRankChangeItemPersistAndReplay(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - eco, items, _ := economyFixture(t, repo, &economyGraph{}) - eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{69: {1: true}}) - rewards := []gamedata.Reward{{Type: 38, Count: 1}, {Type: 39, Count: 20}, {Type: 40, Count: 30}, {Type: 54, Count: 40}, {Type: 70, Count: 50}, {Type: 69, ID: 1, Count: 2}} - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - first, err := eco.Apply("new reward types", nil, rewards) - if err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - assertBalances := func(e *Economy) { - t.Helper() - got, err := e.AdditionalCurrencies() - if err != nil { - t.Fatal(err) - } - for field, want := range map[int]uint64{42: 1, 44: 20, 45: 30, 61: 40, 71: 50} { - if got[field] != want { - t.Fatalf("UserDB field%d=%d want%d", field, got[field], want) - } - } - } - assertBalances(eco) - if len(items.All()) != 1 || items.All()[0].Type != 69 { - t.Fatal("currencies stored as inventory", items.All()) - } - types := map[uint64]bool{} - _ = wire.Walk(first, func(f wire.Field) error { - if f.Number == 1 { - typ, _, _ := wire.Varint(f.Value, 3) - types[typ] = true - } - return nil - }) - for _, reward := range rewards { - if !types[reward.Type] { - t.Fatal("native currency/item receipt missing", reward.Type) - } - } - eco, items, _ = economyFixture(t, repo, &economyGraph{}) - eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{69: {1: true}}) - replay, err := eco.Apply("new reward types", nil, rewards) - if err != nil || !bytes.Equal(first, replay) || len(items.All()) != 1 { - t.Fatal("restart replay duplicated", err) - } - assertBalances(eco) - if _, err = eco.Apply("invalid item", nil, []gamedata.Reward{{Type: 54, Count: 100}, {Type: 69, ID: 999, Count: 1}}); err == nil { - t.Fatal("unknown rank change design accepted") - } - assertBalances(eco) - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, err = eco.Apply("rollback", nil, []gamedata.Reward{{Type: 39, Count: 100}, {Type: 70, Count: 100}}); err != nil { - t.Fatal(err) - } - _ = op.Rollback() - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - eco, _, _ = economyFixture(t, repo, &economyGraph{}) - assertBalances(eco) -} diff --git a/go/internal/server/events/avatar_rewards_test.go b/go/internal/server/events/avatar_rewards_test.go deleted file mode 100644 index b1a14cd..0000000 --- a/go/internal/server/events/avatar_rewards_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package events - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "path/filepath" - "testing" -) - -func avatarDesign() *gamedata.AvatarRewardDesign { - return &gamedata.AvatarRewardDesign{Sets: map[uint64][]gamedata.BattleReward{1: {{Type: 49, ID: 10001, Count: 1}, {Type: 50, ID: 7, Count: 1}, {Type: 61, ID: 2, Count: 1}}}, Items: map[uint64]map[uint64]bool{49: {10001: true}, 50: {7: true}, 61: {2: true}}} -} -func TestAvatarSetBatchPersistsMembersAndRollsBackWithAccount(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - eco, _, _ := economyFixture(t, repo, &economyGraph{}) - eco.AttachAvatarRewards(avatarDesign()) - rewards := []gamedata.Reward{{Type: 62, ID: 1, Count: 1}, {Type: 4, Count: 10}, {Type: 8, ID: 1000, Count: 2}} - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, err = eco.Apply("mission", nil, rewards); err != nil { - t.Fatal(err) - } - _ = op.Rollback() - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - eco, items, wallet := economyFixture(t, repo, &economyGraph{}) - eco.AttachAvatarRewards(avatarDesign()) - if len(items.All()) != 0 || wallet.Snapshot().Gold != 5 { - t.Fatal("rollback retained avatar grant") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - reply, err := eco.Apply("mission", nil, rewards) - if err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if len(items.All()) != 4 || wallet.Snapshot().Gold != 15 { - t.Fatal("mixed grant did not persist", items.All()) - } - for _, item := range items.All() { - if item.Type == 62 { - t.Fatal("synthetic set stored") - } - } - types := map[uint64]bool{} - _ = wire.Walk(reply, func(f wire.Field) error { - if f.Number == 1 { - typ, _, _ := wire.Varint(f.Value, 3) - types[typ] = true - } - return nil - }) - for _, typ := range []uint64{49, 50, 61, 4, 8} { - if !types[typ] { - t.Fatal("native member missing", typ) - } - } - eco, items, wallet = economyFixture(t, repo, &economyGraph{}) - eco.AttachAvatarRewards(avatarDesign()) - replay, err := eco.Apply("mission", nil, rewards) - if err != nil || !bytes.Equal(reply, replay) || len(items.All()) != 4 || wallet.Snapshot().Gold != 15 { - t.Fatal("restart replay duplicated", err) - } - _, avatar, _, err := eco.OwnedItemInfo("/AvatarInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - count := 0 - _ = wire.Walk(avatar, func(f wire.Field) error { - if f.Number == 2 { - count++ - } - return nil - }) - if count != 3 { - t.Fatal("relogin lost avatar ownership", count) - } - before := wallet.Snapshot().Gold - if _, err = eco.Apply("unknown", nil, []gamedata.Reward{{Type: 4, Count: 50}, {Type: 62, ID: 999, Count: 1}}); err == nil || wallet.Snapshot().Gold != before || len(items.All()) != 4 { - t.Fatal("unknown set partially granted") - } -} diff --git a/go/internal/server/events/boxes.go b/go/internal/server/events/boxes.go deleted file mode 100644 index d522c4d..0000000 --- a/go/internal/server/events/boxes.go +++ /dev/null @@ -1,86 +0,0 @@ -package events - -import ( - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/json" - "fmt" - "math" - "strconv" - "sync" -) - -type BoxService struct { - mu sync.Mutex - items *player.Inventory - economy *Economy - store stateio.Store -} -type boxReceipt struct{ Request, Response []byte } - -func OpenBoxes(store stateio.Store, items *player.Inventory, economy *Economy) (*BoxService, error) { - if store == nil || items == nil || economy == nil { - return nil, fmt.Errorf("events: invalid box runtime") - } - return &BoxService{store: store, items: items, economy: economy}, nil -} -func (s *BoxService) Handle(path string, req []byte) (int, []byte, bool, error) { - return s.HandleSession(path, req, "local") -} -func (s *BoxService) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { - if path != "/UseRandomBox" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - seq, _, e := wire.Varint(req, 1) - index, _, e2 := wire.Varint(req, 2) - count, _, e3 := wire.Varint(req, 3) - if e != nil || e2 != nil || e3 != nil || seq == 0 || index == 0 || count == 0 || count > 1000 || seq > math.MaxInt32 || index > math.MaxInt64 { - return 143, nil, true, fmt.Errorf("events: invalid box request") - } - key := session + ":" + strconv.FormatUint(seq, 10) - receipts := map[string]boxReceipt{} - raw, e := s.store.Load("eventboxes") - if e != nil { - return 143, nil, true, e - } - if raw != nil { - if e = json.Unmarshal(raw, &receipts); e != nil { - return 143, nil, true, e - } - } - if r, ok := receipts[key]; ok { - if !bytes.Equal(req, r.Request) { - return 143, nil, true, fmt.Errorf("events: box sequence conflict") - } - return 143, r.Response, true, nil - } - var box player.Item - for _, item := range s.items.All() { - if item.InvenIndex == index { - box = item - break - } - } - if box.Type != 9 || box.Count < count { - return 143, nil, true, fmt.Errorf("events: box unavailable") - } - box.Count = count - bundle, e := s.economy.OpenBox("usebox:"+key, box) - if e != nil { - return 143, nil, true, e - } - out := wire.AppendBytes(nil, 1, bundle) - receipts[key] = boxReceipt{append([]byte(nil), req...), out} - raw, e = json.Marshal(receipts) - if e != nil { - return 143, nil, true, e - } - if e = s.store.Save("eventboxes", raw); e != nil { - return 143, nil, true, e - } - return 143, out, true, nil -} diff --git a/go/internal/server/events/buff_rewards_test.go b/go/internal/server/events/buff_rewards_test.go deleted file mode 100644 index 228657c..0000000 --- a/go/internal/server/events/buff_rewards_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package events - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "path/filepath" - "testing" -) - -func TestPermanentBuffRewardOwnershipAndRestartReplay(t *testing.T) { - store := stateio.NewMemory() - design := map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}} - s, err := OpenBuffRewards(store, design) - if err != nil { - t.Fatal(err) - } - reward := []gamedata.Reward{{Type: 63, ID: 1, Count: 1}} - if err := s.GrantOnce("mission:1", reward); err != nil { - t.Fatal(err) - } - s, err = OpenBuffRewards(store, design) - if err != nil { - t.Fatal(err) - } - if err := s.GrantOnce("mission:1", reward); err != nil { - t.Fatal(err) - } - buffs, err := s.SnapshotBuffs() - if err != nil || len(buffs) != 1 || buffs[0].Value != 0.02 || buffs[0].Category != 1 { - t.Fatal("buff missing or doubled", buffs, err) - } - if err := s.GrantOnce("mission:1", []gamedata.Reward{{Type: 63, ID: 1, Count: 2}}); err == nil { - t.Fatal("identity definition changed") - } - if err := s.GrantOnce("unknown", []gamedata.Reward{{Type: 63, ID: 999, Count: 1}}); err == nil { - t.Fatal("unknown buff granted") - } - if err := s.GrantOnce("mission:2", reward); err != nil { - t.Fatal(err) - } - buffs, err = s.SnapshotBuffs() - if err != nil || buffs[0].Value != 0.04 { - t.Fatal("distinct permanent rewards not accumulated", buffs, err) - } -} - -func TestPermanentBuffAndCurrencyAccountRollback(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - design := map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}} - eco, _, _ := economyFixture(t, repo, &economyGraph{}) - buffs, err := OpenBuffRewards(repo, design) - if err != nil { - t.Fatal(err) - } - eco.AttachBuffRewards(buffs) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, err = eco.Apply("buff batch", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}, {Type: 4, Count: 100}}); err != nil { - t.Fatal(err) - } - _ = op.Rollback() - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - eco, _, wallet := economyFixture(t, repo, &economyGraph{}) - buffs, err = OpenBuffRewards(repo, design) - if err != nil { - t.Fatal(err) - } - stats, err := buffs.SnapshotBuffs() - if err != nil || len(stats) != 0 || wallet.Snapshot().Gold != 5 { - t.Fatal("rollback retained buff or currency", stats, err) - } - eco.AttachBuffRewards(buffs) - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, err = eco.Apply("buff batch", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}, {Type: 4, Count: 100}}); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - stats, err = buffs.SnapshotBuffs() - if err != nil || len(stats) != 1 || stats[0].Value != 0.02 || wallet.Snapshot().Gold != 105 { - t.Fatal("retry did not persist", stats, err) - } -} - -func TestEconomyBuffRewardChangesOwnedStatsWithoutInventory(t *testing.T) { - store := stateio.NewMemory() - eco, items, _ := economyFixture(t, store, &economyGraph{}) - buffs, err := OpenBuffRewards(store, map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}}) - if err != nil { - t.Fatal(err) - } - eco.AttachBuffRewards(buffs) - bundle, err := eco.Apply("mission:buff", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}}) - if err != nil { - t.Fatal(err) - } - if len(items.All()) != 0 { - t.Fatal("buff became fake inventory item") - } - if _, present, _ := wire.Bytes(bundle, 1); present { - t.Fatal("buff encoded as native inventory grant") - } - if _, present, _ := wire.Bytes(bundle, 6); !present { - t.Fatal("buff reward view missing") - } - replay, err := eco.Apply("mission:buff", nil, []gamedata.Reward{{Type: 63, ID: 1, Count: 1}}) - if err != nil || !bytes.Equal(bundle, replay) { - t.Fatal("economy buff replay changed", err) - } - stats, err := buffs.SnapshotBuffs() - if err != nil || len(stats) != 1 || stats[0].Value != 0.02 { - t.Fatal("permanent buff repeated or missing", stats, err) - } -} diff --git a/go/internal/server/events/economy.go b/go/internal/server/events/economy.go deleted file mode 100644 index 7f64a24..0000000 --- a/go/internal/server/events/economy.go +++ /dev/null @@ -1,465 +0,0 @@ -package events - -import ( - "bytes" - "crypto/sha256" - "encoding/json" - "fmt" - "maps" - "math" - "sort" - "strconv" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type HuntingAP interface { - CanExchangeAP(costs, rewards []gamedata.Reward) error - ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error -} - -type rewardResolver interface { - Resolve([]gamedata.BattleReward) ([]gamedata.BattleReward, error) -} -type equipmentRoller interface { - RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) -} - -type economyReceipt struct{ Digest, Definition, Bundle []byte } -type economySnapshot struct { - Balances map[uint64]uint64 `json:"balances"` - Receipts map[string]economyReceipt `json:"receipts"` -} - -// Economy dispatches verified static rewards to their owning domains. It is -// called inside the same account transaction as the gameplay operation. -type Economy struct { - ownedDesign map[uint64]map[uint64]bool - avatarRewards *gamedata.AvatarRewardDesign - buffRewards *BuffRewards - mu sync.Mutex - store stateio.Store - items *player.Inventory - wallet *player.Wallet - collection *player.CollectionStore - equipment *player.EquipmentInventory - costumes player.CostumeDesignSource - options equipmentRoller - graph rewardResolver - hunting HuntingAP - prestige map[uint64]uint64 - prestigePortrait func() uint64 - apCaps map[uint64]uint64 - resetSeconds int64 - now func() time.Time - initial map[uint64]uint64 -} - -func NewEconomy(store stateio.Store, items *player.Inventory, wallet *player.Wallet, collection *player.CollectionStore, equipment *player.EquipmentInventory, costumes player.CostumeDesignSource, options equipmentRoller, graph rewardResolver, initial map[uint64]uint64) (*Economy, error) { - if store == nil || items == nil || wallet == nil || collection == nil || equipment == nil || costumes == nil || options == nil || graph == nil { - return nil, fmt.Errorf("events: invalid economy configuration") - } - e := &Economy{store: store, items: items, wallet: wallet, collection: collection, equipment: equipment, costumes: costumes, options: options, graph: graph, initial: map[uint64]uint64{}} - for t, n := range initial { - if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 { - return nil, fmt.Errorf("events: invalid initial currency %d", t) - } - e.initial[t] = n - } - _, err := e.load() - return e, err -} - -// AdditionalCurrencyFields maps EElementType to its UserDBInfo field. These -// balances use a gameplay entry, leaving the frozen wallet schema unchanged. -var AdditionalCurrencyFields = map[uint64]int{15: 16, 16: 17, 18: 18, 24: 22, 30: 34, 31: 35, 32: 36, 33: 37, 38: 42, 39: 44, 40: 45, 43: 48, 44: 54, 54: 61, 60: 60, 70: 71} - -func (e *Economy) AttachAvatarRewards(d *gamedata.AvatarRewardDesign) { e.avatarRewards = d } - -func (e *Economy) AttachHuntingAP(h HuntingAP) { e.hunting = h } -func (e *Economy) AdditionalCurrencies() (map[int]uint64, error) { - e.mu.Lock() - defer e.mu.Unlock() - s, err := e.load() - if err != nil { - return nil, err - } - if err = e.refreshAP(&s); err != nil { - return nil, err - } - out := map[int]uint64{} - for t, f := range AdditionalCurrencyFields { - out[f] = s.Balances[t] - } - return out, nil -} -func (e *Economy) load() (economySnapshot, error) { - s := economySnapshot{Balances: map[uint64]uint64{}, Receipts: map[string]economyReceipt{}} - maps.Copy(s.Balances, e.initial) - b, err := e.store.Load("event_economy") - if err != nil || b == nil { - return s, err - } - if err = stateio.RequireExactJSONObject(b, "balances", "receipts"); err != nil { - return s, err - } - if err = json.Unmarshal(b, &s); err != nil { - return s, err - } - if s.Balances == nil || s.Receipts == nil { - return s, fmt.Errorf("events: invalid saved economy") - } - for t, n := range s.Balances { - if _, ok := AdditionalCurrencyFields[t]; !ok || n > math.MaxInt32 { - return s, fmt.Errorf("events: invalid saved currency %d", t) - } - } - for id, r := range s.Receipts { - if id == "" || len(r.Digest) != sha256.Size { - return s, fmt.Errorf("events: invalid economy receipt") - } - } - return s, nil -} - -func walletCurrency(t uint64) bool { - switch t { - case 2, 3, 4, 12, 20, 22, 68: - return true - } - return false -} -func extraCurrency(t uint64) bool { _, ok := AdditionalCurrencyFields[t]; return ok } -func apCurrency(t uint64) bool { return t == 21 || t == 23 } -func inventoryType(t uint64) bool { - switch t { - case 5, 7, 8, 9, 13, 14, 17, 19, 25, 26, 27, 29, 34, 45, 46, 47, 49, 50, 61, 69: - return true - } - return false -} - -func (e *Economy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards}) - d := sha256.Sum256(definition) - e.mu.Lock() - s, err := e.load() - if err != nil { - e.mu.Unlock() - return nil, err - } - if r, ok := s.Receipts[identity]; ok { - e.mu.Unlock() - if !bytes.Equal(r.Definition, d[:]) { - return nil, fmt.Errorf("events: reward identity reused with different definition") - } - return append([]byte(nil), r.Bundle...), nil - } - e.mu.Unlock() - var selected []player.Item - // Merge repeated definitions before selecting stacks so the same quantity - // cannot be selected twice from a single inventory entry. - totals := map[[2]uint64]uint64{} - for _, r := range costs { - k := [2]uint64{r.Type, r.ID} - if r.Count == 0 || r.Count > math.MaxInt64 || totals[k] > math.MaxInt64-r.Count { - return nil, fmt.Errorf("events: invalid cost") - } - totals[k] += r.Count - } - keys := make([][2]uint64, 0, len(totals)) - for k := range totals { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i][0] != keys[j][0] { - return keys[i][0] < keys[j][0] - } - return keys[i][1] < keys[j][1] - }) - for _, k := range keys { - n := totals[k] - if walletCurrency(k[0]) || extraCurrency(k[0]) || apCurrency(k[0]) { - if k[1] != 0 { - return nil, fmt.Errorf("events: currency has item id") - } - selected = append(selected, player.Item{Type: k[0], Count: n}) - continue - } - items, err := e.items.SelectMutable(k[0], k[1], n) - if err != nil { - return nil, err - } - selected = append(selected, items...) - } - var rs []gamedata.BattleReward - for _, r := range rewards { - rs = append(rs, gamedata.BattleReward(r)) - } - return e.consumeAndGrant(identity, selected, rs, d[:], false) -} - -func (e *Economy) ConsumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward) ([]byte, error) { - return e.consumeAndGrant(identity, consumed, rewards, nil, false) -} - -func (e *Economy) consumeAndGrant(identity string, consumed []player.Item, rewards []gamedata.BattleReward, definition []byte, openBox bool) ([]byte, error) { - e.mu.Lock() - defer e.mu.Unlock() - if identity == "" { - return nil, fmt.Errorf("events: empty reward identity") - } - s, err := e.load() - if err != nil { - return nil, err - } - raw, err := json.Marshal(struct { - Costs []player.Item - Rewards []gamedata.BattleReward - }{consumed, rewards}) - if err != nil { - return nil, err - } - digest := sha256.Sum256(raw) - if receipt, ok := s.Receipts[identity]; ok { - if !bytes.Equal(receipt.Digest, digest[:]) { - return nil, fmt.Errorf("events: reward identity reused with different contents") - } - return append([]byte(nil), receipt.Bundle...), nil - } - if err = e.refreshAP(&s); err != nil { - return nil, err - } - var itemCosts []player.Item - var walletCosts, apCosts []gamedata.Reward - for _, c := range consumed { - if c.Count == 0 || c.Count > math.MaxInt32 { - return nil, fmt.Errorf("events: invalid consumption count") - } - r := gamedata.Reward{Type: c.Type, ID: c.ID, Count: c.Count} - switch { - case walletCurrency(c.Type): - if c.ID != 0 || c.InvenIndex != 0 { - return nil, fmt.Errorf("events: invalid currency cost") - } - walletCosts = append(walletCosts, r) - case apCurrency(c.Type): - if c.ID != 0 || c.InvenIndex != 0 { - return nil, fmt.Errorf("events: invalid hunting AP cost") - } - apCosts = append(apCosts, r) - case extraCurrency(c.Type): - if c.ID != 0 || c.InvenIndex != 0 || s.Balances[c.Type] < c.Count { - return nil, fmt.Errorf("events: insufficient currency %d", c.Type) - } - s.Balances[c.Type] -= c.Count - default: - if !inventoryType(c.Type) { - return nil, fmt.Errorf("events: unsupported consumed item type %d", c.Type) - } - itemCosts = append(itemCosts, c) - } - } - if len(itemCosts) > 0 { - if err = e.items.CanConsume(itemCosts); err != nil { - return nil, err - } - } - var expanded []gamedata.BattleReward - if openBox { - expanded, err = e.graph.Resolve(rewards) - } else { - expanded, err = e.resolveGranted(rewards) - } - if err != nil { - return nil, err - } - expanded, err = e.avatarRewards.Expand(expanded) - if err != nil { - return nil, err - } - var walletRewards, apRewards, buffItems []gamedata.Reward - var itemRewards []gamedata.BattleReward - var costumes []uint64 - var equips []player.Equipment - var bundle []byte - for _, r := range expanded { - if r.Count == 0 || r.Count > math.MaxInt32 { - return nil, fmt.Errorf("events: invalid reward count") - } - switch { - case walletCurrency(r.Type): - if r.ID != 0 { - return nil, fmt.Errorf("events: currency reward has item id") - } - walletRewards = append(walletRewards, gamedata.Reward(r)) - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count})) - case apCurrency(r.Type): - if r.ID != 0 { - return nil, fmt.Errorf("events: invalid AP reward") - } - apRewards = append(apRewards, gamedata.Reward(r)) - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count})) - case extraCurrency(r.Type): - if r.ID != 0 || s.Balances[r.Type] > math.MaxInt32-r.Count { - return nil, fmt.Errorf("events: currency overflow") - } - s.Balances[r.Type] += r.Count - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, Count: r.Count})) - case r.Type == 63: - if e.buffRewards == nil { - return nil, fmt.Errorf("events: buff reward runtime unavailable") - } - buffItems = append(buffItems, gamedata.Reward(r)) - bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count})) - case r.Type == 11: - if _, ok := e.costumes.Character(r.ID); !ok || r.Count > 1000 { - return nil, fmt.Errorf("events: unknown or excessive costume reward %d", r.ID) - } - for n := uint64(0); n < r.Count; n++ { - costumes = append(costumes, r.ID) - } - case r.Type == 10: - if r.Count > 1000 { - return nil, fmt.Errorf("events: excessive equipment reward") - } - for n := uint64(0); n < r.Count; n++ { - main, sub, private, rollErr := e.options.RollOptions(r.ID) - if rollErr != nil { - return nil, rollErr - } - entry := player.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}} - for _, o := range main { - entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) - } - for _, o := range sub { - entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: o.GroupID, ID: o.ID}) - } - if private != nil { - entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID} - } - equips = append(equips, entry) - } - case inventoryType(r.Type): - if r.Type == 47 || r.Type == 69 { - if !e.ownedDesign[r.Type][r.ID] { - return nil, fmt.Errorf("events: unknown owned item design %d:%d", r.Type, r.ID) - } - } - if r.Type == 49 || r.Type == 50 || r.Type == 61 { - valid := e.avatarRewards != nil && e.avatarRewards.Items[r.Type][r.ID] - if !valid && !e.ownedDesign[r.Type][r.ID] { - return nil, fmt.Errorf("events: unknown avatar member %d:%d", r.Type, r.ID) - } - } - if r.Type == 45 { - if _, ok := e.prestige[r.ID]; !ok { - return nil, fmt.Errorf("events: unknown prestige skin %d", r.ID) - } - } - if r.ID == 0 { - return nil, fmt.Errorf("events: zero inventory reward id") - } - itemRewards = append(itemRewards, r) - default: - return nil, fmt.Errorf("events: unsupported reward type %d", r.Type) - } - } - if len(buffItems) > 0 { - if err = e.buffRewards.Validate(buffItems); err != nil { - return nil, err - } - } - if err = e.wallet.CanExchange(walletCosts, walletRewards); err != nil { - return nil, err - } - if len(apCosts)+len(apRewards) > 0 { - if e.hunting == nil { - return nil, fmt.Errorf("events: hunting AP runtime unavailable") - } - if err = e.hunting.CanExchangeAP(apCosts, apRewards); err != nil { - return nil, err - } - } - // Validation and random generation finish before the first account write. - if len(itemCosts) > 0 { - if err = e.items.Consume(itemCosts); err != nil { - return nil, err - } - } - if err = e.wallet.ExchangeOnce(identity+":wallet", walletCosts, walletRewards); err != nil { - return nil, err - } - if len(apCosts)+len(apRewards) > 0 { - if err = e.hunting.ExchangeAPOnce(identity+":ap", apCosts, apRewards); err != nil { - return nil, err - } - } - if len(buffItems) > 0 { - if err = e.buffRewards.GrantOnce(identity+":buff-items", buffItems); err != nil { - return nil, err - } - } - items, err := e.items.GrantOnce(identity+":items", itemRewards) - if err != nil { - return nil, err - } - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: item.Type, ID: item.ID, Count: item.Count})) - } - if len(costumes) > 0 { - grant, err := e.collection.GrantCostumes(identity+":costumes", costumes, e.costumes) - if err != nil { - return nil, err - } - var overflow uint64 - for _, x := range grant.Exchanges { - if x.ExchangeItemType != 20 || x.ExchangeCount > math.MaxInt64-overflow { - return nil, fmt.Errorf("events: invalid costume exchange") - } - overflow += x.ExchangeCount - } - if overflow > 0 { - if _, err = e.wallet.GrantMileageOnce(identity+":overflow", overflow); err != nil { - return nil, err - } - } - bundle = append(bundle, player.CollectionRewardBundle(e.collection, grant)...) - } - for i, entry := range equips { - saved, err := e.equipment.GrantGeneratedOnce(identity+":equipment:"+strconv.Itoa(i), entry) - if err != nil { - return nil, err - } - bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(saved)) - bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{Type: 10, ID: saved.ID, Count: 1})) - } - s.Receipts[identity] = economyReceipt{Digest: digest[:], Definition: definition, Bundle: bundle} - data, err := json.Marshal(s) - if err != nil { - return nil, err - } - if err = e.store.Save("event_economy", data); err != nil { - return nil, err - } - return bundle, nil -} - -func (e *Economy) resolveGranted(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - if g, ok := e.graph.(interface { - ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error) - }); ok { - return g.ResolveGranted(rewards) - } - return e.graph.Resolve(rewards) -} - -func (e *Economy) OpenBox(identity string, box player.Item) ([]byte, error) { - return e.consumeAndGrant(identity, []player.Item{box}, []gamedata.BattleReward{{Type: 9, ID: box.ID, Count: box.Count}}, nil, true) -} diff --git a/go/internal/server/events/economy_test.go b/go/internal/server/events/economy_test.go deleted file mode 100644 index bfa4ffd..0000000 --- a/go/internal/server/events/economy_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package events - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "fmt" - "path/filepath" - "testing" - "time" -) - -type economyCostumes struct{} - -func (economyCostumes) Character(id uint64) (gamedata.CharacterDesign, bool) { - return gamedata.CharacterDesign{ID: 6060, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 200}, id == 60601 -} - -type economyOptions struct{} - -func (economyOptions) RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) { - return nil, nil, nil, nil -} - -type economyGraph struct{ calls int } - -func (g *economyGraph) Resolve(rs []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - g.calls++ - var out []gamedata.BattleReward - for _, r := range rs { - if r.Type == 9 { - out = append(out, gamedata.BattleReward{Type: 4, Count: r.Count * 10}) - } else { - out = append(out, r) - } - } - return out, nil -} -func economyFixture(t *testing.T, store stateio.Store, g *economyGraph) (*Economy, *player.Inventory, *player.Wallet) { - t.Helper() - items, e := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - wallet, e := player.OpenWallet(store, player.Currency{Gold: 5}) - if e != nil { - t.Fatal(e) - } - collection, e := player.OpenCollectionStore(store, nil) - if e != nil { - t.Fatal(e) - } - equips, e := player.OpenEquipmentInventory(store) - if e != nil { - t.Fatal(e) - } - for _, f := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, equips.EnsurePersisted} { - if e = f(); e != nil { - t.Fatal(e) - } - } - eco, e := NewEconomy(store, items, wallet, collection, equips, economyCostumes{}, economyOptions{}, g, map[uint64]uint64{15: 3}) - if e != nil { - t.Fatal(e) - } - return eco, items, wallet -} -func TestEconomyReplayBeforeCostSelectionAndRandom(t *testing.T) { - g := &economyGraph{} - eco, _, wallet := economyFixture(t, stateio.NewMemory(), g) - cost := []gamedata.Reward{{Type: 4, Count: 5}} - rewards := []gamedata.Reward{{Type: 8, ID: 1000, Count: 2}} - a, e := eco.Apply("request", cost, rewards) - if e != nil { - t.Fatal(e) - } - b, e := eco.Apply("request", cost, rewards) - if e != nil || !bytes.Equal(a, b) || g.calls != 1 || wallet.Snapshot().Gold != 0 { - t.Fatalf("retry cost/RNG %v calls %d", e, g.calls) - } - if _, e = eco.Apply("request", cost, nil); e == nil { - t.Fatal("identity definition conflict accepted") - } -} -func TestEconomyRejectsUnknownCostAndCurrencyID(t *testing.T) { - eco, _, wallet := economyFixture(t, stateio.NewMemory(), &economyGraph{}) - if _, e := eco.ConsumeAndGrant("unknown", []player.Item{{Type: 999, ID: 1, InvenIndex: 1, Count: 1}}, nil); e == nil { - t.Fatal("unknown cost accepted") - } - if _, e := eco.Apply("badcurrency", nil, []gamedata.Reward{{Type: 4, ID: 3, Count: 1}}); e == nil { - t.Fatal("currency ID accepted") - } - if wallet.Snapshot().Gold != 5 { - t.Fatal("validation mutated wallet") - } -} -func TestEconomySQLiteAtomicRollbackAndRetry(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, e := accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - g := &economyGraph{} - eco, _, wallet := economyFixture(t, repo, g) - op, e := repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - cost := []gamedata.Reward{{Type: 4, Count: 5}} - rewards := []gamedata.Reward{{Type: 8, ID: 1000, Count: 2}, {Type: 15, Count: 2}} - if _, e = eco.Apply("atomic", cost, rewards); e != nil { - t.Fatal(e) - } - if wallet.Snapshot().Gold != 0 { - t.Fatal("operation not applied") - } - _ = op.Rollback() - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, e = accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - eco, items, wallet := economyFixture(t, repo, &economyGraph{}) - if wallet.Snapshot().Gold != 5 || len(items.All()) != 0 { - t.Fatal("rollback kept rewards/cost") - } - currencies, e := eco.AdditionalCurrencies() - if e != nil || currencies[16] != 3 { - t.Fatalf("extra currency survived rollback %v %v", currencies, e) - } - op, e = repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - if _, e = eco.Apply("atomic", cost, rewards); e != nil { - t.Fatal(e) - } - if e = op.Commit(); e != nil { - t.Fatal(e) - } - if wallet.Snapshot().Gold != 0 || len(items.All()) != 1 { - t.Fatal("retry failed") - } -} -func TestEconomyOptionalCurrencyOverflow(t *testing.T) { - eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) - if _, e := eco.Apply("overflow", nil, []gamedata.Reward{{Type: 15, Count: 2147483647}}); e == nil { - t.Fatal("optional currency overflow accepted") - } - currencies, e := eco.AdditionalCurrencies() - if e != nil || currencies[16] != 3 { - t.Fatal(fmt.Sprint(currencies, e)) - } -} - -func TestEconomyDailyFreeAPOnly(t *testing.T) { - eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) - if e := eco.AttachAPRefresh(map[uint64]uint64{30: 5, 32: 60}, gamedata.HuntingAPDesign{ResetSeconds: 32400}); e != nil { - t.Fatal(e) - } - now := time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC) - eco.now = func() time.Time { return now } - if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 5}}); e != nil { - t.Fatal(e) - } - if _, e := eco.Apply("day1", []gamedata.Reward{{Type: 30, Count: 5}}, nil); e != nil { - t.Fatal(e) - } - if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 1}}); e == nil { - t.Fatal("CanApply ignored spent AP") - } - now = now.Add(2 * time.Minute) - if e := eco.CanApply([]gamedata.Reward{{Type: 30, Count: 5}}); e != nil { - t.Fatal(e) - } - currencies, e := eco.AdditionalCurrencies() - if e != nil || currencies[16] != 3 || currencies[34] != 5 || currencies[36] != 60 { - t.Fatalf("refresh changed wrong currency %+v %v", currencies, e) - } - if _, e = eco.ChargeInfo(); e != nil { - t.Fatal(e) - } -} - -func (g *economyGraph) ResolveGranted(rs []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - g.calls++ - return rs, nil -} -func TestEconomyBoxPreservedUntilExplicitUse(t *testing.T) { - store := stateio.NewMemory() - g := &economyGraph{} - eco, items, wallet := economyFixture(t, store, g) - if _, e := eco.Apply("grantbox", nil, []gamedata.Reward{{Type: 9, ID: 100, Count: 2}}); e != nil { - t.Fatal(e) - } - owned := items.All() - if len(owned) != 1 || owned[0].Type != 9 || wallet.Snapshot().Gold != 5 { - t.Fatal("reward auto-opened manual box") - } - boxes, e := OpenBoxes(store, items, eco) - if e != nil { - t.Fatal(e) - } - req := wire.AppendVarint(nil, 1, 1) - req = wire.AppendVarint(req, 2, owned[0].InvenIndex) - req = wire.AppendVarint(req, 3, 2) - code, a, _, e := boxes.HandleSession("/UseRandomBox", req, "session") - if e != nil || code != 143 || wallet.Snapshot().Gold != 25 || len(items.All()) != 0 { - t.Fatalf("openbox %d %v gold %d", code, e, wallet.Snapshot().Gold) - } - _, b, _, e := boxes.HandleSession("/UseRandomBox", req, "session") - if e != nil || !bytes.Equal(a, b) || g.calls != 2 { - t.Fatalf("box retry rerolled %v calls%d", e, g.calls) - } -} - -func TestPrestigeSkinRewardAndSpecialQuery(t *testing.T) { - eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) - eco.AttachPrestigeSkins(map[uint64]uint64{9901: 60601}) - if _, e := eco.Apply("skin", nil, []gamedata.Reward{{Type: 45, ID: 9901, Count: 1}}); e != nil { - t.Fatal(e) - } - code, out, ok, e := eco.PrestigeSkinInfo("/PrestigeSkinInfo", wire.AppendVarint(nil, 1, 1)) - entry, found, _ := wire.Bytes(out, 1) - costume, _, _ := wire.Varint(entry, 1) - design, _, _ := wire.Varint(entry, 2) - if e != nil || !ok || code != 425 || !found || costume != 60601 || design != 9901 { - t.Fatalf("skin wire %x %v", out, e) - } -} - -func TestCurrencyEnumProjectionMiniGameAndDeco(t *testing.T) { - eco, _, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) - if _, e := eco.Apply("coins", nil, []gamedata.Reward{{Type: 43, Count: 7}, {Type: 44, Count: 11}}); e != nil { - t.Fatal(e) - } - fields, e := eco.AdditionalCurrencies() - if e != nil || fields[48] != 7 || fields[54] != 11 { - t.Fatalf("enum projection %+v %v", fields, e) - } -} - -func TestOwnedEventItemsAndColosseumCoin(t *testing.T) { - eco, items, _ := economyFixture(t, stateio.NewMemory(), &economyGraph{}) - eco.AttachOwnedItemDesign(map[uint64]map[uint64]bool{47: {100: true}, 49: {200: true}}) - if _, e := eco.Apply("eventowned", nil, []gamedata.Reward{{Type: 47, ID: 100, Count: 1}, {Type: 49, ID: 200, Count: 1}, {Type: 60, Count: 10}}); e != nil { - t.Fatal(e) - } - if len(items.All()) != 2 { - t.Fatal("owned event rewards absent") - } - currencies, e := eco.AdditionalCurrencies() - if e != nil || currencies[60] != 10 { - t.Fatal("colosseum coin missing") - } - code, out, _, e := eco.OwnedItemInfo("/AvatarInfo", wire.AppendVarint(nil, 1, 1)) - item, found, _ := wire.Bytes(out, 2) - typ, _, _ := wire.Varint(item, 3) - id, _, _ := wire.Varint(item, 2) - if e != nil || code != 467 || !found || typ != 49 || id != 200 { - t.Fatalf("avatar query%x %v", out, e) - } - if _, e = eco.Apply("badavatar", nil, []gamedata.Reward{{Type: 49, ID: 201, Count: 1}}); e == nil { - t.Fatal("unknown avatar accepted") - } -} diff --git a/go/internal/server/events/installed_reward_coverage_test.go b/go/internal/server/events/installed_reward_coverage_test.go deleted file mode 100644 index 0141b68..0000000 --- a/go/internal/server/events/installed_reward_coverage_test.go +++ /dev/null @@ -1,334 +0,0 @@ -package events_test - -import ( - "encoding/binary" - "fmt" - "os" - "path/filepath" - "testing" - - "bd2server/internal/server/calendar" - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/hunting" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type auditedLeaves struct{} - -func (auditedLeaves) Resolve(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - return rewards, nil -} -func auditArray(raw []byte, number int) []uint64 { - var result []uint64 - _ = wire.Walk(raw, func(f wire.Field) error { - if f.Number != number { - return nil - } - if f.Type == 0 { - v, _ := binary.Uvarint(f.Value) - result = append(result, v) - } - if f.Type == 2 { - for p := f.Value; len(p) > 0; { - v, n := binary.Uvarint(p) - if n <= 0 { - break - } - result = append(result, v) - p = p[n:] - } - } - return nil - }) - return result -} -func TestInstalledScheduledTaskAttendanceAndPassRewardCoverage(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - const version = "20260923193640" - design, err := gamedata.LoadEventTasksDesign(root, version) - if err != nil { - t.Fatal(err) - } - schedules, err := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", version) - if err != nil { - t.Fatal(err) - } - play, err := gamedata.LoadEventPlayCatalog(root, version) - if err != nil { - t.Fatal(err) - } - exchanges, err := gamedata.LoadEventExchangeCatalog(root, version) - if err != nil { - t.Fatal(err) - } - var roots []gamedata.BattleReward - appendReward := func(r gamedata.Reward) { - if r.Type != 0 && r.Count != 0 { - roots = append(roots, gamedata.BattleReward(r)) - } - } - missionGroups := map[uint64]bool{} - appendPass := func(id uint64) { - pass := design.Passes[id] - missionGroups[pass.MissionGroup] = true - appendReward(pass.Core) - for _, level := range design.PassLevels[pass.LevelGroup] { - appendReward(level.Basic) - appendReward(level.Premium) - } - for _, buy := range design.PassBuys[id] { - for _, reward := range buy.Rewards { - appendReward(reward) - } - } - } - for _, schedule := range schedules.Events { - switch schedule.Type { - case 0: - group := design.Attendance[schedule.ID].Group - groups := map[uint64]bool{group: true} - for _, row := range design.AttendanceGroups { - if row.Group == group { - groups[row.ID] = true - } - } - for group := range groups { - for _, reward := range design.AttendanceRewards[group] { - appendReward(reward.Basic) - appendReward(reward.Premium) - } - } - case 1: - for key, box := range design.LimitRewards { - if key[0] == schedule.ID { - roots = append(roots, gamedata.BattleReward{Type: 9, ID: box, Count: 1}) - } - } - case 4: - missionGroups[schedule.ID] = true - case 7: - for _, entry := range exchanges.Groups[schedule.ID].Entries { - appendReward(entry.Reward) - } - case 9: - for _, row := range play.Rows("PackEventBattleTable", 4, schedule.ID) { - rewards, err := gamedata.EventPlayRewards(row, 9, 10, 8) - if err != nil { - t.Fatal(err) - } - roots = append(roots, rewards...) - } - case 10: - for _, row := range play.Rows("PackEventStoryTable", 1, schedule.ID) { - rewards, err := gamedata.EventPlayRewards(row, 11, 10, 9) - if err != nil { - t.Fatal(err) - } - roots = append(roots, rewards...) - } - case 12, 13, 17, 19: - game, err := gamedata.LoadEventGame(root, version, schedule.Type, schedule.ID) - if err != nil { - t.Fatal(err) - } - for _, rows := range [][]gamedata.EventGameReward{game.Cells, game.Lines, game.Complete} { - for _, row := range rows { - roots = append(roots, row.Rewards...) - } - } - case 5: - appendPass(schedule.ID) - } - } - for id := range missionGroups { - for _, group := range design.MissionGroups[id].Groups { - for _, mission := range design.Missions { - if mission.Group == group { - for _, reward := range mission.Rewards { - appendReward(reward) - } - } - } - } - } - // The installed mini-game families have verified direct reward fields; - // validate each design row, including the next published game variants. - for _, spec := range []struct { - table string - typ, id, count int - }{{"FieldMiniGameRewardTable", 6, 5, 4}, {"SichuanRewardTable", 6, 5, 4}, {"HopscotchRewardTable", 7, 6, 4}, {"ActionGameMissionTable", 10, 9, 8}, {"MGDRewardTable", 5, 3, 2}} { - for _, row := range play.Tables[spec.table] { - rewards, err := gamedata.EventPlayRewards(row, spec.typ, spec.id, spec.count) - if err != nil { - t.Fatal(err) - } - roots = append(roots, rewards...) - } - } - if schedules.MonsterHunt != nil { - seen := map[uint64]bool{} - for _, season := range schedules.MonsterHunt.Seasons { - if seen[season.HuntID] { - continue - } - seen[season.HuntID] = true - hunt, err := gamedata.LoadMonsterHunt(root, version, season.HuntID) - if err != nil { - t.Fatal(err) - } - for _, reward := range hunt.Rewards { - roots = append(roots, reward.Clear...) - roots = append(roots, reward.Daily...) - } - for _, rows := range hunt.Ranks { - for _, reward := range rows { - roots = append(roots, reward.Rewards...) - } - } - } - } - if len(roots) == 0 { - t.Fatal("published calendar has no audited reward definitions") - } - // Walk every possible OPEN-box branch, not one RNG sample. DIRECT boxes are - // actual inventory rewards and remain intact. This is a regression oracle - // for the verified protobuf reward graph fields. - db, closeDB, err := gamedata.OpenDatabase(root, version, "common") - if err != nil { - t.Fatal(err) - } - defer closeDB() - tables := map[string]map[uint64][]byte{} - for _, name := range []string{"RandomBoxTable", "RewardGroupTable"} { - rows, err := db.Query("SELECT id,ProtoBuf FROM " + name) - if err != nil { - t.Fatal(err) - } - tables[name] = map[uint64][]byte{} - for rows.Next() { - var id uint64 - var raw []byte - if err = rows.Scan(&id, &raw); err != nil { - t.Fatal(err) - } - tables[name][id] = raw - } - if err = rows.Err(); err != nil { - t.Fatal(err) - } - if err := rows.Close(); err != nil { - t.Fatal(err) - } - } - leaves := map[[2]uint64]gamedata.BattleReward{} - var walk func(gamedata.BattleReward, map[uint64]bool) - walk = func(reward gamedata.BattleReward, seen map[uint64]bool) { - if reward.Type != 9 { - reward.Count = 1 - leaves[[2]uint64{reward.Type, reward.ID}] = reward - return - } - raw, ok := tables["RandomBoxTable"][reward.ID] - if !ok { - t.Fatalf("unknown scheduled box%d", reward.ID) - } - drop, _, _ := wire.Varint(raw, 1) - if drop == 1 { - reward.Count = 1 - leaves[[2]uint64{9, reward.ID}] = reward - return - } - if seen[reward.ID] { - t.Fatalf("scheduled box cycle%d", reward.ID) - } - seen[reward.ID] = true - defer delete(seen, reward.ID) - gid, _, _ := wire.Varint(raw, 9) - group, ok := tables["RewardGroupTable"][gid] - if !ok { - t.Fatalf("unknown scheduled reward group%d", gid) - } - types, ids, counts := auditArray(group, 6), auditArray(group, 5), auditArray(group, 4) - if len(types) == 0 || len(types) != len(ids) || len(types) != len(counts) { - t.Fatalf("malformed scheduled reward group%d", gid) - } - for i, typ := range types { - if counts[i] != 0 { - walk(gamedata.BattleReward{Type: typ, ID: ids[i], Count: 1}, seen) - } - } - } - for _, reward := range roots { - walk(reward, map[uint64]bool{}) - } - costumes, err := gamedata.LoadRewardCostumeCatalog(root, version) - if err != nil { - t.Fatal(err) - } - equipmentDesign, err := gamedata.LoadRewardEquipmentCatalog(root, version) - if err != nil { - t.Fatal(err) - } - owned, err := gamedata.LoadOwnedEventItemDesign(root, version) - if err != nil { - t.Fatal(err) - } - prestige, err := gamedata.LoadPrestigeSkins(root, version) - if err != nil { - t.Fatal(err) - } - avatars, err := gamedata.LoadAvatarRewardDesign(root, version) - if err != nil { - t.Fatal(err) - } - buffDesign, err := gamedata.LoadBuffRewardDesign(root, version) - if err != nil { - t.Fatal(err) - } - store := stateio.NewMemory() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(store, nil) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - economy, err := events.NewEconomy(store, items, wallet, collection, equipment, costumes, equipmentDesign, auditedLeaves{}, nil) - if err != nil { - t.Fatal(err) - } - economy.AttachOwnedItemDesign(owned) - economy.AttachPrestigeSkins(prestige) - economy.AttachAvatarRewards(avatars) - buffs, err := events.OpenBuffRewards(store, buffDesign) - if err != nil { - t.Fatal(err) - } - economy.AttachBuffRewards(buffs) - ap, err := hunting.Open(store, root, version, items, wallet, func() (int, error) { return 21, nil }, 0, 0) - if err != nil { - t.Fatal(err) - } - economy.AttachHuntingAP(ap) - for key, reward := range leaves { - if _, err = economy.Apply(fmt.Sprintf("coverage:%d:%d", key[0], key[1]), nil, []gamedata.Reward{gamedata.Reward(reward)}); err != nil { - t.Errorf("scheduled reward leaf %d:%d unsupported: %v", key[0], key[1], err) - } - } - t.Logf("validated %d published reward roots and %d distinct possible leaves against real grant domains", len(roots), len(leaves)) -} diff --git a/go/internal/server/events/owned_items.go b/go/internal/server/events/owned_items.go deleted file mode 100644 index 50b4760..0000000 --- a/go/internal/server/events/owned_items.go +++ /dev/null @@ -1,28 +0,0 @@ -package events - -import ( - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "fmt" -) - -func (e *Economy) AttachOwnedItemDesign(d map[uint64]map[uint64]bool) { e.ownedDesign = d } - -// ID card items are loaded through ItemInfo; there is no IdCardInfo ownership -// endpoint. AvatarInfo separately includes its owned ItemDBInfo list. -func (e *Economy) OwnedItemInfo(path string, req []byte) (int, []byte, bool, error) { - if path != "/AvatarInfo" { - return 0, nil, false, nil - } - seq, _, err := wire.Varint(req, 1) - if err != nil || seq == 0 { - return 467, nil, true, fmt.Errorf("events: missing sequence") - } - var out []byte - for _, item := range e.items.All() { - if item.Type == 49 || item.Type == 50 || item.Type == 61 { - out = wire.AppendBytes(out, 2, player.ItemWire(item)) - } - } - return 467, out, true, nil -} diff --git a/go/internal/server/events/registry_test.go b/go/internal/server/events/registry_test.go deleted file mode 100644 index 046f702..0000000 --- a/go/internal/server/events/registry_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package events - -import ( - "testing" - "time" - - "bd2server/internal/server/wire" -) - -func TestRegistryExplicitCalendarAndIsolation(t *testing.T) { - r := NewRegistry() - r.now = func() time.Time { return time.UnixMilli(100) } - if len(r.List()) != 0 { - t.Fatal("empty calendar activated events") - } - rows := []Schedule{{UID: 77, Type: 12, ID: 3, Start: 100, End: 200}} - if err := r.Replace(rows); err != nil { - t.Fatal(err) - } - rows[0].ID = 99 - got := r.List() - got[0].ID = 88 - s, err := r.Resolve(77) - if err != nil || s.ID != 3 { - t.Fatalf("calendar aliased caller: %+v %v", s, err) - } - code, out, ok, err := r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || !ok || code != 163 { - t.Fatalf("schedule: %d %t %v", code, ok, err) - } - b, _, err := wire.Bytes(out, 1) - if err != nil { - t.Fatal(err) - } - if active, _, _ := wire.Varint(b, 7); active != 1 { - t.Fatal("start boundary inactive") - } - r.now = func() time.Time { return time.UnixMilli(200) } - _, out, _, err = r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 2)) - if err != nil { - t.Fatal(err) - } - b, _, _ = wire.Bytes(out, 1) - if active, _, _ := wire.Varint(b, 7); active != 0 { - t.Fatal("end boundary active") - } - if _, err = r.Resolve(78); err == nil { - t.Fatal("unknown uid resolved") - } - if err = r.Replace([]Schedule{{UID: 77, Type: 2, ID: 3, Start: 100, End: 200}}); err == nil { - t.Fatal("unknown type accepted") - } - if s, _ = r.Resolve(77); s.ID != 3 { - t.Fatal("failed replacement changed calendar") - } -} - -func TestPublicZeroUIDRowsRetainSignedWindowsAndRejectAmbiguousLookup(t *testing.T) { - r := NewRegistry() - r.now = func() time.Time { return time.UnixMilli(100) } - rows := []Schedule{ - {Type: 4, ID: 1, Start: -32400000, End: 200}, - {Type: 4, ID: 2, Start: -32400000, End: 200}, - {UID: 99, Type: 4, ID: 3, Start: 300, End: 400}, - } - if err := r.Replace(rows); err != nil { - t.Fatal(err) - } - if _, err := r.Resolve(0); err == nil { - t.Fatal("ambiguous UID 0 lookup succeeded") - } - _, body, _, err := r.Handle("/EventScheduleInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - count := 0 - if err := wire.Walk(body, func(f wire.Field) error { - if f.Number != 1 { - return nil - } - count++ - start, _, _ := wire.Varint(f.Value, 5) - active, _, _ := wire.Varint(f.Value, 7) - if count <= 2 && (int64(start) != -32400000 || active != 1) { - t.Fatalf("public start=%d active=%d", int64(start), active) - } - if count == 3 && active != 0 { - t.Fatal("future row activated") - } - return nil - }); err != nil { - t.Fatal(err) - } - if count != 3 { - t.Fatalf("rows=%d", count) - } - if err := r.Replace(append(rows, rows[0])); err == nil { - t.Fatal("duplicate semantic public event accepted") - } - if len(r.List()) != 3 { - t.Fatal("failed replacement changed calendar") - } -} diff --git a/go/internal/server/events/skin_handler.go b/go/internal/server/events/skin_handler.go deleted file mode 100644 index db92679..0000000 --- a/go/internal/server/events/skin_handler.go +++ /dev/null @@ -1,15 +0,0 @@ -package events - -// SkinHandler restores skin ownership through the client's dedicated query. -type SkinHandler struct{ Economy *Economy } - -func (h SkinHandler) Handle(path string, request []byte) (int, []byte, bool, error) { - return h.HandleSession(path, request, "local") -} - -func (h SkinHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { - if code, response, handled, err := h.Economy.OwnedItemInfo(path, request); handled || err != nil { - return code, response, handled, err - } - return h.Economy.HandleSession(path, request, session) -} diff --git a/go/internal/server/events/skins.go b/go/internal/server/events/skins.go deleted file mode 100644 index 001ff1b..0000000 --- a/go/internal/server/events/skins.go +++ /dev/null @@ -1,166 +0,0 @@ -package events - -import ( - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/json" - "fmt" - "math" - "strconv" -) - -func (e *Economy) AttachPrestigeSkins(design map[uint64]uint64) { e.prestige = design } -func (e *Economy) AttachPrestigePortrait(portrait func() uint64) { e.prestigePortrait = portrait } - -type prestigeSkinReceipt struct{ Request, Response []byte } -type prestigeSkinState struct { - Selections map[uint64]uint64 `json:"selections"` - Receipts map[string]prestigeSkinReceipt `json:"receipts"` -} - -func (e *Economy) loadPrestigeSkins() (prestigeSkinState, error) { - s := prestigeSkinState{Selections: map[uint64]uint64{}, Receipts: map[string]prestigeSkinReceipt{}} - raw, err := e.store.Load("prestige_skin_sets") - if err != nil || raw == nil { - return s, err - } - if err = stateio.RequireExactJSONObject(raw, "selections", "receipts"); err != nil { - return s, err - } - if err = json.Unmarshal(raw, &s); err != nil { - return s, err - } - if s.Selections == nil || s.Receipts == nil { - return s, fmt.Errorf("events: invalid saved prestige skins") - } - for costume, design := range s.Selections { - if costume == 0 || design == 0 || e.prestige[design] != costume { - return s, fmt.Errorf("events: invalid saved prestige selection") - } - } - return s, nil -} - -// PrestigeSkinSelections returns a detached projection for costume responses. -func (e *Economy) PrestigeSkinSelections() (map[uint64]uint64, error) { - e.mu.Lock() - defer e.mu.Unlock() - s, err := e.loadPrestigeSkins() - return s.Selections, err -} - -func (e *Economy) Handle(path string, request []byte) (int, []byte, bool, error) { - return e.HandleSession(path, request, "local") -} -func (e *Economy) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { - if path != "/PrestigeSkinSet" { - return e.PrestigeSkinInfo(path, req) - } - e.mu.Lock() - defer e.mu.Unlock() - if err := wire.Walk(req, func(f wire.Field) error { - if (f.Number == 1 && f.Type != 0) || (f.Number == 2 && f.Type != 2) { - return fmt.Errorf("events: invalid prestige request field") - } - return nil - }); err != nil { - return 426, nil, true, err - } - seq, _, err := wire.Varint(req, 1) - info, found, err2 := wire.Bytes(req, 2) - if err != nil || err2 != nil || !found || seq == 0 || seq > math.MaxInt32 || session == "" { - return 426, nil, true, fmt.Errorf("events: invalid prestige set request") - } - if err := wire.Walk(info, func(f wire.Field) error { - if f.Number >= 1 && f.Number <= 4 && f.Type != 0 { - return fmt.Errorf("events: invalid prestige info field") - } - return nil - }); err != nil { - return 426, nil, true, err - } - costume, _, err := wire.Varint(info, 1) - design, _, err2 := wire.Varint(info, 2) - setting, _, err3 := wire.Varint(info, 3) - timeValue, _, err4 := wire.Varint(info, 4) - if err != nil || err2 != nil || err3 != nil || err4 != nil || costume == 0 || costume > math.MaxInt32 || design == 0 || design > math.MaxInt32 || setting > 1 || timeValue > math.MaxInt64 || e.prestige[design] != costume { - return 426, nil, true, fmt.Errorf("events: invalid prestige skin") - } - s, err := e.loadPrestigeSkins() - if err != nil { - return 426, nil, true, err - } - key := session + ":" + strconv.FormatUint(seq, 10) - if receipt, ok := s.Receipts[key]; ok { - if !bytes.Equal(receipt.Request, req) { - return 426, nil, true, fmt.Errorf("events: prestige sequence conflict") - } - return 426, append([]byte(nil), receipt.Response...), true, nil - } - owned := false - for _, item := range e.items.All() { - if item.Type == 45 && item.ID == design && item.Count > 0 { - owned = true - break - } - } - if !owned { - return 426, nil, true, fmt.Errorf("events: prestige skin not owned") - } - if setting == 1 { - s.Selections[costume] = design - } else if s.Selections[costume] == design { - delete(s.Selections, costume) - } - portrait := costume - if e.prestigePortrait != nil { - portrait = e.prestigePortrait() - } - out := wire.AppendVarint(nil, 1, portrait) - out = wire.AppendVarint(out, 2, s.Selections[portrait]) - s.Receipts[key] = prestigeSkinReceipt{Request: append([]byte(nil), req...), Response: append([]byte(nil), out...)} - raw, err := json.Marshal(s) - if err == nil { - err = e.store.Save("prestige_skin_sets", raw) - } - if err != nil { - return 426, nil, true, err - } - return 426, out, true, nil -} -func (e *Economy) PrestigeSkinInfo(path string, req []byte) (int, []byte, bool, error) { - if path != "/PrestigeSkinInfo" { - return 0, nil, false, nil - } - seq, _, err := wire.Varint(req, 1) - if err != nil || seq == 0 || seq > math.MaxInt32 { - return 425, nil, true, fmt.Errorf("events: missing sequence") - } - e.mu.Lock() - defer e.mu.Unlock() - s, err := e.loadPrestigeSkins() - if err != nil { - return 425, nil, true, err - } - var out []byte - seen := map[uint64]bool{} - for _, item := range e.items.All() { - if item.Type != 45 || item.Count == 0 || seen[item.ID] { - continue - } - costume, ok := e.prestige[item.ID] - if !ok { - return 425, nil, true, fmt.Errorf("events: owned skin missing design") - } - seen[item.ID] = true - b := wire.AppendVarint(nil, 1, costume) - b = wire.AppendVarint(b, 2, item.ID) - if s.Selections[costume] == item.ID { - b = wire.AppendVarint(b, 3, 1) - } - b = wire.AppendVarint(b, 4, item.TimeValue) - out = wire.AppendBytes(out, 1, b) - } - return 425, out, true, nil -} diff --git a/go/internal/server/events/skins_test.go b/go/internal/server/events/skins_test.go deleted file mode 100644 index 0ae10fc..0000000 --- a/go/internal/server/events/skins_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package events - -import ( - "bytes" - "fmt" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func skinSetRequest(seq, costume, design uint64, set bool) []byte { - info := wire.AppendVarint(nil, 1, costume) - info = wire.AppendVarint(info, 2, design) - if set { - info = wire.AppendVarint(info, 3, 1) - } - return wire.AppendBytes(wire.AppendVarint(nil, 1, seq), 2, info) -} - -func TestPrestigeSkinSelectionPersistsAndReplaysWithoutUndoingLaterChanges(t *testing.T) { - store := stateio.NewMemory() - eco, _, _ := economyFixture(t, store, &economyGraph{}) - design := map[uint64]uint64{9901: 60601, 9902: 60601, 9903: 60602} - eco.AttachPrestigeSkins(design) - if _, err := eco.Apply("skins", nil, []gamedata.Reward{{Type: 45, ID: 9901, Count: 1}, {Type: 45, ID: 9902, Count: 1}, {Type: 45, ID: 9903, Count: 1}}); err != nil { - t.Fatal(err) - } - portrait := uint64(60601) - eco.AttachPrestigePortrait(func() uint64 { return portrait }) - handler := SkinHandler{Economy: eco} - first := skinSetRequest(1, 60601, 9901, true) - code, original, handled, err := handler.HandleSession("/PrestigeSkinSet", first, "s1") - if err != nil || code != 426 || !handled { - t.Fatalf("set: %d %v %v", code, handled, err) - } - for _, req := range [][]byte{skinSetRequest(2, 60601, 9902, true), skinSetRequest(3, 60602, 9903, true), skinSetRequest(4, 60601, 9901, false)} { - _, out, _, callErr := handler.HandleSession("/PrestigeSkinSet", req, "s1") - if callErr != nil { - t.Fatal(callErr) - } - id, _, _ := wire.Varint(out, 1) - design, _, _ := wire.Varint(out, 2) - if id != 60601 || design != 9902 { - t.Fatalf("set changed unrelated portrait: %x", out) - } - } - // Disabling an older skin cannot clear the newer choice on the same costume. - sets, err := eco.PrestigeSkinSelections() - if err != nil || sets[60601] != 9902 || sets[60602] != 9903 { - t.Fatalf("selections: %v %v", sets, err) - } - portraitID, _, _ := wire.Varint(original, 1) - portraitDesign, _, _ := wire.Varint(original, 2) - if portraitID != 60601 || portraitDesign != 9901 { - t.Fatalf("portrait: %x", original) - } - _, _, _, err = handler.HandleSession("/PrestigeSkinSet", skinSetRequest(1, 60601, 9902, true), "s1") - if err == nil { - t.Fatal("sequence conflict accepted") - } - reopened, _, _ := economyFixture(t, store, &economyGraph{}) - reopened.AttachPrestigeSkins(design) - _, replay, _, err := reopened.HandleSession("/PrestigeSkinSet", first, "s1") - if err != nil || !bytes.Equal(original, replay) { - t.Fatalf("restart replay %x %v", replay, err) - } - _, info, _, err := reopened.PrestigeSkinInfo("/PrestigeSkinInfo", wire.AppendVarint(nil, 1, 9)) - if err != nil { - t.Fatal(err) - } - selected := map[uint64]bool{} - if err = wire.Walk(info, func(f wire.Field) error { - id, _, _ := wire.Varint(f.Value, 2) - set, _, _ := wire.Varint(f.Value, 3) - selected[id] = set == 1 - return nil - }); err != nil { - t.Fatal(err) - } - if selected[9901] || !selected[9902] || !selected[9903] { - t.Fatalf("info selection: %v", selected) - } - if _, _, _, err = reopened.HandleSession("/PrestigeSkinSet", skinSetRequest(1, 60601, 9902, false), "s2"); err != nil { - t.Fatal(err) - } - sets, err = reopened.PrestigeSkinSelections() - if err != nil || sets[60601] != 0 || sets[60602] != 9903 { - t.Fatalf("unset disturbed another costume: %v %v", sets, err) - } -} - -type failedSkinStore struct { - *stateio.Memory - fail bool -} - -func (s *failedSkinStore) Save(name string, payload []byte) error { - if s.fail && name == "prestige_skin_sets" { - return fmt.Errorf("injected skin save failure") - } - return s.Memory.Save(name, payload) -} - -func TestPrestigeSkinRejectsUnownedAndFailedSaveLeavesSelectionUnchanged(t *testing.T) { - store := &failedSkinStore{Memory: stateio.NewMemory()} - eco, _, _ := economyFixture(t, store, &economyGraph{}) - eco.AttachPrestigeSkins(map[uint64]uint64{9901: 60601, 9902: 60601}) - if _, err := eco.Apply("skin", nil, []gamedata.Reward{{Type: 45, ID: 9901, Count: 1}}); err != nil { - t.Fatal(err) - } - for _, req := range [][]byte{skinSetRequest(1, 60601, 9902, true), skinSetRequest(1, 60602, 9901, true), wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 60601), 2, 9901), 3, nil))} { - if _, _, _, err := eco.HandleSession("/PrestigeSkinSet", req, "session"); err == nil { - t.Fatalf("invalid request accepted: %x", req) - } - } - req := skinSetRequest(1, 60601, 9901, true) - store.fail = true - if _, _, _, err := eco.HandleSession("/PrestigeSkinSet", req, "session"); err == nil { - t.Fatal("save failure succeeded") - } - sets, err := eco.PrestigeSkinSelections() - if err != nil || len(sets) != 0 { - t.Fatalf("failed selection leaked %v %v", sets, err) - } - store.fail = false - if _, _, _, err = eco.HandleSession("/PrestigeSkinSet", req, "session"); err != nil { - t.Fatal(err) - } - sets, err = eco.PrestigeSkinSelections() - if err != nil || sets[60601] != 9901 { - t.Fatalf("retry not committed: %v %v", sets, err) - } -} diff --git a/go/internal/server/eventtasks/automatic_attendance_test.go b/go/internal/server/eventtasks/automatic_attendance_test.go deleted file mode 100644 index 78587f7..0000000 --- a/go/internal/server/eventtasks/automatic_attendance_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "errors" - "testing" - "time" -) - -func claimEntries(b []byte) int { - n := 0 - _ = wire.Walk(b, func(f wire.Field) error { - if f.Number == 5 { - n++ - } - return nil - }) - return n -} -func TestAutomaticAttendanceLoginMarkerReplayAndRestart(t *testing.T) { - s, eco, store := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - if _, b, _, err := s.Handle("/LoginEvent", req(1)); err != nil || claimEntries(b) != 0 || eco.calls != 0 { - t.Fatal("login event silently granted", err) - } - _, response, _, err := s.Handle("/Attendance", req(2)) - if err != nil || m.calls != 1 || eco.calls != 0 || claimEntries(response) != 1 || s.state.Attendance["1"].Count != 1 { - t.Fatal("shared marker prevented actual grant", err) - } - reopened, err := Open(store, s.design, s.registry, eco) - if err != nil { - t.Fatal(err) - } - reopened.AttachAttendanceMail(m) - reopened.now = s.now - reopened.SetSession("test") - _, replay, _, err := reopened.Handle("/Attendance", req(2)) - if err != nil || !bytes.Equal(response, replay) || m.calls != 1 || eco.calls != 0 { - t.Fatal("durable replay changed", err) - } - reopened.SetSession("reconnect") - _, response, _, err = reopened.Handle("/Attendance", req(1)) - if err != nil || claimEntries(response) != 0 || m.calls != 1 || eco.calls != 0 { - t.Fatal("history returned as new reward", err) - } - if _, present, _ := wire.Bytes(response, 1001); present { - t.Fatal("empty grant had bundle") - } -} -func TestAutomaticLimitAttendanceUsesScheduleDateAndSkipsInactive(t *testing.T) { - s, eco, _ := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - now := s.now() - reg := events.NewRegistry() - rows := []events.Schedule{ - {UID: 10, Type: 1, ID: 7, Start: now.Add(-48 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()}, - {UID: 11, Type: 1, ID: 8, Start: now.Add(time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()}, - {UID: 12, Type: 1, ID: 9, Start: now.Add(-48 * time.Hour).UnixMilli(), End: now.UnixMilli()}, - } - if err := reg.Replace(rows); err != nil { - t.Fatal(err) - } - s.registry = reg - s.design.LimitRewards = map[[2]uint64]uint64{{7, 1}: 100, {7, 2}: 200, {7, 3}: 300, {7, 4}: 400, {8, 1}: 500, {9, 3}: 600} - s.state.LoginDays[now.Add(-24*time.Hour).Format("2006-01-02")] = now.Add(-24 * time.Hour).UnixMilli() - _, response, _, err := s.Handle("/Attendance", req(1)) - if err != nil || claimEntries(response) != 1 || m.calls != 1 || eco.calls != 0 || len(m.rewards) != 1 || m.rewards[0].ID != 300 { - t.Fatal("wrong calendar day granted", m.rewards, err) - } - if len(s.state.Attendance) != 1 { - t.Fatal("inactive calendar mutated") - } - now = now.Add(24 * time.Hour) - s.now = func() time.Time { return now } - _, response, _, err = s.Handle("/Attendance", req(2)) - if err != nil || claimEntries(response) != 1 || m.rewards[len(m.rewards)-1].ID != 400 { - t.Fatal("next calendar day missing", err) - } -} -func TestAutomaticAttendanceUIDZeroSeparateDesignsAndSingleSettlement(t *testing.T) { - s, eco, _ := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - now := s.now() - reg := events.NewRegistry() - if err := reg.Replace([]events.Schedule{{Type: 0, ID: 1, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}, {Type: 0, ID: 2, Start: now.Add(-time.Hour).UnixMilli(), End: now.Add(time.Hour).UnixMilli()}}); err != nil { - t.Fatal(err) - } - s.registry = reg - s.design.Attendance[2] = gamedata.EventAttendance{ID: 2, Group: 2} - s.design.AttendanceRewards[2] = []gamedata.EventAttendanceReward{{ID: 1, Group: 2, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 20}}, {ID: 2, Group: 2, Day: 2, Basic: gamedata.Reward{Type: 4, Count: 30}}} - _, response, _, err := s.Handle("/Attendance", req(1)) - if err != nil || m.calls != 1 || eco.calls != 0 || len(m.rewards) != 2 || claimEntries(response) != 2 || len(s.state.Attendance) != 2 { - t.Fatal("public zero identities collided", err) - } -} -func TestAutomaticAttendanceMailFailureRestoresProgressAndClaims(t *testing.T) { - s, eco, _ := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - m.err = errors.New("settlement failure") - if _, _, _, err := s.Handle("/Attendance", req(1)); err == nil { - t.Fatal("failed settlement accepted") - } - if len(s.state.Attendance) != 0 || len(s.state.LoginDays) != 0 || len(s.state.Receipts) != 0 { - t.Fatal("failed settlement persisted markers") - } - m.err = nil - _, response, _, err := s.Handle("/Attendance", req(1)) - if err != nil || eco.calls != 0 || m.calls != 1 || claimEntries(response) != 1 || !s.state.Attendance["1"].Obtained["1/1"] { - t.Fatal("retry lost eligibility", err) - } -} - -// The account transaction restores mailbox state; this store exercises the -// domain restoration that must also drop newly inserted map entries. -type attendanceFailStore struct{ stateio.Store } - -func (s attendanceFailStore) Save(string, []byte) error { return errors.New("save failure") } -func TestAutomaticAttendanceSaveFailureRestoresState(t *testing.T) { - s, _, store := setup(t) - s.store = attendanceFailStore{store} - if _, _, _, err := s.Handle("/Attendance", req(1)); err == nil { - t.Fatal("failed save accepted") - } - if len(s.state.Attendance) != 0 || len(s.state.LoginDays) != 0 || len(s.state.Receipts) != 0 { - t.Fatal("failed save retained new state") - } - persisted, err := store.Load("eventtasks") - if err != nil || len(persisted) != 0 { - t.Fatal("failed save persisted attendance", err) - } -} - -func TestAttendancePreviouslyGrantedRewardsAreNotMailedAgain(t *testing.T) { - s, eco, _ := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - a := s.attendance(s.registry.List()[0]) - a.Group, a.Count, a.LastDay = 1, 1, s.day() - a.Obtained["1/1"], a.History["1/1"] = true, true - _, response, _, err := s.Handle("/Attendance", req(1)) - if err != nil || claimEntries(response) != 0 || m.calls != 0 || eco.calls != 0 { - t.Fatal("existing direct reward mailed again", err) - } -} -func TestExplicitAttendanceClaimUsesMailboxAndSharedLedger(t *testing.T) { - s, eco, _ := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - if _, _, _, err := s.Handle("/LoginEvent", req(1)); err != nil { - t.Fatal(err) - } - claim := wire.AppendVarint(req(2), 2, 1) - claim = wire.AppendVarint(claim, 3, 1) - claim = wire.AppendVarint(claim, 4, 1) - _, response, _, err := s.Handle("/EventReward", claim) - if err != nil || len(response) != 0 || m.calls != 1 || eco.calls != 0 || m.identity != "test:/EventReward:2" { - t.Fatal("explicit claim bypassed mailbox", err) - } - _, response, _, err = s.Handle("/Attendance", req(3)) - if err != nil || claimEntries(response) != 0 || m.calls != 1 || eco.calls != 0 { - t.Fatal("auto duplicated explicit mail", err) - } -} -func TestAttendanceMissingMailboxDoesNotGrantOrPersist(t *testing.T) { - s, eco, _ := setup(t) - s.AttachAttendanceMail(nil) - if _, _, _, err := s.Handle("/Attendance", req(1)); err == nil { - t.Fatal("missing mailbox accepted") - } - if eco.calls != 0 || len(s.state.Attendance) != 0 || len(s.state.LoginDays) != 0 || len(s.state.Receipts) != 0 { - t.Fatal("missing mailbox granted or retained state") - } -} diff --git a/go/internal/server/eventtasks/calendar_identity_test.go b/go/internal/server/eventtasks/calendar_identity_test.go deleted file mode 100644 index b0162a9..0000000 --- a/go/internal/server/eventtasks/calendar_identity_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "testing" -) - -func TestZeroUIDMissionGroupsKeepIndependentClaimsAfterRestart(t *testing.T) { - s, economy, store := setup(t) - now := s.now().UnixMilli() - rows := []events.Schedule{{Type: 4, ID: 7, Start: now - 1, End: now + 10000}, {Type: 4, ID: 8, Start: now - 1, End: now + 10000}} - if err := s.registry.(*events.Registry).Replace(rows); err != nil { - t.Fatal(err) - } - s.design.MissionGroups[8] = gamedata.EventMissionGroup{ID: 8, Groups: []uint64{9}} - if err := s.RecordEvent(2, 0, 2, nil); err != nil { - t.Fatal(err) - } - claim := func(seq, group uint64) []byte { - b := wire.AppendVarint(req(seq), 3, 2) - b = wire.AppendVarint(b, 4, 9) - b = wire.AppendVarint(b, 5, 10) - return wire.AppendVarint(b, 6, group) - } - if _, _, _, err := s.Handle("/MissionClear", claim(1, 7)); err != nil { - t.Fatal(err) - } - if !s.mission(rows[0], 10).Claimed || s.mission(rows[1], 10).Claimed { - t.Fatal("claim leaked across zero-UID groups") - } - restarted, err := Open(store, s.design, s.registry, economy) - if err != nil { - t.Fatal(err) - } - restarted.now = s.now - if !restarted.mission(rows[0], 10).Claimed || restarted.mission(rows[1], 10).Claimed { - t.Fatal("independent claims lost after restart") - } - if _, _, _, err := restarted.Handle("/MissionClear", claim(2, 8)); err != nil { - t.Fatal(err) - } - if economy.calls != 2 { - t.Fatalf("reward grants=%d", economy.calls) - } -} - -func TestInactiveCalendarDoesNotHideActivePassMissions(t *testing.T) { - s, _, _ := setup(t) - now := s.now().UnixMilli() - if err := s.registry.(*events.Registry).Replace([]events.Schedule{ - {UID: 2, Type: 4, ID: 7, Start: now - 20, End: now - 10}, - {UID: 3, Type: 5, ID: 8, Start: now - 1, End: now + 10000}, - {UID: 4, Type: 4, ID: 9, Start: now + 10000, End: now + 20000}, - }); err != nil { - t.Fatal(err) - } - if err := s.RecordEvent(2, 0, 1, nil); err != nil { - t.Fatal(err) - } - _, out, _, err := s.Handle("/EventMissionInfo", req(1)) - if err != nil { - t.Fatal(err) - } - count := 0 - if err := wire.Walk(out, func(f wire.Field) error { - if f.Number == 1 { - count++ - if scalar(f.Value, 1) != 7 || scalar(f.Value, 4) != 1 { - t.Fatalf("unexpected mission entry %x", f.Value) - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("active mission count=%d", count) - } -} diff --git a/go/internal/server/eventtasks/notify_changes.go b/go/internal/server/eventtasks/notify_changes.go deleted file mode 100644 index 2a1b84d..0000000 --- a/go/internal/server/eventtasks/notify_changes.go +++ /dev/null @@ -1,114 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/wire" - "fmt" - "sort" -) - -// The baseline belongs to the current account transaction, not a client session. -// Reads and retries therefore do not replay historical progress as notifications. -type missionNoticeKey struct { - Schedule string - Event, Group, Task uint64 -} - -// visibleMissionValues is a read-only view. In particular, it must not call -// mission(), which initializes and resets persisted progress as a side effect. -func (s *Service) visibleMissionValues() map[missionNoticeKey]uint64 { - out := map[missionNoticeKey]uint64{} - for _, v := range s.taskSchedules() { - if v.Type != 4 || !s.active(v) { - continue - } - group := s.design.MissionGroups[v.ID] - period := "" - if group.Type == 1 { - period = s.day() - } - if group.Type == 2 { - y, w := s.now().UTC().ISOWeek() - period = fmt.Sprintf("%d-%d", y, w) - } - for _, t := range s.design.Missions { - if !s.availableTask(v, t) { - continue - } - k := missionNoticeKey{scheduleKey(v), v.ID, t.Group, t.ID} - value := uint64(0) - if m := s.state.Missions[k.Schedule+"/"+key(t.ID)]; m != nil && m.Period == period { - value = m.Value - } - out[k] = value - } - } - return out -} - -func (s *Service) notifyMissionChanges() ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.beforeMissions == nil { - return nil, nil - } - current := s.visibleMissionValues() - var keys []missionNoticeKey - for k, value := range current { - if value != s.beforeMissions[k] { - keys = append(keys, k) - } - } - sort.Slice(keys, func(i, j int) bool { - a, b := keys[i], keys[j] - if a.Event != b.Event { - return a.Event < b.Event - } - if a.Group != b.Group { - return a.Group < b.Group - } - if a.Task != b.Task { - return a.Task < b.Task - } - return a.Schedule < b.Schedule - }) - var out, groupMap, rows []byte - var eventID, groupID uint64 - flushGroup := func() { - if len(rows) == 0 { - return - } - entry := wire.AppendVarint(nil, 1, groupID) - entry = wire.AppendBytes(entry, 2, rows) - groupMap = wire.AppendBytes(groupMap, 1, entry) - rows = nil - } - flushEvent := func() { - flushGroup() - if len(groupMap) == 0 { - return - } - entry := wire.AppendVarint(nil, 1, eventID) - entry = wire.AppendBytes(entry, 2, groupMap) - out = wire.AppendBytes(out, 4, entry) - groupMap = nil - } - for _, k := range keys { - if k.Event != eventID { - flushEvent() - eventID = k.Event - groupID = k.Group - } - if k.Group != groupID { - flushGroup() - groupID = k.Group - } - entry := wire.AppendVarint(nil, 1, k.Task) - entry = wire.AppendVarint(entry, 2, current[k]) - rows = wire.AppendBytes(rows, 1, entry) - } - flushEvent() - // Consume only this request's baseline; the next BeforeDispatch takes a fresh - // snapshot even when a transaction rolls back or the session changes. - s.beforeMissions = nil - return out, nil -} diff --git a/go/internal/server/eventtasks/notify_changes_test.go b/go/internal/server/eventtasks/notify_changes_test.go deleted file mode 100644 index 23bef9a..0000000 --- a/go/internal/server/eventtasks/notify_changes_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bd2server/internal/server/world" - "bytes" - "encoding/json" - "testing" -) - -func notice(t *testing.T, s *Service, seq uint64) []byte { - t.Helper() - b, err := s.AfterDispatch("/MiniGameRouletteInfo", req(seq), nil) - if err != nil { - t.Fatal(err) - } - return b -} - -func TestRouletteReadsDoNotReplayHistoricalMissionNotifications(t *testing.T) { - s, _, _ := setup(t) - if err := s.RecordEvent(2, 0, 1, nil); err != nil { - t.Fatal(err) - } - for _, session := range []string{"test", "new-session"} { - s.BeginSession(session) - for seq := uint64(1); seq <= 3; seq++ { - if err := s.BeforeDispatch("/MiniGameRouletteInfo", req(seq)); err != nil { - t.Fatal(err) - } - if got := notice(t, s, seq); len(got) != 0 { - t.Fatalf("read notified old mission: %x", got) - } - } - } -} - -func TestMissionNotificationContainsOnlyChangesAndAbsoluteZero(t *testing.T) { - s, _, _ := setup(t) - before, _ := json.Marshal(s.state) - if err := s.BeforeDispatch("/MiniGameRouletteInfo", req(1)); err != nil { - t.Fatal(err) - } - after, _ := json.Marshal(s.state) - if !bytes.Equal(before, after) { - t.Fatal("baseline initialized persistent missions") - } - if err := s.RecordEvent(2, 0, 1, nil); err != nil { - t.Fatal(err) - } - first := notice(t, s, 1) - if len(first) == 0 { - t.Fatal("actual progress did not notify") - } - if len(notice(t, s, 1)) != 0 { - t.Fatal("same request notified twice") - } - if err := s.BeforeDispatch("/MiniGameRouletteInfo", req(2)); err != nil { - t.Fatal(err) - } - if len(notice(t, s, 2)) != 0 { - t.Fatal("subsequent read notified") - } - if err := s.BeforeDispatch("/reset", req(3)); err != nil { - t.Fatal(err) - } - for _, m := range s.state.Missions { - m.Value = 0 - } - zero := notice(t, s, 3) - if len(zero) == 0 { - t.Fatal("zero transition was omitted") - } - var value uint64 = 99 - _ = wire.Walk(zero, func(event wire.Field) error { - groupMap, _, _ := wire.Bytes(event.Value, 2) - return wire.Walk(groupMap, func(group wire.Field) error { - rows, _, _ := wire.Bytes(group.Value, 2) - return wire.Walk(rows, func(row wire.Field) error { value = scalar(row.Value, 2); return nil }) - }) - }) - if value != 0 { - t.Fatalf("notification is not absolute zero: %d", value) - } -} - -type noticeProvider struct{ count uint64 } - -func (p *noticeProvider) Snapshot() (world.GameplayAchievementSnapshot, error) { - return world.GameplayAchievementSnapshot{Items: map[[2]uint64]uint64{{5, 8}: p.count}}, nil -} -func (*noticeProvider) Events(string, []byte, []byte, world.GameplayAchievementSnapshot, world.GameplayAchievementSnapshot) ([]world.GameplayAchievementEvent, error) { - return nil, nil -} - -func TestObserverBatchRollbackRetryKeepsMissionDelta(t *testing.T) { - s, _, store := setup(t) - task := s.design.Missions[10] - task.Type = 32 - s.design.Missions[10] = task - p := ¬iceProvider{count: 1} - s.AttachGameplayProvider(p) - if err := s.save(); err != nil { - t.Fatal(err) - } - checkpoint, err := store.Load("eventtasks") - if err != nil { - t.Fatal(err) - } - if err = s.BeforeDispatch("/batch", req(1)); err != nil { - t.Fatal(err) - } - p.count = 2 - first := notice(t, s, 1) - if len(first) == 0 { - t.Fatal("observer item gain did not notify") - } - // Account transaction rollback restores the domain snapshot and inventory. - if err = store.Save("eventtasks", checkpoint); err != nil { - t.Fatal(err) - } - s.state = snapshot{} - if err = json.Unmarshal(checkpoint, &s.state); err != nil { - t.Fatal(err) - } - p.count = 1 - if err = s.BeforeDispatch("/batch", req(1)); err != nil { - t.Fatal(err) - } - p.count = 2 - retry := notice(t, s, 1) - if !bytes.Equal(first, retry) { - t.Fatalf("rolled-back retry lost delta: %x / %x", first, retry) - } - if err = s.BeforeDispatch("/batch", req(1)); err != nil { - t.Fatal(err) - } - if len(notice(t, s, 1)) != 0 { - t.Fatal("committed retry repeated delta") - } -} - -func TestMissionDeltaSeparatesPermanentGroupsAndOmitsEmptyGroups(t *testing.T) { - s, _, _ := setup(t) - r := events.NewRegistry() - if err := r.Replace([]events.Schedule{{UID: 0, Type: 4, ID: 7, Start: 1, End: 9999999999999}, {UID: 0, Type: 4, ID: 8, Start: 1, End: 9999999999999}}); err != nil { - t.Fatal(err) - } - s.registry = r - s.design.MissionGroups[8] = gamedata.EventMissionGroup{ID: 8, Groups: []uint64{11}} - s.design.Missions[12] = gamedata.EventTask{ID: 12, Group: 11, Type: 99, Target: 2} - if err := s.RecordEvent(99, 0, 1, nil); err != nil { - t.Fatal(err) - } - if err := s.BeforeDispatch("/play", req(1)); err != nil { - t.Fatal(err) - } - if err := s.RecordEvent(2, 0, 1, nil); err != nil { - t.Fatal(err) - } - got := notice(t, s, 1) - count := 0 - if err := wire.Walk(got, func(f wire.Field) error { - count++ - if f.Number != 4 || scalar(f.Value, 1) != 7 { - t.Fatalf("unchanged group emitted: %x", f.Value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("wanted changed group only, got %d", count) - } - if err := s.BeforeDispatch("/read", req(2)); err != nil { - t.Fatal(err) - } - if len(notice(t, s, 2)) != 0 { - t.Fatal("empty event/group emitted") - } -} diff --git a/go/internal/server/eventtasks/observer.go b/go/internal/server/eventtasks/observer.go deleted file mode 100644 index dbdd0a6..0000000 --- a/go/internal/server/eventtasks/observer.go +++ /dev/null @@ -1,120 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/world" - "encoding/json" - "fmt" -) - -func (s *Service) BeginSession(id string) { s.SetSession(id) } -func (s *Service) inventorySnapshot() (world.GameplayAchievementSnapshot, error) { - if provider, ok := s.provider.(interface { - InventorySnapshot() (world.GameplayAchievementSnapshot, error) - }); ok { - return provider.InventorySnapshot() - } - return s.provider.Snapshot() -} -func (s *Service) BeforeDispatch(string, []byte) error { - s.mu.Lock() - s.beforeMissions = s.visibleMissionValues() - s.mu.Unlock() - if s.provider == nil { - return nil - } - var e error - s.before, e = s.inventorySnapshot() - return e -} -func (s *Service) AttachGameplayProvider(p world.GameplayAchievementProvider) { s.provider = p } -func (s *Service) AfterDispatch(path string, request, response []byte) ([]byte, error) { - if s.provider != nil { - after, e := s.inventorySnapshot() - if e != nil { - return nil, e - } - rk := fmt.Sprintf("observer:%s:%s:%d", s.session, path, scalar(request, 1)) - s.mu.Lock() - _, seen := s.state.Receipts[rk] - s.mu.Unlock() - if !seen { - type delta struct{ condition, sub, count uint64 } - var deltas []delta - for kind, old := range s.before.Items { - current := after.Items[kind] - if current < old { - condition := uint64(12) - if kind[1] == 0 { - condition = 11 - } - deltas = append(deltas, delta{condition, kind[0], old - current}) - } - } - for kind, current := range after.Items { - old := s.before.Items[kind] - if current > old { - deltas = append(deltas, delta{32, kind[0], current - old}) - } - } - for idx, current := range after.Equipment { - old, ok := s.before.Equipment[idx] - if ok && current.Level > old.Level { - deltas = append(deltas, delta{14, 0, current.Level - old.Level}) - } - } - for idx, current := range after.Costumes { - old, ok := s.before.Costumes[idx] - if ok && current.Level > old.Level { - deltas = append(deltas, delta{104, current.ID, current.Level - old.Level}) - } - } - if len(deltas) > 0 { - s.mu.Lock() - before, e := json.Marshal(s.state) - if e != nil { - s.mu.Unlock() - return nil, e - } - for _, d := range deltas { - s.recordEventLocked(d.condition, d.sub, d.count, s.unlocked) - } - s.state.Receipts[rk] = receipt{Digest: "observer"} - e = s.save() - if e != nil { - s.state = snapshot{} - _ = json.Unmarshal(before, &s.state) - } - s.mu.Unlock() - if e != nil { - return nil, e - } - } - } - } - return s.notifyMissionChanges() -} - -func (s *Service) CompleteSingleTargetEvent(condition uint64, unlocked func(uint64, uint64) bool) error { - s.mu.Lock() - defer s.mu.Unlock() - before, _ := json.Marshal(s.state) - for _, v := range s.taskSchedules() { - if !s.active(v) { - continue - } - for _, t := range s.design.Missions { - if t.Type != condition || t.SubType != 0 || t.Target != 1 || len(t.Params) != 0 || !s.availableTask(v, t) { - continue - } - if (t.UnlockPack > 0 || t.UnlockQuest > 0) && (unlocked == nil || !unlocked(t.UnlockPack, t.UnlockQuest)) { - continue - } - s.mission(v, t.ID).Value = 1 - } - } - if e := s.save(); e != nil { - _ = json.Unmarshal(before, &s.state) - return e - } - return nil -} diff --git a/go/internal/server/eventtasks/observer_performance_test.go b/go/internal/server/eventtasks/observer_performance_test.go deleted file mode 100644 index d64896e..0000000 --- a/go/internal/server/eventtasks/observer_performance_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/stateio" - "bd2server/internal/server/world" - "errors" - "testing" - "time" -) - -type narrowNoticeProvider struct { - noticeProvider - fullCalls, narrowCalls int -} - -func (p *narrowNoticeProvider) Snapshot() (world.GameplayAchievementSnapshot, error) { - p.fullCalls++ - return p.noticeProvider.Snapshot() -} -func (p *narrowNoticeProvider) InventorySnapshot() (world.GameplayAchievementSnapshot, error) { - p.narrowCalls++ - return p.noticeProvider.Snapshot() -} - -type failedObserverStore struct{ stateio.Store } - -func (s failedObserverStore) Save(string, []byte) error { return errors.New("observer save failure") } - -func TestObserverUsesInventoryProjectionAndRestoresMemoryOnSaveFailure(t *testing.T) { - s, _, store := setup(t) - p := &narrowNoticeProvider{noticeProvider: noticeProvider{count: 1}} - s.AttachGameplayProvider(p) - task := s.design.Missions[10] - task.Type = 32 - s.design.Missions[10] = task - if e := s.BeforeDispatch("/grant", req(1)); e != nil { - t.Fatal(e) - } - p.count = 2 - s.store = failedObserverStore{store} - if _, e := s.AfterDispatch("/grant", req(1), nil); e == nil { - t.Fatal("observer persistence failure ignored") - } - if len(s.state.Missions) != 0 || len(s.state.Receipts) != 0 { - t.Fatal("failed observer save retained partial tasks or receipt") - } - if p.fullCalls != 0 || p.narrowCalls != 2 { - t.Fatalf("observer requested expensive full snapshot: full=%d narrow=%d", p.fullCalls, p.narrowCalls) - } - s.store = store - p.count = 1 - if err := s.BeforeDispatch("/grant", req(1)); err != nil { - t.Fatal(err) - } - p.count = 2 - b, e := s.AfterDispatch("/grant", req(1), nil) - if e != nil || len(b) == 0 { - t.Fatal("retry after failed observer persistence lost progress") - } -} - -type writeCountingStore struct { - stateio.Store - writes int - bytes int -} - -func (s *writeCountingStore) Save(name string, b []byte) error { - s.writes++ - s.bytes += len(b) - return s.Store.Save(name, b) -} - -func TestReadOnlyBatchHasNoObserverWritesButRealDeltaNotifiesOnce(t *testing.T) { - s, _, store := setup(t) - counter := &writeCountingStore{Store: store} - s.store = counter - p := ¬iceProvider{count: 1} - s.AttachGameplayProvider(p) - start := time.Now() - for seq := uint64(1); seq <= 57; seq++ { - if e := s.BeforeDispatch("/read", req(seq)); e != nil { - t.Fatal(e) - } - b, e := s.AfterDispatch("/read", req(seq), nil) - if e != nil || len(b) > 0 { - t.Fatalf("read-only changed missions: %x %v", b, e) - } - } - if counter.writes != 0 || len(s.state.Receipts) != 0 { - t.Fatalf("read-only 57 packets made %d writes/%d receipts", counter.writes, len(s.state.Receipts)) - } - t.Logf("57 unchanged observer boundaries: %s, writes=%d", time.Since(start), counter.writes) - task := s.design.Missions[10] - task.Type = 32 - s.design.Missions[10] = task - if e := s.BeforeDispatch("/grant", req(58)); e != nil { - t.Fatal(e) - } - p.count = 2 - b, e := s.AfterDispatch("/grant", req(58), nil) - if e != nil || len(b) == 0 || counter.writes != 1 { - t.Fatalf("real delta not persisted/notified once: writes=%d body=%x error=%v", counter.writes, b, e) - } - // Even if an upstream replay temporarily exposes the same before/after - // delta, the committed request receipt must not increment tasks twice. - p.count = 1 - if err := s.BeforeDispatch("/grant", req(58)); err != nil { - t.Fatal(err) - } - p.count = 2 - b, e = s.AfterDispatch("/grant", req(58), nil) - if e != nil || len(b) != 0 || counter.writes != 1 { - t.Fatal("replay repeated mission increment or write") - } - if e = s.RecordEvent(999999, 0, 1, nil); e != nil || counter.writes != 1 { - t.Fatal("irrelevant condition wrote state") - } - if e = s.RecordEvent(32, 0, 100, nil); e != nil { - t.Fatal(e) - } - writes := counter.writes - if e = s.RecordEvent(32, 0, 100, nil); e != nil || counter.writes != writes { - t.Fatal("capped task still wrote whole snapshot") - } -} - -func BenchmarkUnchanged57PacketObserverBatch(b *testing.B) { - // Snapshot costs belong to the provider; this benchmark isolates event - // mission observation and persistence decisions without a user's database. - s, _, _ := setup(b) - p := ¬iceProvider{count: 1} - s.AttachGameplayProvider(p) - b.ReportAllocs() - b.ResetTimer() - for n := 0; n < b.N; n++ { - for seq := uint64(1); seq <= 57; seq++ { - if e := s.BeforeDispatch("/read", req(seq)); e != nil { - b.Fatal(e) - } - if _, e := s.AfterDispatch("/read", req(seq), nil); e != nil { - b.Fatal(e) - } - } - } -} diff --git a/go/internal/server/eventtasks/paid_attendance_test.go b/go/internal/server/eventtasks/paid_attendance_test.go deleted file mode 100644 index 66364b4..0000000 --- a/go/internal/server/eventtasks/paid_attendance_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "testing" -) - -func TestAttendancePremiumRequiresOwnedTicketAndDoesNotDuplicate(t *testing.T) { - for _, paid := range []bool{false, true} { - s, eco, store := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 1, Ticket: 77} - s.design.AttendanceRewards[1] = []gamedata.EventAttendanceReward{{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}, Premium: gamedata.Reward{Type: 4, Count: 100}}} - s.AttachAttendancePremium(func(ticket uint64) bool { return paid && ticket == 77 }) - claim := req(1) - code, response, handled, err := s.Handle("/Attendance", claim) - if err != nil || code != 0 || !handled { - t.Fatal(err) - } - - if _, present, _ := wire.Bytes(response, 1001); present { - t.Fatal("attendance directly granted rewards") - } - if m.identity != "test:/Attendance:1" || m.title == "" || m.body == "" || !m.sentAt.Equal(s.now()) { - t.Fatal("mail identity or content absent") - } - - want := 1 - if paid { - want = 2 - } - if len(m.rewards) != want { - t.Fatal("premium ticket ignored", paid, m.rewards) - } - if _, replay, _, err := s.Handle("/Attendance", claim); err != nil || m.calls != 1 || eco.calls != 0 || !bytes.Equal(replay, response) { - t.Fatal("claim replay duplicated", err) - } - reopened, err := Open(store, s.design, s.registry, eco) - if err != nil { - t.Fatal(err) - } - reopened.AttachAttendanceMail(m) - reopened.now = s.now - reopened.SetSession("test") - _, replay, _, err := reopened.Handle("/Attendance", claim) - if err != nil || m.calls != 1 || eco.calls != 0 || !bytes.Equal(replay, response) { - t.Fatal("restart lost reward envelope", err) - } - } -} diff --git a/go/internal/server/eventtasks/pass_reward_integration_test.go b/go/internal/server/eventtasks/pass_reward_integration_test.go deleted file mode 100644 index 41ac06e..0000000 --- a/go/internal/server/eventtasks/pass_reward_integration_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package eventtasks - -import ( - "bytes" - "testing" - - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type passRewardDesign struct{} - -func (passRewardDesign) Character(uint64) (gamedata.CharacterDesign, bool) { - return gamedata.CharacterDesign{}, false -} -func (passRewardDesign) RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) { - return nil, nil, nil, nil -} -func (passRewardDesign) Resolve(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - return r, nil -} - -func realPassEconomy(t *testing.T, store stateio.Store) (*events.Economy, *player.Inventory, *player.Wallet) { - t.Helper() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(store, nil) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, equipment.EnsurePersisted} { - if err := persist(); err != nil { - t.Fatal(err) - } - } - design := passRewardDesign{} - economy, err := events.NewEconomy(store, items, wallet, collection, equipment, design, design, design, nil) - if err != nil { - t.Fatal(err) - } - return economy, items, wallet -} - -// A spy alone cannot detect a claim recorded without a durable inventory grant. -// Exercise the native BASIC -> PREMIUM one-click flow through the real economy. -func TestPassRewardAllPersistsEveryCurrencyAndItemAcrossRestart(t *testing.T) { - s, _, store := setup(t) - economy, items, wallet := realPassEconomy(t, store) - s.economy = economy - s.design.PassLevels[8] = []gamedata.EventPassLevel{ - {ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}, - {ID: 2, NeedExp: 30, Basic: gamedata.Reward{Type: 3, Count: 7}, Premium: gamedata.Reward{Type: 8, ID: 1000, Count: 3}}, - {ID: 3, NeedExp: 40, Basic: gamedata.Reward{Type: 8, ID: 1000, Count: 11}, Premium: gamedata.Reward{Type: 4, Count: 100}}, - {ID: 4, Basic: gamedata.Reward{Type: 3, Count: 999}, Premium: gamedata.Reward{Type: 4, Count: 999}}, - } - p := s.pass(s.registry.List()[2]) - p.Exp = 50 - p.Premium = true - basic := passClaimRequest(1, 0, true, 0) - premium := passClaimRequest(2, 1, true, 0) - basicReply := claimPass(t, s, basic) - premiumReply := claimPass(t, s, premium) - assertBalances := func(items *player.Inventory, wallet *player.Wallet) { - t.Helper() - c := wallet.Snapshot() - if c.FreeJewelry != 12 || c.Gold != 100 { - t.Fatalf("durable currencies: %+v", c) - } - var total uint64 - for _, item := range items.All() { - if item.Type == 8 && item.ID == 1000 { - total += item.Count - } - } - if total != 16 { - t.Fatalf("durable ticket quantity=%d want=16", total) - } - } - assertBalances(items, wallet) - for _, response := range [][]byte{basicReply, premiumReply} { - bundle, _, err := wire.Bytes(response, 1) - if err != nil { - t.Fatal(err) - } - count := 0 - if err := wire.Walk(bundle, func(f wire.Field) error { - if f.Number == 1 { - count++ - } - return nil - }); err != nil { - t.Fatal(err) - } - if count != 3 { - t.Fatalf("bundle omitted rewards: entries=%d want=3", count) - } - } - economy, items, wallet = realPassEconomy(t, store) - reopened, err := Open(store, s.design, s.registry, economy) - if err != nil { - t.Fatal(err) - } - reopened.now = s.now - reopened.SetSession("test") - if !bytes.Equal(claimPass(t, reopened, basic), basicReply) || !bytes.Equal(claimPass(t, reopened, premium), premiumReply) { - t.Fatal("replay response changed") - } - assertBalances(items, wallet) - for typ := uint64(0); typ <= 1; typ++ { - claimPass(t, reopened, passClaimRequest(3+typ, typ, true, 0)) - } - assertBalances(items, wallet) - if reopened.pass(reopened.registry.List()[2]).Claimed[key(4, 0)] || reopened.pass(reopened.registry.List()[2]).Claimed[key(4, 1)] { - t.Fatal("locked level was claimed") - } -} diff --git a/go/internal/server/eventtasks/pass_reward_test.go b/go/internal/server/eventtasks/pass_reward_test.go deleted file mode 100644 index 4c4d0b4..0000000 --- a/go/internal/server/eventtasks/pass_reward_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "testing" -) - -func passClaimRequest(seq, rewardType uint64, all bool, level uint64) []byte { - b := req(seq) - if all { - b = wire.AppendVarint(b, 2, 1) - } - b = wire.AppendVarint(b, 3, 8) - if level != 0 { - b = wire.AppendVarint(b, 4, level) - } - if rewardType != 0 { - b = wire.AppendVarint(b, 5, rewardType) - } - return b -} - -func claimPass(t *testing.T, s *Service, request []byte) []byte { - t.Helper() - code, response, handled, err := s.Handle("/PassReward", request) - if err != nil || code != 126 || !handled { - t.Fatalf("pass claim failed: code=%d handled=%t err=%v", code, handled, err) - } - if _, present, err := wire.Bytes(response, 1); err != nil || !present { - t.Fatalf("native receiver requires a non-null reward bundle: %x, %v", response, err) - } - return response -} - -func TestPassRewardNativeOneClickAfterPurchaseAndRestart(t *testing.T) { - s, e, store := setup(t) - s.design.PassLevels[8] = []gamedata.EventPassLevel{ - {ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 1}, Premium: gamedata.Reward{Type: 4, Count: 10}}, - {ID: 2, NeedExp: 30, Basic: gamedata.Reward{Type: 3, Count: 2}, Premium: gamedata.Reward{Type: 4, Count: 20}}, - {ID: 3, NeedExp: 40, Basic: gamedata.Reward{Type: 3, Count: 3}, Premium: gamedata.Reward{Type: 4, Count: 30}}, - {ID: 4, Basic: gamedata.Reward{Type: 3, Count: 4}, Premium: gamedata.Reward{Type: 4, Count: 40}}, - } - s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, LevelsGranted: 3, Cost: gamedata.Reward{Type: 3, Count: 1000}}}} - buy := wire.AppendVarint(req(1), 2, 8) - buy = wire.AppendVarint(buy, 3, 1) - if _, _, _, err := s.Handle("/PassBuy", buy); err != nil { - t.Fatal(err) - } - - // PassRootUI.ReceiveAllReward sends BASIC first, then PREMIUM_1 in its callback. - basic := passClaimRequest(2, 0, true, 0) - basicReply := claimPass(t, s, basic) - for _, lv := range s.design.PassLevels[8] { - p := s.pass(s.registry.List()[2]) - if !p.Claimed[key(lv.ID, 0)] || p.Claimed[key(lv.ID, 1)] { - t.Fatalf("basic phase incorrectly claimed premium at level %d", lv.ID) - } - } - if len(e.rewards) != 4 || e.rewards[0].Type != 3 || e.rewards[3].Count != 4 { - t.Fatalf("basic phase granted wrong rewards: %+v", e.rewards) - } - reopened, err := Open(store, s.design, s.registry, e) - if err != nil { - t.Fatal(err) - } - reopened.now = s.now - reopened.SetSession("test") - if replay := claimPass(t, reopened, basic); !bytes.Equal(replay, basicReply) || e.calls != 2 { - t.Fatal("restart replay repeated basic grants") - } - premium := passClaimRequest(3, 1, true, 0) - premiumReply := claimPass(t, reopened, premium) - if len(e.rewards) != 8 || e.rewards[4].Type != 4 || e.rewards[7].Count != 40 || e.calls != 3 { - t.Fatalf("premium phase granted wrong rewards: %+v", e.rewards) - } - for _, lv := range reopened.design.PassLevels[8] { - p := reopened.pass(reopened.registry.List()[2]) - if !p.Claimed[key(lv.ID, 0)] || !p.Claimed[key(lv.ID, 1)] { - t.Fatalf("claim state incomplete at level %d", lv.ID) - } - } - if replay := claimPass(t, reopened, premium); !bytes.Equal(replay, premiumReply) || e.calls != 3 { - t.Fatal("premium replay repeated grants") - } - // A new one-click request after claiming must complete without granting again. - for typ := uint64(0); typ <= 1; typ++ { - response := claimPass(t, reopened, passClaimRequest(4+typ, typ, true, 0)) - bundle, _, _ := wire.Bytes(response, 1) - if len(bundle) != 0 || e.calls != 3 || len(e.rewards) != 8 { - t.Fatal("already-claimed all request repeated rewards") - } - } -} - -func TestPassRewardBasicAlreadyClaimedBeforePremiumPurchase(t *testing.T) { - s, e, _ := setup(t) - s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 4, Count: 50}}} - claimPass(t, s, passClaimRequest(1, 0, true, 0)) - s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, Cost: gamedata.Reward{Type: 3, Count: 1000}}}} - buy := wire.AppendVarint(req(2), 2, 8) - buy = wire.AppendVarint(buy, 3, 1) - if _, _, _, err := s.Handle("/PassBuy", buy); err != nil { - t.Fatal(err) - } - claimPass(t, s, passClaimRequest(3, 0, true, 0)) - if len(e.rewards) != 1 || e.calls != 2 { - t.Fatal("empty basic phase granted again") - } - claimPass(t, s, passClaimRequest(4, 1, true, 0)) - if len(e.rewards) != 2 || e.rewards[1].Type != 4 || e.rewards[1].Count != 50 || e.calls != 3 { - t.Fatalf("empty basic phase prevented premium claim: %+v", e.rewards) - } -} - -func TestPassRewardSingleClaimsAndLockedLevels(t *testing.T) { - s, e, _ := setup(t) - s.design.PassLevels[8] = []gamedata.EventPassLevel{ - {ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}, Premium: gamedata.Reward{Type: 4, Count: 50}}, - {ID: 2, Basic: gamedata.Reward{Type: 3, Count: 6}, Premium: gamedata.Reward{Type: 4, Count: 60}}, - } - if _, _, _, err := s.Handle("/PassReward", passClaimRequest(1, 1, true, 0)); err == nil || e.calls != 0 { - t.Fatal("premium reward granted without purchase") - } - claimPass(t, s, passClaimRequest(2, 0, false, 1)) - if len(e.rewards) != 1 || e.rewards[0].Count != 5 { - t.Fatal("single basic reward incorrect") - } - for _, request := range [][]byte{passClaimRequest(3, 0, false, 1), passClaimRequest(4, 0, false, 2), passClaimRequest(5, 0, false, 99), passClaimRequest(6, 2, true, 0)} { - if _, _, _, err := s.Handle("/PassReward", request); err == nil || e.calls != 1 { - t.Fatal("duplicate, locked or fabricated reward accepted") - } - } - s.pass(s.registry.List()[2]).Premium = true - claimPass(t, s, passClaimRequest(7, 1, false, 1)) - if len(e.rewards) != 2 || e.rewards[1].Count != 50 || s.pass(s.registry.List()[2]).Claimed[key(2, 1)] { - t.Fatal("single premium claim changed another level") - } -} - -func TestPassRewardKeepsCurrentNewbieStepInEveryResponse(t *testing.T) { - s, _, _ := setup(t) - d := s.design.Passes[8] - d.NewbieStep = 1 - s.design.Passes[8] = d - s.state.NewbieStep = 1 - s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 5}}, {ID: 2, Basic: gamedata.Reward{Type: 3, Count: 6}}} - response := claimPass(t, s, passClaimRequest(1, 0, true, 0)) - if scalar(response, 2) != 1 || s.state.NewbieStep != 1 { - t.Fatal("partial claim resets native guide step") - } - response = claimPass(t, s, passClaimRequest(2, 0, true, 0)) - if scalar(response, 2) != 1 { - t.Fatal("empty phase resets native guide step") - } -} diff --git a/go/internal/server/eventtasks/service_test.go b/go/internal/server/eventtasks/service_test.go deleted file mode 100644 index d525a1f..0000000 --- a/go/internal/server/eventtasks/service_test.go +++ /dev/null @@ -1,339 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "errors" - "strings" - "testing" - "time" -) - -type economyStub struct { - calls int - rewards []gamedata.Reward - costs []gamedata.Reward - err error -} - -func (e *economyStub) Apply(_ string, c []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { - e.calls++ - e.costs = append(e.costs, c...) - e.rewards = append(e.rewards, r...) - return []byte{10, 0}, e.err -} - -type attendanceMailStub struct { - calls int - rewards []gamedata.Reward - identity, title, body string - sentAt time.Time - err error - issued map[string]bool -} - -func (m *attendanceMailStub) IssueAttachmentsOnce(identity, title, body string, rewards []gamedata.Reward, sentAt time.Time) error { - if m.err != nil { - return m.err - } - if m.issued == nil { - m.issued = map[string]bool{} - } - if m.issued[identity] { - return nil - } - m.issued[identity] = true - m.calls++ - m.rewards = append(m.rewards, rewards...) - m.identity, m.title, m.body, m.sentAt = identity, title, body, sentAt - return nil -} -func setup(t testing.TB) (*Service, *economyStub, stateio.Store) { - t.Helper() - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - registry := events.NewRegistry() - if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 2, Type: 4, ID: 7, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}, {UID: 3, Type: 5, ID: 8, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(24 * time.Hour).UnixMilli()}}); err != nil { - t.Fatal(err) - } - d := &gamedata.EventTasksDesign{Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 1}}, AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{1: {{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}}}}, MissionGroups: map[uint64]gamedata.EventMissionGroup{7: {ID: 7, Groups: []uint64{9}}}, Missions: map[uint64]gamedata.EventTask{10: {ID: 10, Group: 9, Type: 2, Target: 2, PassExp: 20, Rewards: []gamedata.Reward{{Type: 4, Count: 5}}}}, Passes: map[uint64]gamedata.EventPass{8: {ID: 8, MissionGroup: 7, LevelGroup: 8}}, PassLevels: map[uint64][]gamedata.EventPassLevel{8: {{ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 8, ID: 1000, Count: 1}}}}} - e := &economyStub{} - store := stateio.NewMemory() - s, err := Open(store, d, registry, e) - if err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return now } - s.SetSession("test") - s.AttachAttendanceMail(&attendanceMailStub{}) - return s, e, store -} -func req(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) } -func TestMissionRejectsFabricatedProgressAndReplaySurvivesRestart(t *testing.T) { - s, e, store := setup(t) - update := req(1) - x := wire.AppendVarint(nil, 1, 9) - x = wire.AppendVarint(x, 2, 10) - x = wire.AppendVarint(x, 3, 2) - x = wire.AppendVarint(x, 4, 7) - update = wire.AppendBytes(update, 2, x) - if _, _, _, err := s.Handle("/MissionUpdate", update); err == nil { - t.Fatal("client manufactured completion") - } - if err := s.RecordEvent(2, 0, 2, nil); err != nil { - t.Fatal(err) - } - clear := req(2) - clear = wire.AppendVarint(clear, 2, 1) - clear = wire.AppendVarint(clear, 3, 2) - clear = wire.AppendVarint(clear, 4, 9) - clear = wire.AppendVarint(clear, 6, 7) - _, reply, _, err := s.Handle("/MissionClear", clear) - if err != nil { - t.Fatal(err) - } - if e.calls != 1 || s.pass(s.registry.List()[2]).Exp != 20 { - t.Fatal("settlement or pass exp missing") - } - next, err := Open(store, s.design, s.registry, e) - if err != nil { - t.Fatal(err) - } - next.now = s.now - next.SetSession("test") - _, replay, _, err := next.Handle("/MissionClear", clear) - if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 { - t.Fatal("restart replay duplicated reward") - } - mutated := wire.AppendVarint(clear, 5, 10) - if _, _, _, err = next.Handle("/MissionClear", mutated); err == nil { - t.Fatal("changed replay accepted") - } - passReq := wire.AppendVarint(req(3), 2, 1) - passReq = wire.AppendVarint(passReq, 3, 8) - if _, _, _, err = next.Handle("/PassReward", passReq); err != nil { - t.Fatal(err) - } - if e.calls != 2 { - t.Fatal("pass reward not granted") - } -} -func TestAttendanceDailyCounterAndClaimEligibility(t *testing.T) { - s, eco, _ := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - if _, _, _, err := s.Handle("/Attendance", req(1)); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/Attendance", req(2)); err != nil { - t.Fatal(err) - } - if s.state.Attendance["1"].Count != 1 { - t.Fatal("reconnect counted attendance twice") - } - claim := req(3) - claim = wire.AppendVarint(claim, 2, 1) - claim = wire.AppendVarint(claim, 3, 1) - claim = wire.AppendVarint(claim, 4, 1) - if _, _, _, err := s.Handle("/EventReward", claim); err == nil { - t.Fatal("automatic reward claimed again") - } - if eco.calls != 0 || m.calls != 1 { - t.Fatal("attendance reward missing") - } - again := wire.AppendVarint(nil, 1, 4) - again = append(again, claim[2:]...) - if _, _, _, err := s.Handle("/EventReward", again); err == nil { - t.Fatal("claimed attendance awarded again") - } -} - -func TestAttendanceNextGroupAndRepeatedChainReward(t *testing.T) { - s, eco, _ := setup(t) - m := s.attendanceMail.(*attendanceMailStub) - s.design.Attendance[1] = gamedata.EventAttendance{ID: 1, Group: 20} - s.design.AttendanceGroups = map[[2]uint64]gamedata.EventAttendanceGroup{{20, 5}: {Group: 20, ID: 5, Next: 6}, {20, 6}: {Group: 20, ID: 6, Next: 5}} - s.design.AttendanceRewards = map[uint64][]gamedata.EventAttendanceReward{5: {{Group: 5, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 5}}}, 6: {{Group: 6, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 6}}}} - today := s.now() - s.now = func() time.Time { return today } - registry := events.NewRegistry() - if err := registry.Replace([]events.Schedule{{UID: 1, Type: 0, ID: 1, Start: today.Add(-24 * time.Hour).UnixMilli(), End: today.Add(7 * 24 * time.Hour).UnixMilli()}}); err != nil { - t.Fatal(err) - } - s.registry = registry - seq := uint64(1) - for _, group := range []uint64{5, 6, 5} { - if _, _, _, err := s.Handle("/Attendance", req(seq)); err != nil { - t.Fatal(err) - } - seq++ - if s.state.Attendance["1"].Group != group || s.state.Attendance["1"].Count != 1 { - t.Fatalf("wrong chain state %+v", s.state.Attendance["1"]) - } - claim := wire.AppendVarint(req(seq), 2, 1) - claim = wire.AppendVarint(claim, 3, group) - claim = wire.AppendVarint(claim, 4, 1) - if _, _, _, err := s.Handle("/EventReward", claim); err == nil { - t.Fatal("automatic cycle reward claimed again") - } - seq++ - today = today.Add(24 * time.Hour) - } - if eco.calls != 0 || m.calls != 3 || len(s.state.Attendance["1"].History) != 2 { - t.Fatal("repeated cycle reward or retained history missing") - } -} - -func TestPrivateServerPremiumCashPassOnceAndRestartReplay(t *testing.T) { - s, e, store := setup(t) - s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {{ID: 1, Type: 1, CashID: 42, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}}}} - authorizations := 0 - authorize := func(id, typ uint64) bool { - authorizations++ - return id == 8 && typ == 1 && authorizations == 1 - } - s.AttachCashAuthorization(authorize) - request := wire.AppendVarint(req(1), 2, 8) - request = wire.AppendVarint(request, 3, 1) - _, reply, _, err := s.Handle("/PassBuy", request) - if err != nil { - t.Fatal(err) - } - if e.calls != 1 || !s.state.Passes["3"].Premium { - t.Fatal("premium cash pass not activated") - } - reopened, err := Open(store, s.design, s.registry, e) - if err != nil { - t.Fatal(err) - } - reopened.now = s.now - reopened.SetSession("test") - reopened.AttachCashAuthorization(authorize) - _, again, _, err := reopened.Handle("/PassBuy", request) - if err != nil || !bytes.Equal(reply, again) || e.calls != 1 { - t.Fatal("cash pass replay duplicated reward") - } - duplicate := wire.AppendVarint(req(2), 2, 8) - duplicate = wire.AppendVarint(duplicate, 3, 1) - if _, _, _, err = reopened.Handle("/PassBuy", duplicate); err == nil || e.calls != 1 { - t.Fatal("premium cash purchase granted twice") - } - if authorizations != 1 { - t.Fatal("replay or duplicate consumed another cash entitlement") - } -} - -func TestPassPurchasesGrantLevelsInsteadOfRequiringThem(t *testing.T) { - for _, tc := range []struct { - name string - typ, exp, grant, wantExp uint64 - baseCost, wantCost uint64 - cash bool - }{ - {"first level upgrade", 0, 0, 1, 20, 50, 50, false}, - {"level weighted price", 0, 25, 1, 55, 50, 100, false}, - {"three level upgrade", 0, 0, 3, 90, 150, 150, false}, - {"premium from level one", 1, 0, 3, 90, 1000, 1000, false}, - {"premium surplus capped", 1, 55, 3, 90, 1000, 1000, false}, - {"cash premium grants levels", 3, 0, 3, 90, 1, 1, true}, - {"cash premium at max", 3, 90, 3, 90, 1, 1, true}, - } { - t.Run(tc.name, func(t *testing.T) { - s, e, _ := setup(t) - s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, NeedExp: 20}, {ID: 2, NeedExp: 30}, {ID: 3, NeedExp: 40}, {ID: 4}} - buy := gamedata.EventPassBuy{ID: 1, Type: tc.typ, LevelsGranted: tc.grant, Cost: gamedata.Reward{Type: 3, Count: tc.baseCost}} - authorizations := 0 - if tc.cash { - buy.CashID = 42 - buy.Cost = gamedata.Reward{Type: 19, ID: 77, Count: 1} - s.AttachCashAuthorization(func(id, typ uint64) bool { - authorizations++ - return id == 8 && typ == 3 - }) - } - s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {buy}} - p := s.pass(s.registry.List()[2]) - p.Exp = tc.exp - request := wire.AppendVarint(req(1), 2, 8) - request = wire.AppendVarint(request, 3, tc.typ) - _, reply, _, err := s.Handle("/PassBuy", request) - if err != nil { - t.Fatal(err) - } - if scalar(reply, 1) != tc.wantExp || p.Exp != tc.wantExp || p.Premium != (tc.typ == 1 || tc.typ == 3) { - t.Fatalf("wrong pass state %+v reply=%x", p, reply) - } - if e.calls != 1 || len(e.costs) != 1 || e.costs[0].Count != tc.wantCost || e.costs[0].Type != buy.Cost.Type { - t.Fatalf("wrong purchase charge %+v", e.costs) - } - _, replay, _, err := s.Handle("/PassBuy", request) - if err != nil || !bytes.Equal(reply, replay) || e.calls != 1 || tc.cash && authorizations != 1 { - t.Fatal("retry repeated payment or activation") - } - }) - } -} - -func TestPassPurchaseRejectsWithoutChangingProgress(t *testing.T) { - for _, tc := range []struct { - name, wantError string - typ, exp uint64 - cash, denied bool - paymentError bool - }{ - {"level purchase at max", "pass already at max level", 0, 20, false, false, false}, - {"cash checker missing", "cash entitlement required", 3, 0, true, false, false}, - {"cash purchase missing", "cash entitlement required", 3, 0, true, true, false}, - {"insufficient diamonds", "pass payment failed pass=8 type=1", 1, 0, false, false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - s, e, _ := setup(t) - s.design.PassLevels[8] = []gamedata.EventPassLevel{{ID: 1, NeedExp: 20}, {ID: 2}} - buy := gamedata.EventPassBuy{ID: 1, Type: tc.typ, LevelsGranted: 3, Cost: gamedata.Reward{Type: 3, Count: 1000}} - if tc.cash { - buy.CashID = 42 - } - if tc.denied { - s.AttachCashAuthorization(func(uint64, uint64) bool { return false }) - } - if tc.paymentError { - e.err = errors.New("insufficient currency") - } - s.design.PassBuys = map[uint64][]gamedata.EventPassBuy{8: {buy}} - s.pass(s.registry.List()[2]).Exp = tc.exp - request := wire.AppendVarint(req(1), 2, 8) - request = wire.AppendVarint(request, 3, tc.typ) - if _, _, _, err := s.Handle("/PassBuy", request); err == nil || !strings.Contains(err.Error(), tc.wantError) { - t.Fatalf("expected %q, got %v", tc.wantError, err) - } - p := s.pass(s.registry.List()[2]) - if p.Exp != tc.exp || p.Premium || len(s.state.Receipts) != 0 { - t.Fatalf("failed purchase changed state %+v", p) - } - if !tc.paymentError && e.calls != 0 { - t.Fatal("ineligible purchase reached payment") - } - }) - } -} - -func TestPassRewardsUseLevelStartExperience(t *testing.T) { - for _, tc := range []struct{ exp, wantClaims uint64 }{{0, 1}, {19, 1}, {20, 2}, {50, 3}} { - s, e, _ := setup(t) - s.design.PassLevels[8] = []gamedata.EventPassLevel{ - {ID: 1, NeedExp: 20, Basic: gamedata.Reward{Type: 3, Count: 1}}, - {ID: 2, NeedExp: 30, Basic: gamedata.Reward{Type: 3, Count: 2}}, - {ID: 3, Basic: gamedata.Reward{Type: 3, Count: 3}}, - } - s.pass(s.registry.List()[2]).Exp = tc.exp - request := wire.AppendVarint(req(1), 2, 1) - request = wire.AppendVarint(request, 3, 8) - if _, _, _, err := s.Handle("/PassReward", request); err != nil { - t.Fatal(err) - } - if uint64(len(e.rewards)) != tc.wantClaims { - t.Fatalf("exp=%d claimed %+v; wanted %d", tc.exp, e.rewards, tc.wantClaims) - } - } -} diff --git a/go/internal/server/eventtasks/tracker_missions_test.go b/go/internal/server/eventtasks/tracker_missions_test.go deleted file mode 100644 index f4189e5..0000000 --- a/go/internal/server/eventtasks/tracker_missions_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package eventtasks - -import ( - "bd2server/internal/server/gamedata" - "testing" -) - -// MC_FIELD_SPAWN_EVENT_REWARD subtype zero is the normal monster kind, -// unlike other event conditions where a zero subtype means any item. -func TestTrackerMissionsSeparateNormalAndSpecialRewards(t *testing.T) { - s, _, _ := setup(t) - s.design.Missions = map[uint64]gamedata.EventTask{10: {ID: 10, Group: 9, Type: 349, SubType: 0, Target: 20}, 11: {ID: 11, Group: 9, Type: 349, SubType: 1, Target: 5}} - if err := s.RecordEvent(349, 1, 1, nil); err != nil { - t.Fatal(err) - } - v, err := s.registry.Resolve(2) - if err != nil { - t.Fatal(err) - } - if s.mission(v, 10).Value != 0 || s.mission(v, 11).Value != 1 { - t.Fatal("special capture advanced normal capture mission") - } - if err := s.RecordEvent(349, 0, 1, nil); err != nil { - t.Fatal(err) - } - if s.mission(v, 10).Value != 1 || s.mission(v, 11).Value != 1 { - t.Fatal("normal capture advanced special capture mission") - } -} diff --git a/go/internal/server/feature/bootstrap_test.go b/go/internal/server/feature/bootstrap_test.go deleted file mode 100644 index e4936ee..0000000 --- a/go/internal/server/feature/bootstrap_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package feature - -import ( - "bytes" - "testing" - - "bd2server/internal/server/wire" -) - -func TestBootstrapProtocolDefaults(t *testing.T) { - for _, test := range []struct { - path string - code int - proto []byte - }{ - {"/AvatarInfo", 467, []byte{0x0a, 0}}, - {"/GuildRaidSeasonReward", 310, []byte{0x0a, 0}}, - } { - t.Run(test.path, func(t *testing.T) { - code, proto, handled, err := Handle(test.path, wire.AppendVarint(nil, 1, 42)) - if err != nil || !handled || code != test.code || !bytes.Equal(proto, test.proto) { - t.Fatalf("code=%d proto=%x handled=%v err=%v", code, proto, handled, err) - } - if len(proto) != 0 { - proto[0] ^= 0xff - } - _, again, _, err := Handle(test.path, wire.AppendVarint(nil, 1, 43)) - if err != nil || !bytes.Equal(again, test.proto) { - t.Fatalf("response mutation escaped into defaults: proto=%x err=%v", again, err) - } - if _, _, handled, err := Handle(test.path, nil); !handled || err == nil { - t.Fatal("bootstrap endpoint accepted missing sequence") - } - }) - } -} diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go deleted file mode 100644 index ef0d9c3..0000000 --- a/go/internal/server/feature/feature_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package feature - -import ( - "errors" - "testing" - - "bd2server/internal/server/wire" -) - -func TestHandleAuditedEmptyResponses(t *testing.T) { - for path, wantCode := range EmptyPacketCodes() { - t.Run(path, func(t *testing.T) { - gotCode, gotProto, ok, err := Handle(path, wire.AppendVarint(nil, 1, 42)) - if err != nil || !ok || gotCode != wantCode || len(gotProto) != 0 { - t.Fatalf("Handle(%q): code=%d proto=%x ok=%t err=%v; want code=%d, empty, handled", path, gotCode, gotProto, ok, err, wantCode) - } - }) - } -} - -func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) { - for _, path := range []string{"/EventScheduleInfo", "/LoginEvent", "/ChargeCostInfo", "/EventMissionInfo", "/EventRewardHistory", "/PackEventStoryInfo", "/PackEventBattleInfo", "/Attendance", "/AttendanceInfo", "/FireWorksInfo", "/FriendshipSpecialEpisodeInfo", "/MiniEventHubInfo", "/PrestigeSkinInfo"} { - if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil { - t.Fatalf("stateful event route %s has stateless fallback", path) - } - } - for _, path := range []string{"/HuntDispatchInfo", "/HuntingGroundInfo", "/HuntingGroundInfoList", "/HuntingGroundEnter", "/UserLevelReward", "/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate", "/RecipeInfo", "/PackInfo", "/DeckInfo", "/RootSortIdInfo"} { - if _, _, handled, err := Handle(path, wire.AppendVarint(nil, 1, 1)); handled || err != nil { - t.Fatalf("stateful route %s still has a stateless success fallback", path) - } - } - if code, proto, ok, err := Handle("/not-a-real-endpoint", wire.AppendVarint(nil, 1, 1)); code != 0 || proto != nil || ok || err != nil { - t.Fatalf("unknown route was not fail-closed: code=%d proto=%x ok=%t err=%v", code, proto, ok, err) - } - if _, _, ok, err := Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 1)); ok || err != nil { - t.Fatalf("stateful CharAwakeInfo must not be handled by feature defaults: ok=%v err=%v", ok, err) - } - for _, path := range []string{"/PackPreviewInfo", "/PackJamEvent", "/PresetInfo", "/DeckCostumeSettingInfo", "/FriendshipInfo", "/MasterTitleInfo", "/MasterTitleInfoUpdate"} { - if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil { - t.Fatalf("stateful %s must not be handled by feature defaults: ok=%v err=%v", path, ok, err) - } - } - for name, request := range map[string][]byte{ - "empty": nil, - "no-sequence": wire.AppendVarint(nil, 2, 1), - "zero-sequence": wire.AppendVarint(nil, 1, 0), - "malformed": {0x08, 0x80}, - } { - t.Run(name, func(t *testing.T) { - _, proto, ok, err := Handle("/QuestMaxClearInfo", request) - if !ok || proto != nil || !errors.Is(err, ErrInvalidRequest) { - t.Fatalf("invalid request: proto=%x ok=%t err=%v", proto, ok, err) - } - }) - } -} - -func TestEmptyPacketCodesReturnsCopy(t *testing.T) { - codes := EmptyPacketCodes() - codes["/QuestMaxClearInfo"] = -1 - if code, _, ok, err := Handle("/QuestMaxClearInfo", wire.AppendVarint(nil, 1, 1)); err != nil || !ok || code != 137 { - t.Fatalf("registry escaped its copy: code=%d ok=%t err=%v", code, ok, err) - } -} - -func TestStandaloneNativeDefaults(t *testing.T) { - for path, want := range map[string]int{ - "/EquipInfo": 34, - "/UpdateAgeGate": 0, - "/ActiveMap": 0, - "/PvpBattleDeckInfo": 87, - "/PvpBattleHistory": 97, - "/PvpBattleOnceRewardInfo": 277, - } { - code, proto, ok, err := Handle(path, wire.AppendVarint(nil, 1, 55)) - if err != nil || !ok || code != want || len(proto) != 0 { - t.Fatalf("%s: code=%d proto=%x ok=%v err=%v", path, code, proto, ok, err) - } - } -} diff --git a/go/internal/server/feature/recipes.go b/go/internal/server/feature/recipes.go deleted file mode 100644 index ebc2a93..0000000 --- a/go/internal/server/feature/recipes.go +++ /dev/null @@ -1,85 +0,0 @@ -package feature - -import ( - "encoding/binary" - "fmt" - "math" - "slices" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -type RecipeItems interface{ All() []player.Item } - -type RecipeService struct { - design *gamedata.CookingRecipeDesign - initial []uint64 - items RecipeItems -} - -func (s *RecipeService) Knows(id uint64) bool { - if !s.design.IDs[id] { - return false - } - if slices.Contains(s.initial, id) { - return true - } - for _, item := range s.items.All() { - if item.Type == 7 && item.ID == id && item.Count > 0 { - return true - } - } - return false -} - -func NewRecipeService(design *gamedata.CookingRecipeDesign, initial []uint64, items RecipeItems) (*RecipeService, error) { - if design == nil || len(design.IDs) == 0 || items == nil { - return nil, fmt.Errorf("recipes: missing design or inventory") - } - for _, id := range initial { - if !design.IDs[id] { - return nil, fmt.Errorf("recipes: initial recipe %d is absent from GameData", id) - } - } - return &RecipeService{design: design, initial: append([]uint64(nil), initial...), items: items}, nil -} - -func (s *RecipeService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/RecipeInfo" { - return 0, nil, false, nil - } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > math.MaxInt32 { - return 0, nil, true, ErrInvalidRequest - } - known := map[uint64]bool{} - for _, id := range s.initial { - known[id] = true - } - for _, item := range s.items.All() { - // EElementType.CookingRecipe is protocol value 7. Ownership comes from - // seed state or real reward grants, never from every row in CookingTable. - if item.Type != 7 || item.Count == 0 { - continue - } - if !s.design.IDs[item.ID] { - return 0, nil, true, fmt.Errorf("recipes: owned recipe %d is absent from GameData", item.ID) - } - known[item.ID] = true - } - ids := make([]uint64, 0, len(known)) - for id := range known { - ids = append(ids, id) - } - slices.Sort(ids) - var packed []byte - for _, id := range ids { - packed = binary.AppendUvarint(packed, id) - } - if len(packed) == 0 { - return 46, nil, true, nil - } - return 46, wire.AppendBytes(nil, 2, packed), true, nil -} diff --git a/go/internal/server/feature/recipes_test.go b/go/internal/server/feature/recipes_test.go deleted file mode 100644 index a04b823..0000000 --- a/go/internal/server/feature/recipes_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package feature - -import ( - "encoding/binary" - "reflect" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -func TestRecipesFollowOwnedGrantsAndRestart(t *testing.T) { - store := stateio.NewMemory() - starter := &player.Starter{Version: versionconfig.State()} - items, err := player.OpenInventory(store, starter) - if err != nil { - t.Fatal(err) - } - design := &gamedata.CookingRecipeDesign{IDs: map[uint64]bool{501: true, 701: true}} - service, err := NewRecipeService(design, []uint64{701}, items) - if err != nil { - t.Fatal(err) - } - check := func(service *RecipeService, want []uint64) { - t.Helper() - code, payload, handled, err := service.Handle("/RecipeInfo", wire.AppendVarint(nil, 1, 17)) - if err != nil || !handled || code != 46 { - t.Fatalf("recipe response: %d %v %v", code, handled, err) - } - packed, _, err := wire.Bytes(payload, 2) - var ids []uint64 - for len(packed) > 0 { - id, n := binary.Uvarint(packed) - if n <= 0 { - t.Fatal("malformed packed recipes") - } - ids = append(ids, id) - packed = packed[n:] - } - if err != nil || !reflect.DeepEqual(ids, want) { - t.Fatalf("recipes=%v want=%v err=%v", ids, want, err) - } - } - check(service, []uint64{701}) - if _, err := items.GrantOnce("recipe-reward", []gamedata.BattleReward{{Type: 7, ID: 501, Count: 1}, {Type: 7, ID: 701, Count: 1}, {Type: 1, ID: 999, Count: 1}}); err != nil { - t.Fatal(err) - } - check(service, []uint64{501, 701}) - reopened, err := player.OpenInventory(store, starter) - if err != nil { - t.Fatal(err) - } - service, err = NewRecipeService(design, []uint64{701}, reopened) - if err != nil { - t.Fatal(err) - } - check(service, []uint64{501, 701}) - if _, err := reopened.GrantOnce("unknown-recipe", []gamedata.BattleReward{{Type: 7, ID: 800, Count: 1}}); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/RecipeInfo", wire.AppendVarint(nil, 1, 18)); err == nil { - t.Fatal("unknown owned recipe accepted") - } -} - -func TestRecipeDesignRejectsUnknownInitialOwnership(t *testing.T) { - if _, err := NewRecipeService(&gamedata.CookingRecipeDesign{IDs: map[uint64]bool{501: true}}, []uint64{101}, statefulRecipeItems{}); err == nil { - t.Fatal("unknown initial recipe accepted") - } -} - -type statefulRecipeItems struct{} - -func (statefulRecipeItems) All() []player.Item { return nil } diff --git a/go/internal/server/gacha/first_gacha.go b/go/internal/server/gacha/first_gacha.go deleted file mode 100644 index e0e967a..0000000 --- a/go/internal/server/gacha/first_gacha.go +++ /dev/null @@ -1,170 +0,0 @@ -package gacha - -import ( - "errors" - "fmt" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -const firstGachaResponseDigest = "first-gacha-confirm-v1" - -type firstGachaPreview struct { - sequence uint64 - rewards []gamedata.FirstGachaReward - equipment map[int]player.Equipment - response []byte -} - -func (s *Service) previewFirstGacha(request []byte) (int, []byte, bool, error) { - if s.first == nil { - return 175, nil, true, errors.New("gacha: first gacha is not configured") - } - if s.FirstGachaCompleted() { - return 175, nil, true, errors.New("gacha: first gacha already completed") - } - for _, field := range []int{3, 4} { - value, _, err := wire.Varint(request, field) - if err != nil || value != 0 { - return 175, nil, true, errors.New("gacha: first gacha preview cannot use a cash product") - } - } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 175, nil, true, errors.New("gacha: invalid first gacha preview sequence") - } - session := s.loginIdentity() - s.firstMu.Lock() - defer s.firstMu.Unlock() - if previous, found := s.firstPreviews[session]; found && previous.sequence == seq { - return 175, append([]byte(nil), previous.response...), true, nil - } - rewards, err := s.first.Roll() - if err != nil { - return 175, nil, true, err - } - preview := firstGachaPreview{sequence: seq, rewards: append([]gamedata.FirstGachaReward(nil), rewards...), equipment: make(map[int]player.Equipment)} - var bundle []byte - for sortID, reward := range rewards { - switch reward.Type { - case 11: - costume := wire.AppendVarint(nil, 2, reward.ID) - if sortID != 0 { - costume = wire.AppendVarint(costume, 6, uint64(sortID)) - } - bundle = wire.AppendBytes(bundle, 3, costume) - case 10: - entry, err := firstGachaEquipment(s.first.EquipmentCatalog(), reward.ID, uint64(sortID)) - if err != nil { - return 175, nil, true, err - } - preview.equipment[sortID] = entry - bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry)) - default: - return 175, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type) - } - } - preview.response = wire.AppendBytes(nil, 1, bundle) - s.firstPreviews[session] = preview - if s.onPreview != nil { - if err := s.onPreview(); err != nil { - delete(s.firstPreviews, session) - return 175, nil, true, fmt.Errorf("gacha: update first preview mission: %w", err) - } - } - return 175, append([]byte(nil), preview.response...), true, nil -} - -func firstGachaEquipment(catalog *gamedata.EquipmentGachaCatalog, id, sortID uint64) (player.Equipment, error) { - if catalog == nil { - return player.Equipment{}, errors.New("gacha: first gacha equipment catalog missing") - } - main, sub, private, err := catalog.RollOptions(id) - if err != nil { - return player.Equipment{}, err - } - entry := player.Equipment{ID: id, SortID: sortID, Rank: []uint64{0, 0, 0}} - for _, option := range main { - entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) - } - for _, option := range sub { - entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) - } - if private != nil { - entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID} - } - return entry, nil -} - -func (s *Service) confirmFirstGacha(_ []byte, seq, buyType uint64, tickets []player.Item, explicitIdentity string) (int, []byte, bool, error) { - if s.first == nil || s.equipmentInventory == nil { - return 146, nil, true, errors.New("gacha: first gacha runtime is not attached") - } - if buyType != 1 || len(tickets) != 0 { - return 146, nil, true, errors.New("gacha: first gacha requires normal free confirmation") - } - identity := explicitIdentity - if identity == "" { - identity = s.requestIdentity(s.first.GachaID, seq) - } - responseIdentity := identity + ":response" - if response, found, err := s.collection.GachaBatchResponse(responseIdentity, firstGachaResponseDigest); found || err != nil { - return 146, response, true, err - } - if s.FirstGachaCompleted() { - return 146, nil, true, errors.New("gacha: first gacha already completed") - } - session := s.loginIdentity() - s.firstMu.Lock() - preview, found := s.firstPreviews[session] - s.firstMu.Unlock() - if !found || len(preview.rewards) != s.first.Count { - return 146, nil, true, errors.New("gacha: first gacha confirmation requires a preview") - } - var costumeIDs, costumeSortIDs []uint64 - var equipment []player.Equipment - for sortID, reward := range preview.rewards { - switch reward.Type { - case 11: - costumeIDs = append(costumeIDs, reward.ID) - costumeSortIDs = append(costumeSortIDs, uint64(sortID)) - case 10: - candidate, ok := preview.equipment[sortID] - if !ok || candidate.ID != reward.ID { - return 146, nil, true, fmt.Errorf("gacha: first preview missing equipment slot %d", sortID) - } - saved, err := s.equipmentInventory.GrantGeneratedOnce(fmt.Sprintf("%s:first-equip:%d", identity, sortID), candidate) - if err != nil { - return 146, nil, true, err - } - equipment = append(equipment, saved) - default: - return 146, nil, true, fmt.Errorf("gacha: unsupported first reward type %d", reward.Type) - } - } - grant, err := s.collection.GrantRegularPurchase(identity, costumeIDs, s.first.CostumeCatalog(), player.GachaPurchase{ - Group: s.first.Group, BuyType: 1, RewardCount: uint64(s.first.Count), RewardSortIDs: costumeSortIDs, - }) - if err != nil { - return 146, nil, true, err - } - if err := s.creditOverflow(identity, grant); err != nil { - return 146, nil, true, err - } - bundle := s.rewardBundle(grant) - for _, entry := range equipment { - bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry)) - } - response := wire.AppendBytes(nil, 1, bundle) - if err := s.collection.RecordGachaBatch(responseIdentity, firstGachaResponseDigest, response); err != nil { - return 146, nil, true, err - } - if s.onPreview != nil { - if err := s.onPreview(); err != nil { - return 146, nil, true, fmt.Errorf("gacha: update first gacha mission: %w", err) - } - } - return 146, response, true, nil -} diff --git a/go/internal/server/gacha/first_gacha_test.go b/go/internal/server/gacha/first_gacha_test.go deleted file mode 100644 index 54ba214..0000000 --- a/go/internal/server/gacha/first_gacha_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package gacha - -import ( - "bytes" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestFirstGachaPreviewConfirmAndReplay(t *testing.T) { - characters := make(map[uint64]gamedata.CharacterDesign) - equipmentDesign := make(map[uint64]gamedata.EquipmentDesign) - program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 1, DropType: 1} - for i := range uint64(5) { - costumeID := 61001 + i - equipmentID := 71001 + i - characters[costumeID] = fixtureCharacter(6100+i, 100+i) - equipmentDesign[equipmentID] = gamedata.EquipmentDesign{ID: equipmentID, Grade: 4} - program.Entries = append(program.Entries, - gamedata.FirstGachaRewardEntry{ItemType: 11, ItemID: costumeID, Count: 1, Weight: 1}, - gamedata.FirstGachaRewardEntry{ItemType: 10, ItemID: equipmentID, Count: 1, Weight: 1}) - } - first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, equipmentDesign) - if err != nil { - t.Fatal(err) - } - infinite, err := fixtureInfiniteGachaDesign(10, []uint64{61001}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 1: {ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}}, - }, characters) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(storage) - if err != nil { - t.Fatal(err) - } - service, err := NewService(infinite, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachFirstGacha(first); err != nil { - t.Fatal(err) - } - service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment) - service.BeginSession("first-session") - - previewRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 11), 2, 20) - code, preview, handled, err := service.Handle("/GachaBuyPreview", previewRequest) - if err != nil || !handled || code != 175 { - t.Fatalf("preview code=%d handled=%v err=%v", code, handled, err) - } - bundle, found, err := wire.Bytes(preview, 1) - if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 { - t.Fatalf("preview costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err) - } - if len(collection.Costumes()) != 0 || len(equipment.All()) != 0 || service.FirstGachaCompleted() { - t.Fatal("preview changed authoritative ownership") - } - _, replayPreview, _, err := service.Handle("/GachaBuyPreview", previewRequest) - if err != nil || !bytes.Equal(preview, replayPreview) { - t.Fatalf("same preview request was not idempotent: err=%v", err) - } - // session.Server activates every SessionAware handler before every request. - // Re-activating the same login between preview and confirmation must not - // discard the server-owned result selected by the player. - service.BeginSession("first-session") - - buyRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 20), 3, 1) - code, response, handled, err := service.Handle("/GachaBuy", buyRequest) - if err != nil || !handled || code != 146 { - t.Fatalf("confirm code=%d handled=%v err=%v", code, handled, err) - } - bundle, found, err = wire.Bytes(response, 1) - if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 { - t.Fatalf("confirm costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err) - } - if !service.FirstGachaCompleted() || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 { - t.Fatalf("confirmed state completed=%v costumes=%d equipment=%d", service.FirstGachaCompleted(), len(collection.Costumes()), len(equipment.All())) - } - if user := collection.GachaUser(2); user.TotalBuyCount != 10 || user.Point != 0 { - t.Fatalf("first gacha accounting=%+v", user) - } - for i, costume := range collection.Costumes() { - if costume.SortID != uint64(i*2) { - t.Fatalf("costume %d sort=%d want=%d", costume.ID, costume.SortID, i*2) - } - } - _, replay, _, err := service.Handle("/GachaBuy", buyRequest) - if err != nil || !bytes.Equal(response, replay) || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 { - t.Fatalf("confirm replay mutated state: costumes=%d equipment=%d err=%v", len(collection.Costumes()), len(equipment.All()), err) - } - service.BeginSession("second-session") - if _, _, _, err := service.Handle("/GachaBuyPreview", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20)); err == nil { - t.Fatal("completed first gacha reopened in a new session") - } -} - -func TestFirstGachaRejectsConfirmationWithoutPreview(t *testing.T) { - // The full happy-path test proves attachment. This assertion locks down the - // security boundary: a forged buy cannot skip the server-owned reroll. - service := newFirstGachaTestService(t) - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20), 3, 1) - if _, _, handled, err := service.Handle("/GachaBuy", request); err == nil || !handled { - t.Fatalf("direct confirmation handled=%v err=%v", handled, err) - } -} - -func newFirstGachaTestService(t *testing.T) *Service { - t.Helper() - characters := map[uint64]gamedata.CharacterDesign{61001: fixtureCharacter(6100, 100)} - program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 10, Entries: []gamedata.FirstGachaRewardEntry{{ItemType: 11, ItemID: 61001, Count: 1, Weight: 1}}} - first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, nil) - if err != nil { - t.Fatal(err) - } - infinite, err := fixtureInfiniteGachaDesign(10, []uint64{61001}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}}}, characters) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, _ := player.OpenCollectionStore(storage, nil) - wallet, _ := player.OpenWallet(storage, player.Currency{}) - equipment, _ := player.OpenEquipmentInventory(storage) - service, err := NewService(infinite, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachFirstGacha(first); err != nil { - t.Fatal(err) - } - service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment) - service.BeginSession("first-no-preview") - return service -} diff --git a/go/internal/server/gacha/gacha_test.go b/go/internal/server/gacha/gacha_test.go deleted file mode 100644 index 8938883..0000000 --- a/go/internal/server/gacha/gacha_test.go +++ /dev/null @@ -1,1995 +0,0 @@ -package gacha - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - const version = "20260923193640" - infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID}) - if err != nil { - t.Fatal(err) - } - regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{10001, 135, 205, 121}, []uint64{29}) - if err != nil { - t.Fatal(err) - } - equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{10002, 9, 133, 206}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - granted, err := inventory.GrantOnce("ticket", []gamedata.BattleReward{{Type: 8, ID: 1104, Count: 20}}) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(storage) - if err != nil { - t.Fatal(err) - } - service, err := NewService(infinite, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.AttachInventory(inventory) - service.AttachEquipmentGacha(equipmentCatalog, equipment) - service.BeginSession("ticket-only") - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 71200001) - request = wire.AppendVarint(request, 3, 1) - ticket := granted[0] - ticket.Count = 10 - request = wire.AppendBytes(request, 4, player.ItemWire(ticket)) - code, response, handled, err := service.Handle("/GachaBuy", request) - if err != nil || !handled || code != 146 { - t.Fatalf("ticket-only draw code=%d handled=%v err=%v", code, handled, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found || countFields(bundle, 4) != 10 { - t.Fatalf("ticket-only reward equipment=%d found=%v err=%v", countFields(bundle, 4), found, err) - } - if len(equipment.All()) != 10 { - t.Fatalf("ticket-only persisted equipment=%d", len(equipment.All())) - } - if user := collection.GachaUser(71200001); user != (player.GachaUserState{}) { - t.Fatalf("standalone draw invented schedule accounting: %+v", user) - } - remaining := inventory.All() - foundTicket := false - for _, item := range remaining { - if item.InvenIndex == ticket.InvenIndex { - foundTicket = true - if item.Count != 10 { - t.Fatalf("ticket count=%d want=10", item.Count) - } - } - } - if !foundTicket { - t.Fatal("remaining ticket stack missing") - } - _, replay, _, err := service.Handle("/GachaBuy", request) - if err != nil || string(replay) != string(response) || len(equipment.All()) != 10 { - t.Fatalf("ticket-only replay changed result: equipment=%d err=%v", len(equipment.All()), err) - } -} - -func TestCostumeFixedStatesOmitDisabledThresholds(t *testing.T) { - result := gamedata.GachaFixedRoll{ - CostumeGrade4Count: 7, - CostumeGrade5Count: 8, - CostumeGrade4Sort: 2, - CostumeGrade5Sort: 3, - } - - fiveOnly := costumeFixedStates(gamedata.GachaFixedDesign{ID: 3, CostumeGrade5Count: 10}, result) - if len(fiveOnly) != 1 || fiveOnly[0].FixedID != 3 || fiveOnly[0].Type != 1 || fiveOnly[0].Count != 8 || fiveOnly[0].ApplySort != 3 { - t.Fatalf("five-only states=%+v", fiveOnly) - } - fourOnly := costumeFixedStates(gamedata.GachaFixedDesign{ID: 5, CostumeGrade4Count: 10}, result) - if len(fourOnly) != 1 || fourOnly[0].FixedID != 5 || fourOnly[0].Type != 0 || fourOnly[0].Count != 7 || fourOnly[0].ApplySort != 2 { - t.Fatalf("four-only states=%+v", fourOnly) - } - both := costumeFixedStates(gamedata.GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100}, result) - if len(both) != 2 || both[0].Type != 0 || both[1].Type != 1 { - t.Fatalf("two-threshold states=%+v", both) - } -} - -func fixtureCharacter(id, hp uint64) gamedata.CharacterDesign { - return gamedata.CharacterDesign{ - ID: id, HP: hp, CostumeMaxLevel: 5, - OverflowItemType: 20, OverflowItemCount: 2, - } -} - -func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) { - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{ - 60901: fixtureCharacter(6090, 253), - }) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachPreviewEventIndex(1171); err != nil { - t.Fatal(err) - } - schedule := &ScheduleSeed{ - ClientVersion: "test", - Schedules: []ScheduleWindow{ - {GroupID: 166, StartTime: 10, EndTime: 20}, - {GroupID: infiniteScheduleGroupID, StartTime: 10, EndTime: 20}, - {GroupID: paidTwelvePickGroupID, StartTime: 10, EndTime: 20}, - {GroupID: 206, StartTime: 10, EndTime: 20}, - }, - StepUps: []ScheduleWindow{{GroupID: 29, StartTime: 10, EndTime: 20}}, - } - if err := service.AttachSchedule(schedule); err != nil { - t.Fatal(err) - } - preview := wire.AppendVarint(nil, 1, 1) - preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID) - preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID) - preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID) - code, response, ok, err := service.Handle("/GachaBuyPreview", preview) - if err != nil || !ok || code != 175 { - t.Fatalf("preview code=%d ok=%v err=%v", code, ok, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found || countFields(bundle, 3) != 10 { - t.Fatalf("preview bundle fields=%d found=%v err=%v", countFields(bundle, 3), found, err) - } - if len(collection.Characters()) != 0 { - t.Fatal("preview changed ownership") - } - eventIndex, locked := collection.PreviewLock() - if locked || eventIndex != 1171 { - t.Fatalf("new preview event=%d locked=%v", eventIndex, locked) - } - _, infoBeforeLock, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 2)) - if got := collectScheduleWindows(t, infoBeforeLock, 1); !sameScheduleGroups(got, 166, infiniteScheduleGroupID, paidTwelvePickGroupID, 206) { - t.Fatalf("pre-purchase schedule groups=%v", scheduleGroupIDs(got)) - } - if got := collectScheduleWindows(t, infoBeforeLock, 7); !sameScheduleGroups(got, 29) { - t.Fatalf("pre-purchase step-up groups=%v", scheduleGroupIDs(got)) - } - previewState, found, parseErr := wire.Bytes(infoBeforeLock, 9) - gotEvent, eventFound, eventErr := wire.Varint(previewState, 1) - previewBundle, bundleFound, bundleErr := wire.Bytes(previewState, 8) - if err != nil || parseErr != nil || eventErr != nil || bundleErr != nil || !found || !eventFound || !bundleFound || gotEvent != eventIndex || countFields(previewBundle, 3) != 10 { - t.Fatalf("gacha info preview event=%d items=%d found=%v/%v/%v err=%v/%v/%v/%v", gotEvent, countFields(previewBundle, 3), found, eventFound, bundleFound, err, parseErr, eventErr, bundleErr) - } - lock := wire.AppendVarint(nil, 1, 2) - lock = wire.AppendVarint(lock, 2, eventIndex) - code, response, ok, err = service.Handle("/GachaBuyPreviewLock", lock) - if err != nil || !ok || code != 175 || len(response) != 0 { - t.Fatalf("preview lock code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err) - } - if got, locked := collection.PreviewLock(); !locked || got != eventIndex { - t.Fatalf("preview lock event=%d locked=%v", got, locked) - } - - buy := wire.AppendVarint(nil, 1, 3) - buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID) - product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID) - product = wire.AppendVarint(product, 3, 1) - buy = wire.AppendBytes(buy, 4, product) - code, response, ok, err = service.Handle("/CashShopBuy", buy) - if err != nil || !ok || code != 61 { - t.Fatalf("confirm code=%d ok=%v err=%v", code, ok, err) - } - if service.FirstGachaCompleted() { - t.Fatal("infinite product incorrectly completed the subtype-3 first pick") - } - reward, found, err := wire.Bytes(response, 1) - if err != nil || !found || countFields(reward, 2) != 1 || countFields(reward, 3) != 1 || countFields(reward, 9) != 9 || countFields(reward, 8) != 4 { - t.Fatalf("reward chars=%d costumes=%d upgrades=%d exchanges=%d found=%v err=%v", countFields(reward, 2), countFields(reward, 3), countFields(reward, 9), countFields(reward, 8), found, err) - } - restored, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if got := restored.Characters(); len(got) != 1 || got[0].ID != 6090 { - t.Fatalf("restored characters=%+v", got) - } - if got := restored.Costumes(); len(got) != 1 || got[0].ID != 60901 || got[0].Level != 5 { - t.Fatalf("restored costumes=%+v", got) - } - if got := wallet.Snapshot().Mileage; got != 8 { - t.Fatalf("mileage=%d want=8", got) - } - if got, locked := restored.PreviewLock(); !locked || got != eventIndex { - t.Fatalf("restored preview lock event=%d locked=%v", got, locked) - } - code, response, ok, err = service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 4)) - postPurchaseSchedules := collectScheduleWindows(t, response, 1) - postPurchaseStepUps := collectScheduleWindows(t, response, 7) - if err != nil || !ok || code != 145 || countFields(response, 9) != 0 || !sameScheduleGroups(postPurchaseSchedules, 166, paidTwelvePickGroupID, 206) || !sameScheduleGroups(postPurchaseStepUps, 29) { - t.Fatalf("post-purchase info code=%d schedules=%v step-ups=%v preview=%d ok=%v err=%v", code, scheduleGroupIDs(postPurchaseSchedules), scheduleGroupIDs(postPurchaseStepUps), countFields(response, 9), ok, err) - } - if !sameScheduleGroups(schedule.Schedules, 166, infiniteScheduleGroupID, paidTwelvePickGroupID, 206) { - t.Fatalf("account filtering mutated public seed=%v", scheduleGroupIDs(schedule.Schedules)) - } - code, response, ok, err = service.Handle("/CashShopPurchaseCountInfo", wire.AppendVarint(nil, 1, 5)) - countInfo, found, parseErr := wire.Bytes(response, 1) - productGroup, groupFound, groupErr := wire.Varint(countInfo, 1) - productID, productFound, productErr := wire.Varint(countInfo, 2) - _, saleGroupFound, saleGroupErr := wire.Varint(countInfo, 3) - count, countFound, countErr := wire.Varint(countInfo, 4) - if err != nil || parseErr != nil || groupErr != nil || productErr != nil || saleGroupErr != nil || countErr != nil || !ok || code != 432 || countFields(response, 1) != 1 || !found || !groupFound || !productFound || saleGroupFound || !countFound || productGroup != fixtureInfiniteProductGroupID || productID != fixtureInfiniteProductID || count != 1 { - t.Fatalf("purchase count code=%d group=%d product=%d saleGroupFound=%v count=%d found=%v/%v/%v/%v ok=%v err=%v/%v/%v/%v/%v/%v", code, productGroup, productID, saleGroupFound, count, found, groupFound, productFound, countFound, ok, err, parseErr, groupErr, productErr, saleGroupErr, countErr) - } - if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err == nil { - t.Fatal("purchased infinite gacha accepted a new preview") - } - _, infoAfterRejectedPreview, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 6)) - if err != nil || !sameScheduleGroups(collectScheduleWindows(t, infoAfterRejectedPreview, 1), 166, paidTwelvePickGroupID, 206) || countFields(infoAfterRejectedPreview, 9) != 0 { - t.Fatalf("rejected preview changed post-purchase availability: err=%v", err) - } -} - -func sameScheduleGroups(windows []ScheduleWindow, want ...uint64) bool { - if len(windows) != len(want) { - return false - } - for i := range windows { - if windows[i].GroupID != want[i] { - return false - } - } - return true -} - -func scheduleGroupIDs(windows []ScheduleWindow) []uint64 { - groups := make([]uint64, len(windows)) - for i := range windows { - groups[i] = windows[i].GroupID - } - return groups -} - -func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) { - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachPreviewEventIndex(1171); err != nil { - t.Fatal(err) - } - preview := wire.AppendVarint(nil, 1, 1) - preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID) - preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID) - preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID) - if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil { - t.Fatal(err) - } - firstEvent, locked := collection.PreviewLock() - if locked || firstEvent != 1171 { - t.Fatalf("first preview event=%d locked=%v", firstEvent, locked) - } - buy := wire.AppendVarint(nil, 1, 2) - buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID) - product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID) - product = wire.AppendVarint(product, 3, 1) - buy = wire.AppendBytes(buy, 4, product) - if _, _, _, err := service.Handle("/CashShopBuy", buy); err == nil { - t.Fatal("unlocked preview was confirmed") - } - lock := wire.AppendVarint(nil, 1, 3) - lock = wire.AppendVarint(lock, 2, firstEvent) - if _, _, _, err := service.Handle("/GachaBuyPreviewLock", lock); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil { - t.Fatal(err) - } - if eventIndex, locked := collection.PreviewLock(); locked || eventIndex != firstEvent { - t.Fatalf("reroll event=%d locked=%v, want %d/unlocked", eventIndex, locked, firstEvent) - } -} - -func TestRegularGachaBuySpendsWalletAndPersistsCostume(t *testing.T) { - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 500}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 77) - request = wire.AppendVarint(request, 2, 10100084) - request = wire.AppendVarint(request, 3, 1) - code, response, ok, err := service.Handle("/GachaBuy", request) - if err != nil || !ok || code != 146 { - t.Fatalf("code=%d ok=%v err=%v", code, ok, err) - } - if wallet.Snapshot().FreeJewelry != 300 { - t.Fatalf("wallet=%+v", wallet.Snapshot()) - } - bundle, found, _ := wire.Bytes(response, 1) - if !found || countFields(bundle, 2) != 1 || countFields(bundle, 3) != 1 { - t.Fatalf("bundle=%x", bundle) - } - if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().FreeJewelry != 300 || len(collection.Characters()) != 1 { - t.Fatalf("retry wallet=%+v chars=%d", wallet.Snapshot(), len(collection.Characters())) - } -} - -func TestRegularGachaSequenceReuseAcrossLoginIsANewPurchase(t *testing.T) { - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 400}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 10100084) - request = wire.AppendVarint(request, 3, 1) - service.BeginSession("login-a") - if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { - t.Fatal(err) - } - // Same login + sequence is an HTTP retry. - if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { - t.Fatal(err) - } - if got := wallet.Snapshot().FreeJewelry; got != 200 { - t.Fatalf("retry balance=%d want=200", got) - } - service.BeginSession("login-b") - if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { - t.Fatal(err) - } - if got := wallet.Snapshot().FreeJewelry; got != 0 { - t.Fatalf("new-login balance=%d want=0", got) - } - if got := collection.Costumes(); len(got) != 1 || got[0].Level != 1 { - t.Fatalf("costumes=%+v", got) - } -} - -func TestPaidRegularGachaBuySpendsPaidJewelry(t *testing.T) { - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 8100118: {ID: 8100118, Count: 10, PriceType: 2, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Jewelry: 5000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 88) - request = wire.AppendVarint(request, 2, 8100118) - // GB_NORMAL uses GachaTable.PriceType to distinguish paid (2) from free - // jewelry (3); it is not itself the paid/free currency selector. - request = wire.AppendVarint(request, 3, 1) - code, response, ok, err := service.Handle("/GachaBuy", request) - if err != nil || !ok || code != 146 { - t.Fatalf("code=%d ok=%v err=%v", code, ok, err) - } - if wallet.Snapshot().Jewelry != 3000 || wallet.Snapshot().FreeJewelry != 0 { - t.Fatalf("wallet=%+v", wallet.Snapshot()) - } - bundle, found, _ := wire.Bytes(response, 1) - if !found || countFields(bundle, 3) != 1 || countFields(bundle, 9) != 9 || countFields(bundle, 8) != 4 { - t.Fatalf("bundle=%x", bundle) - } -} - -func TestPriceType19IsRejectedWithoutChargingDiamonds(t *testing.T) { - character := fixtureCharacter(6090, 253) - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - regular.Gachas[9100037] = gamedata.RegularGacha{ID: 9100037, Count: 10, PriceType: 19, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}} - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 9999, Jewelry: 9999}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 9100037) - request = wire.AppendVarint(request, 3, 1) - if _, _, handled, err := service.Handle("/GachaBuy", request); !handled || err == nil { - t.Fatalf("priceType19 handled=%v err=%v", handled, err) - } - if got := wallet.Snapshot(); got.FreeJewelry != 9999 || got.Jewelry != 9999 { - t.Fatalf("unsupported purchase charged wallet: %+v", got) - } - if len(collection.Characters()) != 0 || len(collection.Costumes()) != 0 { - t.Fatal("unsupported purchase granted collection items") - } -} - -func TestUnverifiedGachaRPCsRemainUnhandled(t *testing.T) { - character := fixtureCharacter(6090, 253) - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - for _, path := range []string{"/GachaLog"} { - if code, response, handled, err := service.Handle(path, request); err != nil || handled || code != 0 || response != nil { - t.Fatalf("%s code=%d bytes=%x handled=%v err=%v", path, code, response, handled, err) - } - } -} - -func newMultiBuyTestService(t *testing.T, storage stateio.Store) *Service { - t.Helper() - character := fixtureCharacter(6090, 253) - characters := map[uint64]gamedata.CharacterDesign{60901: character} - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 11: {ID: 11, Count: 1, FreeCountDay: 2, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - 12: {ID: 12, Count: 1, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - 13: {ID: 13, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, characters) - if err != nil { - t.Fatal(err) - } - for _, group := range []gamedata.GachaGroupDesign{{ID: 1, OneTimeGachaID: 11, TenTimeGachaID: 13, PointCount: 1}, {ID: 2, OneTimeGachaID: 12, PointCount: 1}} { - if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - } - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 1000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.BeginSession("multi-login") - service.now = func() time.Time { return time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) } - service.schedule = &ScheduleSeed{Schedules: []ScheduleWindow{{GroupID: 1, StartTime: 1, EndTime: 2000000000000}, {GroupID: 2, StartTime: 1, EndTime: 2000000000000}}} - return service -} - -func multiBuyRequest(seq uint64, packed bool, ids ...uint64) []byte { - request := wire.AppendVarint(nil, 1, seq) - var values []byte - for _, id := range ids { - if packed { - values = binary.AppendUvarint(values, id) - } else { - request = wire.AppendVarint(request, 2, id) - } - } - if packed { - request = wire.AppendBytes(request, 2, values) - } - return request -} - -func multiFixedSnapshots(t *testing.T, response []byte, wantIDs []uint64) [][4]uint64 { - t.Helper() - index := 0 - snapshots := make([][4]uint64, 0, len(wantIDs)) - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number != 1 || field.Type != 2 || index >= len(wantIDs) { - return errors.New("unexpected multi-buy result") - } - id, found, err := wire.Varint(field.Value, 1) - if err != nil || !found || id != wantIDs[index] { - return fmt.Errorf("result %d id=%d found=%v want=%d", index, id, found, wantIDs[index]) - } - point, found, err := wire.Varint(field.Value, 3) - if err != nil || !found || point != 1 { - return fmt.Errorf("result %d point=%d found=%v", index, point, found) - } - seen := [4]bool{} - counts := [4]uint64{} - if err := wire.Walk(field.Value, func(resultField wire.Field) error { - if resultField.Number != 4 { - return nil - } - fixedID, fixedFound, err := wire.Varint(resultField.Value, 1) - if err != nil || !fixedFound || fixedID != 1 { - return fmt.Errorf("result %d fixed id=%d found=%v", index, fixedID, fixedFound) - } - fixedType, _, err := wire.Varint(resultField.Value, 2) - if err != nil || fixedType >= uint64(len(seen)) || seen[fixedType] { - return fmt.Errorf("result %d invalid fixed type=%d", index, fixedType) - } - count, _, err := wire.Varint(resultField.Value, 3) - if err != nil { - return err - } - applySort, applyFound, err := wire.Varint(resultField.Value, 4) - if err != nil || !applyFound || applySort != ^uint64(0) { - return fmt.Errorf("result %d type=%d apply=%d found=%v", index, fixedType, applySort, applyFound) - } - seen[fixedType] = true - counts[fixedType] = count - return nil - }); err != nil { - return err - } - for fixedType, found := range seen { - if !found { - return fmt.Errorf("result %d missing fixed type %d", index, fixedType) - } - } - snapshots = append(snapshots, counts) - index++ - return nil - }); err != nil { - t.Fatal(err) - } - if index != len(wantIDs) { - t.Fatalf("results=%d want=%d", index, len(wantIDs)) - } - return snapshots -} - -func TestGachaMultiBuyPreservesOrderRepeatedIDsAndRestartReplay(t *testing.T) { - storage := stateio.NewMemory() - service := newMultiBuyTestService(t, storage) - request := multiBuyRequest(10, true, 12, 11, 11) - code, response, handled, err := service.Handle("/GachaMultiBuy", request) - if err != nil || code != 146 || !handled { - t.Fatalf("multi code=%d handled=%v err=%v", code, handled, err) - } - var gotIDs []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number != 1 || field.Type != 2 { - return errors.New("invalid result") - } - id, _, err := wire.Varint(field.Value, 1) - if err != nil { - return err - } - gotIDs = append(gotIDs, id) - bundle, found, err := wire.Bytes(field.Value, 2) - if err != nil || !found || (countFields(bundle, 3)+countFields(bundle, 9)) != 1 { - return errors.New("missing costume/upgrade") - } - point, _, err := wire.Varint(field.Value, 3) - if err != nil || point != 1 { - return errors.New("missing point") - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(gotIDs) != 3 || gotIDs[0] != 12 || gotIDs[1] != 11 || gotIDs[2] != 11 { - t.Fatalf("order=%v", gotIDs) - } - if service.wallet.Snapshot().FreeJewelry != 1000 { - t.Fatal("free batch charged currency") - } - if owned := service.collection.Costumes(); len(owned) != 1 || owned[0].Level != 2 { - t.Fatalf("owned=%+v", owned) - } - if service.collection.GachaDailyCount("2026-09-30", 1, 0) != 2 || service.collection.GachaUser(1).Point != 2 { - t.Fatal("repeated IDs did not make independent draws") - } - service = newMultiBuyTestService(t, storage) - _, replay, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, false, 12, 11, 11)) - if err != nil || !bytes.Equal(response, replay) { - t.Fatalf("restart/unpacked replay differs: %v", err) - } - if service.collection.Costumes()[0].Level != 2 || service.collection.GachaUser(1).Point != 2 { - t.Fatal("replay mutated rewards") - } - if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, true, 11, 12, 11)); err == nil { - t.Fatal("same sequence changed ordered request") - } - service.BeginSession("next-login") - if _, _, _, err := service.Handle("/GachaMultiBuy", request); err == nil { - t.Fatal("new login bypassed exhausted daily limits") - } - service.now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) } - if _, _, _, err := service.Handle("/GachaMultiBuy", request); err != nil { - t.Fatalf("next UTC reset did not restore allowance: %v", err) - } -} - -func TestGachaMultiBuyInvalidOrExcessBatchDoesNotGrantAnySlot(t *testing.T) { - for name, ids := range map[string][]uint64{"unknown": {11, 999}, "excess": {11, 11, 11}, "ten-pull": {11, 13}, "zero": {11, 0}, "negative-int32": {11, ^uint64(0)}, "empty": {}} { - t.Run(name, func(t *testing.T) { - service := newMultiBuyTestService(t, stateio.NewMemory()) - code, _, handled, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, ids...)) - if code != 146 || !handled || err == nil { - t.Fatalf("code=%d handled=%v err=%v", code, handled, err) - } - if len(service.collection.Costumes()) != 0 || service.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || service.collection.GachaUser(1).Point != 0 { - t.Fatal("invalid batch partly granted") - } - }) - } - service := newMultiBuyTestService(t, stateio.NewMemory()) - service.schedule.Schedules[1].EndTime = 2 - if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil || len(service.collection.Costumes()) != 0 { - t.Fatal("closed schedule partly granted") - } -} - -func TestGachaMultiBuyUsesGameDefaultBonusAllowanceAndRepeatedIDs(t *testing.T) { - service := newMultiBuyTestService(t, stateio.NewMemory()) - if err := service.regular.SetGachaEventAddFreeCount(1); err != nil { - t.Fatal(err) - } - service.schedule.Schedules[0].FreeCountBonus = true - request := multiBuyRequest(1, true, 11, 11, 11) - if code, response, handled, err := service.Handle("/GachaMultiBuy", request); err != nil || !handled || code != 146 || countFields(response, 1) != 3 { - t.Fatalf("bonus multi code=%d results=%d handled=%v err=%v", code, countFields(response, 1), handled, err) - } - if got := service.collection.GachaDailyCount("2026-09-30", 1, 0); got != 3 { - t.Fatalf("bonus daily count=%d want=3", got) - } - service.BeginSession("bonus-next-login") - if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(2, true, 11)); err == nil { - t.Fatal("bonus allowance accepted a fourth draw") - } -} - -func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - service := newMultiBuyTestService(t, repo) - if err := service.collection.EnsurePersisted(); err != nil { - t.Fatal(err) - } - service.AttachDrawMission(func(uint64) error { return errors.New("injected mission failure after reward") }) - operation, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil { - t.Fatal("expected failure") - } - // A dirty rollback intentionally fail-stops domain memory. Reopen below - // verifies the disk state and prevents stale in-memory objects being reused. - if err := operation.Rollback(); err == nil || repo.Check() == nil { - t.Fatal("dirty rollback must fail-stop the current repository") - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - reopened := newMultiBuyTestService(t, repo) - if len(reopened.collection.Costumes()) != 0 || reopened.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || reopened.collection.GachaUser(1).Point != 0 { - t.Fatal("failed request persisted a partial batch") - } - if _, found, _ := reopened.collection.GachaBatchResponse("multi-gacha:session:multi-login:seq:1", "ignored"); found { - t.Fatal("failed request stored completion") - } -} - -func TestGachaMultiBuyCurrent23510CostumeAndEquipmentPools(t *testing.T) { - root := filepath.Join("..", "..", "..", "..", "data", "resources", "GameData") - const version = "20260923193640" - if _, err := os.Stat(filepath.Join(root, version, "release", "common-dbdata.bin")); os.IsNotExist(err) { - t.Skip("installed 2.35.10 GameData archive is unavailable") - } - storage := stateio.NewMemory() - service := newMultiBuyTestService(t, storage) - regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{71, 166, 205, 207}, nil) - if err != nil { - t.Fatal(err) - } - if bonus := regular.GachaEventAddFreeCount(); bonus != 1 { - t.Fatalf("2.35.10 GachaEventAddFreeCount=%d want=1", bonus) - } - equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{72, 153, 206, 208}) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(storage) - if err != nil { - t.Fatal(err) - } - service.regular = regular - service.AttachEquipmentGacha(equipmentCatalog, equipment) - service.schedule.Schedules = nil - for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} { - service.schedule.Schedules = append(service.schedule.Schedules, ScheduleWindow{GroupID: groupID, StartTime: 1, EndTime: 2000000000000}) - } - // Exact ordered ID list captured from the official 2.35.10 client at - // 2026-10-01 00:44:17. Each result must carry the complete fixed-id 1 - // snapshot, including the untouched equipment counters. - ids := []uint64{10100146, 10100145, 10100040, 10100113} - request := multiBuyRequest(98, true, ids...) - code, response, handled, err := service.Handle("/GachaMultiBuy", request) - if err != nil || !handled || code != 146 { - t.Fatalf("installed mixed batch: code=%d handled=%v err=%v", code, handled, err) - } - if countFields(response, 1) != 4 || len(equipment.All()) != 0 { - t.Fatalf("results=%d equipment=%d", countFields(response, 1), len(equipment.All())) - } - costumeSnapshots := multiFixedSnapshots(t, response, ids) - for index, snapshot := range costumeSnapshots { - if snapshot[2] != 0 || snapshot[3] != 0 { - t.Fatalf("costume result %d changed equipment counters: %v", index, snapshot) - } - } - wantCostumeCounters := costumeSnapshots[len(costumeSnapshots)-1] - equipmentIDs := []uint64{20100034, 20100070, 20100082, 20100083} - equipmentRequest := multiBuyRequest(99, true, equipmentIDs...) - _, equipmentResponse, _, err := service.Handle("/GachaMultiBuy", equipmentRequest) - if err != nil || countFields(equipmentResponse, 1) != 4 || len(equipment.All()) != 4 { - t.Fatalf("equipment results=%d equipment=%d err=%v", countFields(equipmentResponse, 1), len(equipment.All()), err) - } - equipmentSnapshots := multiFixedSnapshots(t, equipmentResponse, equipmentIDs) - for index, snapshot := range equipmentSnapshots { - if snapshot[0] != wantCostumeCounters[0] || snapshot[1] != wantCostumeCounters[1] { - t.Fatalf("equipment result %d lost costume counters: got=%v want=%v", index, snapshot, wantCostumeCounters) - } - } - if service.wallet.Snapshot().FreeJewelry != 1000 { - t.Fatal("installed free batch spent diamonds") - } - for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} { - if service.collection.GachaDailyCount("2026-09-30", groupID, 0) != 1 || service.collection.GachaUser(groupID).Point != 1 { - t.Fatalf("missing daily allowance/point group=%d", groupID) - } - } - if states := service.collection.GachaFixedStates(); len(states) != 4 { - t.Fatalf("missing four costume/equipment fixed types: %+v", states) - } - _, replay, _, err := service.Handle("/GachaMultiBuy", request) - if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 4 { - t.Fatalf("installed replay changed equipment/rewards: %v", err) - } -} - -func TestEquipmentPointExchangeUsesGameDataAndIsIdempotent(t *testing.T) { - root := filepath.Join("..", "..", "..", "..", "data", "resources", "GameData") - const version = "20260923193640" - if _, err := os.Stat(filepath.Join(root, version, "release", "common-dbdata.bin")); os.IsNotExist(err) { - t.Skip("installed 2.35.10 GameData archive is unavailable") - } - infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID}) - if err != nil { - t.Fatal(err) - } - regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{71}, nil) - if err != nil { - t.Fatal(err) - } - equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{208}) - if err != nil { - t.Fatal(err) - } - group, found := equipmentCatalog.Group(208) - if !found || group.PickUpExchangeCost != 200 || group.PickUpItemID != 943213 { - t.Fatalf("equipment group 208=%+v found=%v", group, found) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 1234}) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(storage) - if err != nil { - t.Fatal(err) - } - if _, err := collection.GrantEquipmentPurchase("seed-equipment-points", 200, player.GachaPurchase{ - Group: gamedata.GachaGroupDesign{ID: 208, PointCount: 1}, - }); err != nil { - t.Fatal(err) - } - service, err := NewService(infinite, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.AttachEquipmentGacha(equipmentCatalog, equipment) - service.BeginSession("equipment-exchange-login") - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, 208) - code, response, handled, err := service.Handle("/GachaPointExchange", request) - if err != nil || !handled || code != 147 { - t.Fatalf("equipment exchange code=%d handled=%v err=%v", code, handled, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found || countFields(bundle, 4) != 1 { - t.Fatalf("equipment exchange bundle=%x found=%v err=%v", bundle, found, err) - } - entries := equipment.All() - if len(entries) != 1 || entries[0].ID != 943213 || len(entries[0].Rank) != 3 || entries[0].InvenIndex == 0 { - t.Fatalf("equipment exchange entry=%+v", entries) - } - if user := collection.GachaUser(208); user.Point != 0 || user.ExchangeItemCount != 1 || user.TotalBuyCount != 0 { - t.Fatalf("equipment exchange user=%+v", user) - } - if got := wallet.Snapshot().FreeJewelry; got != 1234 { - t.Fatalf("equipment exchange changed wallet=%d", got) - } - _, replay, _, err := service.Handle("/GachaPointExchange", request) - if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 1 { - t.Fatalf("equipment exchange replay changed result: equipment=%d err=%v", len(equipment.All()), err) - } - invalid := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 78), 2, 208), 3, 943213) - if _, _, _, err := service.Handle("/GachaPointExchange", invalid); err == nil || len(equipment.All()) != 1 { - t.Fatalf("equipment exchange accepted invalid selection: equipment=%d err=%v", len(equipment.All()), err) - } -} - -func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) { - const stepUpGroupID = 29 - stepUpGachaIDs := []uint64{8100118, 8100119, 8100120, 8100121} - character := fixtureCharacter(6090, 253) - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - gachas := make(map[uint64]gamedata.RegularGacha, len(stepUpGachaIDs)) - for _, id := range stepUpGachaIDs { - gachas[id] = gamedata.RegularGacha{ID: id, Count: 1, PriceType: 2, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}} - } - regular, err := fixtureRegularCatalog(gachas, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - steps := gamedata.GachaStepUpDesign{ID: stepUpGroupID} - for i, id := range stepUpGachaIDs { - steps.Steps = append(steps.Steps, gamedata.GachaStepDesign{Step: uint64(i + 1), GroupID: uint64(20118 + i), GachaID: id, FixedID: 5}) - } - if err := regular.AddStepUpDesign(steps); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Jewelry: 1000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.BeginSession("first-login") - for i, id := range stepUpGachaIDs { - request := wire.AppendVarint(nil, 1, uint64(i+1)) - request = wire.AppendVarint(request, 2, id) - request = wire.AppendVarint(request, 3, 1) - if _, _, _, err := service.Handle("/GachaBuy", request); err != nil { - t.Fatalf("step %d: %v", i+1, err) - } - } - if got := collection.StepUpProgress(stepUpGroupID); got != 4 { - t.Fatalf("progress=%d want=4", got) - } - restored, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - restarted, err := NewService(design, regular, restored, wallet) - if err != nil { - t.Fatal(err) - } - restarted.BeginSession("second-login") - if err := restarted.AttachSchedule(&ScheduleSeed{ClientVersion: "test", Schedules: []ScheduleWindow{{GroupID: 1, StartTime: 1, EndTime: 2}}, StepUps: []ScheduleWindow{{GroupID: stepUpGroupID, StartTime: 1, EndTime: 2}}}); err != nil { - t.Fatal(err) - } - _, info, _, err := restarted.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - if countFields(info, 7) != 1 || countFields(info, 8) != 1 { - t.Fatalf("step-up schedule/user count=%d/%d info=%x", countFields(info, 7), countFields(info, 8), info) - } - user, found, err := wire.Bytes(info, 8) - if err != nil || !found { - t.Fatalf("step user found=%v err=%v", found, err) - } - group, _, _ := wire.Varint(user, 1) - completed, _, _ := wire.Varint(user, 2) - if group != stepUpGroupID || completed != 4 { - t.Fatalf("step user group=%d completed=%d", group, completed) - } - balance := wallet.Snapshot().Jewelry - rebuy := wire.AppendVarint(nil, 1, 2) - rebuy = wire.AppendVarint(rebuy, 2, stepUpGachaIDs[0]) - rebuy = wire.AppendVarint(rebuy, 3, 1) - if _, _, _, err := restarted.Handle("/GachaBuy", rebuy); err == nil { - t.Fatal("completed step-up accepted another purchase") - } - if got := wallet.Snapshot().Jewelry; got != balance { - t.Fatalf("rejected purchase changed wallet: %d -> %d", balance, got) - } -} - -func TestInstalledStepUpGroups29And30UseGenericIndependentProgress(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") - } - const version = "20260923193640" - design, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID}) - if err != nil { - t.Fatal(err) - } - regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, nil, []uint64{29, 30}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Jewelry: 20000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - sequence := uint64(100) - for _, groupID := range []uint64{29, 30} { - stepUp, ok := regular.StepUp(groupID) - if !ok || len(stepUp.Steps) != 4 { - t.Fatalf("step-up group %d=%+v ok=%v", groupID, stepUp, ok) - } - sequence++ - wrong := wire.AppendVarint(nil, 1, sequence) - wrong = wire.AppendVarint(wrong, 2, stepUp.Steps[0].GachaID) - wrong = wire.AppendVarint(wrong, 3, 2) - before := wallet.Snapshot().Jewelry - if _, _, handled, err := service.Handle("/GachaBuy", wrong); !handled || err == nil { - t.Fatalf("step-up group %d accepted GB_CASH handled=%v err=%v", groupID, handled, err) - } - if got := wallet.Snapshot().Jewelry; got != before { - t.Fatalf("rejected GB_CASH changed jewelry %d -> %d", before, got) - } - for _, step := range stepUp.Steps { - sequence++ - request := wire.AppendVarint(nil, 1, sequence) - request = wire.AppendVarint(request, 2, step.GachaID) - request = wire.AppendVarint(request, 3, 1) - code, _, handled, err := service.Handle("/GachaBuy", request) - if err != nil || !handled || code != 146 { - t.Fatalf("group=%d step=%d gacha=%d code=%d handled=%v err=%v", groupID, step.Step, step.GachaID, code, handled, err) - } - if got := collection.StepUpProgress(groupID); got != step.Step { - t.Fatalf("group=%d progress=%d want=%d", groupID, got, step.Step) - } - } - } - if collection.StepUpProgress(29) != 4 || collection.StepUpProgress(30) != 4 { - t.Fatalf("independent progress 29=%d 30=%d", collection.StepUpProgress(29), collection.StepUpProgress(30)) - } - for _, groupID := range []uint64{29, 30} { - stepUp, _ := regular.StepUp(groupID) - for _, step := range stepUp.Steps { - user := collection.GachaUser(step.GroupID) - if user.TotalBuyCount != 10 || user.OneCashPickCount != 0 || user.TenCashPickCount != 0 { - t.Fatalf("step-up group %d child %d counters=%+v", groupID, step.GroupID, user) - } - } - } - if got := wallet.Snapshot().Jewelry; got != 10000 { - t.Fatalf("two step-up groups left jewelry=%d want=10000", got) - } -} - -func TestRegularTenPullContainsTenClientDisplayEntriesAtMaxLevel(t *testing.T) { - character := fixtureCharacter(6090, 253) - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 11000084: {ID: 11000084, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 2000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 11000084) - request = wire.AppendVarint(request, 3, 1) - _, response, _, err := service.Handle("/GachaBuy", request) - if err != nil { - t.Fatal(err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("reward bundle found=%v err=%v", found, err) - } - // Gacha result builds a slot from CostumeInfo (field 3) or - // ItemAutoUpgradeInfo (field 9). With one new costume followed by nine - // duplicates, all ten pulls must therefore be represented by 1 + 9. - if got := countFields(bundle, 3) + countFields(bundle, 9); got != 10 { - t.Fatalf("client display entries=%d costumes=%d upgrades=%d bundle=%x", got, countFields(bundle, 3), countFields(bundle, 9), bundle) - } - if countFields(bundle, 8) != 4 { - t.Fatalf("overflow exchanges=%d want=4", countFields(bundle, 8)) - } - if got := collection.Costumes()[0].Level; got != 5 { - t.Fatalf("costume level=%d want=5", got) - } - grant, ok := collection.Grant("regular-gacha:11000084:seq:1") - if !ok || len(grant.Upgrades) != 9 || len(grant.Exchanges) != 4 { - t.Fatalf("grant=%+v", grant) - } - for _, exchange := range grant.Exchanges { - matched := false - for _, upgrade := range grant.Upgrades { - if upgrade.CostumeID == exchange.OriginalItemID && upgrade.SortID == exchange.SortID && upgrade.Before == 5 && upgrade.After == 5 { - matched = true - break - } - } - if !matched { - t.Fatalf("exchange has no display upgrade: %+v grant=%+v", exchange, grant) - } - } -} - -func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing.T) { - character := fixtureCharacter(6090, 253) - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - pool := []gamedata.WeightedCostume{ - {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - {Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 11000145: {Grades: map[uint64]uint64{60901: 5}, ID: 11000145, Count: 10, PriceType: 3, Price: 2000, Pool: pool}, - }, map[uint64]gamedata.CharacterDesign{60901: character}) - if err != nil { - t.Fatal(err) - } - if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: 205, FixedID: 1, PointCount: 1, PickUpExchangeCost: 200, PickUpCostumeID: 60901, TenTimeGachaID: 11000145}, gamedata.GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 2000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.BeginSession("point-test-login") - buy := wire.AppendVarint(nil, 1, 41) - buy = wire.AppendVarint(buy, 2, 11000145) - buy = wire.AppendVarint(buy, 3, 1) - _, buyResponse, _, err := service.Handle("/GachaBuy", buy) - if err != nil { - t.Fatal(err) - } - if points, _, _ := wire.Varint(buyResponse, 2); points != 10 { - t.Fatalf("buy points=%d want=10", points) - } - if user := collection.GachaUser(205); user.Point != 10 || user.TotalBuyCount != 10 { - t.Fatalf("gacha user=%+v", user) - } - exchange := wire.AppendVarint(nil, 1, 42) - exchange = wire.AppendVarint(exchange, 2, 205) - exchange = wire.AppendVarint(exchange, 3, 5) - for i := range 2 { - code, response, ok, err := service.Handle("/GachaPointManualExchange", exchange) - if err != nil || !ok || code != 188 { - t.Fatalf("exchange %d code=%d ok=%v err=%v", i, code, ok, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found || countFields(bundle, 10) != 1 { - t.Fatalf("exchange reward bundle=%x found=%v err=%v", bundle, found, err) - } - view, _, _ := wire.Bytes(bundle, 10) - itemType, _, _ := wire.Varint(view, 2) - count, _, _ := wire.Varint(view, 3) - if itemType != 22 || count != 5 { - t.Fatalf("hope powder view type=%d count=%d", itemType, count) - } - } - if user := collection.GachaUser(205); user.Point != 5 || user.ExchangeMileageCount != 5 { - t.Fatalf("point exchange user=%+v", user) - } - if got := wallet.Snapshot().HopePowder; got != 5 { - t.Fatalf("hope powder=%d want=5", got) - } - restored, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if user := restored.GachaUser(205); user.Point != 5 || user.ExchangeMileageCount != 5 { - t.Fatalf("restored gacha user=%+v", user) - } - if fixed := restored.GachaFixedStates(); len(fixed) != 2 || fixed[0].ApplySort != -1 || fixed[1].ApplySort != -1 { - t.Fatalf("restored guaranteed counters=%+v", fixed) - } - restarted, err := NewService(design, regular, restored, wallet) - if err != nil { - t.Fatal(err) - } - restarted.BeginSession("new-login") - _, info, _, err := restarted.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 43)) - if err != nil || countFields(info, 2) != 1 || countFields(info, 4) != 2 { - t.Fatalf("gacha info users=%d fixed=%d err=%v", countFields(info, 2), countFields(info, 4), err) - } -} - -func TestCashShopPurchaseCountStartsEmpty(t *testing.T) { - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 99) - code, response, ok, err := service.Handle("/CashShopPurchaseCountInfo", request) - if err != nil || !ok || code != 432 || len(response) != 0 { - t.Fatalf("code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err) - } -} - -func TestTwelvePickSelectionSavePersistsAndReturnsInGachaInfo(t *testing.T) { - fiveStars := make([]uint64, 12) - characters := make(map[uint64]gamedata.CharacterDesign, 14) - for i := range fiveStars { - fiveStars[i] = uint64(5001 + i) - characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100) - } - characters[4001] = fixtureCharacter(400, 100) - characters[3001] = fixtureCharacter(300, 100) - design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}}, characters) - if err != nil { - t.Fatal(err) - } - if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, SelectCount: 12}, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 100) - for slot, itemID := range fiveStars { - entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID) - if slot != 0 { - entry = wire.AppendVarint(entry, 2, uint64(slot)) - } - entry = wire.AppendVarint(entry, 3, itemID) - request = wire.AppendBytes(request, 2, entry) - } - code, response, ok, err := service.Handle("/GachaSelectionSave", request) - if err != nil || !ok || code != 198 || len(response) != 0 { - t.Fatalf("save code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err) - } - restored, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if got := restored.GachaSelections(paidTwelvePickGroupID); len(got) != 12 || got[0].ItemID != fiveStars[0] || got[11].Slot != 11 { - t.Fatalf("restored selections=%+v", got) - } - code, response, ok, err = service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 101)) - if err != nil || !ok || code != 145 || countFields(response, 5) != 12 { - t.Fatalf("info code=%d selections=%d ok=%v err=%v", code, countFields(response, 5), ok, err) - } -} - -func TestDailyFreeAndPaidSingleDrawsResetByUTCDate(t *testing.T) { - characters := map[uint64]gamedata.CharacterDesign{5001: fixtureCharacter(500, 100)} - design, err := fixtureInfiniteGachaDesign(10, []uint64{5001}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 101: {ID: 101, Count: 1, DailyPayGachaCount: 1, DailyPayGachaPriceCount: 90, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}, - }, characters) - if err != nil { - t.Fatal(err) - } - if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: 205, PointCount: 1, OneTimeGachaID: 101}, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Jewelry: 1000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC) - service.now = func() time.Time { return now } - service.schedule = &ScheduleSeed{Schedules: []ScheduleWindow{{GroupID: 205, StartTime: 1, EndTime: 2000000000000}}} - buy := func(seq, buyType uint64) error { - request := wire.AppendVarint(nil, 1, seq) - request = wire.AppendVarint(request, 2, 101) - if buyType != 0 { - request = wire.AppendVarint(request, 3, buyType) - } - code, _, handled, buyErr := service.Handle("/GachaBuy", request) - if code != 146 || !handled { - t.Fatalf("buy seq=%d type=%d code=%d handled=%v", seq, buyType, code, handled) - } - return buyErr - } - if err := buy(1, 0); err != nil { - t.Fatal(err) - } - if err := buy(1, 0); err != nil { // transport retry is idempotent - t.Fatal(err) - } - if err := buy(2, 0); err == nil { - t.Fatal("second daily free draw was accepted") - } - if err := buy(3, 2); err != nil { - t.Fatal(err) - } - if got := wallet.Snapshot().Jewelry; got != 910 { - t.Fatalf("paid daily jewelry=%d want=910", got) - } - if err := buy(4, 2); err == nil { - t.Fatal("second daily paid draw was accepted") - } - _, info, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5)) - if err != nil { - t.Fatal(err) - } - user, found, err := wire.Bytes(info, 2) - free, freeFound, freeErr := wire.Varint(user, 4) - paid, paidFound, paidErr := wire.Varint(user, 5) - if err != nil || freeErr != nil || paidErr != nil || !found || !freeFound || !paidFound || free != 1 || paid != 1 { - t.Fatalf("daily info free=%d paid=%d found=%v/%v/%v err=%v/%v/%v", free, paid, found, freeFound, paidFound, err, freeErr, paidErr) - } - now = now.Add(24 * time.Hour) - if err := buy(6, 0); err != nil { - t.Fatal(err) - } - if err := buy(7, 2); err != nil { - t.Fatal(err) - } - if got := wallet.Snapshot().Jewelry; got != 820 { - t.Fatalf("next-day paid jewelry=%d want=820", got) - } -} - -func TestDailyResetKeyUsesOfficialUTCBoundaryNotHostMidnight(t *testing.T) { - shanghai, err := time.LoadLocation("Asia/Shanghai") - if err != nil { - t.Fatal(err) - } - before := time.Date(2026, 9, 24, 23, 59, 59, 0, time.UTC) - after := before.Add(time.Second) - if before.In(shanghai).Day() != after.In(shanghai).Day() { - t.Fatal("fixture unexpectedly crosses Shanghai midnight") - } - if got := dailyResetKey(before); got != "2026-09-24" { - t.Fatalf("before key=%q", got) - } - if got := dailyResetKey(after); got != "2026-09-25" { - t.Fatalf("after key=%q", got) - } - // Shanghai midnight is 16:00 UTC and must not reset the official bucket. - localMidnight := time.Date(2026, 9, 25, 0, 0, 0, 0, shanghai) - if got := dailyResetKey(localMidnight.Add(-time.Second)); got != dailyResetKey(localMidnight) { - t.Fatalf("host midnight changed official key: %q -> %q", dailyResetKey(localMidnight.Add(-time.Second)), got) - } -} - -func TestMoonriseSelectionCashProductAndOneTimeTicketDraw(t *testing.T) { - fiveStars := make([]uint64, 12) - characters := make(map[uint64]gamedata.CharacterDesign, 14) - choices := make([]gamedata.CostumeRewardEntry, 12) - for i := range fiveStars { - fiveStars[i] = uint64(5001 + i) - characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100) - choices[i] = gamedata.CostumeRewardEntry{ItemType: 11, ItemID: fiveStars[i], Count: 1, Weight: 1} - } - characters[4001] = fixtureCharacter(400, 100) - characters[3001] = fixtureCharacter(300, 100) - design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters) - if err != nil { - t.Fatal(err) - } - moonrise := gamedata.RegularGacha{ID: moonriseProductID, Count: 10, PriceType: moonriseTicketType, PriceID: moonriseTicketID, Price: 1, RewardGroup: &gamedata.CostumeRewardGroup{ - ID: moonriseProductID, DropCount: 1, DropType: 1, Entries: []gamedata.CostumeRewardEntry{ - {ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}}, - {ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []gamedata.CostumeRewardEntry{ - {ItemType: 11, ItemID: 4001, Count: 1, Weight: 1443}, - {ItemType: 11, ItemID: 3001, Count: 1, Weight: 8557}, - }}}, - }, - }} - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters) - if err != nil { - t.Fatal(err) - } - if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, BuyLimitCount: 10, CashProductGroupID: moonriseProductGroupID, CashProductID: moonriseProductID, CashRewards: []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}}, TenTimeGachaID: moonriseProductID, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.AttachInventory(inventory) - if err := service.AttachSchedule(&ScheduleSeed{ClientVersion: "test", Schedules: []ScheduleWindow{{GroupID: paidTwelvePickGroupID, StartTime: 1, EndTime: 2}}, StepUps: []ScheduleWindow{{GroupID: 30, StartTime: 1, EndTime: 2}}}); err != nil { - t.Fatal(err) - } - product := wire.AppendVarint(nil, 1, moonriseProductID) - product = wire.AppendVarint(product, 3, 1) - buyProduct := wire.AppendVarint(nil, 1, 1) - buyProduct = wire.AppendVarint(buyProduct, 3, moonriseProductGroupID) - buyProduct = wire.AppendBytes(buyProduct, 4, product) - if code, _, handled, err := service.Handle("/CashShopBuy", buyProduct); err != nil || !handled || code != 61 { - t.Fatalf("cash product code=%d handled=%v err=%v", code, handled, err) - } - if !inventory.WasGranted(moonriseTicketGrant) { - t.Fatal("moonrise cash product purchase was not persisted") - } - if _, bought := collection.Grant(moonriseProductGrant); !bought { - t.Fatal("moonrise cash purchase marker was not persisted") - } - selection := wire.AppendVarint(nil, 1, 2) - for slot, itemID := range fiveStars { - entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID) - if slot != 0 { - entry = wire.AppendVarint(entry, 2, uint64(slot)) - } - entry = wire.AppendVarint(entry, 3, itemID) - selection = wire.AppendBytes(selection, 2, entry) - } - if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil { - t.Fatal(err) - } - draw := wire.AppendVarint(nil, 1, 3) - draw = wire.AppendVarint(draw, 2, moonriseProductID) - draw = wire.AppendVarint(draw, 3, 3) - if code, response, handled, err := service.Handle("/GachaBuy", draw); err != nil || !handled || code != 146 || countFields(response, 4) != 3 { - t.Fatalf("moonrise draw code=%d handled=%v err=%v", code, handled, err) - } - grant, found := collection.Grant(moonriseDrawGrant) - if !found || len(grant.ViewCostumeIDs) != 0 { - // The completion grant is deliberately an empty marker; the concrete - // result remains keyed by the request identity for retry replay. - if !found { - t.Fatal("moonrise completion marker missing") - } - } - if got := collection.GachaUser(paidTwelvePickGroupID).TotalBuyCount; got != 10 { - t.Fatalf("moonrise total buy count=%d want=10", got) - } - if _, _, _, err := service.Handle("/GachaBuy", wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, moonriseProductID), 3, 3)); err == nil { - t.Fatal("second moonrise draw was accepted") - } - _, info, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5)) - if err != nil || len(collectScheduleWindows(t, info, 1)) != 0 || !sameScheduleGroups(collectScheduleWindows(t, info, 7), 30) { - t.Fatalf("post-moonrise schedules=%v step=%v err=%v", scheduleGroupIDs(collectScheduleWindows(t, info, 1)), scheduleGroupIDs(collectScheduleWindows(t, info, 7)), err) - } - counts := service.PurchaseCountDBInfos() - if len(counts) != 1 { - t.Fatalf("purchase counts=%d want=1", len(counts)) - } - group, _, _ := wire.Varint(counts[0], 1) - id, _, _ := wire.Varint(counts[0], 2) - if group != moonriseProductGroupID || id != moonriseProductID { - t.Fatalf("purchase count group=%d id=%d", group, id) - } -} - -func TestSpecialSelectionUsesDifferentProductGroupTicketAndChoiceCount(t *testing.T) { - const ( - moonriseProductID = 890001 - moonriseProductGroupID = 770001 - moonriseTicketID = 660003 - paidTwelvePickGroupID = 550004 - moonriseTicketType = 19 - moonriseTicketGrant = "cash-product-reward:770001:890001:0" - moonriseProductGrant = "cash-product:770001:890001" - moonriseDrawGrant = "special-gacha:550004:890001" - ) - fiveStars := make([]uint64, 4) - characters := make(map[uint64]gamedata.CharacterDesign, 14) - choices := make([]gamedata.CostumeRewardEntry, 4) - for i := range fiveStars { - fiveStars[i] = uint64(5001 + i) - characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100) - choices[i] = gamedata.CostumeRewardEntry{ItemType: 11, ItemID: fiveStars[i], Count: 1, Weight: 1} - } - characters[4001] = fixtureCharacter(400, 100) - characters[3001] = fixtureCharacter(300, 100) - design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters) - if err != nil { - t.Fatal(err) - } - moonrise := gamedata.RegularGacha{ID: moonriseProductID, Count: 10, PriceType: moonriseTicketType, PriceID: moonriseTicketID, Price: 1, RewardGroup: &gamedata.CostumeRewardGroup{ - ID: moonriseProductID, DropCount: 1, DropType: 1, Entries: []gamedata.CostumeRewardEntry{ - {ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}}, - {ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []gamedata.CostumeRewardEntry{ - {ItemType: 11, ItemID: 4001, Count: 1, Weight: 1443}, - {ItemType: 11, ItemID: 3001, Count: 1, Weight: 8557}, - }}}, - }, - }} - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters) - if err != nil { - t.Fatal(err) - } - if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, BuyLimitCount: 10, CashProductGroupID: moonriseProductGroupID, CashProductID: moonriseProductID, CashRewards: []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}}, TenTimeGachaID: moonriseProductID, SelectCount: 4, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.AttachInventory(inventory) - if err := service.AttachSchedule(&ScheduleSeed{ClientVersion: "test", Schedules: []ScheduleWindow{{GroupID: paidTwelvePickGroupID, StartTime: 1, EndTime: 2}}, StepUps: []ScheduleWindow{{GroupID: 30, StartTime: 1, EndTime: 2}}}); err != nil { - t.Fatal(err) - } - product := wire.AppendVarint(nil, 1, moonriseProductID) - product = wire.AppendVarint(product, 3, 1) - buyProduct := wire.AppendVarint(nil, 1, 1) - buyProduct = wire.AppendVarint(buyProduct, 3, moonriseProductGroupID) - buyProduct = wire.AppendBytes(buyProduct, 4, product) - if code, _, handled, err := service.Handle("/CashShopBuy", buyProduct); err != nil || !handled || code != 61 { - t.Fatalf("cash product code=%d handled=%v err=%v", code, handled, err) - } - if !inventory.WasGranted(moonriseTicketGrant) { - t.Fatal("moonrise cash product purchase was not persisted") - } - if _, bought := collection.Grant(moonriseProductGrant); !bought { - t.Fatal("moonrise cash purchase marker was not persisted") - } - selection := wire.AppendVarint(nil, 1, 2) - for slot, itemID := range fiveStars { - entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID) - if slot != 0 { - entry = wire.AppendVarint(entry, 2, uint64(slot)) - } - entry = wire.AppendVarint(entry, 3, itemID) - selection = wire.AppendBytes(selection, 2, entry) - } - if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil { - t.Fatal(err) - } - draw := wire.AppendVarint(nil, 1, 3) - draw = wire.AppendVarint(draw, 2, moonriseProductID) - draw = wire.AppendVarint(draw, 3, 3) - if code, response, handled, err := service.Handle("/GachaBuy", draw); err != nil || !handled || code != 146 || countFields(response, 4) != 3 { - t.Fatalf("moonrise draw code=%d handled=%v err=%v", code, handled, err) - } - grant, found := collection.Grant(moonriseDrawGrant) - if !found || len(grant.ViewCostumeIDs) != 0 { - // The completion grant is deliberately an empty marker; the concrete - // result remains keyed by the request identity for retry replay. - if !found { - t.Fatal("moonrise completion marker missing") - } - } - if got := collection.GachaUser(paidTwelvePickGroupID).TotalBuyCount; got != 10 { - t.Fatalf("moonrise total buy count=%d want=10", got) - } - if _, _, _, err := service.Handle("/GachaBuy", wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, moonriseProductID), 3, 3)); err == nil { - t.Fatal("second moonrise draw was accepted") - } - _, info, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5)) - if err != nil || len(collectScheduleWindows(t, info, 1)) != 0 || !sameScheduleGroups(collectScheduleWindows(t, info, 7), 30) { - t.Fatalf("post-moonrise schedules=%v step=%v err=%v", scheduleGroupIDs(collectScheduleWindows(t, info, 1)), scheduleGroupIDs(collectScheduleWindows(t, info, 7)), err) - } - counts := service.PurchaseCountDBInfos() - if len(counts) != 1 { - t.Fatalf("purchase counts=%d want=1", len(counts)) - } - group, _, _ := wire.Varint(counts[0], 1) - id, _, _ := wire.Varint(counts[0], 2) - if group != moonriseProductGroupID || id != moonriseProductID { - t.Fatalf("purchase count group=%d id=%d", group, id) - } -} - -func TestSelectionChangeCountPersistsEnforcesLimitAndReturnsInGachaInfo(t *testing.T) { - fiveStars := []uint64{5001, 5002} - characters := map[uint64]gamedata.CharacterDesign{ - 5001: fixtureCharacter(500, 100), - 5002: fixtureCharacter(501, 100), - 4001: fixtureCharacter(400, 100), - 3001: fixtureCharacter(300, 100), - } - design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 101: {ID: 101, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}, - }, characters) - if err != nil { - t.Fatal(err) - } - const groupID = 7001 - if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ - ID: groupID, PointCount: 1, TenTimeGachaID: 101, SelectCount: 1, SelectionChangeCount: 2, - }, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - save := func(seq, itemID uint64) error { - request := wire.AppendVarint(nil, 1, seq) - entry := wire.AppendVarint(nil, 1, groupID) - entry = wire.AppendVarint(entry, 3, itemID) - request = wire.AppendBytes(request, 2, entry) - code, response, handled, saveErr := service.Handle("/GachaSelectionSave", request) - if code != 198 || !handled || len(response) != 0 { - t.Fatalf("save code=%d handled=%v response=%x", code, handled, response) - } - return saveErr - } - if err := save(1, 5001); err != nil { - t.Fatal(err) - } - // Re-saving the exact same slots is not a selection change and must not - // consume the finite GameData allowance. - if err := save(2, 5001); err != nil { - t.Fatal(err) - } - if err := save(3, 5002); err != nil { - t.Fatal(err) - } - if err := save(4, 5001); err == nil { - t.Fatal("selection save exceeded GameData change limit") - } - - _, info, handled, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5)) - if err != nil || !handled || countFields(info, 6) != 1 { - t.Fatalf("info change fields=%d handled=%v err=%v", countFields(info, 6), handled, err) - } - change, found, err := wire.Bytes(info, 6) - gotGroup, groupFound, groupErr := wire.Varint(change, 1) - gotCount, countFound, countErr := wire.Varint(change, 2) - if err != nil || groupErr != nil || countErr != nil || !found || !groupFound || !countFound || gotGroup != groupID || gotCount != 2 { - t.Fatalf("change group=%d count=%d found=%v/%v/%v err=%v/%v/%v", gotGroup, gotCount, found, groupFound, countFound, err, groupErr, countErr) - } - restored, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - counts := restored.GachaSelectionChangeCounts() - if len(counts) != 1 || counts[0].GroupID != groupID || counts[0].Count != 2 { - t.Fatalf("restored change counts=%+v", counts) - } -} - -func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing.T) { - fiveStars := make([]uint64, 12) - characters := make(map[uint64]gamedata.CharacterDesign, 14) - for i := range fiveStars { - fiveStars[i] = uint64(5001 + i) - characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100) - } - characters[4001] = fixtureCharacter(400, 100) - characters[3001] = fixtureCharacter(300, 100) - design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters) - if err != nil { - t.Fatal(err) - } - pool := []gamedata.WeightedCostume{ - {Weight: 300, Children: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}, - {Weight: 1400, Children: []gamedata.WeightedCostume{{ID: 4001, Weight: 1}}}, - {Weight: 8300, Children: []gamedata.WeightedCostume{{ID: 3001, Weight: 1}}}, - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 101: {ID: 101, Count: 10, PriceType: 3, Price: 2000, Pool: pool, TicketIDs: []uint64{1108, 1000}}, - }, characters) - if err != nil { - t.Fatal(err) - } - if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: twelvePickGroupID, FixedID: 8, PointCount: 1, TenTimeGachaID: 101, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1, IsSelectedFromPity: true}, gamedata.GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 2000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.BeginSession("ordinary-12pick") - save := wire.AppendVarint(nil, 1, 1) - for slot, itemID := range fiveStars { - choice := wire.AppendVarint(nil, 1, twelvePickGroupID) - if slot != 0 { - choice = wire.AppendVarint(choice, 2, uint64(slot)) - } - choice = wire.AppendVarint(choice, 3, itemID) - save = wire.AppendBytes(save, 2, choice) - } - if _, _, _, err := service.Handle("/GachaSelectionSave", save); err != nil { - t.Fatal(err) - } - buy := wire.AppendVarint(nil, 1, 2) - buy = wire.AppendVarint(buy, 2, 101) - buy = wire.AppendVarint(buy, 3, 1) - code, response, ok, err := service.Handle("/GachaBuy", buy) - if err != nil || !ok || code != 146 { - t.Fatalf("buy code=%d ok=%v err=%v", code, ok, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found || countFields(bundle, 3)+countFields(bundle, 9) != 10 { - t.Fatalf("bundle=%x found=%v err=%v", bundle, found, err) - } - if user := collection.GachaUser(twelvePickGroupID); user.Point != 10 || user.TotalBuyCount != 10 { - t.Fatalf("user=%+v", user) - } - fixed := collection.GachaFixedStates() - if len(fixed) != 2 || fixed[0].FixedID != 8 || fixed[1].FixedID != 8 { - t.Fatalf("fixed=%+v", fixed) - } - if wallet.Snapshot().FreeJewelry != 0 { - t.Fatalf("wallet=%+v", wallet.Snapshot()) - } -} - -func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{infiniteScheduleGroupID}) - if err != nil { - t.Fatal(err) - } - regular, _, err := gamedata.LoadActiveGachaForSchedules(root, "20260923193640", []uint64{1009}, nil) - if err != nil { - t.Fatal(err) - } - choices := regular.FiveStarIDs(31) - if len(choices) < 3 { - t.Fatalf("newbie selectable five-stars=%v", choices) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Jewelry: 6000}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(infinite, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.BeginSession("newbie-30") - selection := wire.AppendVarint(nil, 1, 1) - selected := map[uint64]bool{} - for slot, costumeID := range choices[:3] { - selected[costumeID] = true - entry := wire.AppendVarint(nil, 1, 1009) - if slot != 0 { - entry = wire.AppendVarint(entry, 2, uint64(slot)) - } - entry = wire.AppendVarint(entry, 3, costumeID) - selection = wire.AppendBytes(selection, 2, entry) - } - if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil { - t.Fatal(err) - } - for draw := range uint64(3) { - request := wire.AppendVarint(nil, 1, draw+2) - request = wire.AppendVarint(request, 2, 31) - request = wire.AppendVarint(request, 3, 1) - code, response, handled, err := service.Handle("/GachaBuy", request) - if err != nil || !handled || code != 146 { - t.Fatalf("draw %d code=%d handled=%v err=%v", draw+1, code, handled, err) - } - point, _, err := wire.Varint(response, 2) - if err != nil || point != 0 { - t.Fatalf("draw %d point=%d err=%v", draw+1, point, err) - } - grant, ok := collection.Grant(service.requestIdentity(31, draw+2)) - if !ok || len(grant.ViewCostumeIDs) != 10 { - t.Fatalf("grant=%+v", grant) - } - for _, sortID := range grant.SelectionApplySortIDs { - if sortID >= uint64(len(grant.ViewCostumeIDs)) || !selected[grant.ViewCostumeIDs[sortID]] { - t.Fatalf("invalid selected guarantee sort=%d grant=%+v", sortID, grant) - } - } - foundLast := false - for _, sortID := range grant.SelectionApplySortIDs { - if sortID == 9 { - foundLast = true - } - } - if !foundLast || !selected[grant.ViewCostumeIDs[9]] { - t.Fatalf("nonresetting newbie tenth-slot guarantee missing: %+v", grant) - } - - } - user := collection.GachaUser(1009) - if user.TotalBuyCount != 30 || user.Point != 0 || wallet.Snapshot().Jewelry != 0 { - t.Fatalf("newbie state user=%+v wallet=%+v", user, wallet.Snapshot()) - } - before := wallet.Snapshot() - fourth := wire.AppendVarint(nil, 1, 5) - fourth = wire.AppendVarint(fourth, 2, 31) - fourth = wire.AppendVarint(fourth, 3, 1) - if _, _, _, err := service.Handle("/GachaBuy", fourth); err == nil { - t.Fatal("newbie draw accepted after 30 pulls") - } - if wallet.Snapshot() != before || collection.GachaUser(1009).TotalBuyCount != 30 { - t.Fatalf("rejected fourth draw mutated state wallet=%+v user=%+v", wallet.Snapshot(), collection.GachaUser(1009)) - } -} - -func countFields(data []byte, number int) int { - count := 0 - _ = wire.Walk(data, func(field wire.Field) error { - if field.Number == number { - count++ - } - return nil - }) - return count -} diff --git a/go/internal/server/gacha/legacy_design_test.go b/go/internal/server/gacha/legacy_design_test.go deleted file mode 100644 index 7c8a91c..0000000 --- a/go/internal/server/gacha/legacy_design_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package gacha - -import "bd2server/internal/server/gamedata" - -const ( - moonriseProductGroupID = 1500001 - moonriseProductID = 9100037 - moonriseTicketType = 19 - moonriseTicketID = 450030 - moonriseProductGrant = "cash-product:1500001:9100037" - moonriseTicketGrant = "cash-product-reward:1500001:9100037:0" - moonriseDrawGrant = "special-gacha:30011:9100037" -) - -const ( - infiniteScheduleGroupID = 30010 - twelvePickGroupID = 10001 - paidTwelvePickGroupID = 30011 -) - -const infiniteGrant = "cash-product:1100001:9100033" - -const ( - fixtureInfiniteGachaID = 9100033 - fixtureInfiniteProductGroupID = 1100001 - fixtureInfiniteProductID = 9100033 -) - -func fixtureInfiniteGachaDesign(count int, ids []uint64, characters map[uint64]gamedata.CharacterDesign) (*gamedata.InfiniteGachaDesign, error) { - return fixtureInfiniteGachaDesignWithRates(count, ids, ids, ids, characters) -} -func fixtureInfiniteGachaDesignWithRates(count int, five, four, three []uint64, characters map[uint64]gamedata.CharacterDesign) (*gamedata.InfiniteGachaDesign, error) { - d, e := gamedata.NewInfiniteGachaDesignWithRates(count, five, four, three, characters) - if e == nil { - d.GroupID = infiniteScheduleGroupID - d.GachaID = fixtureInfiniteGachaID - d.ProductGroupID = fixtureInfiniteProductGroupID - d.ProductID = fixtureInfiniteProductID - } - return d, e -} - -func fixtureRegularCatalog(gachas map[uint64]gamedata.RegularGacha, characters map[uint64]gamedata.CharacterDesign) (*gamedata.RegularGachaCatalog, error) { - for id, g := range gachas { - if len(g.Grades) == 0 { - g.Grades = map[uint64]uint64{} - var collect func([]gamedata.WeightedCostume, uint64) - collect = func(p []gamedata.WeightedCostume, grade uint64) { - for _, w := range p { - if w.ID != 0 { - g.Grades[w.ID] = grade - } else { - collect(w.Children, grade) - } - } - } - for i, w := range g.Pool { - grade := uint64(5 - i) - if len(g.Pool) == 4 { - grade = uint64(6 - i) - if i == 0 { - grade = 5 - } - } - collect([]gamedata.WeightedCostume{w}, grade) - } - if g.FixedCostumeID != 0 { - g.Grades[g.FixedCostumeID] = 5 - } - gachas[id] = g - } - } - return gamedata.NewRegularGachaCatalog(gachas, characters) -} diff --git a/go/internal/server/gacha/login_purchase_count_test.go b/go/internal/server/gacha/login_purchase_count_test.go deleted file mode 100644 index e1acc6a..0000000 --- a/go/internal/server/gacha/login_purchase_count_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package gacha - -import ( - "path/filepath" - "testing" - - "bd2server/internal/server/account" - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) { - statePath := filepath.Join(t.TempDir(), "state.db") - repository, err := accountstate.Open(statePath) - if err != nil { - t.Fatal(err) - } - - design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{ - 60901: {ID: 6090, HP: 253, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}, - }) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog( - map[uint64]gamedata.RegularGacha{ - 1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}, - }, - map[uint64]gamedata.CharacterDesign{ - 60901: {ID: 6090, HP: 253, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}, - }, - ) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(repository, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repository, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachPreviewEventIndex(1171); err != nil { - t.Fatal(err) - } - - preview := wire.AppendVarint(nil, 1, 1) - preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID) - preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID) - preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID) - if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil { - t.Fatal(err) - } - lock := wire.AppendVarint(nil, 1, 2) - lock = wire.AppendVarint(lock, 2, 1171) - if _, _, _, err := service.Handle("/GachaBuyPreviewLock", lock); err != nil { - t.Fatal(err) - } - buy := wire.AppendVarint(nil, 1, 3) - buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID) - product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID) - product = wire.AppendVarint(product, 3, 1) - buy = wire.AppendBytes(buy, 4, product) - if _, _, _, err := service.Handle("/CashShopBuy", buy); err != nil { - t.Fatal(err) - } - if err := repository.Close(); err != nil { - t.Fatal(err) - } - - // Reopen the database and construct fresh stores/services so this assertion - // cannot pass from an in-memory grant left by the purchase call above. - repository, err = accountstate.Open(statePath) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repository.Close(); err != nil { - t.Error(err) - } - }() - collection, err = player.OpenCollectionStore(repository, nil) - if err != nil { - t.Fatal(err) - } - if _, found := collection.Grant(infiniteGrant); !found { - t.Fatal("SQLite-backed infinite cash-product grant was not restored") - } - wallet, err = player.OpenWallet(repository, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err = NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - - stale := wire.AppendVarint(nil, 1, 999) - userTemplate := wire.AppendVarint(nil, 1, 42) - userTemplate = wire.AppendBytes(userTemplate, 26, stale) - loginSeed := &account.LoginSeed{Version: account.StateVersion(), PacketCode: 11, UserInfo: userTemplate} - if err := loginSeed.AttachPurchaseCounts(service); err != nil { - t.Fatal(err) - } - loginResponse, err := loginSeed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef")) - if err != nil { - t.Fatal(err) - } - user, found, err := wire.Bytes(loginResponse, 1) - if err != nil || !found { - t.Fatalf("LoginUser missing UserDBInfo: found=%v err=%v", found, err) - } - loginCounts := purchaseCountFields(user, 26) - if len(loginCounts) != 1 { - t.Fatalf("LoginUser purchase counts=%d, want 1", len(loginCounts)) - } - assertInfinitePurchaseCount(t, loginCounts[0]) - - code, rpcResponse, handled, err := service.Handle("/CashShopPurchaseCountInfo", wire.AppendVarint(nil, 1, 5)) - if err != nil || !handled || code != 432 { - t.Fatalf("purchase-count RPC code=%d handled=%v err=%v", code, handled, err) - } - rpcCounts := purchaseCountFields(rpcResponse, 1) - if len(rpcCounts) != 1 || string(rpcCounts[0]) != string(loginCounts[0]) { - t.Fatalf("LoginUser/RPC purchase counts differ: login=%x rpc=%x", loginCounts, rpcCounts) - } -} - -func assertInfinitePurchaseCount(t *testing.T, data []byte) { - t.Helper() - group, groupFound, groupErr := wire.Varint(data, 1) - id, idFound, idErr := wire.Varint(data, 2) - saleGroup, saleGroupFound, saleGroupErr := wire.Varint(data, 3) - count, countFound, countErr := wire.Varint(data, 4) - if groupErr != nil || idErr != nil || saleGroupErr != nil || countErr != nil || - !groupFound || !idFound || saleGroupFound || !countFound || - group != fixtureInfiniteProductGroupID || id != fixtureInfiniteProductID || saleGroup != 0 || count != 1 { - t.Fatalf("PurchaseCountDBInfo group=%d id=%d saleGroup=%d count=%d found=%v/%v/%v/%v errors=%v/%v/%v/%v", - group, id, saleGroup, count, groupFound, idFound, saleGroupFound, countFound, - groupErr, idErr, saleGroupErr, countErr) - } -} - -func purchaseCountFields(data []byte, number int) [][]byte { - var result [][]byte - if err := 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 - }); err != nil { - return nil - } - return result -} diff --git a/go/internal/server/gacha/permanent_equipment_test.go b/go/internal/server/gacha/permanent_equipment_test.go deleted file mode 100644 index 9db4d21..0000000 --- a/go/internal/server/gacha/permanent_equipment_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package gacha - -import ( - "bytes" - "fmt" - "os" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestPermanentEquipmentBuyUsesActiveCatalogWithoutSchedule(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - regular, catalog, err := gamedata.LoadActiveGachaForSchedules(root, "20260923193640", nil, nil) - if err != nil { - t.Fatal(err) - } - for _, ticketCount := range []uint64{5, 10} { - t.Run(fmt.Sprintf("tickets_%d", ticketCount), func(t *testing.T) { - storage := stateio.NewMemory() - service := newMultiBuyTestService(t, storage) - service.regular = regular - service.schedule.Schedules = nil - equipment, err := player.OpenEquipmentInventory(storage) - if err != nil { - t.Fatal(err) - } - service.AttachEquipmentGacha(catalog, equipment) - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - items, err := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 1000, Count: ticketCount + 1}}) - if err != nil { - t.Fatal(err) - } - service.AttachInventory(inventory) - ticket := items[0] - ticket.Count = ticketCount - request := wire.AppendVarint(nil, 1, 90) - request = wire.AppendVarint(request, 2, 201) - request = wire.AppendVarint(request, 3, 1) - request = wire.AppendBytes(request, 4, player.ItemWire(ticket)) - code, response, handled, err := service.Handle("/GachaBuy", request) - if err != nil || !handled || code != 146 { - t.Fatalf("code=%d handled=%v err=%v", code, handled, err) - } - bundle, _, _ := wire.Bytes(response, 1) - if countFields(bundle, 4) != 10 || len(equipment.All()) != 10 { - t.Fatal("ten equipment instances missing") - } - if got := service.wallet.Snapshot().FreeJewelry; got != 1000-(10-ticketCount)*200 { - t.Fatalf("free diamonds=%d", got) - } - user := service.collection.GachaUser(10002) - if user.Point != 10 || user.TotalBuyCount != 10 { - t.Fatalf("user=%+v", user) - } - if countFields(response, 3) != 4 { - t.Fatal("shared pity snapshot missing") - } - for _, item := range inventory.All() { - if item.InvenIndex == ticket.InvenIndex && item.Count != 1 { - t.Fatalf("remaining tickets=%d", item.Count) - } - } - _, replay, _, err := service.Handle("/GachaBuy", request) - if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 10 { - t.Fatalf("retry changed draw: %v", err) - } - }) - } -} diff --git a/go/internal/server/gacha/point_exchange_test.go b/go/internal/server/gacha/point_exchange_test.go deleted file mode 100644 index f836a82..0000000 --- a/go/internal/server/gacha/point_exchange_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package gacha - -import ( - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestGachaPointExchangeGrantsUpgradesOverflowsAndRetries(t *testing.T) { - const ( - groupID = uint64(205) - gachaID = uint64(11000145) - rollID = uint64(60901) - pickupID = uint64(21201) - cost = uint64(200) - ) - characters := map[uint64]gamedata.CharacterDesign{ - rollID: {ID: 6090, HP: 253, CostumeMaxLevel: 100, OverflowItemType: 20, OverflowItemCount: 2}, - pickupID: {ID: 2120, HP: 300, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}, - } - infinite, err := fixtureInfiniteGachaDesign(10, []uint64{rollID}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - gachaID: {ID: gachaID, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}}, - }, characters) - if err != nil { - t.Fatal(err) - } - group := gamedata.GachaGroupDesign{ - ID: groupID, GachaType: 1, PointCount: 140, - PickUpExchangeCost: cost, PickUpCostumeID: pickupID, TenTimeGachaID: gachaID, - } - if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - seed := make([]uint64, 10) - for i := range seed { - seed[i] = rollID - } - if _, err := collection.GrantRegularPurchase("point-seed", seed, regular, player.GachaPurchase{Group: group, BuyType: 1}); err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(infinite, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.BeginSession("point-costume-login") - - var lastRequest, lastResponse []byte - for exchange := range uint64(7) { - request := wire.AppendVarint(nil, 1, 100+exchange) - request = wire.AppendVarint(request, 2, groupID) - code, response, handled, err := service.Handle("/GachaPointExchange", request) - if err != nil || !handled || code != 147 { - t.Fatalf("exchange %d code=%d handled=%v err=%v", exchange, code, handled, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("exchange %d reward bundle found=%v err=%v", exchange, found, err) - } - switch { - case exchange == 0: - if countFields(bundle, 2) != 1 || countFields(bundle, 3) != 1 || countFields(bundle, 9) != 0 { - t.Fatalf("new pickup reward characters=%d costumes=%d upgrades=%d bundle=%x", countFields(bundle, 2), countFields(bundle, 3), countFields(bundle, 9), bundle) - } - case exchange < 6: - if countFields(bundle, 3) != 0 || countFields(bundle, 9) != 1 || countFields(bundle, 8) != 0 { - t.Fatalf("upgrade %d costumes=%d upgrades=%d exchanges=%d bundle=%x", exchange, countFields(bundle, 3), countFields(bundle, 9), countFields(bundle, 8), bundle) - } - default: - if countFields(bundle, 9) != 1 || countFields(bundle, 8) != 1 || countFields(bundle, 10) != 1 { - t.Fatalf("overflow upgrades=%d exchanges=%d repaid=%d bundle=%x", countFields(bundle, 9), countFields(bundle, 8), countFields(bundle, 10), bundle) - } - } - lastRequest, lastResponse = request, response - } - if user := collection.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 7 || user.ExchangeMileageCount != 0 { - t.Fatalf("gacha user after exchanges=%+v", user) - } - if got := wallet.Snapshot().Mileage; got != 2 { - t.Fatalf("overflow mileage=%d want=2", got) - } - owned := collection.Costumes() - if len(owned) != 2 || owned[1].ID != pickupID || owned[1].Level != 5 { - t.Fatalf("owned costumes=%+v", owned) - } - - // A byte-identical transport retry must recover the persisted grant and - // wallet credit without another debit, level change, or exchange count. - code, replay, handled, err := service.Handle("/GachaPointExchange", lastRequest) - if err != nil || !handled || code != 147 || string(replay) != string(lastResponse) { - t.Fatalf("retry code=%d handled=%v err=%v response=%x want=%x", code, handled, err, replay, lastResponse) - } - if user := collection.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 7 { - t.Fatalf("retry changed gacha user=%+v", user) - } - if got := wallet.Snapshot().Mileage; got != 2 { - t.Fatalf("retry changed mileage=%d", got) - } - - restored, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - restarted, err := NewService(infinite, regular, restored, wallet) - if err != nil { - t.Fatal(err) - } - restarted.BeginSession("point-costume-login") - _, replay, _, err = restarted.Handle("/GachaPointExchange", lastRequest) - if err != nil || string(replay) != string(lastResponse) { - t.Fatalf("restart retry err=%v response=%x want=%x", err, replay, lastResponse) - } - if user := restored.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 7 { - t.Fatalf("restored gacha user=%+v", user) - } -} - -func TestGachaPointExchangeUsesOnlySavedSingleSelection(t *testing.T) { - const ( - groupID = uint64(1001) - gachaID = uint64(11000001) - rollID = uint64(60901) - selected = uint64(64901) - ) - characters := map[uint64]gamedata.CharacterDesign{ - rollID: fixtureCharacter(6090, 253), - selected: fixtureCharacter(6490, 300), - } - infinite, err := fixtureInfiniteGachaDesign(10, []uint64{rollID}, characters) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - gachaID: {ID: gachaID, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}}, - }, characters) - if err != nil { - t.Fatal(err) - } - group := gamedata.GachaGroupDesign{ID: groupID, GachaType: 1, PointCount: 200, PickUpExchangeCost: 200, OneTimeGachaID: gachaID, SelectCount: 1} - if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if _, err := collection.GrantRegularPurchase("selection-point-seed", []uint64{rollID}, regular, player.GachaPurchase{Group: group, BuyType: 1}); err != nil { - t.Fatal(err) - } - if err := collection.SaveGachaSelections(groupID, []player.GachaSelection{{GroupID: groupID, Slot: 1, ItemID: selected}}, 0); err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(infinite, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - service.BeginSession("selection-point-login") - - bad := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, groupID), 3, rollID) - if code, _, handled, err := service.Handle("/GachaPointExchange", bad); err == nil || !handled || code != 147 { - t.Fatalf("unsaved selection code=%d handled=%v err=%v", code, handled, err) - } - if user := collection.GachaUser(groupID); user.Point != 200 || user.ExchangeItemCount != 0 { - t.Fatalf("rejected selection changed user=%+v", user) - } - - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, groupID), 3, selected) - code, response, handled, err := service.Handle("/GachaPointExchange", request) - if err != nil || !handled || code != 147 { - t.Fatalf("saved selection code=%d handled=%v err=%v", code, handled, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found || countFields(bundle, 3) != 1 { - t.Fatalf("saved selection reward found=%v costumes=%d err=%v bundle=%x", found, countFields(bundle, 3), err, bundle) - } - if user := collection.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 1 { - t.Fatalf("saved selection user=%+v", user) - } -} diff --git a/go/internal/server/gacha/runtime_dependency_test.go b/go/internal/server/gacha/runtime_dependency_test.go deleted file mode 100644 index 1a0ffe2..0000000 --- a/go/internal/server/gacha/runtime_dependency_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package gacha - -import ( - "go/ast" - "go/parser" - "go/token" - "io/fs" - "path/filepath" - "strconv" - "strings" - "testing" -) - -// Captures are evidence and golden-test inputs. This audit prevents a server -// source file from acquiring a runtime dependency on the fixture reader or an -// on-disk capture path as gacha evolves. -func TestServerRuntimeHasNoCaptureFixtureDependency(t *testing.T) { - root := filepath.Join("..", "..", "..") - for _, directory := range []string{filepath.Join(root, "cmd"), filepath.Join(root, "internal")} { - err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - if filepath.Base(path) == "fixture" { - return filepath.SkipDir - } - return nil - } - if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { - return nil - } - file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) - if err != nil { - return err - } - for _, imported := range file.Imports { - value, _ := strconv.Unquote(imported.Path.Value) - if strings.HasSuffix(value, "/internal/fixture") { - t.Errorf("runtime file %s imports capture fixture package", path) - } - } - ast.Inspect(file, func(node ast.Node) bool { - literal, ok := node.(*ast.BasicLit) - if !ok || literal.Kind != token.STRING { - return true - } - value, _ := strconv.Unquote(literal.Value) - normalized := strings.ToLower(strings.ReplaceAll(value, "\\", "/")) - if strings.Contains(normalized, "data/capture/") { - t.Errorf("runtime file %s contains capture path literal", path) - } - return true - }) - return nil - }) - if err != nil { - t.Fatal(err) - } - } -} diff --git a/go/internal/server/gacha/schedule_test.go b/go/internal/server/gacha/schedule_test.go deleted file mode 100644 index e306ab2..0000000 --- a/go/internal/server/gacha/schedule_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package gacha - -import ( - "encoding/base64" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func fixtureSchedule(t *testing.T) (*ScheduleSeed, error) { - golden, err := base64.StdEncoding.DecodeString("ChEIpgEQgMi/3Iw0GJiIvcaRNAoSCLrqARCA8PTEiDQYmIi9xpE0ChIIu+oBEIDw9MSINBiYiL3GkTQKEQjOARCA8PTEiDQYmIi9xpE0ChEIzQEQgPD0xIg0GJiIvcaRNAoRCNABEIDIv9yMNBiYiL3GkTQKEQiZARCAyL/cjDQYmIi9xpE0ChAISBCAyL/cjDQYmIi9xpE0ChAIRxCAyL/cjDQYmIi9xpE0ChEIzwEQgMi/3Iw0GJiIvcaRNAoRCPEHEIDo2beLNBiA+L34jzQSBAgCGAo6EAgdEIDw9MSINBiYiL3GkTQ6EAgeEIDIv9yMNBiYiL3GkTRKUAiTCUJLGgMQvR4aBRCFBzABGgUQ0ggwAhoGEOn7AzADGgYQzeMDMAQaBhDF7gMwBRoGEJnsAzAGGgYQvfkDMAcaBhDu6QMwCBoGEKnvAzAJ") - if err != nil { - t.Fatal(err) - } - return &ScheduleSeed{ClientVersion: "2.35.10", Schedules: collectScheduleWindows(t, golden, 1), StepUps: collectScheduleWindows(t, golden, 7)}, nil -} - -func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) { - character := gamedata.CharacterDesign{ID: 1, HP: 1, CostumeMaxLevel: 5} - catalog, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{ - 11: {ID: 11, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 101, Weight: 1}}}, - 12: {ID: 12, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 102, Weight: 1}}}, - }, map[uint64]gamedata.CharacterDesign{101: character, 102: character}) - if err != nil { - t.Fatal(err) - } - for _, group := range []gamedata.GachaGroupDesign{ - {ID: 1, GachaType: 1, PointCount: 1, PickUpCostumeID: 101, OneTimeGachaID: 11}, - {ID: 2, GachaType: 1, PointCount: 1, PickUpCostumeID: 102, OneTimeGachaID: 12}, - } { - if err := catalog.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil { - t.Fatal(err) - } - } - seed := &ScheduleSeed{Schedules: []ScheduleWindow{ - {GroupID: 1, StartTime: 100, EndTime: 200}, - {GroupID: 2, StartTime: 200, EndTime: 300}, - }} - if got := ActivePickupCostumes(catalog, seed, 100); !got[101] || got[102] || len(got) != 1 { - t.Fatalf("at start active=%v", got) - } - if got := ActivePickupCostumes(catalog, seed, 200); got[101] || !got[102] || len(got) != 1 { - t.Fatalf("at end active=%v", got) - } -} - -func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T) { - seed, err := fixtureSchedule(t) - if err != nil { - t.Fatal(err) - } - character := gamedata.CharacterDesign{ID: 1, HP: 1, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 1} - design, err := fixtureInfiniteGachaDesign(10, []uint64{11}, map[uint64]gamedata.CharacterDesign{11: character}) - if err != nil { - t.Fatal(err) - } - regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 11, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{11: character}) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := NewService(design, regular, collection, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachSchedule(seed); err != nil { - t.Fatal(err) - } - code, response, handled, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || !handled || code != 145 { - t.Fatalf("code=%d handled=%v err=%v", code, handled, err) - } - regularWindows := collectScheduleWindows(t, response, 1) - stepWindows := collectScheduleWindows(t, response, 7) - if len(regularWindows) != 11 || len(stepWindows) != 2 || countFields(response, 9) != 0 { - t.Fatalf("schedule=%d step=%d preview=%d", len(regularWindows), len(stepWindows), countFields(response, 9)) - } - for i := range seed.Schedules { - if regularWindows[i] != seed.Schedules[i] { - t.Fatalf("schedule %d response=%+v seed=%+v", i, regularWindows[i], seed.Schedules[i]) - } - } -} - -func collectScheduleWindows(t *testing.T, proto []byte, number int) []ScheduleWindow { - t.Helper() - var result []ScheduleWindow - if err := wire.Walk(proto, func(field wire.Field) error { - if field.Number != number { - return nil - } - group, _, err := wire.Varint(field.Value, 1) - if err != nil { - return err - } - start, _, err := wire.Varint(field.Value, 2) - if err != nil { - return err - } - end, _, err := wire.Varint(field.Value, 3) - if err != nil { - return err - } - free, _, err := wire.Varint(field.Value, 4) - if err != nil { - return err - } - cash, _, err := wire.Varint(field.Value, 5) - if err != nil { - return err - } - result = append(result, ScheduleWindow{GroupID: group, StartTime: start, EndTime: end, FreeCountBonus: free != 0, CashCountBonus: cash != 0}) - return nil - }); err != nil { - t.Fatal(err) - } - return result -} diff --git a/go/internal/server/gameconfig/config_test.go b/go/internal/server/gameconfig/config_test.go deleted file mode 100644 index e42ce8f..0000000 --- a/go/internal/server/gameconfig/config_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package gameconfig - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoad(t *testing.T) { - for _, tc := range []struct { - name, text string - wantTrue, wantError bool - }{ - {"enabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":true}}`, true, false}, - {"disabled", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":false}}`, false, false}, - {"omitted bool", `{"schema_version":1,"gacha":{}}`, false, false}, - {"omitted gacha", `{"schema_version":1}`, false, false}, - {"string bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":"true"}}`, false, true}, - {"unknown root", `{"schema_version":1,"unknown":true}`, false, true}, - {"unknown rule", `{"schema_version":1,"gacha":{"typo":true}}`, false, true}, - {"trailing object", `{"schema_version":1} {}`, false, true}, - {"trailing garbage", `{"schema_version":1} invalid`, false, true}, - {"null root", `null`, false, true}, - {"null section", `{"schema_version":1,"gacha":null}`, false, true}, - {"null bool", `{"schema_version":1,"gacha":{"include_collaboration_ur_weapons":null}}`, false, true}, - {"null schema", `{"schema_version":null}`, false, true}, - {"wrong version", `{"schema_version":2}`, false, true}, - {"missing version", `{}`, false, true}, - {"malformed", `{`, false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), FileName) - if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil { - t.Fatal(err) - } - cfg, err := Load(path) - if (err != nil) != tc.wantError { - t.Fatalf("Load error = %v, wantError %v", err, tc.wantError) - } - if err == nil && (cfg.SchemaVersion != 1 || cfg.Gacha.IncludeCollaborationURWeapons != tc.wantTrue) { - t.Fatalf("unexpected config: %+v", cfg) - } - }) - } -} - -func TestStartingChapterConfiguration(t *testing.T) { - for _, tc := range []struct { - text string - want int - }{ - {`{"schema_version":1}`, 21}, - {`{"schema_version":1,"story":{}}`, 21}, - {`{"schema_version":1,"story":{"start_pack_id":1}}`, 1}, - {`{"schema_version":1,"story":{"start_pack_id":21}}`, 21}, - {`{"schema_version":1,"story":{"start_pack_id":0}}`, 0}, - {`{"schema_version":1,"story":{"start_pack_id":22}}`, 0}, - {`{"schema_version":1,"story":{"start_pack_id":"1"}}`, 0}, - {`{"schema_version":1,"story":{"start_pack_id":null}}`, 0}, - {`{"schema_version":1,"story":null}`, 0}, - } { - path := filepath.Join(t.TempDir(), FileName) - if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil { - t.Fatal(err) - } - cfg, err := Load(path) - if tc.want == 0 { - if err == nil { - t.Fatalf("accepted invalid entry: %s", tc.text) - } - } else if err != nil || cfg.Story.StartPackID != tc.want { - t.Fatalf("configuration %s = %+v, %v", tc.text, cfg, err) - } - } -} - -func TestMissingFileCreatesDefaults(t *testing.T) { - path := filepath.Join(t.TempDir(), FileName) - cfg, err := Load(path) - if err != nil || cfg != Default() { - t.Fatalf("config = %+v, error = %v", cfg, err) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("Load did not create missing config: %v", err) - } -} - -func TestReadError(t *testing.T) { - if _, err := Load(t.TempDir()); err == nil { - t.Fatal("directory must not use defaults") - } -} - -func TestBesideExecutable(t *testing.T) { - path, err := BesideExecutable() - if err != nil { - t.Fatal(err) - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - if path != filepath.Join(filepath.Dir(executable), FileName) { - t.Fatalf("unexpected path %s", path) - } -} diff --git a/go/internal/server/gameconfig/purchases_test.go b/go/internal/server/gameconfig/purchases_test.go deleted file mode 100644 index ed1d4fe..0000000 --- a/go/internal/server/gameconfig/purchases_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package gameconfig - -import ( - "os" - "path/filepath" - "testing" -) - -func TestDiamondRechargeConfiguration(t *testing.T) { - for _, tc := range []struct { - text, currency string - invalid bool - }{ - {`{"schema_version":1}`, "free", false}, - {`{"schema_version":1,"purchases":{}}`, "free", false}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":""}}}`, "", false}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"ban"}}}`, "ban", false}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"gold","gold_per_paid_diamond":10}}}`, "gold", false}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"diamonds","diamonds_per_paid_diamond":2}}}`, "diamonds", false}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":"paid_diamonds"}}}`, "", true}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"currency":null}}}`, "", true}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"gold_per_paid_diamond":0}}}`, "", true}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"gold_per_paid_diamond":1000000001}}}`, "", true}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"diamonds_per_paid_diamond":1.1}}}`, "", true}, - {`{"schema_version":1,"purchases":{"diamond_recharge":{"typo":1}}}`, "", true}, - } { - path := filepath.Join(t.TempDir(), FileName) - if err := os.WriteFile(path, []byte(tc.text), 0600); err != nil { - t.Fatal(err) - } - cfg, err := Load(path) - if (err != nil) != tc.invalid { - t.Fatalf("%s: error=%v", tc.text, err) - } - if err == nil && cfg.Purchases.DiamondRecharge.Currency != tc.currency { - t.Fatalf("%s: %+v", tc.text, cfg.Purchases) - } - } -} diff --git a/go/internal/server/gamedata/achievement_test.go b/go/internal/server/gamedata/achievement_test.go deleted file mode 100644 index 665809a..0000000 --- a/go/internal/server/gamedata/achievement_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "encoding/binary" - "math" - "testing" -) - -func TestAchievementCounterDesignUsesRootGroupsAndBothContents(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - db.SetMaxOpenConns(1) - if _, err = db.Exec("CREATE TABLE AchievementTable (ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - for _, row := range [][3]uint64{{7, 0, 0}, {7, 0, 0}, {7, 1, 0}, {8, 0, 7}} { - raw := wire.AppendVarint(nil, 9, row[0]) - raw = wire.AppendVarint(raw, 4, row[1]) - raw = wire.AppendVarint(raw, 13, row[2]) - if _, err = db.Exec("INSERT INTO AchievementTable VALUES (?)", raw); err != nil { - t.Fatal(err) - } - } - d, err := loadAchievementCounterDesign(db) - if err != nil { - t.Fatal(err) - } - if len(d.Groups) != 1 || len(d.Groups[7]) != 2 || d.Groups[7][0] != 0 || d.Groups[7][1] != 1 { - t.Fatalf("wrong group index: %#v", d) - } -} - -func TestAchievementRewardDesignIncludesOrdinaryZeroContentsAndTargets(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE AchievementTable(ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - raw := wire.AppendVarint(wire.AppendVarint(nil, 9, 987), 11, 1) - raw = wire.AppendVarint(raw, 8, 9) - raw = append(raw, 25) - raw = binary.LittleEndian.AppendUint64(raw, math.Float64bits(10)) - if _, err := db.Exec("INSERT INTO AchievementTable VALUES (?)", raw); err != nil { - t.Fatal(err) - } - d := &MissionDesign{Achievements: map[AchievementKey]AchievementDesign{}} - if err := loadAchievementRows(db, d); err != nil { - t.Fatal(err) - } - got, ok := d.Achievements[AchievementKey{GroupID: 987, ID: 1}] - if !ok || got.Target != 10 || got.AddExp != 9 { - t.Fatalf("ordinary=%+v exists=%v", got, ok) - } -} diff --git a/go/internal/server/gamedata/attributes_test.go b/go/internal/server/gamedata/attributes_test.go deleted file mode 100644 index 5311c4a..0000000 --- a/go/internal/server/gamedata/attributes_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestAggregateStatsAddsFlatHealth(t *testing.T) { - got := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Flat: 7}}) - if got.Health != 512 { - t.Fatalf("health=%v, want 512", got.Health) - } -} - -func TestRealTutorialStatsDoNotMisclassifyAttackAsHealth(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - base, err := CharacterBaseStats(root, "20260923193640", 350, 20) - if err != nil { - t.Fatal(err) - } - if base.Health != 505 { - t.Fatalf("base health=%v, want 505", base.Health) - } - option, err := EquipmentOptionContribution(root, "20260923193640", EquipmentOption{GroupID: 1010010, ID: 3}) - if err != nil { - t.Fatal(err) - } - if option.Stat != StatAttack || option.Flat != 7 { - t.Fatalf("option=%+v", option) - } - if got := AggregateStats(base, []StatContribution{option}); got.Health != 505 { - t.Fatalf("attack equipment changed health: %+v", got) - } -} - -func TestLoadedCharacterStatDesignMatchesDirectLookup(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - design, err := LoadPictorialDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - loaded, err := design.CharStats.BaseStats(350, 20) - if err != nil { - t.Fatal(err) - } - direct, err := CharacterBaseStats(root, "20260923193640", 350, 20) - if err != nil { - t.Fatal(err) - } - if loaded != direct { - t.Fatalf("loaded stats=%+v, direct database lookup=%+v", loaded, direct) - } - if _, err := design.CharStats.BaseStats(0, 20); err == nil { - t.Fatal("zero character id unexpectedly resolved") - } - if _, err := design.CharStats.BaseStats(350, 0); err == nil { - t.Fatal("zero character level unexpectedly resolved") - } -} - -func TestAggregateStatsAppliesPercentAfterFlat(t *testing.T) { - got := AggregateStats(BaseStats{Health: 100}, []StatContribution{{Stat: StatHealth, Flat: 7, Percent: 0.1}}) - if got.Health != 117 { - t.Fatalf("health=%v, want 117", got.Health) - } -} - -func TestAggregateStatsMatchesOfficialGrowthSnapshot(t *testing.T) { - // Before tutorial equipment 10010 entered the pictorial book, the real - // AllCharRefresh response advertised HEALTH_PERCENT=0.015. The subsequent - // official CharGrowth response returned HP=512 for level-20 char 350. - got := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Percent: 0.015}}) - if got.Health != 512 { - t.Fatalf("health=%v, want official snapshot 512", got.Health) - } - - // Acquiring the tutorial equipment raised that account-wide pictorial - // bonus to 0.0175. It displays as +8 HP, independently of the equipment's - // own option 3, which is ATTACK_VALUE +7. - afterPictorial := AggregateStats(BaseStats{Health: 505}, []StatContribution{{Stat: StatHealth, Percent: 0.0175}}) - if afterPictorial.Health != 513 { - t.Fatalf("post-pictorial health=%v, want 513", afterPictorial.Health) - } -} diff --git a/go/internal/server/gamedata/avatar_rewards_test.go b/go/internal/server/gamedata/avatar_rewards_test.go deleted file mode 100644 index 406f871..0000000 --- a/go/internal/server/gamedata/avatar_rewards_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "math" - "testing" -) - -func TestAvatarRewardsLoadAndValidateTypedMembers(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, table := range []string{"AvatarItemTable", "AvatarMotionTable", "AvatarCharTable", "AvatarSetTable"} { - if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - for _, row := range []struct { - table string - field int - id uint64 - }{{"AvatarItemTable", 3, 10001}, {"AvatarMotionTable", 5, 7}, {"AvatarCharTable", 6, 2}} { - if _, err = db.Exec("INSERT INTO "+row.table+" VALUES(?)", wire.AppendVarint(nil, row.field, row.id)); err != nil { - t.Fatal(err) - } - } - set := wire.AppendVarint(nil, 6, 1) - for _, member := range []BattleReward{{49, 10001, 1}, {50, 7, 1}, {61, 2, 1}} { - set = wire.AppendVarint(set, 5, member.Type) - set = wire.AppendVarint(set, 4, member.ID) - set = wire.AppendVarint(set, 3, member.Count) - } - if _, err = db.Exec("INSERT INTO AvatarSetTable VALUES(?)", set); err != nil { - t.Fatal(err) - } - d, err := loadAvatarRewardDesign(db) - if err != nil { - t.Fatal(err) - } - leaves, err := d.Expand([]BattleReward{{Type: 62, ID: 1, Count: 2}, {Type: 4, Count: 10}}) - if err != nil || len(leaves) != 4 || leaves[0].Count != 2 || leaves[2].Type != 61 { - t.Fatal("avatar set members malformed", leaves, err) - } - if _, err = d.Expand([]BattleReward{{Type: 62, ID: 99, Count: 1}}); err == nil { - t.Fatal("unknown set accepted") - } - d.Sets[1][0].Count = 2 - if _, err = d.Expand([]BattleReward{{Type: 62, ID: 1, Count: math.MaxInt32}}); err == nil { - t.Fatal("avatar quantity overflow accepted") - } -} diff --git a/go/internal/server/gamedata/battle_difficulty.go b/go/internal/server/gamedata/battle_difficulty.go deleted file mode 100644 index a28ab1d..0000000 --- a/go/internal/server/gamedata/battle_difficulty.go +++ /dev/null @@ -1,59 +0,0 @@ -package gamedata - -import ( - "database/sql" - "fmt" -) - -// BattleDeckForDifficulty mirrors QuicklyQuestHelper.GetBattleDeckIdByDifficulty -// for the three story difficulties, and verifies the resulting design row. -// Already-selected difficulty decks retain their ID. -func BattleDeckForDifficulty(root, version string, packID int, deckID, difficulty uint64) (uint64, error) { - db, cleanup, err := openPackDatabase(root, version, packID) - if err != nil { - return 0, err - } - defer cleanup() - return battleDeckForDifficultyFromDB(db, deckID, difficulty) -} - -func openPackDatabase(root, version string, packID int) (*sql.DB, func(), error) { - if packID <= 0 { - return nil, nil, fmt.Errorf("gamedata: invalid battle pack %d", packID) - } - return OpenDatabase(root, version, fmt.Sprintf("pack%d", packID)) -} - -func battleDeckForDifficultyFromDB(db *sql.DB, deckID, difficulty uint64) (uint64, error) { - if deckID == 0 || deckID > 2147483647 || difficulty > 2 { - return 0, fmt.Errorf("gamedata: unsupported battle deck/difficulty %d/%d", deckID, difficulty) - } - var data []byte - if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deckID).Scan(&data); err != nil { - return 0, fmt.Errorf("gamedata: battle deck %d: %w", deckID, err) - } - actual, err := phaseScalar(data, 24) - if err != nil { - return 0, err - } - if actual == difficulty { - return deckID, nil - } - // Only an ordinary deck can be promoted. Changing a previously selected - // difficulty deck would conceal stale client quest state. - if actual != 0 || difficulty == 0 { - return 0, fmt.Errorf("gamedata: deck %d difficulty %d differs from selected %d", deckID, actual, difficulty) - } - selected := deckID + difficulty*100000 - if err := db.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", selected).Scan(&data); err != nil { - return 0, fmt.Errorf("gamedata: difficulty deck %d: %w", selected, err) - } - actual, err = phaseScalar(data, 24) - if err != nil { - return 0, err - } - if actual != difficulty { - return 0, fmt.Errorf("gamedata: difficulty deck %d has difficulty %d, want %d", selected, actual, difficulty) - } - return selected, nil -} diff --git a/go/internal/server/gamedata/battle_difficulty_test.go b/go/internal/server/gamedata/battle_difficulty_test.go deleted file mode 100644 index e3df71b..0000000 --- a/go/internal/server/gamedata/battle_difficulty_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "os" - "testing" -) - -func TestBattleDifficultyUsesExistingDesignRows(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE BattleDeckTable(id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - for _, row := range []struct{ id, d uint64 }{{1, 0}, {100001, 1}, {200001, 2}, {7, 0}, {100007, 2}} { - if _, err := db.Exec("INSERT INTO BattleDeckTable VALUES(?,?)", row.id, wire.AppendVarint(nil, 24, row.d)); err != nil { - t.Fatal(err) - } - } - for _, c := range []struct{ id, d, w uint64 }{{1, 0, 1}, {1, 1, 100001}, {1, 2, 200001}, {100001, 1, 100001}} { - got, err := battleDeckForDifficultyFromDB(db, c.id, c.d) - if err != nil || got != c.w { - t.Fatalf("%+v got %d: %v", c, got, err) - } - } - for _, c := range []struct{ id, d uint64 }{{1, 3}, {7, 1}, {7, 2}, {100001, 2}, {100001, 0}} { - if _, err := battleDeckForDifficultyFromDB(db, c.id, c.d); err == nil { - t.Fatalf("accepted %+v", c) - } - } -} - -func TestInstalledStoryBattleDifficultyDecks(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - // Python read-only inspection of current BattleDeckTable confirmed these - // independently authored rows and their field 24 difficulty IDs. - for _, pack := range []int{1, 21} { - for difficulty := uint64(0); difficulty <= 2; difficulty++ { - want := uint64(1) + difficulty*100000 - got, err := BattleDeckForDifficulty(root, "20260923193640", pack, 1, difficulty) - if err != nil || got != want { - t.Fatalf("pack%d difficulty%d got %d: %v", pack, difficulty, got, err) - } - } - } -} diff --git a/go/internal/server/gamedata/battle_phase_test.go b/go/internal/server/gamedata/battle_phase_test.go deleted file mode 100644 index 872e2fc..0000000 --- a/go/internal/server/gamedata/battle_phase_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "reflect" - "testing" -) - -func TestBattleDeckPhasesDesignRows(t *testing.T) { - db, e := sql.Open("sqlite", ":memory:") - if e != nil { - t.Fatal(e) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - db.SetMaxOpenConns(1) - for _, q := range []string{"CREATE TABLE FieldMonsterTable(id INTEGER PRIMARY KEY, ProtoBuf BLOB)", "CREATE TABLE PhaseBattleTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"} { - if _, e := db.Exec(q); e != nil { - t.Fatal(e) - } - } - for _, v := range []struct { - id int - b []byte - }{{8, []byte{0xa8, 1, 1}}, {1, nil}, {2, []byte{0xa8, 1, 7}}} { - if _, e := db.Exec("INSERT INTO FieldMonsterTable VALUES (?,?)", v.id, v.b); e != nil { - t.Fatal(e) - } - } - // Rows reproduce Python-confirmed pack22 fields; group7 tests nonadjacent IDs. - for _, v := range []struct{ g, id, d int }{{1, 2, 9}, {1, 1, 8}, {7, 50, 99}, {7, 10, 77}, {7, 30, 88}} { - b := []byte{0x10, byte(v.d), 0x18, byte(v.g), 0x20, byte(v.id)} - if _, e := db.Exec("INSERT INTO PhaseBattleTable VALUES (?,?,?)", v.g, v.id, b); e != nil { - t.Fatal(e) - } - } - for _, c := range []struct { - m, d uint64 - w []BattlePhase - }{{8, 8, []BattlePhase{{1, 1, 8}, {1, 2, 9}}}, {8, 9, []BattlePhase{{1, 1, 8}, {1, 2, 9}}}, {1, 1, nil}, {2, 77, []BattlePhase{{7, 10, 77}, {7, 30, 88}, {7, 50, 99}}}} { - g, e := battleDeckPhasesFromDB(db, c.m, c.d) - if e != nil { - t.Fatal(e) - } - if !reflect.DeepEqual(g, c.w) { - t.Fatalf("got %+v want %+v", g, c.w) - } - } - if _, e := battleDeckPhasesFromDB(db, 8, 1); e == nil { - t.Fatal("accepted unrelated deck") - } - for _, b := range [][]byte{{0x10, 8, 0x18, 2, 0x20, 1}, {0x10, 8, 0x18, 1, 0x20, 3}, {0x18, 1, 0x20, 1}} { - if _, e := db.Exec("UPDATE PhaseBattleTable SET ProtoBuf=? WHERE groupId=1 AND id=1", b); e != nil { - t.Fatal(e) - } - if _, e := battleDeckPhasesFromDB(db, 8, 8); e == nil { - t.Fatalf("accepted invalid row %x", b) - } - } -} - -func TestBattleDeckPhasesRetainSelectedDifficulty(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, q := range []string{"CREATE TABLE FieldMonsterTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE PhaseBattleTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE BattleDeckTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"} { - if _, err := db.Exec(q); err != nil { - t.Fatal(err) - } - } - if _, err := db.Exec("INSERT INTO FieldMonsterTable VALUES(8,?)", wire.AppendVarint(nil, 21, 1)); err != nil { - t.Fatal(err) - } - for _, v := range []struct{ id, deck uint64 }{{1, 8}, {2, 9}} { - data := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, v.deck), 3, 1), 4, v.id) - if _, err := db.Exec("INSERT INTO PhaseBattleTable VALUES(1,?,?)", v.id, data); err != nil { - t.Fatal(err) - } - for d := uint64(0); d <= 2; d++ { - if _, err := db.Exec("INSERT INTO BattleDeckTable VALUES(?,?)", v.deck+d*100000, wire.AppendVarint(nil, 24, d)); err != nil { - t.Fatal(err) - } - } - } - got, err := battleDeckPhasesFromDB(db, 8, 200008) - want := []BattlePhase{{1, 1, 200008}, {1, 2, 200009}} - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("got %v error %v", got, err) - } -} -func TestPhaseScalarRejectsInvalidDesign(t *testing.T) { - for _, b := range [][]byte{{0x1a, 1, 1}, {0x18, 1, 0x18, 2}} { - if _, e := phaseScalar(b, 3); e == nil { - t.Fatalf("accepted %x", b) - } - } -} diff --git a/go/internal/server/gamedata/battle_test.go b/go/internal/server/gamedata/battle_test.go deleted file mode 100644 index 68458cc..0000000 --- a/go/internal/server/gamedata/battle_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestInstalledPack21FirstMonsterRewards(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - rewards, err := BattleRewards(root, "20260923193640", 21, 1) - if err != nil { - t.Fatal(err) - } - if len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 8, Count: 3}) { - t.Fatalf("pack21 monster1 rewards = %+v, want slime type8/item8 x3", rewards) - } -} - -func TestInstalledPack22DeckAbsentFromPack21Rewards(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - rewards, err := BattleDeckRewards(root, "20260923193640", 22, 9) - if err != nil { - t.Fatal(err) - } - if len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 14, Count: 1}) { - t.Fatalf("pack22 deck9 rewards = %+v, want type8/item14 x1", rewards) - } - if _, err := BattleDeckRewards(root, "20260923193640", 21, 9); err == nil { - t.Fatal("pack21 unexpectedly contains pack22-only deck9") - } -} - -func TestInstalledTutorialGrowthReachesLevel20(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - level, exp, refunds, err := CharacterGrowth(root, "20260923193640", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 3}}) - if err != nil { - t.Fatal(err) - } - if level != 20 || exp != 0 { - t.Fatalf("char350 + resource8x3 = level %d exp %d, want level20 exp0", level, exp) - } - if len(refunds) != 2 || refunds[0] != (GrowthMaterial{ID: 8, Count: 1}) || refunds[1] != (GrowthMaterial{ID: 7, Count: 3}) { - t.Fatalf("refunds=%v, want resource8x1 + resource7x3", refunds) - } -} - -func TestInstalledTutorialGrowthClientSelectionRefund(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - level, exp, refunds, err := CharacterGrowth(root, "20260923193640", 350, 1, 0, []GrowthMaterial{{ID: 8, Count: 2}}) - if err != nil { - t.Fatal(err) - } - if level != 20 || exp != 0 || len(refunds) != 1 || refunds[0] != (GrowthMaterial{ID: 7, Count: 3}) { - t.Fatalf("growth = level%d exp%d refunds%v, want official resource7x3", level, exp, refunds) - } -} diff --git a/go/internal/server/gamedata/cash_entitlements_test.go b/go/internal/server/gamedata/cash_entitlements_test.go deleted file mode 100644 index 649429c..0000000 --- a/go/internal/server/gamedata/cash_entitlements_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestCashEntitlementDesignUsesTicketTypesAndAttendanceMapping(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, table := range []string{"AvatarSetTable", "ContentTicketTable", "ContentOpenTable", "AttendanceRewardTable"} { - if _, err = db.Exec("CREATE TABLE " + table + "(ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - if _, err := db.Exec("INSERT INTO AvatarSetTable VALUES(?)", wire.AppendVarint(nil, 6, 10)); err != nil { - t.Fatal(err) - } - raw := wire.AppendVarint(nil, 4, 38) - raw = wire.AppendVarint(raw, 8, 2) - if _, err := db.Exec("INSERT INTO ContentTicketTable VALUES(?)", raw); err != nil { - t.Fatal(err) - } - raw = wire.AppendVarint(nil, 6, 38) - raw = wire.AppendVarint(raw, 2, 1) - if _, err := db.Exec("INSERT INTO ContentOpenTable VALUES(?)", raw); err != nil { - t.Fatal(err) - } - for _, id := range []uint64{2, 1} { - raw = wire.AppendVarint(nil, 1, 38) - raw = wire.AppendVarint(raw, 2, id) - raw = wire.AppendVarint(raw, 3, 60) - raw = wire.AppendVarint(raw, 5, 3) - if _, err := db.Exec("INSERT INTO AttendanceRewardTable VALUES(?)", raw); err != nil { - t.Fatal(err) - } - } - d, err := loadCashEntitlementDesign(db) - if err != nil || !d.AvatarSets[10] || d.TicketTypes[38] != 2 || d.AttendanceTypes[38] != 1 || len(d.Attendance[38]) != 2 || d.Attendance[38][0].ID != 1 { - t.Fatalf("%+v %v", d, err) - } -} diff --git a/go/internal/server/gamedata/cash_products_test.go b/go/internal/server/gamedata/cash_products_test.go deleted file mode 100644 index 12610ef..0000000 --- a/go/internal/server/gamedata/cash_products_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestCashCatalogPreservesKeysAliasesAndLimits(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - db.SetMaxOpenConns(1) - for _, q := range []string{"CREATE TABLE CashProductTable(groupId INTEGER,id INTEGER,saleGroup INTEGER,ProtoBuf BLOB)", "CREATE TABLE RandomBoxTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CashShopTable(ProtoBuf BLOB)", "CREATE TABLE CashPackageTable(ProtoBuf BLOB)", "CREATE TABLE EventShopTable(ProtoBuf BLOB)"} { - if _, err = db.Exec(q); err != nil { - t.Fatal(err) - } - } - for i, count := range []uint64{200, 100} { - raw := wire.AppendVarint(nil, 2, 1) - raw = wire.AppendVarint(raw, 6, 2) - raw = wire.AppendVarint(raw, 5, 0) - raw = wire.AppendVarint(raw, 4, count) - raw = wire.AppendVarint(raw, 8, 100) - if _, err = db.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", i+1, raw); err != nil { - t.Fatal(err) - } - if _, err = db.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", i+1, wire.AppendVarint(nil, 9, uint64(i+1))); err != nil { - t.Fatal(err) - } - raw = wire.AppendString(nil, 4, "shared-sku") - raw = wire.AppendString(raw, 1, "shared-ios") - for _, f := range []struct { - n int - v uint64 - }{{5, 99}, {6, uint64(i + 1)}, {15, 7}, {9, 1}, {7, 2200}, {14, uint64(i + 1)}, {2, 333}, {12, 1}, {13, 3}, {16, 2}, {3, 1}} { - raw = wire.AppendVarint(raw, f.n, f.v) - } - if _, err = db.Exec("INSERT INTO CashProductTable VALUES(99,?,7,?)", i+1, raw); err != nil { - t.Fatal(err) - } - } - shop := wire.AppendVarint(nil, 4, 8) - shop = wire.AppendVarint(shop, 5, 1) - shop = wire.AppendVarint(shop, 10, 99) - if _, err = db.Exec("INSERT INTO CashShopTable VALUES(?)", shop); err != nil { - t.Fatal(err) - } - packageRaw := wire.AppendVarint(nil, 8, 99) - packageRaw = wire.AppendVarint(packageRaw, 9, 1) - packageRaw = wire.AppendVarint(packageRaw, 17, 7) - packageRaw = wire.AppendVarint(packageRaw, 11, 12) - if _, err = db.Exec("INSERT INTO CashPackageTable VALUES(?)", packageRaw); err != nil { - t.Fatal(err) - } - c, err := loadCashCatalog(db) - if err != nil { - t.Fatal(err) - } - if len(c.Products) != 2 || len(c.Shops) != 1 || len(c.Packages) != 1 { - t.Fatalf("incomplete %+v", c) - } - for _, p := range c.Products { - if !p.Recharge || p.NominalPaidDiamonds != 100 || p.GoogleSKU != "shared-sku" || p.AppleSKU != "shared-ios" || p.BonusRandomBoxID != 333 || p.PurchaseLimitType != 3 || p.TimeLimitType != 2 { - t.Fatalf("lost metadata %+v", p) - } - } -} -func TestDecodeCashProductRejectsMalformedMonetaryFields(t *testing.T) { - for _, raw := range [][]byte{{255}, wire.AppendVarint(nil, 5, 1), wire.AppendVarint(wire.AppendVarint(nil, 5, 1), 6, 1<<40)} { - if _, err := decodeCashProduct(raw); err == nil { - t.Fatalf("accepted %x", raw) - } - } -} diff --git a/go/internal/server/gamedata/cash_rewards_test.go b/go/internal/server/gamedata/cash_rewards_test.go deleted file mode 100644 index 7cadbb7..0000000 --- a/go/internal/server/gamedata/cash_rewards_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "testing" -) - -func TestCashCombinationsGuaranteeOneAndHundredPreserveManualBoxes(t *testing.T) { - raw := wire.AppendVarint(nil, 2, 1) - for _, f := range []struct { - n int - v uint64 - }{{6, 3}, {6, 8}, {5, 0}, {5, 1000}, {4, 60}, {4, 2}, {8, 1}, {8, 100}} { - raw = wire.AppendVarint(raw, f.n, f.v) - } - c := &CashRewardResolver{boxes: map[uint64]uint64{1: 10}, direct: map[uint64]bool{1: true}, groups: map[uint64][]byte{10: raw}} - got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 2}, {Type: 8, ID: 2, Count: 1}}) - if err != nil || len(got) != 3 || got[0].Type != 3 || got[0].Count != 120 || got[1].Count != 4 || got[2].ID != 2 || got[2].Type != 8 { - t.Fatalf("cash rewards %+v %v", got, err) - } - raw = wire.AppendVarint(raw, 8, 35) - c.groups[10] = raw - if _, err = c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 1}}); err == nil { - t.Fatal("malformed ratio accepted") - } -} - -func TestLoginPassTopLevelWrapperExpandsAndNestedManualGiftStaysOwned(t *testing.T) { - raw := wire.AppendVarint(nil, 2, 1) - for _, f := range []struct { - n int - v uint64 - }{{6, 3}, {6, 9}, {5, 0}, {5, 200}, {4, 125}, {4, 1}, {8, 1}, {8, 100}} { - raw = wire.AppendVarint(raw, f.n, f.v) - } - c := &CashRewardResolver{boxes: map[uint64]uint64{100: 1000, 200: 2000}, direct: map[uint64]bool{100: false, 200: true}, groups: map[uint64][]byte{1000: raw}} - got, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 100, Count: 1}}) - if err != nil || len(got) != 2 || got[0].Type != 3 || got[0].Count != 125 || got[1].Type != 9 || got[1].ID != 200 { - t.Fatalf("login pass wrapper=%+v err=%v", got, err) - } -} - -func TestCashDeliverySeparatesNestedMailAndKeepsEntitlementAndManualBox(t *testing.T) { - group := func(mail uint64, rewards []BattleReward) []byte { - raw := wire.AppendVarint(nil, 2, 1) - if mail > 0 { - raw = wire.AppendVarint(raw, 7, mail) - } - for _, r := range rewards { - raw = wire.AppendVarint(raw, 6, r.Type) - raw = wire.AppendVarint(raw, 5, r.ID) - raw = wire.AppendVarint(raw, 4, r.Count) - raw = wire.AppendVarint(raw, 8, 100) - } - return raw - } - c := &CashRewardResolver{boxes: map[uint64]uint64{1: 10, 2: 20, 3: 30}, direct: map[uint64]bool{3: true}, groups: map[uint64][]byte{ - 10: group(0, []BattleReward{{Type: 19, ID: 36, Count: 1}, {Type: 9, ID: 2, Count: 1}}), - 20: group(40, []BattleReward{{Type: 2, Count: 150}, {Type: 9, ID: 3, Count: 1}}), - }} - plan, err := c.ResolveDelivery([]BattleReward{{Type: 9, ID: 1, Count: 2}}) - if err != nil || len(plan.Direct) != 1 || plan.Direct[0].Type != 19 || plan.Direct[0].Count != 2 || len(plan.Mail) != 1 || plan.Mail[0].TemplateID != 40 || len(plan.Mail[0].Rewards) != 2 || plan.Mail[0].Rewards[0].Count != 300 || plan.Mail[0].Rewards[1].ID != 3 { - t.Fatalf("delivery=%+v err=%v", plan, err) - } - // Claim expansion retains the old behavior for callers that don't purchase. - leaves, err := c.ResolveGranted([]BattleReward{{Type: 9, ID: 1, Count: 1}}) - if err != nil || len(leaves) != 3 { - t.Fatalf("ordinary resolve=%+v err=%v", leaves, err) - } -} diff --git a/go/internal/server/gamedata/char_awake_test.go b/go/internal/server/gamedata/char_awake_test.go deleted file mode 100644 index 2f58cb3..0000000 --- a/go/internal/server/gamedata/char_awake_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func testCharAwakeDesign() *CharAwakeDesign { - levels := func(stat uint64, values ...float64) []CharAwakeGrowth { - result := make([]CharAwakeGrowth, len(values)) - for i, value := range values { - result[i] = CharAwakeGrowth{ID: uint64(i + 1), Costs: []CharAwakeCost{{Type: 8, ID: 701 + uint64(i), Count: uint64(i + 1)}, {Type: 4, Count: 100}}, StatType: stat, StatValue: value} - } - return result - } - character := CharAwakeCharacter{UniqueCharID: 35, Active: true} - character.ImprintGrowth[0] = levels(1, 5, 10) - character.ImprintGrowth[1] = levels(2, .01, .02) - character.ImprintGrowth[2] = levels(3, 2, 4) - character.AwakeGrowth = []CharAwakeGrowth{ - {ID: 100, Costs: []CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}, StatType: 4, StatValue: .12}, - {ID: 101, StatType: 14, StatValue: .1}, - } - return &CharAwakeDesign{ - Characters: map[uint64]CharAwakeCharacter{35: character}, - Stages: map[uint64]CharAwakeCharacterStage{354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100}}, - } -} - -func TestCharAwakeImprintUsesCrossLevelCostsAndCumulativeStats(t *testing.T) { - design := testCharAwakeDesign() - costs, levels, err := design.ImprintCosts(35, [3]uint64{0, 0, 0}, []CharImprintTarget{{Slot: 1, TargetLevel: 2}, {Slot: 2, TargetLevel: 1}}) - if err != nil { - t.Fatal(err) - } - if levels != [3]uint64{2, 1, 0} || len(costs) != 3 { - t.Fatalf("levels=%v costs=%+v", levels, costs) - } - var gold uint64 - for _, cost := range costs { - if cost.Type == 4 { - gold += cost.Count - } - } - if gold != 300 { - t.Fatalf("gold=%d want=300", gold) - } - stats, err := design.CharAwakeContributions(35, levels, false) - if err != nil || len(stats) != 2 || stats[0].Flat != 10 || stats[1].Percent != .01 { - t.Fatalf("cumulative target-level stats=%+v err=%v", stats, err) - } - if _, _, err := design.ImprintCosts(35, levels, []CharImprintTarget{{Slot: 1, TargetLevel: 1}}); err == nil { - t.Fatal("imprint downgrade accepted") - } -} - -func TestCharAwakeActivationRequiresAllSlotsAndAddsEveryEffect(t *testing.T) { - design := testCharAwakeDesign() - if _, err := design.AwakeCosts(35, [3]uint64{2, 2, 1}, false); err == nil { - t.Fatal("incomplete imprint slots accepted") - } - costs, err := design.AwakeCosts(35, [3]uint64{2, 2, 2}, false) - if err != nil || len(costs) != 2 || costs[0].ID != 705 || costs[1].Type != 4 { - t.Fatalf("awakening costs=%+v err=%v", costs, err) - } - stats, err := design.CharAwakeContributions(35, [3]uint64{2, 2, 2}, true) - if err != nil || len(stats) != 5 || stats[3].Stat != StatAttack || stats[3].Percent != .12 || stats[4].Option != 14 { - t.Fatalf("awakening stats=%+v err=%v", stats, err) - } -} - -func TestCharAwakeAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - design, err := LoadCharAwakeDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - entry := design.Characters[35] - // Current GameData CharAwakeTable contains 87 unique-character rows. - if len(design.Characters) != 87 || len(entry.ImprintGrowth[0]) != 10 || len(entry.ImprintGrowth[1]) != 10 || len(entry.ImprintGrowth[2]) != 10 || len(entry.AwakeGrowth) != 2 { - t.Fatalf("installed awakening design characters=%d char35=%+v", len(design.Characters), entry) - } - if entry.AwakeGrowth[0].Costs[0] != (CharAwakeCost{Type: 8, ID: 705, Count: 200}) || entry.AwakeGrowth[1].StatType != 14 || entry.AwakeGrowth[1].StatValue != .1 { - t.Fatalf("installed char35 awakening=%+v", entry.AwakeGrowth) - } - if unique, err := design.ValidateGrowthCompleted(354, 100); err != nil || unique != 35 { - t.Fatalf("installed final stage unique=%d err=%v", unique, err) - } -} diff --git a/go/internal/server/gamedata/content_open_test.go b/go/internal/server/gamedata/content_open_test.go deleted file mode 100644 index e2f8029..0000000 --- a/go/internal/server/gamedata/content_open_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package gamedata - -import ( - "database/sql" - "testing" -) - -func contentOpeningTestDB(t *testing.T) *sql.DB { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - db.SetMaxOpenConns(1) - t.Cleanup(func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }) - for _, q := range []string{"CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ticketId INTEGER,ProtoBuf BLOB)", "CREATE TABLE ContentTicketTable(id INTEGER,ProtoBuf BLOB)"} { - if _, err := db.Exec(q); err != nil { - t.Fatal(err) - } - } - for _, id := range []uint64{901, 902} { - if _, err := db.Exec("INSERT INTO ContentTicketTable VALUES(?,?)", id, friendshipTestProto(map[int][]uint64{4: {id}, 8: {1}})); err != nil { - t.Fatal(err) - } - } - for _, id := range []uint64{1, 2} { - if _, err := db.Exec("INSERT INTO ContentOpenTable VALUES(23,?,?,?)", id, 900+id, friendshipTestProto(map[int][]uint64{1: {23}, 2: {id}, 4: {100 + id}, 5: {10 + id}, 6: {900 + id}})); err != nil { - t.Fatal(err) - } - } - return db -} - -func TestContentOpeningUsesVersionTicketsAndRequirements(t *testing.T) { - d, err := loadContentOpeningDesign(contentOpeningTestDB(t)) - if err != nil { - t.Fatal(err) - } - if d.Prerequisite != (ContentOpenRule{TicketID: 901, SquadLevel: 11, TutorialID: 101}) || d.Completion != (ContentOpenRule{TicketID: 902, SquadLevel: 12, TutorialID: 102}) { - t.Fatalf("design=%+v", d) - } -} - -func TestContentOpeningRejectsIncompleteAndInvalidCatalog(t *testing.T) { - for _, tc := range []struct { - name, query string - args []any - }{ - {"missing completion", "DELETE FROM ContentOpenTable WHERE id=2", nil}, - {"missing ticket", "DELETE FROM ContentTicketTable WHERE id=902", nil}, - {"temporary ticket", "UPDATE ContentTicketTable SET ProtoBuf=? WHERE id=902", []any{friendshipTestProto(map[int][]uint64{4: {902}, 8: {2}})}}, - {"wrong ticket identity", "UPDATE ContentTicketTable SET ProtoBuf=? WHERE id=901", []any{friendshipTestProto(map[int][]uint64{4: {999}, 8: {1}})}}, - {"wrong opening identity", "UPDATE ContentOpenTable SET ProtoBuf=? WHERE id=2", []any{friendshipTestProto(map[int][]uint64{1: {23}, 2: {1}, 6: {902}})}}, - {"invalid unrelated row", "INSERT INTO ContentOpenTable VALUES(1,1,999,?)", []any{friendshipTestProto(map[int][]uint64{1: {1}, 2: {1}, 6: {999}})}}, - } { - t.Run(tc.name, func(t *testing.T) { - db := contentOpeningTestDB(t) - if _, err := db.Exec(tc.query, tc.args...); err != nil { - t.Fatal(err) - } - if _, err := loadContentOpeningDesign(db); err == nil { - t.Fatal("invalid catalog accepted") - } - }) - } -} diff --git a/go/internal/server/gamedata/cooking_recipes_test.go b/go/internal/server/gamedata/cooking_recipes_test.go deleted file mode 100644 index eda1b1d..0000000 --- a/go/internal/server/gamedata/cooking_recipes_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package gamedata - -import ( - "database/sql" - "testing" -) - -func TestCookingRecipesReadCatalogIdentities(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE CookingTable(id INTEGER); INSERT INTO CookingTable VALUES(503),(707)"); err != nil { - t.Fatal(err) - } - design, err := loadCookingRecipeDesign(db) - if err != nil || len(design.IDs) != 2 || !design.IDs[503] || !design.IDs[707] || design.IDs[101] { - t.Fatalf("design=%+v err=%v", design, err) - } - if _, err := db.Exec("INSERT INTO CookingTable VALUES(0)"); err != nil { - t.Fatal(err) - } - if _, err := loadCookingRecipeDesign(db); err == nil { - t.Fatal("invalid recipe identity accepted") - } -} diff --git a/go/internal/server/gamedata/costume_burst_test.go b/go/internal/server/gamedata/costume_burst_test.go deleted file mode 100644 index 0ef656f..0000000 --- a/go/internal/server/gamedata/costume_burst_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package gamedata - -import ( - "database/sql" - "os" - "strings" - "testing" -) - -func TestCostumeBurstDesignAndUpgradeRule(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil { - t.Fatal(err) - } - for level := 1; level <= 3; level++ { - raw := burstProto(8, 42, 9, uint64(level), 10, 60, 11, 710, 12, 8) - if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, 42, level, raw); err != nil { - t.Fatal(err) - } - } - design, err := loadCostumeBurstDesign(db) - if err != nil { - t.Fatal(err) - } - rule, err := design.UpgradeRule(42, 1) - if err != nil { - t.Fatal(err) - } - if rule.NextLevel != 2 || rule.MaxLevel != 3 || len(rule.Costs) != 1 || rule.Costs[0] != (PromotionCost{Type: 8, ID: 710, Count: 60}) { - t.Fatalf("unexpected rule: %+v", rule) - } - if _, err := design.UpgradeRule(42, 3); err == nil { - t.Fatal("expected max-level error") - } -} - -func TestCostumeBurstRejectsInvalidIdentityCostAndGap(t *testing.T) { - cases := []struct { - name string - raw []byte - rows [][2]int - }{ - {"identity", burstProto(8, 99, 9, 1, 10, 1, 11, 710, 12, 8), [][2]int{{42, 1}}}, - {"zero-count", burstProto(8, 42, 9, 1, 10, 0, 11, 710, 12, 8), [][2]int{{42, 1}}}, - {"gold-id", burstProto(8, 42, 9, 1, 10, 1, 11, 710, 12, 4), [][2]int{{42, 1}}}, - {"gap", burstProto(8, 42, 9, 2, 10, 1, 11, 710, 12, 8), [][2]int{{42, 2}}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec(`CREATE TABLE CostumeBurstTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))`); err != nil { - t.Fatal(err) - } - for _, row := range tc.rows { - if _, err := db.Exec(`INSERT INTO CostumeBurstTable(groupId,id,ProtoBuf) VALUES(?,?,?)`, row[0], row[1], tc.raw); err != nil { - t.Fatal(err) - } - } - if _, err := loadCostumeBurstDesign(db); err == nil || !strings.Contains(err.Error(), "costume burst") { - t.Fatalf("expected costume burst validation error, got %v", err) - } - }) - } -} - -func TestCostumeBurstAgainstInstalledCurrentVersion(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") - } - design, err := LoadCostumeBurstDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - if len(design.Levels) != 56 { - t.Fatalf("installed burst groups=%d, want 56", len(design.Levels)) - } - rows := 0 - for _, levels := range design.Levels { - rows += len(levels) - } - if rows != 168 { - t.Fatalf("installed burst rows=%d, want 168", rows) - } - rule, err := design.UpgradeRule(4202, 0) - if err != nil { - t.Fatal(err) - } - want := []PromotionCost{{Type: 4, Count: 1_500_000}, {Type: 8, ID: 710, Count: 60}} - if rule.NextLevel != 1 || rule.MaxLevel != 3 || len(rule.Costs) != len(want) || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] { - t.Fatalf("installed rule=%+v", rule) - } -} - -// burstProto builds the small varint-only fields used by the loader tests. -func burstProto(fields ...uint64) []byte { - if len(fields)%2 != 0 { - panic("burstProto requires field/value pairs") - } - var out []byte - for i := 0; i < len(fields); i += 2 { - field, value := fields[i], fields[i+1] - out = append(out, byte(field<<3)) - for value >= 0x80 { - out = append(out, byte(value)|0x80) - value >>= 7 - } - out = append(out, byte(value)) - } - return out -} diff --git a/go/internal/server/gamedata/costume_potential_test.go b/go/internal/server/gamedata/costume_potential_test.go deleted file mode 100644 index cb65acb..0000000 --- a/go/internal/server/gamedata/costume_potential_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestCostumePotentialValidatePrerequisitesAndAggregate(t *testing.T) { - d := &CostumePotentialDesign{ - Nodes: map[uint64]map[uint64]CostumePotentialNode{100: { - 1: {ID: 1, Costs: []CostumePotentialCost{{Type: 4, Count: 10}}}, - 2: {ID: 2, ConditionGrade: 2, Prerequisites: []uint64{1}, Costs: []CostumePotentialCost{{Type: 8, ID: 7, Count: 3}}}, - }}, - CostumeUnique: map[uint64]uint64{100: 9}, CharacterGrade: map[uint64]uint64{200: 2}, CharacterUnique: map[uint64]uint64{200: 9}, - } - costs, err := d.Validate(100, 200, 0, nil, []uint64{1, 2}) - if err != nil || len(costs) != 2 { - t.Fatalf("valid chain costs=%+v err=%v", costs, err) - } - if _, err := d.Validate(100, 200, 0, nil, []uint64{2}); err == nil { - t.Fatal("missing prerequisite accepted") - } - if _, err := d.Validate(100, 201, 0, nil, []uint64{1}); err == nil { - t.Fatal("wrong character accepted") - } -} - -func TestCostumePotentialAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - design, err := LoadCostumePotentialDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - nodes := make([]uint64, 0, len(design.Nodes[65103])) - for id := range design.Nodes[65103] { - nodes = append(nodes, id) - } - costs, err := design.Validate(65103, 6514, 0, nil, nodes) - if err != nil { - t.Fatal(err) - } - if len(nodes) != 17 || len(costs) < 17 { - t.Fatalf("installed costume 65103 nodes=%d costs=%d", len(nodes), len(costs)) - } - questCosts, err := design.Validate(3501, 354, 0, nil, []uint64{1}) - if err != nil || len(questCosts) != 1 || questCosts[0].Type != 4 || questCosts[0].Count == 0 { - t.Fatalf("installed quest reward costume 3501 first node costs=%+v err=%v", questCosts, err) - } -} diff --git a/go/internal/server/gamedata/database_cache_test.go b/go/internal/server/gamedata/database_cache_test.go deleted file mode 100644 index 65360e7..0000000 --- a/go/internal/server/gamedata/database_cache_test.go +++ /dev/null @@ -1,344 +0,0 @@ -package gamedata - -import ( - "archive/zip" - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "sync" - "sync/atomic" - "testing" -) - -func cachePlain(t *testing.T, value int) []byte { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err = db.Exec(fmt.Sprintf("CREATE TABLE test(value INTEGER);INSERT INTO test VALUES(%d)", value)); err != nil { - t.Fatal(err) - } - conn, err := db.Conn(context.Background()) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := conn.Close(); err != nil { - t.Error(err) - } - }() - var raw []byte - err = conn.Raw(func(v any) error { - var e error - raw, e = v.(interface{ Serialize() ([]byte, error) }).Serialize() - return e - }) - if err != nil { - t.Fatal(err) - } - return raw -} -func cacheArchive(t *testing.T, root string, marker byte) { - t.Helper() - dir := filepath.Join(root, "v1", "release") - if err := os.MkdirAll(dir, 0700); err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, ArchiveName) - f, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - w := zip.NewWriter(f) - for _, logical := range []string{"common", "pack1", "pack2"} { - name, _ := DatabaseName(logical) - entry, err := w.Create(name) - if err != nil { - t.Fatal(err) - } - if _, err = entry.Write([]byte{marker}); err != nil { - t.Fatal(err) - } - } - if err = w.Close(); err != nil { - t.Fatal(err) - } - if err = f.Close(); err != nil { - t.Fatal(err) - } -} -func TestDatabaseCacheSingleFlightReadOnlyAndLifetime(t *testing.T) { - root := t.TempDir() - cacheArchive(t, root, 1) - plain := cachePlain(t, 7) - c := NewDatabaseCache(1 << 20) - defer func() { - if err := c.Close(); err != nil { - t.Error(err) - } - }() - var loads atomic.Int32 - c.loader = func(string, string, string) ([]byte, error) { loads.Add(1); return plain, nil } - var wg sync.WaitGroup - for range 12 { - wg.Go(func() { - err := c.WithDatabase(root, "v1", "common", func(db *sql.DB) error { - var n int - if err := db.QueryRow("SELECT value FROM test").Scan(&n); err != nil { - return err - } - if n != 7 { - return fmt.Errorf("wrong value") - } - if _, err := db.Exec("INSERT INTO test VALUES(8)"); err == nil { - return fmt.Errorf("write accepted") - } - return nil - }) - if err != nil { - t.Error(err) - } - }) - } - wg.Wait() - if loads.Load() != 1 { - t.Fatal("duplicate decrypt", loads.Load()) - } - db, release, err := c.Open(root, "v1", "common") - if err != nil { - t.Fatal(err) - } - if err = c.Close(); err != nil { - t.Fatal(err) - } - var n int - if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil { - t.Fatal("active lease closed", err) - } - release() - release() - if err = db.Ping(); err == nil { - t.Fatal("released retired database alive") - } -} -func TestDatabaseCacheFailureRetryReplacementAndEviction(t *testing.T) { - root := t.TempDir() - cacheArchive(t, root, 1) - plain := cachePlain(t, 1) - c := NewDatabaseCache(int64(len(plain))) - defer func() { - if err := c.Close(); err != nil { - t.Error(err) - } - }() - var loads int - c.loader = func(string, string, string) ([]byte, error) { - loads++ - if loads == 1 { - return nil, fmt.Errorf("failure") - } - return plain, nil - } - if _, _, err := c.Open(root, "v1", "common"); err == nil { - t.Fatal("failure missing") - } - first, release, err := c.Open(root, "v1", "common") - if err != nil { - t.Fatal(err) - } - release() - second, done, err := c.Open(filepath.Join(root, "."), "v1", "common") - if err != nil || first != second || loads != 2 { - t.Fatal("canonical hit failed", err, loads) - } - done() - _, done, err = c.Open(root, "v1", "pack1") - if err != nil { - t.Fatal(err) - } - done() - if err = first.Ping(); err == nil { - t.Fatal("LRU not evicted") - } - old, oldRelease, err := c.Open(root, "v1", "pack1") - if err != nil { - t.Fatal(err) - } - cacheArchive(t, root, 2) - plain = cachePlain(t, 9) - current, currentRelease, err := c.Open(root, "v1", "pack1") - if err != nil { - t.Fatal(err) - } - if old == current { - t.Fatal("replacement reused stale image") - } - var n int - if err = current.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 9 { - t.Fatal("replacement wrong", n, err) - } - oldRelease() - currentRelease() -} - -func TestDatabaseCacheMutationDuringLoadFailsAndDoesNotPoison(t *testing.T) { - root := t.TempDir() - cacheArchive(t, root, 1) - plain := cachePlain(t, 3) - c := NewDatabaseCache(1 << 20) - defer func() { - if err := c.Close(); err != nil { - t.Error(err) - } - }() - calls := 0 - c.loader = func(string, string, string) ([]byte, error) { - calls++ - if calls == 1 { - cacheArchive(t, root, 2) - } - return plain, nil - } - if _, _, err := c.Open(root, "v1", "common"); err == nil { - t.Fatal("replaced archive was cached") - } - db, release, err := c.Open(root, "v1", "common") - if err != nil { - t.Fatal(err) - } - defer release() - var n int - if err = db.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 3 || calls != 2 { - t.Fatal(n, calls, err) - } -} - -func TestSharedDatabaseCacheCloseStartsFreshGeneration(t *testing.T) { - root := t.TempDir() - cacheArchive(t, root, 1) - plain := cachePlain(t, 11) - sharedDatabaseCacheMu.Lock() - previous := sharedDatabaseCache - current := NewDatabaseCache(1 << 20) - current.loader = func(string, string, string) ([]byte, error) { return plain, nil } - sharedDatabaseCache = current - sharedDatabaseCacheMu.Unlock() - t.Cleanup(func() { - _ = CloseDatabaseCache() - sharedDatabaseCacheMu.Lock() - empty := sharedDatabaseCache - sharedDatabaseCache = previous - sharedDatabaseCacheMu.Unlock() - _ = empty.Close() - }) - old, releaseOld, err := OpenDatabase(root, "v1", "common") - if err != nil { - t.Fatal(err) - } - defer releaseOld() - if err = CloseDatabaseCache(); err != nil { - t.Fatal(err) - } - sharedDatabaseCacheMu.Lock() - fresh := sharedDatabaseCache - fresh.loader = func(string, string, string) ([]byte, error) { return plain, nil } - sharedDatabaseCacheMu.Unlock() - if fresh == current { - t.Fatal("global generation not replaced") - } - var n int - if err = old.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 { - t.Fatal("retired active lease unavailable", n, err) - } - newDB, releaseNew, err := OpenDatabase(root, "v1", "common") - if err != nil { - t.Fatal(err) - } - defer releaseNew() - if newDB == old { - t.Fatal("new generation reused old handle") - } - if err = newDB.QueryRow("SELECT value FROM test").Scan(&n); err != nil || n != 11 { - t.Fatal(n, err) - } - releaseOld() - if err = old.Ping(); err == nil { - t.Fatal("retired released generation retained database") - } -} - -func TestDatabaseCacheRootAndVersionIsolation(t *testing.T) { - rootA, rootB := t.TempDir(), t.TempDir() - cacheArchive(t, rootA, 1) - cacheArchive(t, rootB, 1) - archive, err := os.ReadFile(filepath.Join(rootA, "v1", "release", ArchiveName)) - if err != nil { - t.Fatal(err) - } - v2dir := filepath.Join(rootA, "v2", "release") - if err = os.MkdirAll(v2dir, 0700); err != nil { - t.Fatal(err) - } - if err = os.WriteFile(filepath.Join(v2dir, ArchiveName), archive, 0600); err != nil { - t.Fatal(err) - } - plainA, plainB, plainV2 := cachePlain(t, 1), cachePlain(t, 2), cachePlain(t, 3) - c := NewDatabaseCache(1 << 20) - defer func() { - if err := c.Close(); err != nil { - t.Error(err) - } - }() - loads := 0 - c.loader = func(root, version, _ string) ([]byte, error) { - loads++ - if version == "v2" { - return plainV2, nil - } - if root == rootB { - return plainB, nil - } - return plainA, nil - } - type item struct { - root, version string - want int - } - handles := map[*sql.DB]bool{} - for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} { - db, release, err := c.Open(input.root, input.version, "common") - if err != nil { - t.Fatal(err) - } - var value int - if err = db.QueryRow("SELECT value FROM test").Scan(&value); err != nil || value != input.want { - release() - t.Fatal("root/version crossed", value, input.want, err) - } - if handles[db] { - release() - t.Fatal("isolated inputs shared handle") - } - handles[db] = true - release() - } - if loads != 3 { - t.Fatal("isolated inputs did not load separately", loads) - } - for _, input := range []item{{rootA, "v1", 1}, {rootB, "v1", 2}, {rootA, "v2", 3}} { - if err = c.WithDatabase(input.root, input.version, "common", func(db *sql.DB) error { return db.Ping() }); err != nil { - t.Fatal(err) - } - } - if loads != 3 { - t.Fatal("isolated caches did not hit", loads) - } -} diff --git a/go/internal/server/gamedata/database_test.go b/go/internal/server/gamedata/database_test.go deleted file mode 100644 index 0076d3f..0000000 --- a/go/internal/server/gamedata/database_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package gamedata - -import ( - "archive/zip" - "bytes" - "os" - "path/filepath" - "testing" - - "bd2server/internal/server/dbcrypt" -) - -// These tests cover the production archive/decryption contract. Interactive -// queries against the installed 178 MB GameData belong in tools/gamedata_db.py -// so ordinary Go tests never double as an ad-hoc database console. -func TestReadDatabase(t *testing.T) { - root := t.TempDir() - release := filepath.Join(root, "123", "release") - if err := os.MkdirAll(release, 0o755); err != nil { - t.Fatal(err) - } - plain := make([]byte, dbcrypt.PageSize) - copy(plain, dbcrypt.Header) - copy(plain[100:], []byte("CREATE TABLE QuestTable21")) - encrypted, err := dbcrypt.EncryptPages(plain) - if err != nil { - t.Fatal(err) - } - name, err := DatabaseName("pack21") - if err != nil { - t.Fatal(err) - } - writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), name, encrypted) - got, err := ReadDatabase(root, "123", "pack21") - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, plain) { - t.Fatal("decrypted database mismatch") - } -} - -func TestReadQuestDatabase(t *testing.T) { - root := t.TempDir() - release := filepath.Join(root, "123", "release") - if err := os.MkdirAll(release, 0o755); err != nil { - t.Fatal(err) - } - plain := make([]byte, dbcrypt.PageSize) - copy(plain, dbcrypt.Header) - copy(plain[100:], []byte("CREATE TABLE QuestTable21")) - encrypted, err := dbcrypt.EncryptPages(plain) - if err != nil { - t.Fatal(err) - } - member, err := DatabaseName("common") - if err != nil { - t.Fatal(err) - } - writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), member, encrypted) - got, err := ReadQuestDatabase(root, "123") - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, plain) { - t.Fatal("decrypted quest database mismatch") - } -} - -func writeDatabaseTestArchive(t *testing.T, path, member string, content []byte) { - t.Helper() - file, err := os.Create(path) - if err != nil { - t.Fatal(err) - } - archive := zip.NewWriter(file) - entry, err := archive.Create(member) - if err != nil { - if err := file.Close(); err != nil { - t.Error(err) - } - t.Fatal(err) - } - if _, err := entry.Write(content); err != nil { - if err := archive.Close(); err != nil { - t.Error(err) - } - if err := file.Close(); err != nil { - t.Error(err) - } - t.Fatal(err) - } - if err := archive.Close(); err != nil { - if err := file.Close(); err != nil { - t.Error(err) - } - t.Fatal(err) - } - if err := file.Close(); err != nil { - t.Fatal(err) - } -} diff --git a/go/internal/server/gamedata/dispatch_test.go b/go/internal/server/gamedata/dispatch_test.go deleted file mode 100644 index 735c5be..0000000 --- a/go/internal/server/gamedata/dispatch_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package gamedata - -import ( - "fmt" - "testing" -) - -func TestDispatchWeightedNestedRewards(t *testing.T) { - leaf := &dispatchGroup{Drop: 1, Entries: []dispatchEntry{{Weight: 100, Reward: BattleReward{Type: 8, ID: 44, Count: 3}}}} - weighted := &dispatchGroup{Drop: 0, Count: 2, Entries: []dispatchEntry{{Reward: BattleReward{Type: 9, Count: 1}, Weight: 90, Child: leaf}, {Reward: BattleReward{Type: 4, Count: 5}, Weight: 10}}} - d := &DispatchDesign{Rewards: []BattleReward{{Type: 9, ID: 7, Count: 1}}, boxes: map[uint64]*dispatchGroup{7: weighted}} - draws := 0 - r, err := d.Roll(2, func(n uint64) (uint64, error) { - if n != 100 { - return 0, fmt.Errorf("limit %d", n) - } - draws++ - if draws%3 == 0 { - return 95, nil - } - return 0, nil - }) - if err != nil { - t.Fatal(err) - } - totals := map[uint64]uint64{} - for _, v := range r { - totals[v.Type] += v.Count - } - if totals[8] != 6 || totals[4] != 10 || draws != 6 { - t.Fatalf("rewards %+v draws %d", r, draws) - } - if _, err = d.Roll(1, func(n uint64) (uint64, error) { return n, nil }); err == nil { - t.Fatal("out of range RNG accepted") - } -} -func TestDispatchRewardOverflow(t *testing.T) { - d := &DispatchDesign{Rewards: []BattleReward{{Type: 4, Count: 2147483647}}} - if _, err := d.Roll(2, nil); err == nil { - t.Fatal("overflow accepted") - } -} diff --git a/go/internal/server/gamedata/equipment_collaboration_test.go b/go/internal/server/gamedata/equipment_collaboration_test.go deleted file mode 100644 index 837f2f2..0000000 --- a/go/internal/server/gamedata/equipment_collaboration_test.go +++ /dev/null @@ -1,286 +0,0 @@ -package gamedata - -import ( - "database/sql" - "encoding/binary" - "maps" - "os" - "reflect" - "testing" -) - -func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - c, err := LoadEquipmentGachaGroups(root, "20260923193640", []uint64{10002, 9, 133, 206}) - if err != nil { - t.Fatal(err) - } - before := map[uint64]EquipmentGacha{} - maps.Copy(before, c.Gachas) - if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil { - t.Fatal(err) - } - g := c.Gachas[71200001] - oldIDs := map[uint64]bool{} - for _, branch := range before[g.ID].Pool { - for _, item := range branch.Children { - oldIDs[item.ID] = true - } - } - added := 0 - for _, branch := range g.Pool { - for _, item := range branch.Children { - if !oldIDs[item.ID] { - added++ - if item.ID < 943200 || item.ID > 943213 { - t.Fatalf("unexpected added collaboration weapon %d", item.ID) - } - } - } - } - if len(oldIDs) != 73 || added != 14 { - t.Fatalf("original=%d added=%d", len(oldIDs), added) - } - for index, want := range []int{64, 9, 14} { - if len(g.Pool[index].Children) != want || g.Pool[index].Weight != before[g.ID].Pool[index].Weight { - t.Fatalf("branch %d: %+v", index, g.Pool[index]) - } - } - if len(before[g.ID].Pool[0].Children) != 50 { - t.Fatal("original pool mutated") - } - for id, old := range before { - if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) && !old.TicketOnly { - t.Fatalf("other product %d changed", id) - } - } - // The same UR weapon ticket design is available in a one-item product. - // Its semantic eligibility cannot depend on one versus ten draw IDs. - one := c.Gachas[70200001] - if len(one.Pool) == 0 || len(one.Pool[0].Children) != len(g.Pool[0].Children) { - t.Fatal("equivalent one-ticket UR pool not extended") - } - for _, branch := range g.Pool { - for _, item := range branch.Children { - if _, _, _, err := c.RollOptions(item.ID); err != nil { - t.Fatalf("options %d: %v", item.ID, err) - } - } - } - if err := c.IncludeCollaborationURWeapons(root, "20260923193640"); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(g, c.Gachas[g.ID]) { - t.Fatal("second inclusion changed pool") - } -} - -func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - db.SetMaxOpenConns(1) - for _, schema := range []string{"CREATE TABLE LimitedCostumeTable(id INTEGER)", "CREATE TABLE CostumeTable(id INTEGER,useUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentTable(id INTEGER,privateUniqueCharId INTEGER,ProtoBuf BLOB)", "CREATE TABLE EquipmentOptionTable(id INTEGER,GroupId INTEGER,ProtoBuf BLOB)"} { - if _, err := db.Exec(schema); err != nil { - t.Fatal(err) - } - } - proto := func(values ...uint64) []byte { - var b []byte - for i := 0; i < len(values); i += 2 { - b = binary.AppendUvarint(b, values[i]<<3) - b = binary.AppendUvarint(b, values[i+1]) - } - return b - } - for i := uint64(1); i <= 4; i++ { - grade := uint64(6 - i) - if i == 4 { - grade = 5 - } - if _, err := db.Exec("INSERT INTO CharTable VALUES(?,?,?)", i, i, proto(20, i, 9, grade)); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO EquipmentTable VALUES(?,?,?)", 100+i, i, proto(6, 100+i, 16, i, 3, 4, 18, 3)); err != nil { - t.Fatal(err) - } - } - if _, err := db.Exec("INSERT INTO LimitedCostumeTable VALUES(40),(41)"); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO CostumeTable VALUES(40,4,?),(41,4,?)", proto(27, 4), proto(27, 4)); err != nil { - t.Fatal(err) - } - pool := []WeightedEquipment{{Weight: 150, Children: []WeightedEquipment{{ID: 101, Weight: 1}}}, {Weight: 350, Children: []WeightedEquipment{{ID: 102, Weight: 1}}}, {Weight: 500, Children: []WeightedEquipment{{ID: 103, Weight: 1}}}} - c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{880077: {ID: 880077, TicketOnly: true, TicketIDs: []uint64{990055}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}} - if err := c.includeCollaborationURWeapons(db); err != nil { - t.Fatal(err) - } - if len(c.Gachas[880077].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 { - t.Fatal("extension or isolation failed") - } - if _, ok := c.equipment[104]; !ok { - t.Fatal("new equipment design not loaded") - } - if err := c.includeCollaborationURWeapons(db); err != nil { - t.Fatal(err) - } - if len(c.Gachas[880077].Pool[0].Children) != 2 { - t.Fatal("duplicate owner or repeated inclusion duplicated equipment") - } - // Eligibility follows the product definitions even when there are fewer - // character rarity branches than the current installed three-tier pool. - c.Gachas[990088] = EquipmentGacha{ID: 990088, TicketOnly: true, TicketIDs: []uint64{554433}, Pool: pool[:1]} - if err := c.includeCollaborationURWeapons(db); err != nil { - t.Fatal(err) - } - if len(c.Gachas[990088].Pool[0].Children) != 2 { - t.Fatal("changed product/ticket/branch count ignored") - } - delete(c.Gachas, 990088) - if _, err := db.Exec("UPDATE CharTable SET ProtoBuf=? WHERE id=4", proto(20, 4, 9, 2)); err != nil { - t.Fatal(err) - } - delete(c.Gachas, 880077) - c.Gachas[880077] = EquipmentGacha{ID: 880077, TicketOnly: true, TicketIDs: []uint64{990055}, Pool: pool} - if err := c.includeCollaborationURWeapons(db); err == nil { - t.Fatal("unknown tier accepted") - } - if len(c.Gachas[880077].Pool[0].Children) != 1 { - t.Fatal("failed inclusion published partial pool") - } - invalid := c.Gachas[880077] - invalid.Pool = append([]WeightedEquipment(nil), pool...) - invalid.Pool[0].Children = []WeightedEquipment{{ID: 101, Weight: 1}, {ID: 101, Weight: 1}} - c.Gachas[880077] = invalid - if err := c.includeCollaborationURWeapons(db); err == nil { - t.Fatal("duplicate existing candidate accepted") - } -} - -func TestEquipmentFixedUsesChangedIDThresholdsAndResetDefault(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE GachaFixedTable(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - var raw []byte - for _, v := range []uint64{8, 7, 24, 43} { - raw = binary.AppendUvarint(raw, v) - } - if _, err := db.Exec("INSERT INTO GachaFixedTable VALUES(?,?)", 987, raw); err != nil { - t.Fatal(err) - } - fixed, err := loadEquipmentFixed(db, 987) - if err != nil || fixed.ID != 987 || fixed.SRCount != 7 || fixed.URCount != 43 || fixed.Reset { - t.Fatalf("dynamic fixed=%+v err=%v", fixed, err) - } - raw = append(raw, 48, 1) - if _, err := db.Exec("UPDATE GachaFixedTable SET ProtoBuf=?", raw); err != nil { - t.Fatal(err) - } - fixed, err = loadEquipmentFixed(db, 987) - if err != nil || !fixed.Reset { - t.Fatalf("reset=%+v err=%v", fixed, err) - } -} - -func TestEquipmentFixedProgramsFollowEachGroup(t *testing.T) { - c := &EquipmentGachaCatalog{groups: map[uint64]EquipmentGachaGroup{71: {ID: 71, FixedID: 901}, 72: {ID: 72, FixedID: 902}}, fixedDesigns: map[uint64]EquipmentFixedDesign{901: {ID: 901, SRCount: 7, URCount: 43}, 902: {ID: 902, SRCount: 2, URCount: 5, Reset: true}}} - for _, pair := range []struct{ group, fixed, threshold uint64 }{{71, 901, 43}, {72, 902, 5}} { - fixed, ok := c.FixedForGroup(pair.group) - if !ok || fixed.ID != pair.fixed || fixed.URCount != pair.threshold { - t.Fatalf("group%d fixed=%+v found=%v", pair.group, fixed, ok) - } - } - if _, ok := c.FixedForGroup(73); ok { - t.Fatal("unknown group inherited another fixed program") - } - programs := c.FixedDesigns() - if len(programs) != 2 || programs[0].ID != 901 || programs[1].ID != 902 { - t.Fatalf("fixed designs=%+v", programs) - } -} - -func TestEquipmentPityUsesActualGradesAndReorderedNestedPool(t *testing.T) { - g := EquipmentGacha{Count: 1, Grades: map[uint64]uint64{90: 2, 80: 4, 70: 3}, Pool: []WeightedEquipment{{ID: 90, Weight: 60}, {Weight: 30, Children: []WeightedEquipment{{ID: 80, Weight: 10}, {ID: 70, Weight: 20}}}}} - fixed := EquipmentFixedDesign{ID: 99, SRCount: 7, URCount: 43, Reset: true} - draw := func(limit uint64) (uint64, error) { return 0, nil } - got, state, err := g.rollWith(6, 42, fixed, draw) - if err != nil || len(got) != 1 || got[0] != 80 || state.URSort != 0 || state.SRCount != 0 || state.URCount != 0 { - t.Fatalf("UR actualgrade got=%v state=%+v err=%v", got, state, err) - } - got, state, err = g.rollWith(6, 0, fixed, draw) - if err != nil || got[0] != 70 || state.SRSort != 0 || state.SRCount != 0 || state.URCount != 1 { - t.Fatalf("SR actualgrade got=%v state=%+v err=%v", got, state, err) - } - fixed.Reset = false - got, state, err = g.rollWith(6, 42, fixed, draw) - if err != nil || got[0] != 80 || state.SRCount != 0 || state.URCount != 0 { - t.Fatalf("forced nonreset pity=%v state=%+v err=%v", got, state, err) - } - g.Pool = []WeightedEquipment{{ID: 80, Weight: 1}} - got, state, err = g.rollWith(2, 3, fixed, draw) - if err != nil || got[0] != 80 || state.SRCount != 3 || state.URCount != 4 { - t.Fatalf("natural UR nonreset=%v state=%+v err=%v", got, state, err) - } -} - -func TestEquipmentConditionalGradePreservesMixedBranchProbability(t *testing.T) { - g := EquipmentGacha{Grades: map[uint64]uint64{1: 4, 2: 2, 3: 4}, Pool: []WeightedEquipment{{Weight: 1, Children: []WeightedEquipment{{ID: 1, Weight: 1}, {ID: 2, Weight: 3}}}, {Weight: 1, Children: []WeightedEquipment{{ID: 3, Weight: 1}}}}} - pool, err := g.gradePool(4) - if err != nil || len(pool) != 2 || pool[0].ID != 1 || pool[1].ID != 3 || pool[1].Weight != 4*pool[0].Weight { - t.Fatalf("mixed conditional distribution=%+v err=%v", pool, err) - } -} - -func TestEquipmentGachaLoaderAcceptsChangedPriceAndPoolLength(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, table := range []string{"GachaTable", "RewardGroupTable"} { - if _, err := db.Exec("CREATE TABLE " + table + "(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - proto := func(values ...uint64) []byte { - var raw []byte - for i := 0; i < len(values); i += 2 { - raw = binary.AppendUvarint(raw, values[i]<<3) - raw = binary.AppendUvarint(raw, values[i+1]) - } - return raw - } - if _, err := db.Exec("INSERT INTO GachaTable VALUES(?,?)", 808, proto(5, 2, 7, 909, 10, 666, 12, 3)); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 909, proto(5, 707, 6, 10, 8, 123)); err != nil { - t.Fatal(err) - } - g, err := loadEquipmentGacha(db, 808) - if err != nil || g.Count != 2 || g.Price != 666 || len(g.Pool) != 1 || g.Pool[0].Weight != 123 { - t.Fatalf("changed price/pool=%+v err=%v", g, err) - } -} diff --git a/go/internal/server/gamedata/equipment_craft_test.go b/go/internal/server/gamedata/equipment_craft_test.go deleted file mode 100644 index 54b8154..0000000 --- a/go/internal/server/gamedata/equipment_craft_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestEquipmentCraftAgainstInstalledCurrentVersion(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") - } - design, err := LoadEquipmentCraftDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - recipe, ok := design.Recipe(1) - if !ok || recipe.TalentLevel != 1 || recipe.ResultCount != 1 || len(recipe.Costs) != 2 || - recipe.Costs[0] != (PromotionCost{Type: 8, ID: 204, Count: 3}) || - recipe.Costs[1] != (PromotionCost{Type: 8, ID: 201, Count: 3}) { - t.Fatalf("recipe=%+v found=%t", recipe, ok) - } - design.draw = func(limit uint64) (uint64, error) { return 0, nil } - generated, err := design.Generate(1) - if err != nil || generated.Design.ID != 10010 || len(generated.Main) == 0 { - t.Fatalf("generated=%+v err=%v", generated, err) - } - gain, catalyst, maximum, err := design.Talent(140, 1, 1, 1, 0) - if err != nil || gain == 0 || catalyst == 0 || maximum == 0 || gain > maximum { - t.Fatalf("talent gain=%d catalyst=%d maximum=%d err=%v", gain, catalyst, maximum, err) - } -} - -func TestEquipmentUpgradeBreakAgainstInstalledCurrentVersion(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") - } - design, err := LoadEquipmentUpgradeDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - rewards, err := design.BreakRewards(10010, 0) - if err != nil || len(rewards) == 0 { - t.Fatalf("break rewards=%+v err=%v", rewards, err) - } -} diff --git a/go/internal/server/gamedata/equipment_option_reroll_test.go b/go/internal/server/gamedata/equipment_option_reroll_test.go deleted file mode 100644 index e7c3028..0000000 --- a/go/internal/server/gamedata/equipment_option_reroll_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package gamedata - -import ( - "database/sql" - "math" - "os" - "testing" - - "bd2server/internal/server/wire" -) - -func TestEquipmentOptionRerollDesignReadsCostsAndRollsUnlockedSlots(t *testing.T) { - db := optionRerollTestDatabase(t) - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - - insertOptionRerollEquipment(t, db, 77, 9, []uint64{100}, []uint64{200, 200}, []uint64{300}) - insertOptionRerollCost(t, db, 9, []uint64{0, 5}, []uint64{1000, 5}, []uint64{0, 17}, []uint64{4, 8}) - insertOptionChoice(t, db, 100, 1, 100) - insertOptionChoice(t, db, 200, 7, 25) - insertOptionChoice(t, db, 200, 8, 75) - insertOptionChoice(t, db, 300, 10, 100) - - design, err := loadEquipmentOptionRerollDesign(db) - if err != nil { - t.Fatal(err) - } - item, ok := design.Lookup(77) - if !ok || item.OptionRerollID != 9 || len(item.MainGroups) != 1 || len(item.SubGroups) != 2 || len(item.PrivateGroups) != 1 { - t.Fatalf("equipment=%+v found=%v", item, ok) - } - costs, err := design.Cost(77, 2) - if err != nil || len(costs) != 2 || costs[0] != (PromotionCost{Type: 4, Count: 1000}) || costs[1] != (PromotionCost{Type: 8, ID: 17, Count: 15}) { - t.Fatalf("costs=%+v err=%v", costs, err) - } - - draws := []uint64{0, 24, 25} - design.draw = func(limit uint64) (uint64, error) { - value := draws[0] - draws = draws[1:] - if value >= limit { - t.Fatalf("test draw %d outside limit %d", value, limit) - } - return value, nil - } - roll, err := design.RollUnlocked(77, EquipmentOptionRerollLocks{ - Main: []bool{false}, Sub: []bool{false, true}, Private: []bool{false}, - }) - if err != nil { - t.Fatal(err) - } - if roll.Main[0] != (EquipmentOptionChoice{GroupID: 100, ID: 1}) || - roll.Sub[0] != (EquipmentOptionChoice{GroupID: 200, ID: 7}) || roll.Sub[1] != (EquipmentOptionChoice{}) || - roll.Private[0] != (EquipmentOptionChoice{GroupID: 300, ID: 10}) { - t.Fatalf("roll=%+v", roll) - } - if len(draws) != 0 { - t.Fatalf("unused draws=%v", draws) - } -} - -func TestEquipmentOptionRerollDesignRejectsMismatchedCostArrays(t *testing.T) { - db := optionRerollTestDatabase(t) - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - insertOptionRerollEquipment(t, db, 77, 9, []uint64{100}, []uint64{200}, nil) - // LockItemCount has one entry but the other parallel arrays have two. - insertOptionRerollCost(t, db, 9, []uint64{0}, []uint64{1000, 5}, []uint64{0, 17}, []uint64{4, 8}) - insertOptionChoice(t, db, 100, 1, 100) - insertOptionChoice(t, db, 200, 7, 100) - if _, err := loadEquipmentOptionRerollDesign(db); err == nil { - t.Fatal("mismatched option-reroll resource arrays accepted") - } -} - -func TestEquipmentOptionRerollCostRejectsOverflowAndBadLocks(t *testing.T) { - design := &EquipmentOptionRerollDesign{ - Equipment: map[uint64]EquipmentOptionRerollItem{1: { - ID: 1, OptionRerollID: 2, MainGroups: []uint64{10}, SubGroups: []uint64{20}, - }}, - Costs: map[uint64]EquipmentOptionRerollCost{2: {Resources: []EquipmentOptionRerollResource{{ - Type: 8, ID: 17, BaseCount: math.MaxUint64, LockCount: 1, - }}}}, - Groups: map[uint64]OptionGroup{ - 10: {ID: 10, Choices: []WeightedOption{{ID: 1, Weight: 1}}}, - 20: {ID: 20, Choices: []WeightedOption{{ID: 2, Weight: 1}}}, - }, - } - if _, err := design.Cost(1, 1); err == nil { - t.Fatal("overflowing lock cost accepted") - } - if _, err := design.RollUnlocked(1, EquipmentOptionRerollLocks{Main: []bool{false}}); err == nil { - t.Fatal("short option lock arrays accepted") - } -} - -func TestEquipmentOptionRerollAgainstInstalledCurrentVersion(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - root = os.Getenv("BD2_TEST_GAMEDATA_ROOT") - } - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") - } - design, err := LoadEquipmentOptionRerollDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - item, ok := design.Lookup(943035) - if !ok || item.OptionRerollID != 9435 || item.PrivateUniqueCharID != 35 || len(item.MainGroups) != 2 || item.MainGroups[0] != 1943010 || item.MainGroups[1] != 2943010 || - len(item.SubGroups) != 3 || item.SubGroups[0] != 943000 || item.SubGroups[1] != 943000 || item.SubGroups[2] != 943000 || - len(item.PrivateGroups) != 1 || item.PrivateGroups[0] != 3943035 { - t.Fatalf("943035 design=%+v found=%v", item, ok) - } - for locked, wantMaterial := range []uint64{60, 120, 180} { - cost, err := design.Cost(943035, uint64(locked)) - if err != nil || len(cost) != 2 || cost[0] != (PromotionCost{Type: 4, Count: 120000}) || cost[1] != (PromotionCost{Type: 8, ID: 17, Count: wantMaterial}) { - t.Fatalf("locked=%d cost=%+v err=%v", locked, cost, err) - } - } - if choices := design.Groups[943000].Choices; len(choices) != 8 || choices[0].Weight != 100 { - t.Fatalf("943000 choices=%+v", choices) - } - if choices := design.Groups[1943010].Choices; len(choices) != 2 || choices[0].ID != 3 || choices[1].ID != 4 { - t.Fatalf("1943010 changeable main choices=%+v", choices) - } - if conversion := design.Conversion; conversion == nil || conversion.Ratio != 10 || conversion.SourceType != 8 || conversion.SourceID != 16 || conversion.TargetType != 8 || conversion.TargetID != 17 { - t.Fatalf("reroll material conversion=%+v", conversion) - } -} - -func optionRerollTestDatabase(t *testing.T) *sql.DB { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - if _, err := db.Exec(` - CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); - CREATE TABLE EquipmentOptionRerollTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); - CREATE TABLE EquipmentRerollDefaultTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); - CREATE TABLE EquipmentOptionTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id)); - `); err != nil { - if err := db.Close(); err != nil { - t.Error(err) - } - t.Fatal(err) - } - return db -} - -func insertOptionRerollEquipment(t *testing.T, db *sql.DB, id, rerollID uint64, main, sub, private []uint64) { - t.Helper() - proto := testVarintField(nil, 6, id) - proto = testPackedFields(proto, 12, main) - proto = testVarintField(proto, 15, rerollID) - proto = testPackedFields(proto, 17, private) - proto = testPackedFields(proto, 21, sub) - if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?)", id, proto); err != nil { - t.Fatal(err) - } -} - -func insertOptionRerollCost(t *testing.T, db *sql.DB, id uint64, locks, counts, ids, types []uint64) { - t.Helper() - proto := testVarintField(nil, 1, id) - proto = testPackedFields(proto, 2, locks) - proto = testPackedFields(proto, 3, counts) - proto = testPackedFields(proto, 4, ids) - proto = testPackedFields(proto, 5, types) - if _, err := db.Exec("INSERT INTO EquipmentOptionRerollTable VALUES (?,?)", id, proto); err != nil { - t.Fatal(err) - } -} - -func insertOptionChoice(t *testing.T, db *sql.DB, groupID, id, weight uint64) { - t.Helper() - proto := wire.AppendDouble(nil, 1, float64(id)) - proto = testVarintField(proto, 2, weight) - proto = testVarintField(proto, 3, groupID) - proto = testVarintField(proto, 5, id) - if _, err := db.Exec("INSERT INTO EquipmentOptionTable VALUES (?,?,?)", groupID, id, proto); err != nil { - t.Fatal(err) - } -} - -func testPackedFields(proto []byte, number int, values []uint64) []byte { - for _, value := range values { - proto = testPackedField(proto, number, value) - } - return proto -} diff --git a/go/internal/server/gamedata/equipment_slots_test.go b/go/internal/server/gamedata/equipment_slots_test.go deleted file mode 100644 index 09e2bf6..0000000 --- a/go/internal/server/gamedata/equipment_slots_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package gamedata - -import ( - "database/sql" - "testing" -) - -func TestLoadEquipmentSlotsKeepsProtoDefaultSlotZero(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?),(?,?)", 10010, []byte{}, 943619, testVarintField(nil, 20, 4)); err != nil { - t.Fatal(err) - } - slots, err := loadEquipmentSlots(db) - if err != nil { - t.Fatal(err) - } - if len(slots) != 2 || slots[10010] != 0 || slots[943619] != 4 { - t.Fatalf("slots=%v", slots) - } -} diff --git a/go/internal/server/gamedata/equipment_smelting_test.go b/go/internal/server/gamedata/equipment_smelting_test.go deleted file mode 100644 index 55fa10e..0000000 --- a/go/internal/server/gamedata/equipment_smelting_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestEquipmentSmeltingAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - design, err := LoadEquipmentSmeltingDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - equipment := design.Equipment[943035] - if equipment.Grade != 4 || equipment.RankGroup != 904 || equipment.MaxLevel != 9 { - t.Fatalf("UR equipment smelting design=%+v", equipment) - } - cost, err := design.Cost(943035) - if err != nil || len(cost) != 2 || cost[0] != (PromotionCost{Type: 4, Count: 80}) || cost[1] != (PromotionCost{Type: 8, ID: 10, Count: 30}) { - t.Fatalf("UR smelting cost=%+v err=%v", cost, err) - } - if design.MaxStreak != 5000 || design.Mileage != (EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1}) { - t.Fatalf("smelting limit=%d mileage=%+v", design.MaxStreak, design.Mileage) - } - if got := design.Ranks[[2]uint64{904, 1}].Values; len(got) != 4 || got[0] != 1 || got[3] != 4 { - t.Fatalf("UR slot1 score values=%v", got) - } - if got := design.Ranks[[2]uint64{904, 1}].GrowthPoint; len(got) != 4 || got[0] != 34 || got[3] != 135 { - t.Fatalf("UR slot1 growth points=%v", got) - } - if score, err := design.Score(943035, []uint64{1, 2, 3}); err != nil || score != 1+4+9 { - t.Fatalf("score=%d err=%v", score, err) - } -} - -func TestEquipmentSmeltingMaximumRanksComeFromDesign(t *testing.T) { - design := &EquipmentSmeltingDesign{ - Equipment: map[uint64]EquipmentSmeltingItem{1: {RankGroup: 9}}, - Ranks: map[[2]uint64]EquipmentSmeltingRank{ - {9, 1}: {Values: []uint64{1, 2}}, - {9, 2}: {Values: []uint64{1, 2, 3}}, - {9, 3}: {Values: []uint64{1, 2, 3, 4}}, - }, - } - got, err := design.MaximumRanks(1) - if err != nil || len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 4 { - t.Fatalf("maximum ranks=%v err=%v", got, err) - } -} - -func TestEquipmentSmeltingRejectsInvalidRank(t *testing.T) { - design := &EquipmentSmeltingDesign{ - Equipment: map[uint64]EquipmentSmeltingItem{1: {Grade: 4, RankGroup: 9, MaxLevel: 9}}, - Ranks: map[[2]uint64]EquipmentSmeltingRank{ - {9, 1}: {Values: []uint64{1, 2, 3, 4}}, - {9, 2}: {Values: []uint64{2, 4, 6, 8}}, - {9, 3}: {Values: []uint64{3, 6, 9, 12}}, - }, - } - if _, err := design.Score(1, []uint64{1, 0, 1}); err == nil { - t.Fatal("uninitialized smelting rank accepted") - } - if score, err := design.Score(1, []uint64{4, 4, 4}); err != nil || score != 24 { - t.Fatalf("maximum score=%d err=%v", score, err) - } -} diff --git a/go/internal/server/gamedata/equipment_stats_test.go b/go/internal/server/gamedata/equipment_stats_test.go deleted file mode 100644 index ce7707f..0000000 --- a/go/internal/server/gamedata/equipment_stats_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "encoding/binary" - "math" - "testing" -) - -func TestEquipmentHealthStatsUseRankCurveSubDefaultAndFractionPercent(t *testing.T) { - d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{ - {10, 1}: {Default: 7.9, Growth: 2, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, {1}, {1.5}}}, - {20, 2}: {Default: 0.01359, Growth: 0.01, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, {1}, {1.5}}}, - }} - flat, err := d.HealthContribution(EquipmentOption{GroupID: 10, ID: 1, Level: 1, Rank: [3]int{1, 1, 1}}, false) - if err != nil || flat.Flat != 15 { - t.Fatalf("flat=%+v err=%v", flat, err) - } - percent, err := d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 1, Rank: [3]int{1, 1, 1}}, false) - if err != nil || percent.Percent != 0.0535 { - t.Fatalf("percent=%+v err=%v", percent, err) - } - sub, err := d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 99, Rank: [3]int{99, 99, 99}}, true) - if err != nil || sub.Percent != 0.0135 { - t.Fatalf("sub=%+v err=%v", sub, err) - } - if got := AggregateStats(BaseStats{Health: 100}, []StatContribution{flat, percent, sub}).Health; got != 122 { - t.Fatalf("aggregated health=%v", got) - } - if _, err = d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 2}, false); err == nil { - t.Fatal("accepted nonexistent level") - } - if _, err = d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Rank: [3]int{2, 0, 0}}, false); err == nil { - t.Fatal("accepted nonexistent rank") - } -} - -func TestEquipmentStatLoaderReadsCompositeKeysAndFloatCurves(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err = db.Exec("CREATE TABLE EquipmentOptionTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - raw := wire.AppendDouble(nil, 1, 7.9) - raw = wire.AppendDouble(raw, 4, 2) - raw = wire.AppendVarint(raw, 3, 10) - raw = wire.AppendVarint(raw, 5, 1) - levels := binary.LittleEndian.AppendUint32(nil, math.Float32bits(0)) - levels = binary.LittleEndian.AppendUint32(levels, math.Float32bits(1)) - raw = wire.AppendBytes(raw, 6, levels) - if _, err = db.Exec("INSERT INTO EquipmentOptionTable VALUES(10,1,?)", raw); err != nil { - t.Fatal(err) - } - d, err := loadEquipmentStatDesign(db) - if err != nil { - t.Fatal(err) - } - got, err := d.HealthContribution(EquipmentOption{GroupID: 10, ID: 1, Level: 1}, false) - if err != nil || got.Flat != 9 { - t.Fatalf("health=%+v err=%v", got, err) - } -} - -func TestEquipmentOptionContributionTruncatesRatherThanRounds(t *testing.T) { - flat, err := optionContribution(1, 7.9) - if err != nil || flat.Flat != 7 { - t.Fatalf("flat=%+v err=%v", flat, err) - } - percent, err := optionContribution(2, 0.01359) - if err != nil || percent.Percent != 0.0135 { - t.Fatalf("fraction percent=%+v err=%v", percent, err) - } -} - -func TestEquipmentHealthStatsAllowRankedItemsWithEmptyOptionRankCurves(t *testing.T) { - // EquipmentInfo.GetEquipOptionByRank (0x06003145) returns zero when - // RankValue1/2/3 is empty. Rank belongs to the equipment, not each option. - d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{ - {10, 1}: {Default: 10, Growth: 2, Levels: []float64{0, 1}}, - {20, 2}: {Default: 0.1, Growth: 0.01, Levels: []float64{0, 1}, Ranks: [3][]float64{nil, {0.5, 1}, nil}}, - }} - flat, err := d.HealthContribution(EquipmentOption{GroupID: 10, ID: 1, Level: 1, Rank: [3]int{2, 3, 4}}, false) - if err != nil || flat.Flat != 12 { - t.Fatalf("empty rank curve contribution=%+v err=%v", flat, err) - } - percent, err := d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 1, Rank: [3]int{2, 2, 4}}, false) - if err != nil || percent.Percent != 0.12 { - t.Fatalf("mixed rank curve contribution=%+v err=%v", percent, err) - } -} diff --git a/go/internal/server/gamedata/equipment_ticket_gacha_test.go b/go/internal/server/gamedata/equipment_ticket_gacha_test.go deleted file mode 100644 index 9f993f8..0000000 --- a/go/internal/server/gamedata/equipment_ticket_gacha_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - catalog, err := LoadEquipmentGachaGroups(root, "20260923193640", []uint64{10002, 9, 133, 206}) - if err != nil { - t.Fatal(err) - } - gacha, ok := catalog.Gacha(71200001) - if !ok || !gacha.TicketOnly || gacha.Count != 10 || gacha.Price != 0 || gacha.PriceType != 0 || len(gacha.TicketIDs) != 1 || gacha.TicketIDs[0] != 1104 { - t.Fatalf("ticket-only equipment gacha=%+v ok=%v", gacha, ok) - } - if len(gacha.Pool) != 3 || gacha.Pool[0].Weight != 150 || gacha.Pool[1].Weight != 350 || gacha.Pool[2].Weight != 500 { - t.Fatalf("ticket-only equipment pool=%+v", gacha.Pool) - } - if len(gacha.Pool[0].Children) != 50 || len(gacha.Pool[1].Children) != 9 || len(gacha.Pool[2].Children) != 14 { - t.Fatalf("ticket-only equipment branches=%d/%d/%d", len(gacha.Pool[0].Children), len(gacha.Pool[1].Children), len(gacha.Pool[2].Children)) - } - // These branches separate the owning character's star grade, not the - // equipment rarity. Every candidate is EquipmentTable.Grade=4 (UR). - for _, branch := range gacha.Pool { - for _, item := range branch.Children { - design, found := catalog.equipment[item.ID] - if !found || design.Grade != 4 { - t.Fatalf("UR-guaranteed candidate %d design=%+v found=%v", item.ID, design, found) - } - } - } - if _, grouped := catalog.GroupForGacha(gacha.ID); grouped { - t.Fatal("standalone ticket draw incorrectly attached to a schedule group") - } -} diff --git a/go/internal/server/gamedata/equipment_upgrade_test.go b/go/internal/server/gamedata/equipment_upgrade_test.go deleted file mode 100644 index a720d05..0000000 --- a/go/internal/server/gamedata/equipment_upgrade_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package gamedata - -import ( - "database/sql" - "encoding/binary" - "math" - "os" - "testing" - - "bd2server/internal/server/wire" -) - -func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE EquipmentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE EquipmentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id)); CREATE TABLE EquipmentRankTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil { - t.Fatal(err) - } - equipment := testVarintField(testVarintField(testVarintField(nil, 4, 954), 13, 9), 19, 904) - growth := testVarintField(testVarintField(nil, 4, 954), 5, 670) - growth = testPackedField(growth, 1, 2) - growth = testPackedField(growth, 2, 201) - growth = testPackedField(growth, 3, 8) - growth = testPackedField(growth, 7, 960) - growth = testPackedField(growth, 8, 0) - growth = testPackedField(growth, 9, 4) - growth = wire.AppendDouble(growth, 10, 0.7) - if _, err := db.Exec("INSERT INTO EquipmentTable VALUES (?,?)", 943035, equipment); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO EquipmentGrowthTable VALUES (?,?,?)", 954, 0, growth); err != nil { - t.Fatal(err) - } - var ratio []byte - for _, value := range []float32{1, 0, 0, 0} { - var raw [4]byte - binary.LittleEndian.PutUint32(raw[:], math.Float32bits(value)) - ratio = append(ratio, raw[:]...) - } - for slot := 1; slot <= 3; slot++ { - if _, err := db.Exec("INSERT INTO EquipmentRankTable VALUES (?,?,?)", 904, slot, wire.AppendBytes(nil, 4, ratio)); err != nil { - t.Fatal(err) - } - } - design, err := loadEquipmentUpgradeDesign(db) - if err != nil { - t.Fatal(err) - } - level, maximum, err := design.Level(943035, 0) - if err != nil || maximum != 9 || level.GrowthPoint != 670 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || math.Abs(level.SuccessRatio-0.7) > 1e-12 { - t.Fatalf("level=%+v maximum=%d err=%v", level, maximum, err) - } - if rank, err := design.RollRank(943035, 1); err != nil || rank != 1 { - t.Fatalf("rank=%d err=%v", rank, err) - } - if rewards, err := design.BreakRewards(943035, 0); err != nil || len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 201, Count: 2}) { - t.Fatalf("break rewards=%+v err=%v", rewards, err) - } -} - -func TestEquipmentUpgradeAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - design, err := LoadEquipmentUpgradeDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - level, maximum, err := design.Level(943035, 0) - if err != nil || maximum != 9 || len(level.Costs) != 1 || level.Costs[0] != (PromotionCost{Type: 4, Count: 960}) || level.SuccessRatio != 1 { - t.Fatalf("installed level=%+v maximum=%d err=%v", level, maximum, err) - } - ratios := design.RankRatio[[2]uint64{904, 1}] - if design.RankGroup[943035] != 904 || len(ratios) != 4 || math.Abs(ratios[0]-0.45) > 1e-6 || math.Abs(ratios[3]-0.01) > 1e-6 { - t.Fatalf("installed rank group=%d ratios=%v", design.RankGroup[943035], ratios) - } -} diff --git a/go/internal/server/gamedata/event_actions_test.go b/go/internal/server/gamedata/event_actions_test.go deleted file mode 100644 index dea60ea..0000000 --- a/go/internal/server/gamedata/event_actions_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestInstalledEventActions23510(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - d, e := LoadEventActionsDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - if len(d.Tables["VotingEventTable"]) != 6 || len(d.Tables["FriendshipSpecialEpisodeTable"]) != 120 || len(d.Tables["NpcQuizTable"]) != 43 { - t.Fatal("event actions incomplete") - } - r := d.SpawnRewards[[2]uint64{3009, 3001}] - if r.Type != 4 || r.Count != 10000 { - t.Fatalf("spawn reward mismatch %+v", r) - } -} diff --git a/go/internal/server/gamedata/event_battle_challenge_test.go b/go/internal/server/gamedata/event_battle_challenge_test.go deleted file mode 100644 index 29ec770..0000000 --- a/go/internal/server/gamedata/event_battle_challenge_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "os" - "testing" -) - -func TestEventBattleChallengeInstalledSmoke(t *testing.T) { - root, version := os.Getenv("BD2_GAMEDATA_ROOT"), os.Getenv("BD2_GAMEDATA_VERSION") - if root == "" || version == "" { - t.Skip("installed design env required") - } - d, e := LoadEventBattleChallenges(root, version) - if e != nil { - t.Fatal(e) - } - if len(d) == 0 { - t.Fatal("empty challenges") - } -} -func TestChallengeIndexesPackedAndDuplicate(t *testing.T) { - definitions := []EventBattleChallenge{{Type: 8}, {Type: 2, Value1: 8}} - req := wire.AppendBytes(nil, 5, []byte{0, 1}) - out, e := VerifySubmittedChallenges(req, definitions) - if e != nil || len(out) != 2 { - t.Fatal(out, e) - } - if _, e = VerifySubmittedChallenges(wire.AppendBytes(nil, 5, []byte{0, 0}), definitions); e == nil { - t.Fatal("duplicate index") - } -} diff --git a/go/internal/server/gamedata/event_field_pack_test.go b/go/internal/server/gamedata/event_field_pack_test.go deleted file mode 100644 index 42855c5..0000000 --- a/go/internal/server/gamedata/event_field_pack_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package gamedata - -import ( - "os" - "reflect" - "testing" -) - -func TestInstalledHiddenTrackerPackRules(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - catalog, err := LoadEventPlayCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - // Current GameData PackTable 12010: hidden type 100, no price/reward - // fields. MapTable binds all three maps, independently of minigame rows. - pack, ok := catalog.FieldPacks[12010] - if !ok || pack.BuyPrice != 0 || pack.BuyType != 0 || len(pack.BuyRewards) != 0 || !reflect.DeepEqual(pack.MapIDs, []int{1201001, 1201002, 1201003}) || pack.InitialPosition != "{}" { - t.Fatalf("tracker pack %+v exists %v", pack, ok) - } - for _, game := range catalog.Tables["PackEventMiniGameTable"] { - id, _ := optionalScalar(game, 12) - if id == 12010 { - t.Fatal("tracker unexpectedly became a PackEventMiniGameTable pack; recheck authoritative mapping") - } - } -} diff --git a/go/internal/server/gamedata/event_games_test.go b/go/internal/server/gamedata/event_games_test.go deleted file mode 100644 index 4adaff6..0000000 --- a/go/internal/server/gamedata/event_games_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestEventGameInstalledRules(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - for _, kind := range []uint64{12, 13, 17, 19} { - d, e := LoadEventGame(root, "20260923193640", kind, 1) - if e != nil { - t.Fatalf("kind=%d %v", kind, e) - } - if d.Type != kind || d.Cost == 0 || len(d.Cells) == 0 { - t.Fatalf("kind=%d invalid %+v", kind, d) - } - if kind == 12 && len(d.Moves) == 0 { - t.Fatal("no moves") - } - if kind == 13 && len(d.Lines) == 0 { - t.Fatal("no lines") - } - if kind == 17 && len(d.Complete) == 0 { - t.Fatal("no words") - } - } -} diff --git a/go/internal/server/gamedata/event_play_test.go b/go/internal/server/gamedata/event_play_test.go deleted file mode 100644 index a33bb08..0000000 --- a/go/internal/server/gamedata/event_play_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestEventPlayInstalledCatalog(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not set") - } - c, e := LoadEventPlayCatalog(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - for _, n := range []string{"PackEventStoryTable", "FieldMiniGameSkillTable", "MGDRewardTable"} { - if len(c.Tables[n]) == 0 { - t.Fatalf("empty %s", n) - } - } -} diff --git a/go/internal/server/gamedata/event_tasks_test.go b/go/internal/server/gamedata/event_tasks_test.go deleted file mode 100644 index 7787756..0000000 --- a/go/internal/server/gamedata/event_tasks_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestInstalledEventTasks23510(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - d, e := LoadEventTasksDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - if len(d.Attendance) != 109 || len(d.MissionGroups) != 519 || len(d.Passes) != 141 { - t.Fatal("event design tables not fully loaded") - } - p := d.Passes[141] - if p.MissionGroup != 51 || p.LevelGroup != 141 || d.PassLevels[141][0].NeedExp != 20 || d.PassLevels[141][0].Basic.Type != 9 { - t.Fatalf("pass 141 mismatch %+v", p) - } - if d.MissionGroups[51].Type != 3 || len(d.MissionGroups[51].Groups) != 7 { - t.Fatal("daily-open groups lost") - } - buys := d.PassBuys[140] - if len(buys) != 2 || buys[0].Type != 0 || buys[0].LevelsGranted != 1 || buys[1].Type != 1 || buys[1].LevelsGranted != 3 { - t.Fatalf("pass level purchase bonuses lost: %+v", buys) - } -} diff --git a/go/internal/server/gamedata/field_monsters_test.go b/go/internal/server/gamedata/field_monsters_test.go deleted file mode 100644 index 6bcc2eb..0000000 --- a/go/internal/server/gamedata/field_monsters_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "os" - "reflect" - "testing" -) - -func TestInstalledOverwhelmCatalogIncludesScriptedAndRegeneratingMonsters(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - rows, e := LoadFieldMonsters(root, "20260923193640", 1) - if e != nil { - t.Fatal(e) - } - script, regen := 0, 0 - for _, m := range rows { - if m.GroupID == 0 { - script++ - } else { - regen++ - } - } - if script == 0 || regen == 0 { - t.Fatal("catalog lost scripted or regenerating monsters", script, regen) - } - rules, e := LoadSkyWayOverwhelm(root, "20260923193640") - if e != nil || len(rules) == 0 { - t.Fatal("skyway catalog unavailable", e) - } - rule, e := LoadOverwhelmQuest(root, "20260923193640", 1, 1) - if e != nil || rule.Enemies == nil { - t.Fatal("quest battle rule catalog unavailable", e) - } -} - -func TestFieldMonsterCatalogJoinsRegenerationAndRejectsBrokenReference(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, table := range []string{"FieldMonsterTable", "FieldMonsterRegenTable"} { - if _, err = db.Exec("CREATE TABLE " + table + " (id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - regen := wire.AppendVarint(wire.AppendVarint(nil, 6, 91), 3, 30) - if _, err = db.Exec("INSERT INTO FieldMonsterRegenTable VALUES (?,?)", 17, regen); err != nil { - t.Fatal(err) - } - monster := wire.AppendVarint(wire.AppendVarint(nil, 24, 17), 2, 78) - if _, err = db.Exec("INSERT INTO FieldMonsterTable VALUES (?,?)", 3, monster); err != nil { - t.Fatal(err) - } - rows, err := loadFieldMonsters(db) - if err != nil || len(rows) != 1 || !reflect.DeepEqual(rows[0], FieldMonsterDesign{ID: 3, GroupID: 17, QuestID: 91, BattleDeck: 78, LifeSeconds: 30, BattleDecks: []uint64{78}}) { - t.Fatalf("catalog %+v: %v", rows, err) - } - if _, err = db.Exec("UPDATE FieldMonsterTable SET ProtoBuf=?", wire.AppendVarint(nil, 24, 18)); err != nil { - t.Fatal(err) - } - if _, err = loadFieldMonsters(db); err == nil { - t.Fatal("accepted unknown regeneration group") - } -} diff --git a/go/internal/server/gamedata/field_objects_test.go b/go/internal/server/gamedata/field_objects_test.go deleted file mode 100644 index a4a6cd2..0000000 --- a/go/internal/server/gamedata/field_objects_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" - "time" -) - -func TestFieldObjectLoadIndependentEquipmentAndRandomBox(t *testing.T) { - pack, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := pack.Close(); err != nil { - t.Error(err) - } - }() - common, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := common.Close(); err != nil { - t.Error(err) - } - }() - for _, table := range []string{"FieldRewardObjectGroupTable", "FieldRewardObjectTable"} { - if _, err := pack.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - for _, table := range []string{"RewardGroupTable", "EquipmentTable", "RandomBoxTable"} { - if _, err := common.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - if _, err := common.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", 88, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 9, 88)); err != nil { - t.Fatal(err) - } - if _, err := common.Exec("CREATE TABLE EquipmentOptionTable(GroupId INTEGER, id INTEGER, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - objectGroup := wire.AppendVarint(nil, 10, 66) - objectGroup = wire.AppendVarint(objectGroup, 12, 8) // A newly introduced object kind must not need an allowlist. - if _, err := pack.Exec("INSERT INTO FieldRewardObjectGroupTable VALUES(?,?)", 55, objectGroup); err != nil { - t.Fatal(err) - } - object := wire.AppendVarint(nil, 3, 55) - object = wire.AppendVarint(object, 6, 44) - if _, err := pack.Exec("INSERT INTO FieldRewardObjectTable VALUES(?,?)", 33, object); err != nil { - t.Fatal(err) - } - loot := wire.AppendVarint(nil, 2, 1) - for _, r := range []BattleReward{{Type: 10, ID: 77, Count: 2}, {Type: 9, ID: 88, Count: 1}} { - loot = wire.AppendVarint(loot, 4, r.Count) - loot = wire.AppendVarint(loot, 5, r.ID) - loot = wire.AppendVarint(loot, 6, r.Type) - loot = wire.AppendVarint(loot, 8, 100) - } - if _, err := common.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 66, loot); err != nil { - t.Fatal(err) - } - equip := wire.AppendVarint(nil, 3, 3) - equip = wire.AppendVarint(equip, 12, 99) - if _, err := common.Exec("INSERT INTO EquipmentTable VALUES(?,?)", 77, equip); err != nil { - t.Fatal(err) - } - option := wire.AppendVarint(nil, 5, 7) - option = wire.AppendVarint(option, 2, 100) - if _, err := common.Exec("INSERT INTO EquipmentOptionTable VALUES(?,?,?)", 99, 7, option); err != nil { - t.Fatal(err) - } - d, err := loadFieldObjects(pack, common) - if err != nil { - t.Fatal(err) - } - got, err := d.Objects[33].Draw() - if err != nil || len(got) != 2 || got[0].ID != 77 || got[0].Count != 2 || got[1].Type != 9 || got[1].ID != 88 { - t.Fatalf("rewards=%+v err=%v", got, err) - } - main, _, _, err := d.Equipment.RollOptions(77) - if err != nil || len(main) != 1 || main[0].GroupID != 99 || main[0].ID != 7 { - t.Fatalf("options=%+v err=%v", main, err) - } - // A missing dropType uses the client's proto default RBD_OPEN. Keep a - // nested DIRECT box but prepare the wrapped equipment's option catalog. - if _, err := common.Exec("UPDATE RandomBoxTable SET ProtoBuf=? WHERE id=88", wire.AppendVarint(nil, 9, 89)); err != nil { - t.Fatal(err) - } - if _, err := common.Exec("INSERT INTO RandomBoxTable VALUES(?,?)", 90, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 9, 90)); err != nil { - t.Fatal(err) - } - wrapped := group(0, 1, []BattleReward{{Type: 10, ID: 77, Count: 1}, {Type: 9, ID: 90, Count: 1}}, []uint64{1, 1}) - if _, err := common.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 89, wrapped); err != nil { - t.Fatal(err) - } - d, err = loadFieldObjects(pack, common) - if err != nil { - t.Fatal(err) - } - d.RewardGraph.SetSampler(func(uint64) (uint64, error) { return 0, nil }) - resolved, err := d.RewardGraph.ResolveGranted([]BattleReward{{Type: 9, ID: 88, Count: 1}}) - if err != nil || len(resolved) != 1 || resolved[0].Type != 10 || resolved[0].ID != 77 { - t.Fatalf("OPEN equipment=%+v err=%v", resolved, err) - } - d.RewardGraph.SetSampler(func(n uint64) (uint64, error) { return n - 1, nil }) - resolved, err = d.RewardGraph.ResolveGranted([]BattleReward{{Type: 9, ID: 88, Count: 1}}) - if err != nil || len(resolved) != 1 || resolved[0].Type != 9 || resolved[0].ID != 90 { - t.Fatalf("nested DIRECT box=%+v err=%v", resolved, err) - } - // Invalid options in an unselected/zero-weight OPEN branch must reject the - // catalog before a draw or any owned-item receipt can be written. - wrapped = group(0, 1, []BattleReward{{Type: 10, ID: 77, Count: 1}, {Type: 10, ID: 999, Count: 1}}, []uint64{1, 0}) - if _, err := common.Exec("UPDATE RewardGroupTable SET ProtoBuf=? WHERE id=89", wrapped); err != nil { - t.Fatal(err) - } - if _, err := loadFieldObjects(pack, common); err == nil { - t.Fatal("invalid unselected OPEN equipment branch accepted") - } -} - -func TestFieldResetUsesConfiguredBoundary(t *testing.T) { - s := FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday} - before := time.Date(2026, 10, 4, 23, 59, 59, 0, time.UTC) - after := before.Add(time.Second) - for _, r := range []int{0, 3} { - a, _ := s.Period(r, before) - b, _ := s.Period(r, after) - if a == b { - t.Fatalf("reset%d did not advance at official boundary %s", r, a) - } - } - s.DailyReset = 10 * time.Hour - a, _ := s.Period(0, after) - b, _ := s.Period(0, after.Add(time.Hour)) - if a == b { - t.Fatal("changed design reset ignored") - } - once, _ := s.Period(1, after) - if once != "once" { - t.Fatal(once) - } - if _, err := s.Period(2, after); err == nil { - t.Fatal("invented event reset") - } -} -func TestFieldWeightedDrawCountAndZeroWeight(t *testing.T) { - o := FieldRewardObject{DropCount: 2, Rewards: []BattleReward{{Type: 5, ID: 1, Count: 142}, {Type: 5, ID: 2, Count: 71}}, Ratios: []uint64{0, 100}} - for range 20 { - got, err := o.Draw() - if err != nil || len(got) != 2 || got[0].ID != 2 || got[1].ID != 2 { - t.Fatalf("draw=%+v err=%v", got, err) - } - } - o.DropType = 1 - o.DropCount = 0 - got, err := o.Draw() - if err != nil || len(got) != 1 || got[0].ID != 2 { - t.Fatalf("independent draw=%+v err=%v", got, err) - } -} - -func TestFieldDrawRejectsMalformedUnselectedBranch(t *testing.T) { - o := FieldRewardObject{DropCount: 1, Rewards: []BattleReward{{Type: 5, ID: 1, Count: 1}, {Type: 5, ID: 2}}, Ratios: []uint64{100, 0}} - if _, err := o.Draw(); err == nil { - t.Fatal("zero-weight malformed reward accepted") - } - o.Rewards[1].Count = 1 - o.DropType = 1 - o.Ratios[1] = 101 - if _, err := o.Draw(); err == nil { - t.Fatal("independent percentage above 100 accepted") - } -} - -func TestFieldIndependentDrawUsesEveryPercentageOnce(t *testing.T) { - o := FieldRewardObject{DropType: 1, DropCount: 99, Ratios: []uint64{0, 25, 25, 100}, Rewards: []BattleReward{{ID: 1, Count: 1}, {ID: 2, Count: 1}, {ID: 3, Count: 1}, {ID: 4, Count: 1}}} - values := []uint64{24, 25} - calls := 0 - got, err := o.draw(func(limit uint64) (uint64, error) { - if limit != 100 || calls >= len(values) { - t.Fatalf("unexpected draw limit=%d calls=%d", limit, calls) - } - v := values[calls] - calls++ - return v, nil - }) - if err != nil || calls != 2 || len(got) != 2 || got[0].ID != 2 || got[1].ID != 4 { - t.Fatalf("independent percentage boundary: got=%+v calls=%d err=%v", got, calls, err) - } -} diff --git a/go/internal/server/gamedata/field_pack_test.go b/go/internal/server/gamedata/field_pack_test.go deleted file mode 100644 index fca907a..0000000 --- a/go/internal/server/gamedata/field_pack_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package gamedata - -import ( - "database/sql" - "testing" - - "bd2server/internal/server/wire" -) - -func TestFieldPacksLoadMetadataMapsAndEntryRestrictions(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, query := range []string{"CREATE TABLE PackTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE MapTable(id INTEGER,packId INTEGER)"} { - if _, err := db.Exec(query); err != nil { - t.Fatal(err) - } - } - for _, row := range []struct{ id, typ uint64 }{{901, 3}, {902, 10}, {903, 0}} { - proto := wire.AppendVarint(wire.AppendVarint(nil, 25, row.id), 55, row.typ) - if row.id == 902 { - proto = wire.AppendVarint(proto, 7, 20) - proto = wire.AppendVarint(proto, 65, 1) - } - if _, err := db.Exec("INSERT INTO PackTable VALUES(?,?)", row.id, proto); err != nil { - t.Fatal(err) - } - } - opening := wire.AppendVarint(wire.AppendVarint(nil, 5, 6), 6, 555) - if _, err := db.Exec("INSERT INTO ContentOpenTable VALUES(1,901,?)", opening); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO MapTable VALUES(9011,901),(9012,901),(9021,902),(9031,903)"); err != nil { - t.Fatal(err) - } - packs, err := loadFieldPacks(db) - if err != nil { - t.Fatal(err) - } - first := packs[901] - if first.ID != 901 || first.Type != 3 || first.TicketID != 555 || first.SquadLevel != 6 || !first.HasOpenRule || !first.MapIDs[9011] || first.MapIDs[9031] { - t.Fatalf("arena metadata=%+v", first) - } - second := packs[902] - if second.BuyPrice != 20 || second.UseSchedule != 1 || len(second.MapIDs) != 1 { - t.Fatalf("other arena=%+v", second) - } - if _, found := packs[903]; found { - t.Fatal("story loaded as arena") - } - if _, err := db.Exec("DELETE FROM MapTable WHERE packId=902"); err != nil { - t.Fatal(err) - } - if _, err := loadFieldPacks(db); err == nil { - t.Fatal("arena without valid map accepted") - } -} diff --git a/go/internal/server/gamedata/field_research_test.go b/go/internal/server/gamedata/field_research_test.go deleted file mode 100644 index f3af459..0000000 --- a/go/internal/server/gamedata/field_research_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestInstalledFieldResearch23510(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - d, err := LoadFieldResearch(root, "20260923193640", 1) - if err != nil { - t.Fatal(err) - } - o := d.Objects[401] - if o.CollectionID != 103 || o.Type != 1 || o.Reward.Type != 4 || o.Reward.Count != 345 || len(o.Maps) != 1 || o.Maps[0] != 4 { - t.Fatalf("research table mismatch: %+v", o) - } - chars, err := LoadResearchCharacters(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - if len(chars) == 0 { - t.Fatal("research talents empty") - } -} diff --git a/go/internal/server/gamedata/field_settings_test.go b/go/internal/server/gamedata/field_settings_test.go deleted file mode 100644 index b875aa9..0000000 --- a/go/internal/server/gamedata/field_settings_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package gamedata - -import ( - "os" - "testing" - "time" -) - -func TestInstalledFieldSettingsCapacityAndDispatchReset(t *testing.T) { - root := "../../../../data/resources/GameData" - if _, e := os.Stat(root); e != nil { - t.Skip("installed data missing") - } - s, e := LoadFieldSettingsDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - if s.TalentSlots != 5 || len(s.CharacterTalentClass) == 0 || len(s.CharacterTemporaryPack) == 0 { - t.Fatalf("unexpected field settings design %+v", s) - } - shops, e := LoadNPCShopDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - if len(shops.ShopNPCs[1]) != 1 || shops.ShopNPCs[1][0] != 4 { - t.Fatal("pack1 interaction-3 binding changed", shops.ShopNPCs[1]) - } - d, e := LoadTalentDispatchDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - for _, row := range d { - if row.Seconds != 79200 { - t.Fatal("changed dispatch slider reference", row.ID, row.Seconds) - } - for _, now := range []time.Time{time.Date(2026, 10, 5, 23, 59, 59, 0, time.UTC), time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} { - end := row.EndTime(now) - if !end.After(now) || end.Hour() != 0 || end.Minute() != 0 || end.Second() != 0 { - t.Fatalf("invalid reset deadline %s %s", now, end) - } - } - } -} diff --git a/go/internal/server/gamedata/first_gacha_test.go b/go/internal/server/gamedata/first_gacha_test.go deleted file mode 100644 index efee228..0000000 --- a/go/internal/server/gamedata/first_gacha_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package gamedata - -import ( - "database/sql" - "os" - "strings" - "testing" - - "bd2server/internal/server/wire" -) - -func firstGachaTestProgram() *FirstGachaRewardGroup { - return &FirstGachaRewardGroup{ID: 77, DropCount: 1, DropType: 1, Entries: []FirstGachaRewardEntry{ - {ItemType: 11, ItemID: 5001, Count: 1, Weight: 1}, - {ItemType: 9, ItemID: 78, Count: 1, Weight: 1, Group: &FirstGachaRewardGroup{ID: 78, DropCount: 2, Entries: []FirstGachaRewardEntry{ - {ItemType: 10, ItemID: 1001, Count: 1, Weight: 25}, - {ItemType: 10, ItemID: 1002, Count: 1, Weight: 75}, - }}}, - }} -} - -func TestFirstGachaMixedProgramPreservesCountsAndBranchWeights(t *testing.T) { - design, err := NewFirstGachaDesign(GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 3, firstGachaTestProgram(), - map[uint64]CharacterDesign{5001: {ID: 500, HP: 100}}, - map[uint64]EquipmentDesign{1001: {ID: 1001, Grade: 2}, 1002: {ID: 1002, Grade: 3}}) - if err != nil { - t.Fatal(err) - } - values := []uint64{24, 25} - roll, err := design.roll(func(limit uint64) (uint64, error) { - if limit != 100 { - t.Fatalf("weight total=%d want=100", limit) - } - value := values[0] - values = values[1:] - return value, nil - }) - if err != nil || len(roll) != 3 || roll[0] != (FirstGachaReward{Type: 11, ID: 5001}) || roll[1].ID != 1001 || roll[2].ID != 1002 { - t.Fatalf("roll=%v err=%v", roll, err) - } - if len(design.CostumeCatalog().Gachas) != 0 { - t.Fatal("starter design exposed a zero-price ordinary product") - } - if _, ok := design.Character(5001); !ok { - t.Fatal("starter character metadata missing") - } -} - -func TestFirstGachaRejectsInvalidPrograms(t *testing.T) { - for _, kind := range []string{"cycle", "unsupported type", "mismatched weighted count", "zero weight", "excessive count"} { - t.Run(kind, func(t *testing.T) { - g := firstGachaTestProgram() - switch kind { - case "cycle": - g.Entries[1].ItemID = g.ID - g.Entries[1].Group = g - case "unsupported type": - g.Entries[0].ItemType = 8 - case "mismatched weighted count": - g.Entries[1].Group.Entries[0].Count = 2 - case "zero weight": - g.Entries[0].Weight = 0 - case "excessive count": - g.Entries[1].Group.DropCount = 101 - } - if _, err := firstGachaRewardCount(g, map[*FirstGachaRewardGroup]bool{}); err == nil { - t.Fatal("invalid mixed reward program accepted") - } - }) - } -} - -func TestFirstGachaLoaderRejectsNonStarterOrPricedMetadata(t *testing.T) { - for _, bad := range []string{"group subtype", "group type", "cash product", "scheduled", "priced", "ticket", "daily allowance"} { - t.Run(bad, func(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - db.SetMaxOpenConns(1) - for _, table := range []string{"GameDefaultTable", "GachaGroupTable", "GachaTable"} { - if _, err := db.Exec("CREATE TABLE " + table + " (id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - group := wire.AppendVarint(nil, 18, 2) - group = wire.AppendVarint(group, 16, 3) - group = wire.AppendVarint(group, 33, 20) - product := wire.AppendVarint(nil, 9, 20) - product = wire.AppendVarint(product, 5, 10) - product = wire.AppendVarint(product, 7, 20) - switch bad { - case "group subtype": - group, _, err = wire.ReplaceVarint(group, 16, 1) - case "group type": - group = wire.AppendVarint(group, 17, 1) - case "cash product": - group = wire.AppendVarint(group, 3, 123) - case "scheduled": - group = wire.AppendVarint(group, 28, 1) - case "priced": - product = wire.AppendVarint(product, 10, 200) - case "ticket": - product = wire.AppendVarint(product, 8, 450000) - case "daily allowance": - product = wire.AppendVarint(product, 4, 1) - } - if err != nil { - t.Fatal(err) - } - for _, row := range []struct { - table string - id uint64 - raw []byte - }{{"GameDefaultTable", 0, wire.AppendVarint(nil, 46, 2)}, {"GachaGroupTable", 2, group}, {"GachaTable", 20, product}} { - if _, err := db.Exec("INSERT INTO "+row.table+" VALUES (?,?)", row.id, row.raw); err != nil { - t.Fatal(err) - } - } - _, err = loadFirstGacha(db) - want := "unsupported field" - if bad == "ticket" { - want = "cannot consume tickets" - } - if err == nil || !strings.Contains(err.Error(), want) { - t.Fatalf("metadata was not rejected before reading rewards: err=%v want=%q", err, want) - } - }) - } -} - -func TestFirstGachaAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - design, err := LoadFirstGacha(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - if design.Group.ID != 2 || design.GachaID != 20 || design.Count != 10 || design.Group.PointCount != 0 || len(design.RewardGroup.Entries) != 6 { - t.Fatalf("design=%+v", design) - } - // Current GameData gives one five-star, one four-star, three three-star - // costumes and five exclusive-equipment instances in every reroll. - for range 20 { - roll, err := design.Roll() - if err != nil { - t.Fatal(err) - } - var costumes, equipment int - for _, reward := range roll { - switch reward.Type { - case 11: - costumes++ - if _, ok := design.Character(reward.ID); !ok { - t.Fatalf("unknown costume %d", reward.ID) - } - case 10: - equipment++ - if _, ok := design.EquipmentCatalog().equipment[reward.ID]; !ok { - t.Fatalf("unknown equipment %d", reward.ID) - } - default: - t.Fatalf("unexpected reward type %d", reward.Type) - } - } - if len(roll) != 10 || costumes != 5 || equipment != 5 { - t.Fatalf("mixed draw has costumes=%d equipment=%d results=%v", costumes, equipment, roll) - } - } -} diff --git a/go/internal/server/gamedata/food_test.go b/go/internal/server/gamedata/food_test.go deleted file mode 100644 index 0645bd9..0000000 --- a/go/internal/server/gamedata/food_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestFoodRecoveryUsesFavoriteReplacementAndFloatMidpointEven(t *testing.T) { - food := Food{Type: 1, Point: 10, FavoritePoint: 15, FavoriteUniqueCharIDs: []uint64{35}} - got, err := food.Recovery(350, 100, 2) - if err != nil || got != 30 { - t.Fatalf("favorite=%d err=%v", got, err) - } - got, err = food.Recovery(360, 100, 2) - if err != nil || got != 20 { - t.Fatalf("ordinary=%d err=%v", got, err) - } - for _, test := range []struct{ maximum, want uint64 }{{5, 2}, {7, 4}} { - got, err = (Food{Point: 50, RecoveryType: 1}).Recovery(350, test.maximum, 1) - if err != nil || got != test.want { - t.Fatalf("percentage max=%d got=%d err=%v", test.maximum, got, err) - } - } - for _, food := range []Food{{Type: 2, Point: 100}, {FoodBuffID: 101, Point: 100}, {RecoveryType: 2, Point: 100}, {}} { - if _, err := food.Recovery(350, 100, 1); err == nil { - t.Fatalf("accepted invalid food %+v", food) - } - } -} - -func TestFoodLoaderReadsFavoriteIDsAndProtoDefaults(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err = db.Exec("CREATE TABLE FoodTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - raw := wire.AppendVarint(nil, 7, 101) - raw = wire.AppendVarint(raw, 13, 10) - if _, err = db.Exec("INSERT INTO FoodTable VALUES(101,?)", raw); err != nil { - t.Fatal(err) - } - raw = wire.AppendVarint(nil, 7, 201) - for field, value := range map[int]uint64{1: 15, 2: 35, 4: 1, 13: 10} { - raw = wire.AppendVarint(raw, field, value) - } - if _, err = db.Exec("INSERT INTO FoodTable VALUES(201,?)", raw); err != nil { - t.Fatal(err) - } - design, err := loadFoodDesign(db) - if err != nil { - t.Fatal(err) - } - if design.Foods[101].Type != 0 || design.Foods[201].FavoritePoint != 15 || design.Foods[201].FavoriteUniqueCharIDs[0] != 35 { - t.Fatalf("design=%+v", design) - } -} diff --git a/go/internal/server/gamedata/friendship_test.go b/go/internal/server/gamedata/friendship_test.go deleted file mode 100644 index 6cee7e5..0000000 --- a/go/internal/server/gamedata/friendship_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package gamedata - -import ( - "database/sql" - "reflect" - "testing" - - "bd2server/internal/server/wire" -) - -func TestFriendshipDesignLoadsProtocolDefaultsAndIndependentIdentities(t *testing.T) { - db := friendshipTestDB(t) - d, err := loadFriendshipDesign(db) - if err != nil { - t.Fatal(err) - } - if d.Default.CorrectSelectDialogIndex != 0 || d.Default.IncorrectEXP != 80 || d.Default.MaxLevels != [3]uint64{2, 3, 4} || !reflect.DeepEqual(d.Default.CounselingRewards, []Reward{{Type: 3, Count: 100}}) { - t.Fatalf("defaults=%+v", d.Default) - } - if d.Costumes[901] != 902 { - t.Fatalf("costume mapping=%v", d.Costumes) - } - gift := d.Gifts[[2]uint64{8, 920}] - if gift.Experience(901) != 40 || gift.Experience(902) != 20 { - t.Fatalf("gift favorite uses wrong identity: %+v", gift) - } - if got := d.Gifts[[2]uint64{8, 921}].Experience(901); got != 150 { - t.Fatalf("universal experience=%d", got) - } - if session := d.Sessions[FriendshipKey{901, 7}]; session.DialogGroupID != 931 || session.ChoiceCount != 2 { - t.Fatalf("session=%+v", session) - } - if rewards := d.Levels[FriendshipKey{901, 3}].Rewards; !reflect.DeepEqual(rewards, []Reward{{Type: 47, ID: 950, Count: 1}}) { - t.Fatalf("level rewards=%+v", rewards) - } -} - -func TestFriendshipDesignRejectsPartialRewardsAndBrokenReferences(t *testing.T) { - for _, tc := range []struct { - name, query string - args []any - }{ - {"partial reward", "UPDATE FriendshipLevelTable SET ProtoBuf=? WHERE id=3", []any{friendshipTestProto(map[int][]uint64{1: {901}, 2: {3}, 3: {60}, 6: {47}})}}, - {"missing curve level", "DELETE FROM FriendshipLevelTable WHERE id=2", nil}, - {"missing choices", "DELETE FROM SelectDialogTable", nil}, - {"unknown favorite", "UPDATE FriendshipGiftTable SET ProtoBuf=? WHERE id=1", []any{friendshipTestProto(map[int][]uint64{1: {20}, 2: {40}, 3: {999}, 5: {1}, 6: {920}, 7: {8}})}}, - {"mismatched session identity", "UPDATE CounselingSessionTable SET ProtoBuf=?", []any{friendshipTestProto(map[int][]uint64{1: {901}, 2: {8}, 4: {931}})}}, - } { - t.Run(tc.name, func(t *testing.T) { - db := friendshipTestDB(t) - if _, err := db.Exec(tc.query, tc.args...); err != nil { - t.Fatal(err) - } - if _, err := loadFriendshipDesign(db); err == nil { - t.Fatal("invalid design accepted") - } - }) - } -} - -func friendshipTestProto(fields map[int][]uint64) []byte { - var proto []byte - for field, values := range fields { - for _, value := range values { - proto = wire.AppendVarint(proto, field, value) - } - } - return proto -} - -func friendshipTestDB(t *testing.T) *sql.DB { - t.Helper() - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - db.SetMaxOpenConns(1) - t.Cleanup(func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }) - for _, query := range []string{ - "CREATE TABLE FriendshipDefaultTable(id INTEGER,ProtoBuf BLOB)", - "CREATE TABLE FriendshipCostumeTable(id INTEGER,ProtoBuf BLOB)", - "CREATE TABLE FriendshipGiftTable(id INTEGER,ProtoBuf BLOB)", - "CREATE TABLE FriendshipLevelTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", - "CREATE TABLE CounselingSessionTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", - "CREATE TABLE VisualNovelDialogTable(groupId INTEGER,type INTEGER,selectDialogId INTEGER)", - "CREATE TABLE SelectDialogTable(id INTEGER,ProtoBuf BLOB)", - } { - if _, err := db.Exec(query); err != nil { - t.Fatal(err) - } - } - insert := func(query string, args ...any) { - t.Helper() - if _, err := db.Exec(query, args...); err != nil { - t.Fatal(err) - } - } - insert("INSERT INTO FriendshipDefaultTable VALUES(0,?)", friendshipTestProto(map[int][]uint64{1: {100}, 3: {100}, 5: {3}, 7: {2}, 8: {3}, 9: {4}, 12: {80}, 13: {3}, 14: {1}, 18: {5}})) - insert("INSERT INTO FriendshipCostumeTable VALUES(901,?)", friendshipTestProto(map[int][]uint64{1: {902}, 2: {901}})) - insert("INSERT INTO FriendshipGiftTable VALUES(1,?)", friendshipTestProto(map[int][]uint64{1: {20}, 2: {40}, 3: {901}, 5: {1}, 6: {920}, 7: {8}})) - insert("INSERT INTO FriendshipGiftTable VALUES(2,?)", friendshipTestProto(map[int][]uint64{2: {150}, 4: {1}, 5: {2}, 6: {921}, 7: {8}})) - for level := uint64(1); level <= 4; level++ { - fields := map[int][]uint64{1: {901}, 2: {level}} - if level < 4 { - fields[3] = []uint64{60} - } - if level == 3 { - fields[4] = []uint64{1} - fields[5] = []uint64{950} - fields[6] = []uint64{47} - } - insert("INSERT INTO FriendshipLevelTable VALUES(901,?,?)", level, friendshipTestProto(fields)) - } - insert("INSERT INTO CounselingSessionTable VALUES(901,7,?)", friendshipTestProto(map[int][]uint64{1: {901}, 2: {7}, 4: {931}})) - insert("INSERT INTO VisualNovelDialogTable VALUES(931,4,940)") - insert("INSERT INTO SelectDialogTable VALUES(940,?)", friendshipTestProto(map[int][]uint64{1: {960, 961}, 3: {940}})) - return db -} diff --git a/go/internal/server/gamedata/gacha_active_test.go b/go/internal/server/gamedata/gacha_active_test.go deleted file mode 100644 index 2a3f9cc..0000000 --- a/go/internal/server/gamedata/gacha_active_test.go +++ /dev/null @@ -1,222 +0,0 @@ -package gamedata - -import ( - "os" - "testing" - - "bd2server/internal/server/wire" -) - -func TestPermanentEquipmentGroupRequiresPityAndPoints(t *testing.T) { - // Content-ticket groups have both products too; those alone do not make - // a group an ordinary permanent equipment draw. - content := wire.AppendVarint(nil, 17, 2) - content = wire.AppendVarint(content, 24, 70100001) - content = wire.AppendVarint(content, 33, 71000001) - if isPermanentEquipmentGachaGroup(content) { - t.Fatal("content-ticket group classified as ordinary equipment") - } - ordinary := wire.AppendVarint(content, 10, 1) - ordinary = wire.AppendVarint(ordinary, 27, 1) - if !isPermanentEquipmentGachaGroup(ordinary) { - t.Fatal("permanent equipment group with shared pity and points omitted") - } - if isPermanentEquipmentGachaGroup(wire.AppendVarint(ordinary, 16, 1)) { - t.Fatal("equipment selection group classified as ordinary equipment") - } -} - -func TestSingleSidedCostumeFixedThresholdsDoNotTriggerZeroSide(t *testing.T) { - pool := []WeightedCostume{ - {Weight: 150, ID: 5001}, {Weight: 150, ID: 5002}, - {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001}, - } - gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: pool} - chooseThree := func(limit uint64) (uint64, error) { - if limit == officialRateScale { - return limit - 1, nil - } - return 0, nil - } - roll, state, err := gacha.rollWithCostumeFixed(0, 0, GachaFixedDesign{ID: 3, CostumeGrade5Count: 10}, nil, nil, chooseThree) - if err != nil || roll[0] != 3001 || state.CostumeGrade4Sort != -1 || state.CostumeGrade5Sort != -1 { - t.Fatalf("five-only fixed roll=%v state=%+v err=%v", roll, state, err) - } - roll, state, err = gacha.rollWithCostumeFixed(9, 500, GachaFixedDesign{ID: 5, CostumeGrade4Count: 10, ResetOnMatchingGrade: true}, nil, nil, chooseThree) - if err != nil || roll[0] != 4001 || state.CostumeGrade4Sort != 0 || state.CostumeGrade5Sort != -1 { - t.Fatalf("four-only fixed roll=%v state=%+v err=%v", roll, state, err) - } -} - -func TestCompositeCostumeRewardGroupExecutesAllChildren(t *testing.T) { - program := &CostumeRewardGroup{ID: 1, DropCount: 1, DropType: 1, Entries: []CostumeRewardEntry{ - {ItemType: 11, ItemID: 5001, Count: 1, Weight: 1}, - {ItemType: 9, ItemID: 2, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 2, DropCount: 9, Entries: []CostumeRewardEntry{ - {ItemType: 11, ItemID: 4001, Count: 1, Weight: 1400}, - {ItemType: 11, ItemID: 3001, Count: 1, Weight: 8300}, - }}}, - }} - if count, err := costumeRewardCount(program); err != nil || count != 10 { - t.Fatalf("count=%d err=%v", count, err) - } - gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 10, PriceType: 2, Price: 500, RewardGroup: program} - roll, err := gacha.rollRewardGroup(func(limit uint64) (uint64, error) { return 0, nil }) - if err != nil || len(roll) != 10 || roll[0] != 5001 { - t.Fatalf("roll=%v err=%v", roll, err) - } - for i := 1; i < len(roll); i++ { - if roll[i] != 4001 { - t.Fatalf("roll[%d]=%d", i, roll[i]) - } - } -} - -func TestMoonriseSpecialSelectionUsesThreeChoicesAndGameDataRemainder(t *testing.T) { - choices := make([]CostumeRewardEntry, 12) - selected := make([]uint64, 12) - for i := range choices { - selected[i] = uint64(5001 + i) - choices[i] = CostumeRewardEntry{ItemType: 11, ItemID: selected[i], Count: 1, Weight: 1} - } - gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 9100037, Count: 10, PriceType: 19, PriceID: 450030, Price: 1, RewardGroup: &CostumeRewardGroup{ - ID: 9100037, DropCount: 1, DropType: 1, Entries: []CostumeRewardEntry{ - {ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}}, - {ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []CostumeRewardEntry{ - {ItemType: 11, ItemID: 4001, Count: 1, Weight: 1443}, - {ItemType: 11, ItemID: 3001, Count: 1, Weight: 8557}, - }}}, - }, - }} - draws := []uint64{0, 1, 2, 0, 9999, 0, 9999, 0, 9999, 0} - roll, err := gacha.rollSpecialSelection(selected, func(limit uint64) (uint64, error) { - value := draws[0] - draws = draws[1:] - return value % limit, nil - }) - if err != nil { - t.Fatal(err) - } - if len(roll) != 10 || roll[0] != selected[0] || roll[1] != selected[1] || roll[2] != selected[2] { - t.Fatalf("selection results=%v", roll) - } - for _, id := range roll[3:] { - if id != 4001 && id != 3001 { - t.Fatalf("remainder contains non-GameData costume %d: %v", id, roll) - } - } -} - -func TestAddAndQueryStepUpDesign(t *testing.T) { - characters := map[uint64]CharacterDesign{5001: {ID: 500, HP: 100}} - catalog, err := fixtureRegularCatalog(map[uint64]RegularGacha{ - 10: {ID: 10, Count: 1, PriceType: 2, Price: 1, Pool: []WeightedCostume{{ID: 5001, Weight: 1}}}, - }, characters) - if err != nil { - t.Fatal(err) - } - design := GachaStepUpDesign{ID: 30, Steps: []GachaStepDesign{{Step: 1, GroupID: 20122, GachaID: 10, FixedID: 5, IsDisplayFixedItem: 1}}} - if err := catalog.AddStepUpDesign(design); err != nil { - t.Fatal(err) - } - step, ok := catalog.StepForGacha(10) - if !ok || step.GroupID != 20122 || step.Step != 1 { - t.Fatalf("step=%+v ok=%v", step, ok) - } - group, ok := catalog.StepUp(30) - if !ok || len(group.Steps) != 1 || len(catalog.StepUps()) != 1 { - t.Fatalf("group=%+v ok=%v", group, ok) - } -} - -func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - regular, equipment, err := LoadActiveGachaForSchedules(root, "20260923193640", - []uint64{166, 30010, 30011, 206, 205, 208, 153, 72, 71, 207, 1009}, []uint64{29, 30}) - if err != nil { - t.Fatal(err) - } - for _, id := range []uint64{101, 10100113, 11000113, 10100145, 11000145, 10100040, 11000040, 10100146, 11000146, 31, 9100037, 8100118, 8100119, 8100120, 8100121, 8100122, 8100123, 8100124, 8100125} { - if _, ok := regular.Gacha(id); !ok { - t.Errorf("costume gacha %d missing", id) - } - } - paid, ok := regular.Gacha(9100037) - if !ok || paid.PriceType != 19 || paid.PriceID != 450030 || paid.Price != 1 || paid.RewardGroup == nil { - t.Fatalf("paid selection gacha=%+v ok=%v", paid, ok) - } - if got := regular.SpecialSelectionCount(9100037); got != 3 || len(regular.SpecialSelectionIDs(9100037)) != 129 { - t.Fatalf("paid selection guaranteed=%d eligible=%d", got, len(regular.SpecialSelectionIDs(9100037))) - } - one, ok := regular.Gacha(10100113) - if !ok || one.Count != 1 || one.FreeCountDay != 1 || one.DailyPayGachaCount != 1 || one.DailyPayGachaPriceCount != 90 || one.Price != 200 { - t.Fatalf("daily single gacha=%+v ok=%v", one, ok) - } - moonriseGroup, ok := regular.Group(30011) - if !ok || moonriseGroup.BuyLimitCount != 10 || moonriseGroup.CashProductGroupID != 1500001 || moonriseGroup.CashProductID != 9100037 || moonriseGroup.SelectCount != 12 || moonriseGroup.SelectionChoiceRate != 100 { - t.Fatalf("moonrise group=%+v ok=%v", moonriseGroup, ok) - } - permanentSelection, ok := regular.Group(10001) - if !ok || permanentSelection.TenTimeGachaID != 101 || permanentSelection.SelectCount != 12 || permanentSelection.GachaSubType != 1 { - t.Fatalf("permanent selection group=%+v ok=%v", permanentSelection, ok) - } - newbie, ok := regular.Group(1009) - if !ok || newbie.TenTimeGachaID != 31 || newbie.BuyLimitCount != 30 || newbie.SelectCount != 3 || newbie.PointCount != 0 || newbie.FixedID != 3 || !newbie.UseSelectionOnlyFixedApply { - t.Fatalf("newbie group=%+v ok=%v", newbie, ok) - } - for _, id := range []uint64{8100121, 8100125} { - gacha, ok := regular.Gacha(id) - if !ok || gacha.FixedCostumeID == 0 || gacha.RewardGroup == nil || len(gacha.Pool) != 3 { - t.Fatalf("fixed step gacha %d=%+v ok=%v", id, gacha, ok) - } - } - for _, fixedID := range []uint64{1, 3, 5} { - if _, ok := regular.Fixed(fixedID); !ok { - t.Errorf("fixed %d missing", fixedID) - } - } - for _, groupID := range []uint64{29, 30} { - stepUp, ok := regular.StepUp(groupID) - if !ok || len(stepUp.Steps) != 4 { - t.Errorf("step-up %d=%+v ok=%v", groupID, stepUp, ok) - } - } - for _, id := range []uint64{200, 201, 20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} { - if _, ok := equipment.Gacha(id); !ok { - t.Errorf("equipment gacha %d missing", id) - } - } - permanentEquipment, ok := equipment.Group(10002) - if !ok || permanentEquipment.OneTimeGachaID != 200 || permanentEquipment.TenTimeGachaID != 201 || permanentEquipment.FixedID != 1 || permanentEquipment.PointCount != 1 { - t.Fatalf("permanent equipment group=%+v ok=%v", permanentEquipment, ok) - } - for _, id := range []uint64{200, 201} { - product, _ := equipment.Gacha(id) - if product.TicketOnly || product.PriceType != 3 || product.Price != uint64(product.Count)*200 || len(product.TicketIDs) != 1 || product.TicketIDs[0] != 1000 || len(product.Pool) != 8 { - t.Fatalf("permanent equipment product %d=%+v", id, product) - } - } - for _, groupID := range []uint64{1003, 1004, 1005, 1006} { - if _, ok := equipment.Group(groupID); ok { - t.Errorf("content-ticket equipment group %d became ordinary permanent draw", groupID) - } - } -} - -func TestLegacyRegularGachaLoaderAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - catalog, err := LoadRegularCostumeGachaGroups(root, "20260923193640", []uint64{10001, 135, 205, 121}, []uint64{29}) - if err != nil { - t.Fatal(err) - } - for _, id := range []uint64{100, 101, 10100084, 11000084, 10100145, 11000145, 10100072, 11000072, 8100118, 8100119, 8100120, 8100121} { - if _, ok := catalog.Gacha(id); !ok { - t.Errorf("legacy gacha %d missing", id) - } - } -} diff --git a/go/internal/server/gamedata/gacha_cash_test.go b/go/internal/server/gamedata/gacha_cash_test.go deleted file mode 100644 index 02bed50..0000000 --- a/go/internal/server/gamedata/gacha_cash_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestCashRewardsUseItemCountAndRejectRandomIndependentRolls(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - makeRaw := func(dropType, dropCount, count, ratio, typ uint64) []byte { - raw := wire.AppendVarint(nil, 1, dropCount) - raw = wire.AppendVarint(raw, 2, dropType) - for _, field := range []struct { - n int - v uint64 - }{{4, count}, {5, 9876}, {6, typ}, {8, ratio}} { - raw = wire.AppendVarint(raw, field.n, field.v) - } - return raw - } - for _, tc := range []struct { - id, typ, dropType, dropCount, count, ratio, want uint64 - valid bool - }{ - {1, 19, 0, 2, 7, 1, 14, true}, {2, 8, 1, 0, 7, 100, 7, true}, {3, 8, 1, 0, 7, 35, 0, false}, {4, 10, 0, 1, 1, 1, 0, false}, {5, 4, 0, 1, 1, 1, 0, false}, - } { - if _, err := db.Exec("INSERT INTO RewardGroupTable VALUES (?,?)", tc.id, makeRaw(tc.dropType, tc.dropCount, tc.count, tc.ratio, tc.typ)); err != nil { - t.Fatal(err) - } - got, err := deterministicGachaCashRewards(db, tc.id, map[uint64]bool{}) - if (err == nil) != tc.valid { - t.Fatalf("id=%d valid=%v err=%v", tc.id, tc.valid, err) - } - if tc.valid && (len(got) != 1 || got[0].Count != tc.want) { - t.Fatalf("id=%d count=%+v want=%d", tc.id, got, tc.want) - } - } -} - -func TestInfiniteRewardProgramDoesNotRequireEveryRarity(t *testing.T) { - d := &InfiniteGachaDesign{Count: 2, rewardProgram: &CostumeRewardGroup{ID: 1, DropCount: 2, Entries: []CostumeRewardEntry{{ItemType: 11, ItemID: 5001, Count: 1, Weight: 1}}}} - got, err := d.rollWith(func(uint64) (uint64, error) { return 0, nil }) - if err != nil || len(got) != 2 || got[0] != 5001 { - t.Fatalf("roll=%v err=%v", got, err) - } -} diff --git a/go/internal/server/gamedata/gacha_character_test.go b/go/internal/server/gamedata/gacha_character_test.go deleted file mode 100644 index 246c374..0000000 --- a/go/internal/server/gamedata/gacha_character_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestCostumeCharacterRelationshipDoesNotUseNumericPrefix(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE CostumeTable(id INTEGER,ProtoBuf BLOB);CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO CostumeTable VALUES (?,?)", 76543, wire.AppendVarint(nil, 27, 88)); err != nil { - t.Fatal(err) - } - for _, c := range []struct{ id, growth, temp uint64 }{{999, 1, 0}, {1000, 2, 0}, {222, 1, 4}} { - raw := wire.AppendVarint(wire.AppendVarint(nil, 10, c.growth), 21, c.temp) - if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?,?)", c.id, 88, raw); err != nil { - t.Fatal(err) - } - } - got, err := loadCostumeBaseCharacterID(db, 76543) - if err != nil || got != 999 { - t.Fatalf("character=%d err=%v", got, err) - } - base, family, err := loadCostumeCharacterFamily(db, 76543) - if err != nil || base != 999 || len(family) != 2 { - t.Fatalf("base=%d family=%v err=%v", base, family, err) - } - seen := map[uint64]bool{} - for _, id := range family { - seen[id] = true - } - if !seen[999] || !seen[1000] || seen[222] { - t.Fatalf("promotion family=%v", family) - } - if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?,?)", 777, 88, wire.AppendVarint(nil, 10, 1)); err != nil { - t.Fatal(err) - } - if _, err := loadCostumeBaseCharacterID(db, 76543); err == nil { - t.Fatal("ambiguous base guessed") - } -} diff --git a/go/internal/server/gamedata/gacha_fixed_test.go b/go/internal/server/gamedata/gacha_fixed_test.go deleted file mode 100644 index 34b4bc1..0000000 --- a/go/internal/server/gamedata/gacha_fixed_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package gamedata - -import "testing" - -func TestCostumeGuaranteedFourAndFiveCountsAcrossOneAndTen(t *testing.T) { - pool := []WeightedCostume{ - {Weight: 150, Children: []WeightedCostume{{ID: 5001, Weight: 1}}}, - {Weight: 150, Children: []WeightedCostume{{ID: 5002, Weight: 1}}}, - {Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}}, - {Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}}, - } - fixed := GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true} - chooseThree := func(limit uint64) (uint64, error) { - if limit == 10000 { - return 9999, nil - } - return 0, nil - } - // The first of a ten-pull hits the shared four-star boundary after nine - // unsuccessful singles, and the next nine slots continue the new count. - gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 10, PriceType: 3, Price: 2000, Pool: pool} - roll, state, err := gacha.rollWithCostumeFixed(9, 25, fixed, nil, nil, chooseThree) - if err != nil { - t.Fatal(err) - } - if len(roll) != 10 || roll[0] != 4001 || state.CostumeGrade4Sort != 0 || state.CostumeGrade4Count != 9 || state.CostumeGrade5Count != 35 || state.CostumeGrade5Sort != -1 { - t.Fatalf("four-star boundary roll=%v state=%+v", roll, state) - } - // When both boundaries coincide, the five-star guarantee has priority; - // it resets both counters and is sampled from the genuine 50/50 5-star - // branches, never from a synthetic hardcoded character. - roll, state, err = gacha.rollWithCostumeFixed(9, 99, fixed, nil, nil, chooseThree) - if err != nil { - t.Fatal(err) - } - if roll[0] != 5001 || state.CostumeGrade5Sort != 0 || state.CostumeGrade4Sort != -1 || state.CostumeGrade4Count != 9 || state.CostumeGrade5Count != 9 { - t.Fatalf("five-star priority roll=%v state=%+v", roll, state) - } - // Natural five-stars reset both counters without falsely reporting a - // guaranteed slot in GachaFixedDBInfo.ApplySortId. - gacha.Count = 1 - chooseFive := func(limit uint64) (uint64, error) { return 0, nil } - roll, state, err = gacha.rollWithCostumeFixed(8, 98, fixed, nil, nil, chooseFive) - if err != nil || roll[0] != 5001 || state.CostumeGrade4Count != 0 || state.CostumeGrade5Count != 0 || state.CostumeGrade4Sort != -1 || state.CostumeGrade5Sort != -1 { - t.Fatalf("natural five-star roll=%v state=%+v err=%v", roll, state, err) - } -} - -func TestTwelvePickSelectionAppliesToNaturalAndPityFiveStar(t *testing.T) { - pool := []WeightedCostume{ - {Weight: 300, Children: []WeightedCostume{{ID: 5001, Weight: 1}}}, - {Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}}, - {Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}}, - } - gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 101, Count: 1, PriceType: 3, Price: 200, Pool: pool} - fixed := GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true} - selected := []uint64{5012, 5013} - chooseFirst := func(limit uint64) (uint64, error) { return 0, nil } - roll, state, err := gacha.rollWithCostumeFixed(0, 0, fixed, selected, selected, chooseFirst) - if err != nil || len(roll) != 1 || roll[0] != 5012 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 0 || state.CostumeGrade5Sort != -1 { - t.Fatalf("ordinary selected five roll=%v state=%+v err=%v", roll, state, err) - } - roll, state, err = gacha.rollWithCostumeFixed(9, 99, fixed, selected, selected, chooseFirst) - if err != nil || len(roll) != 1 || roll[0] != 5012 || state.CostumeGrade5Sort != 0 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 0 { - t.Fatalf("guaranteed selected five roll=%v state=%+v err=%v", roll, state, err) - } - chooseThree := func(limit uint64) (uint64, error) { - if limit == 10000 { - return 9999, nil - } - return 0, nil - } - roll, state, err = gacha.rollWithCostumeFixed(0, 0, fixed, selected, selected, chooseThree) - if err != nil || roll[0] != 3001 || len(state.SelectionSorts) != 0 || state.CostumeGrade4Count != 1 || state.CostumeGrade5Count != 1 { - t.Fatalf("ordinary three roll=%v state=%+v err=%v", roll, state, err) - } -} diff --git a/go/internal/server/gamedata/gacha_grades_test.go b/go/internal/server/gamedata/gacha_grades_test.go deleted file mode 100644 index 6a3f979..0000000 --- a/go/internal/server/gamedata/gacha_grades_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package gamedata - -import "testing" - -func TestNonResettingFixedGuaranteeKeepsNaturalFiveStarProgress(t *testing.T) { - g := RegularGacha{Count: 10, Grades: map[uint64]uint64{55: 5}, Pool: []WeightedCostume{{ID: 55, Weight: 1}}} - _, state, err := g.rollWithCostumeFixed(0, 0, GachaFixedDesign{ID: 3, CostumeGrade5Count: 10}, nil, []uint64{55}, func(uint64) (uint64, error) { return 0, nil }) - if err != nil || state.CostumeGrade5Sort != 9 || state.CostumeGrade5Count != 0 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 9 { - t.Fatalf("state=%+v err=%v", state, err) - } -} - -func TestCostumeFixedUsesActualGradesAcrossMixedAndReorderedBranches(t *testing.T) { - g := RegularGacha{Count: 1, Grades: map[uint64]uint64{99: 3, 77: 5, 66: 4, 55: 5}, Pool: []WeightedCostume{ - {Weight: 10, Children: []WeightedCostume{{ID: 99, Weight: 3}, {ID: 77, Weight: 1}}}, - {ID: 66, Weight: 7}, {ID: 55, Weight: 5}, - }} - fixed := GachaFixedDesign{ResetOnMatchingGrade: true, ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100} - first := func(uint64) (uint64, error) { return 0, nil } - roll, state, err := g.rollWithCostumeFixed(9, 99, fixed, nil, nil, first) - if err != nil || roll[0] != 55 || state.CostumeGrade5Count != 0 { - t.Fatalf("five=%v state=%+v err=%v", roll, state, err) - } - roll, state, err = g.rollWithCostumeFixed(9, 0, fixed, nil, nil, first) - if err != nil || roll[0] != 66 || state.CostumeGrade5Count != 1 { - t.Fatalf("four=%v state=%+v err=%v", roll, state, err) - } - // Last branch is five-star despite the historical assumption of grade three. - last := func(limit uint64) (uint64, error) { return limit - 1, nil } - roll, state, err = g.rollWithCostumeFixed(2, 3, fixed, nil, nil, last) - if err != nil || roll[0] != 55 || state.CostumeGrade4Count != 0 || state.CostumeGrade5Count != 0 { - t.Fatalf("natural=%v state=%+v err=%v", roll, state, err) - } - pool, err := g.gradePool(5) - if err != nil || len(pool) != 2 || pool[0].ID != 55 || pool[0].Weight != 10 || pool[1].ID != 77 || pool[1].Weight != 5 { - t.Fatalf("conditioned=%+v err=%v", pool, err) - } -} - -func TestTenDrawGradeFourGuaranteeWorksWithTwoReorderedBranches(t *testing.T) { - g := RegularGacha{Count: 10, Grades: map[uint64]uint64{99: 3, 66: 4}, Pool: []WeightedCostume{{ID: 99, Weight: 9}, {ID: 66, Weight: 1}}} - roll, err := g.rollWith(func(uint64) (uint64, error) { return 0, nil }) - if err != nil || roll[9] != 66 { - t.Fatalf("roll=%v err=%v", roll, err) - } -} diff --git a/go/internal/server/gamedata/gacha_probability_test.go b/go/internal/server/gamedata/gacha_probability_test.go deleted file mode 100644 index e566bc8..0000000 --- a/go/internal/server/gamedata/gacha_probability_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package gamedata - -import "testing" - -func TestInfiniteGachaOfficialRateBoundariesAndFiveStarGuarantee(t *testing.T) { - characters := map[uint64]CharacterDesign{ - 5001: {ID: 500, HP: 100}, - 4001: {ID: 400, HP: 100}, - 3001: {ID: 300, HP: 100}, - } - design, err := NewInfiniteGachaDesignWithRates(6, []uint64{5001}, []uint64{4001}, []uint64{3001}, characters) - if err != nil { - t.Fatal(err) - } - // The first five pairs are rate roll + pool index. They cover both - // boundaries of 3%, the 14% interval and the start of the 83% interval. - // The final value chooses the guaranteed five-star slot. - draws := []uint64{0, 0, 299, 0, 300, 0, 1699, 0, 1700, 0, 0} - position := 0 - roll, err := design.rollWith(func(limit uint64) (uint64, error) { - if position >= len(draws) { - t.Fatal("unexpected extra random draw") - } - value := draws[position] - position++ - if value >= limit { - t.Fatalf("draw %d exceeds limit %d", value, limit) - } - return value, nil - }) - if err != nil { - t.Fatal(err) - } - want := []uint64{5001, 5001, 4001, 4001, 3001, 5001} - for i := range want { - if roll[i] != want[i] { - t.Fatalf("slot %d=%d want=%d; roll=%v", i, roll[i], want[i], roll) - } - } - if position != len(draws) { - t.Fatalf("used %d random draws, want %d", position, len(draws)) - } -} - -func TestOfficialPickupRateDefinition(t *testing.T) { - pool := []WeightedCostume{ - {Weight: 150, ID: 1}, - {Weight: 150, ID: 2}, - {Weight: 1400, ID: 3}, - {Weight: 8300, ID: 4}, - } - if err := validateOfficialPickupRates(pool); err != nil { - t.Fatal(err) - } - pool[0].Weight++ - if err := validateOfficialPickupRates(pool); err == nil { - t.Fatal("accepted a pickup rate different from official GameData") - } -} - -func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T) { - gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, Count: 10, Pool: []WeightedCostume{ - {Weight: 150, ID: 5001}, {Weight: 150, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001}, - }} - roll, err := gacha.rollWith(func(limit uint64) (uint64, error) { - if limit == officialRateScale { - return 9999, nil - } - return 0, nil - }) - if err != nil { - t.Fatal(err) - } - for i := range 9 { - if roll[i] != 3001 { - t.Fatalf("slot %d=%d want grade-3", i, roll[i]) - } - } - if roll[9] != 4001 { - t.Fatalf("guaranteed slot=%d want grade-4; roll=%v", roll[9], roll) - } -} - -func TestFixedPickupTenPullAlwaysStartsWithPickupCostume(t *testing.T) { - gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, Count: 10, FixedCostumeID: 21201, Pool: []WeightedCostume{ - {Weight: 300, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001}, - }} - roll, err := gacha.rollWith(func(limit uint64) (uint64, error) { return limit - 1, nil }) - if err != nil { - t.Fatal(err) - } - if len(roll) != 10 || roll[0] != 21201 { - t.Fatalf("roll=%v", roll) - } - for i := 1; i < 10; i++ { - if roll[i] != 3001 { - t.Fatalf("slot %d=%d want grade-3", i, roll[i]) - } - } -} diff --git a/go/internal/server/gamedata/inn_test.go b/go/internal/server/gamedata/inn_test.go deleted file mode 100644 index f458324..0000000 --- a/go/internal/server/gamedata/inn_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestInnLoaderJoinsNPCRecoveryMapAndReputation(t *testing.T) { - pack, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := pack.Close(); err != nil { - t.Error(err) - } - }() - shared, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := shared.Close(); err != nil { - t.Error(err) - } - }() - for _, table := range []string{"FieldNpcTable", "ReputationGroupTable"} { - if _, err = pack.Exec("CREATE TABLE " + table + " (id INTEGER, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - for _, table := range []string{"CharRecoveryTable", "MapTable"} { - if _, err = shared.Exec("CREATE TABLE " + table + " (id INTEGER, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - npc := wire.AppendBytes(nil, 9, []byte{2}) - npc = wire.AppendVarint(npc, 14, 88) - npc = wire.AppendVarint(npc, 21, 4) - if _, err = pack.Exec("INSERT INTO FieldNpcTable VALUES(?,?)", 7, npc); err != nil { - t.Fatal(err) - } - if _, err = pack.Exec("INSERT INTO ReputationGroupTable VALUES(?,?)", 3, wire.AppendVarint(nil, 2, 15)); err != nil { - t.Fatal(err) - } - if _, err = shared.Exec("INSERT INTO MapTable VALUES(?,?)", 88, wire.AppendVarint(nil, 18, 3)); err != nil { - t.Fatal(err) - } - recovery := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 3, 2), 4, 4), 5, 20) - if _, err = shared.Exec("INSERT INTO CharRecoveryTable VALUES(?,?)", 4, recovery); err != nil { - t.Fatal(err) - } - rules, err := loadInns(pack, shared) - if err != nil || len(rules) != 1 || rules[0] != (InnRule{NPCID: 7, MapID: 88, MapGroup: 3, Currency: 4, ItemCount: 2, FreeSquadLevel: 20, GoodDiscount: 15}) { - t.Fatalf("joined inn %+v: %v", rules, err) - } - if _, err = shared.Exec("DELETE FROM CharRecoveryTable"); err != nil { - t.Fatal(err) - } - if _, err = loadInns(pack, shared); err == nil { - t.Fatal("missing recovery foreign key accepted") - } -} diff --git a/go/internal/server/gamedata/inventory_slots_test.go b/go/internal/server/gamedata/inventory_slots_test.go deleted file mode 100644 index 8c898ea..0000000 --- a/go/internal/server/gamedata/inventory_slots_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package gamedata - -import ( - "database/sql" - "testing" - - "bd2server/internal/server/wire" - _ "modernc.org/sqlite" -) - -func TestLoadInventorySlotDesignUsesGameDefaultFields(t *testing.T) { - db, err := sql.Open("sqlite", "file::memory:?cache=shared") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE GameDefaultTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - var raw []byte - for field, value := range map[int]uint64{ - 9: 300, 10: 4, 11: 300, 12: 4, 13: 300, 14: 4, 15: 300, 16: 4, - 30: 500, 31: 100, 34: 100, 37: 100, 73: 2000, 74: 400, 79: 10000, 80: 500, 83: 400, - } { - raw = wire.AppendVarint(raw, field, value) - } - if _, err := db.Exec("INSERT INTO GameDefaultTable(id,ProtoBuf) VALUES(0,?)", raw); err != nil { - t.Fatal(err) - } - design, err := loadInventorySlotDesign(db) - if err != nil { - t.Fatal(err) - } - if design.Items.Default != 100 || design.Items.Maximum != 500 || design.Equipment.Default != 500 || design.Equipment.Maximum != 2000 || design.Storage.Maximum != 400 || design.EquipmentStorage.Maximum != 400 { - t.Fatalf("design=%+v", design) - } -} diff --git a/go/internal/server/gamedata/legacy_probability_test.go b/go/internal/server/gamedata/legacy_probability_test.go deleted file mode 100644 index a210ddc..0000000 --- a/go/internal/server/gamedata/legacy_probability_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package gamedata - -import "fmt" - -// validateOfficialPickupRates prevents a GameData/schema regression from -// silently changing the published costume pickup rates. The two five-star -// branches are pickup 1.5% plus the ordinary five-star pool 1.5%. -func validateOfficialPickupRates(pool []WeightedCostume) error { - if len(pool) != 4 { - return fmt.Errorf("expected four rarity branches, got %d", len(pool)) - } - want := [...]uint64{150, 150, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate} - for i, item := range pool { - if item.Weight != want[i] { - return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i]) - } - } - return nil -} - -func fixtureRegularCatalog(gachas map[uint64]RegularGacha, characters map[uint64]CharacterDesign) (*RegularGachaCatalog, error) { - for id, g := range gachas { - if len(g.Grades) == 0 { - g.Grades = map[uint64]uint64{} - var collect func([]WeightedCostume, uint64) - collect = func(p []WeightedCostume, grade uint64) { - for _, w := range p { - if w.ID != 0 { - g.Grades[w.ID] = grade - } else { - collect(w.Children, grade) - } - } - } - for i, w := range g.Pool { - grade := uint64(5 - i) - if len(g.Pool) == 4 { - grade = uint64(6 - i) - if i == 0 { - grade = 5 - } - } - collect([]WeightedCostume{w}, grade) - } - if g.FixedCostumeID != 0 { - g.Grades[g.FixedCostumeID] = 5 - } - gachas[id] = g - } - } - return NewRegularGachaCatalog(gachas, characters) -} diff --git a/go/internal/server/gamedata/limited_costume_test.go b/go/internal/server/gamedata/limited_costume_test.go deleted file mode 100644 index 43eb469..0000000 --- a/go/internal/server/gamedata/limited_costume_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package gamedata - -import ( - "os" - "testing" - "time" -) - -func TestRealLimitedCostumesReleasesRowsBeforeCharacterQueries(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - type result struct { - catalog *LimitedCostumeCatalog - err error - } - done := make(chan result, 1) - go func() { c, e := LoadLimitedCostumes(root, "20260923193640"); done <- result{c, e} }() - select { - case r := <-done: - if r.err != nil { - t.Fatal(r.err) - } - ids := r.catalog.IDs() - if len(ids) == 0 { - t.Fatal("empty limited costume catalog") - } - for i, id := range ids { - if i > 0 && ids[i-1] >= id { - t.Fatal("unstable/duplicate membership order") - } - design, ok := r.catalog.Character(id) - if !ok || design.ID == 0 { - t.Fatalf("limited costume %d missing character design", id) - } - } - case <-time.After(15 * time.Second): - t.Fatal("limited costume nested queries blocked on retained result rows") - } -} diff --git a/go/internal/server/gamedata/monster_hunt_test.go b/go/internal/server/gamedata/monster_hunt_test.go deleted file mode 100644 index 80e84a0..0000000 --- a/go/internal/server/gamedata/monster_hunt_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestMonsterHuntHPStageBoundariesAndSignificantDigits(t *testing.T) { - d := &MonsterHunt{MaxLevel: 30, baseHP: 6000, healthRate: 400, healthSlope: 2.6, stage2Level: 12, stage2Ratio: 1.02, stage3Level: 14, stage3Ratio: .86} - // Independently evaluated client formula at the two strict greater-than - // stage boundaries; truncation keeps three significant decimal digits. - for level, want := range map[uint64]uint64{1: 6000, 2: 151000, 12: 168000000, 13: 231000000, 14: 303000000, 15: 330000000} { - got, err := d.HP(level) - if err != nil || got != want { - t.Fatalf("level %d hp=%d err=%v want=%d", level, got, err, want) - } - } - if _, err := d.HP(0); err == nil { - t.Fatal("zero level accepted") - } - if _, err := d.HP(31); err == nil { - t.Fatal("outside design level accepted") - } -} -func TestMonsterHuntInstalledDesign23510(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - d, err := LoadMonsterHunt(root, "20260923193640", 1) - if err != nil { - t.Fatal(err) - } - if d.PackID != 1001 || d.DeckID != 10001 || d.MaxLevel != 30 || len(d.Rewards) != 30 { - t.Fatalf("unexpected installed design %+v", d) - } - hp, err := d.HP(1) - if err != nil || hp != 6600 { - t.Fatalf("base HP %d %v", hp, err) - } - r := d.Rewards[1] - if len(r.Clear) != 1 || r.Clear[0].Type != 8 || r.Clear[0].Count != 5 || len(r.Daily) != 1 || r.Daily[0].Type != 4 || r.Daily[0].Count != 20000 { - t.Fatalf("unexpected rewards %+v", r) - } - p, err := LoadMonsterHuntPresetDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - if p.BaseCount != 5 || p.Maximum != 10 || p.Price != 2000 || p.PriceType != 4 { - t.Fatalf("unexpected hunt presets %+v", p) - } -} diff --git a/go/internal/server/gamedata/npc_shop_test.go b/go/internal/server/gamedata/npc_shop_test.go deleted file mode 100644 index 2475be6..0000000 --- a/go/internal/server/gamedata/npc_shop_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestNPCShopCatalogKeepsCompositeProductKeysAndRejectsOrphans(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, query := range []string{"CREATE TABLE ShopTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ProductTable(id INTEGER,groupId INTEGER,ProtoBuf BLOB)", "CREATE TABLE SellItemTable(id INTEGER,ProtoBuf BLOB)"} { - if _, err = db.Exec(query); err != nil { - t.Fatal(err) - } - } - for _, shop := range []uint64{37, 48} { - p := wire.AppendVarint(wire.AppendVarint(nil, 5, shop), 8, 91) - if _, err = db.Exec("INSERT INTO ShopTable VALUES(?,?)", shop, p); err != nil { - t.Fatal(err) - } - p = nil - for _, f := range [][2]uint64{{7, 2}, {6, shop}, {3, 3}, {5, 5}, {4, 400}, {10, 25}, {12, 4}} { - p = wire.AppendVarint(p, int(f[0]), f[1]) - } - if _, err = db.Exec("INSERT INTO ProductTable VALUES(?,?,?)", 2, shop, p); err != nil { - t.Fatal(err) - } - } - d, err := loadNPCShopDesign(db) - if err != nil || len(d.Products) != 2 || d.Products[37][2].Reward.Count != 3 || d.Products[48][2].Price.Count != 25 { - t.Fatalf("lost composite product keys: %+v %v", d, err) - } - if _, err = db.Exec("DELETE FROM ShopTable WHERE id=48"); err != nil { - t.Fatal(err) - } - if _, err = loadNPCShopDesign(db); err == nil { - t.Fatal("accepted product whose shop is missing") - } -} - -func TestSellCatalogUsesTypesAndRejectsAmbiguousDefinitions(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, q := range []string{"CREATE TABLE ShopTable(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE ProductTable(id INTEGER,groupId INTEGER,ProtoBuf BLOB)", "CREATE TABLE SellItemTable(id INTEGER,ProtoBuf BLOB)"} { - if _, err = db.Exec(q); err != nil { - t.Fatal(err) - } - } - insert := func(id, typ, item uint64) { - t.Helper() - var p []byte - for _, f := range [][2]uint64{{8, id}, {4, typ}, {3, item}, {2, 1}, {13, 4}, {11, 7}} { - p = wire.AppendVarint(p, int(f[0]), f[1]) - } - if _, err = db.Exec("INSERT INTO SellItemTable VALUES(?,?)", id, p); err != nil { - t.Fatal(err) - } - } - for i, typ := range []uint64{5, 8, 10} { - insert(uint64(i+1), typ, 400) - } - d, err := loadNPCShopDesign(db) - if err != nil || len(d.Sell) != 3 || !d.Sell[1].InventorySellable() || !d.Sell[2].InventorySellable() || d.Sell[3].InventorySellable() { - t.Fatal("sell definition type boundaries", d, err) - } - insert(4, 8, 400) - if _, err = loadNPCShopDesign(db); err == nil { - t.Fatal("accepted ambiguous type/item price definition") - } -} diff --git a/go/internal/server/gamedata/pack_detail_test.go b/go/internal/server/gamedata/pack_detail_test.go deleted file mode 100644 index ae068e8..0000000 --- a/go/internal/server/gamedata/pack_detail_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestPackDetailDesignOnlyIncludesClientRegenRewardCategory(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE FieldMonsterTable(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - for _, r := range []struct{ id, regen, typ, skip int }{{1, 0, 0, 0}, {2, 1, 0, 0}, {3, 0, 0, 1}, {4, 1, 3, 1}, {5, 1, 0, 1}, {6, 1, 1, 1}, {7, 1, 0, 2}} { - raw := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 24, uint64(r.regen)), 30, uint64(r.typ)), 31, uint64(r.skip)) - if _, err := db.Exec("INSERT INTO FieldMonsterTable VALUES(?,?)", r.id, raw); err != nil { - t.Fatal(err) - } - } - design, err := loadPackDetailDesign(db) - if err != nil || len(design.RegenMonsterIDs) != 2 || design.RegenMonsterIDs[0] != 5 || design.RegenMonsterIDs[1] != 6 { - t.Fatalf("design=%+v err=%v", design, err) - } - if _, err := db.Exec("INSERT INTO FieldMonsterTable VALUES(8,?)", wire.AppendVarint(wire.AppendVarint(nil, 24, 1), 24, 2)); err != nil { - t.Fatal(err) - } - if _, err := loadPackDetailDesign(db); err == nil { - t.Fatal("duplicate scalar accepted") - } -} diff --git a/go/internal/server/gamedata/pack_jam_test.go b/go/internal/server/gamedata/pack_jam_test.go deleted file mode 100644 index 5f89225..0000000 --- a/go/internal/server/gamedata/pack_jam_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestPackJamDesignLoadsDefaultsAndRejectsUnsupportedRewards(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE PackJamEventTable(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - raw := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 5), 3, 5), 4, 150), 6, 3) - if _, err := db.Exec("INSERT INTO PackJamEventTable VALUES(0,?)", raw); err != nil { - t.Fatal(err) - } - design, err := loadPackJamDesign(db) - if err != nil || design.InsertMin != 5 || design.InsertMax != 5 || design.Reward.ID != 0 || design.Reward.Type != 3 || design.Reward.Count != 150 { - t.Fatalf("design=%+v err=%v", design, err) - } - raw = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 5), 3, 5), 4, 150), 6, 8) - if _, err := db.Exec("UPDATE PackJamEventTable SET ProtoBuf=?", raw); err != nil { - t.Fatal(err) - } - if _, err := loadPackJamDesign(db); err == nil { - t.Fatal("unsupported currency accepted") - } -} diff --git a/go/internal/server/gamedata/pack_recovery_test.go b/go/internal/server/gamedata/pack_recovery_test.go deleted file mode 100644 index b8fe991..0000000 --- a/go/internal/server/gamedata/pack_recovery_test.go +++ /dev/null @@ -1,15 +0,0 @@ -package gamedata - -import "testing" - -func TestPackRecoveryUsesPackTypesAndCompletion(t *testing.T) { - p := &PackRecoveryPolicy{Types: map[int]uint64{99: 0, 101: 1, 303: 4, 909: 6, 707: 1000}} - for _, v := range []struct { - pack int - complete, want bool - }{{99, false, true}, {101, false, false}, {101, true, true}, {303, true, false}, {909, false, false}, {909, true, true}, {707, false, true}, {123, true, false}} { - if got := p.Allowed(v.pack, v.complete); got != v.want { - t.Fatalf("pack%d complete%v got%v", v.pack, v.complete, got) - } - } -} diff --git a/go/internal/server/gamedata/pack_summary_test.go b/go/internal/server/gamedata/pack_summary_test.go deleted file mode 100644 index 5827a03..0000000 --- a/go/internal/server/gamedata/pack_summary_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestPackSummaryTargetsMatchClientCategories(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE PackTable(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - // Client targets: story, character, evil castle, event, square, master story. - targetTypes := map[int]bool{0: true, 1: true, 4: true, 6: true, 11: true, 1000: true} - for _, typ := range []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 100, 1000} { - if _, err := db.Exec("INSERT INTO PackTable VALUES(?,?)", typ+1, wire.AppendVarint(nil, 55, uint64(typ))); err != nil { - t.Fatal(err) - } - } - targets, err := loadPackSummaryTargets(db) - if err != nil { - t.Fatal(err) - } - if len(targets) != len(targetTypes) { - t.Fatalf("unexpected targets %v", targets) - } - for typ := range targetTypes { - if !targets[typ+1] { - t.Fatalf("missing client target type %d", typ) - } - } - if _, err := db.Exec("INSERT INTO PackTable VALUES(2000,?)", wire.AppendVarint(wire.AppendVarint(nil, 55, 1), 55, 2)); err != nil { - t.Fatal(err) - } - if _, err := loadPackSummaryTargets(db); err == nil { - t.Fatal("duplicate scalar pack type accepted") - } -} diff --git a/go/internal/server/gamedata/preset_test.go b/go/internal/server/gamedata/preset_test.go deleted file mode 100644 index 4b5d54c..0000000 --- a/go/internal/server/gamedata/preset_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestPresetDesignReadsChangedLimitsAndPrice(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err = db.Exec("CREATE TABLE GameDefaultTable(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - if _, err = db.Exec("CREATE TABLE PresetTable(id INTEGER)"); err != nil { - t.Fatal(err) - } - if _, err = db.Exec("INSERT INTO PresetTable VALUES(79)"); err != nil { - t.Fatal(err) - } - var raw []byte - for field, value := range map[int]uint64{91: 3, 92: 73, 94: 3, 95: 9} { - raw = wire.AppendVarint(raw, field, value) - } - if _, err = db.Exec("INSERT INTO GameDefaultTable VALUES(0,?)", raw); err != nil { - t.Fatal(err) - } - got, err := loadPresetDesign(db) - if err != nil || got.BaseCount != 3 || got.Maximum != 9 || got.Price != 73 || got.PriceType != 3 || !got.Icons[79] { - t.Fatalf("design=%+v err=%v", got, err) - } -} diff --git a/go/internal/server/gamedata/promotion_test.go b/go/internal/server/gamedata/promotion_test.go deleted file mode 100644 index 4ff8a52..0000000 --- a/go/internal/server/gamedata/promotion_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package gamedata - -import ( - "database/sql" - "encoding/binary" - "os" - "testing" -) - -func TestCharacterGrowthPromotionsAgainstInstalledVersion23510(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - submitted := []PromotionCost{ - {Type: 8, ID: 9, Count: 753}, - {Type: 8, ID: 11, Count: 1}, - {Type: 8, ID: 12, Count: 2}, - {Type: 8, ID: 13, Count: 3}, - {Type: 8, ID: 14, Count: 4}, - {Type: 4, Count: 10000}, - } - result, err := CharacterGrowthPromotions(root, "20260923193640", 6510, 1, 0, submitted) - if err != nil { - t.Fatal(err) - } - if result.CharacterID != 6514 || result.Level != 100 || result.Exp != 0 || len(result.Costs) != 8 { - t.Fatalf("installed 2.35.10 combined promotion=%+v", result) - } -} - -func testVarintField(dst []byte, field int, value uint64) []byte { - dst = binary.AppendUvarint(dst, uint64(field<<3)) - return binary.AppendUvarint(dst, value) -} - -func testPackedField(dst []byte, field int, values ...uint64) []byte { - var packed []byte - for _, value := range values { - packed = binary.AppendUvarint(packed, value) - } - dst = binary.AppendUvarint(dst, uint64(field<<3|2)) - dst = binary.AppendUvarint(dst, uint64(len(packed))) - return append(dst, packed...) -} - -func TestCharacterPromotionUsesCharAndGrowthTables(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, schema := range []string{ - "CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", - "CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", - "CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))", - } { - if _, err := db.Exec(schema); err != nil { - t.Fatal(err) - } - } - current := testVarintField(nil, 1, 101) - current = testVarintField(current, 15, 351) - next := testVarintField(nil, 1, 102) - growth := testVarintField(nil, 1, 101) - growth = testPackedField(growth, 2, 1, 1000) - growth = testPackedField(growth, 3, 11, 0) - growth = testPackedField(growth, 4, 8, 4) - growth = testVarintField(growth, 9, 20) - if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?),(?,?)", 350, current, 351, next); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", 101, growth); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", 101, 20, []byte{}); err != nil { - t.Fatal(err) - } - nextID, costs, err := characterPromotion(db, 350, 20, 0) - if err != nil { - t.Fatal(err) - } - if nextID != 351 || len(costs) != 2 || costs[0] != (PromotionCost{Type: 8, ID: 11, Count: 1}) || costs[1] != (PromotionCost{Type: 4, Count: 1000}) { - t.Fatalf("promotion next=%d costs=%+v", nextID, costs) - } - if _, _, err := characterPromotion(db, 350, 19, 0); err == nil { - t.Fatal("promotion before the stage cap was accepted") - } -} - -func TestCharacterGrowthPromotionsCrossesTwoStagesWithCumulativeCosts(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, schema := range []string{ - "CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", - "CREATE TABLE CharGrowthTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", - "CREATE TABLE CharLevelTable (GroupId INTEGER, id INTEGER, ProtoBuf BLOB, PRIMARY KEY(GroupId,id))", - "CREATE TABLE ResourceTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)", - } { - if _, err := db.Exec(schema); err != nil { - t.Fatal(err) - } - } - for _, row := range []struct { - id, growth, next uint64 - }{{352, 103, 353}, {353, 104, 354}, {354, 105, 0}} { - proto := testVarintField(nil, 1, row.growth) - if row.next != 0 { - proto = testVarintField(proto, 15, row.next) - } - if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", row.id, proto); err != nil { - t.Fatal(err) - } - } - growth103 := testVarintField(nil, 1, 103) - growth103 = testPackedField(growth103, 2, 3, 3000) - growth103 = testPackedField(growth103, 3, 13, 0) - growth103 = testPackedField(growth103, 4, 8, 4) - growth103 = testVarintField(growth103, 9, 60) - growth104 := testVarintField(nil, 1, 104) - growth104 = testPackedField(growth104, 2, 4, 4000) - growth104 = testPackedField(growth104, 3, 14, 0) - growth104 = testPackedField(growth104, 4, 8, 4) - growth104 = testVarintField(growth104, 9, 80) - growth105 := testVarintField(testVarintField(nil, 1, 105), 9, 100) - for _, row := range []struct { - id uint64 - proto []byte - }{{103, growth103}, {104, growth104}, {105, growth105}} { - if _, err := db.Exec("INSERT INTO CharGrowthTable VALUES (?,?)", row.id, row.proto); err != nil { - t.Fatal(err) - } - } - for _, curve := range []struct{ group, first, maximum uint64 }{{103, 1, 60}, {104, 60, 80}, {105, 80, 100}} { - for level := curve.first; level < curve.maximum; level++ { - proto := testVarintField(nil, 8, 10) - if _, err := db.Exec("INSERT INTO CharLevelTable VALUES (?,?,?)", curve.group, level, proto); err != nil { - t.Fatal(err) - } - } - } - resource := testVarintField(nil, 9, 10) - if _, err := db.Exec("INSERT INTO ResourceTable VALUES (?,?)", 9, resource); err != nil { - t.Fatal(err) - } - submitted := []PromotionCost{ - {Type: 8, ID: 9, Count: 40}, - {Type: 8, ID: 13, Count: 3}, - {Type: 8, ID: 14, Count: 4}, - {Type: 4, Count: 7000}, - } - result, err := characterGrowthPromotions(db, 352, 60, 0, submitted) - if err != nil { - t.Fatal(err) - } - if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 || len(result.Refunds) != 0 { - t.Fatalf("combined result=%+v", result) - } - if len(result.Costs) != 4 { - t.Fatalf("cumulative costs=%+v", result.Costs) - } - fromLevelOne := append([]PromotionCost(nil), submitted...) - fromLevelOne[0].Count = 99 - result, err = characterGrowthPromotions(db, 352, 1, 0, fromLevelOne) - if err != nil { - t.Fatal(err) - } - if result.CharacterID != 354 || result.Level != 100 || result.Exp != 0 { - t.Fatalf("level-one combined result=%+v", result) - } - bad := append([]PromotionCost(nil), submitted...) - bad[len(bad)-1].Count = 6000 - if _, err := characterGrowthPromotions(db, 352, 60, 0, bad); err == nil { - t.Fatal("non-prefix aggregate gold was accepted") - } -} diff --git a/go/internal/server/gamedata/quest_collections_test.go b/go/internal/server/gamedata/quest_collections_test.go deleted file mode 100644 index b4391a3..0000000 --- a/go/internal/server/gamedata/quest_collections_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "path/filepath" - "testing" -) - -func TestQuestCollectionsFollowChangedPackQuestAndItemIDs(t *testing.T) { - db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "quest.db")) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - db.SetMaxOpenConns(1) - for _, sqlText := range []string{"CREATE TABLE QuestTable77(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CollectionTable(id INTEGER,ProtoBuf BLOB)"} { - if _, err := db.Exec(sqlText); err != nil { - t.Fatal(err) - } - } - quest := wire.AppendVarint(wire.AppendVarint(nil, 6, 801), 6, 802) - if _, err := db.Exec("INSERT INTO QuestTable77 VALUES(49,?)", quest); err != nil { - t.Fatal(err) - } - for _, collectionID := range []uint64{801, 802} { - if _, err := db.Exec("INSERT INTO CollectionTable VALUES(?,?)", collectionID, wire.AppendVarint(nil, 3, collectionID)); err != nil { - t.Fatal(err) - } - } - got, err := loadQuestDesignDB(db, 77) - if err != nil { - t.Fatal(err) - } - rewards := got[49].CollectionRewards - if len(rewards) != 2 || rewards[0] != (Reward{Type: 17, ID: 801, Count: 1}) || rewards[1] != (Reward{Type: 17, ID: 802, Count: 1}) { - t.Fatalf("rewards=%+v", rewards) - } - if _, err := db.Exec("DELETE FROM CollectionTable WHERE id=802"); err != nil { - t.Fatal(err) - } - if _, err := loadQuestDesignDB(db, 77); err == nil { - t.Fatal("missing collection design accepted") - } -} diff --git a/go/internal/server/gamedata/quest_formation_test.go b/go/internal/server/gamedata/quest_formation_test.go deleted file mode 100644 index 3ab6265..0000000 --- a/go/internal/server/gamedata/quest_formation_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package gamedata - -import ( - "database/sql" - "os" - "path/filepath" - "reflect" - "testing" - - "bd2server/internal/server/wire" -) - -func TestLoadQuestFormationsDB(t *testing.T) { - path := filepath.Join(t.TempDir(), "quest.sqlite") - db, err := sql.Open("sqlite", path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - // Match production's single-connection database lease so nested formation - // and costume lookups require source rows to be released first. - db.SetMaxOpenConns(1) - for _, statement := range []string{ - "CREATE TABLE QuestTable21 (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)", - "CREATE TABLE CharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)", - "CREATE TABLE StoryCharGroupTable (id INTEGER NOT NULL, GroupId INTEGER NOT NULL, ProtoBuf BLOB NOT NULL)", - "CREATE TABLE CostumeTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB NOT NULL)", - } { - if _, err := db.Exec(statement); err != nil { - t.Fatal(err) - } - } - quest := wire.AppendVarint(nil, 4, 2102) - quest = wire.AppendVarint(quest, 12, 9) - quest = wire.AppendVarint(quest, 12, 7) - quest = wire.AppendVarint(quest, 61, 2103) - if _, err := db.Exec("INSERT INTO QuestTable21(id,ProtoBuf) VALUES(29,?)", quest); err != nil { - t.Fatal(err) - } - character := wire.AppendVarint(nil, 1, 10383) - character = wire.AppendVarint(character, 2, 2102) - character = wire.AppendVarint(character, 3, 1) - character = wire.AppendVarint(character, 5, 15) - if _, err := db.Exec("INSERT INTO CharGroupTable(id,GroupId,ProtoBuf) VALUES(1,2102,?)", character); err != nil { - t.Fatal(err) - } - story := wire.AppendVarint(nil, 1, 3801) - story = wire.AppendVarint(story, 2, 2103) - story = wire.AppendVarint(story, 3, 3) - if _, err := db.Exec("INSERT INTO StoryCharGroupTable(id,GroupId,ProtoBuf) VALUES(3,2103,?)", story); err != nil { - t.Fatal(err) - } - costume := wire.AppendVarint(nil, 11, 3801) - costume = wire.AppendVarint(costume, 27, 38) - if _, err := db.Exec("INSERT INTO CostumeTable(id,ProtoBuf) VALUES(3801,?)", costume); err != nil { - t.Fatal(err) - } - - formations, err := loadQuestFormationsDB(db, 21) - if err != nil { - t.Fatal(err) - } - want := QuestFormation{ - QuestID: 29, - CharGroupID: 2102, - StoryCharGroupID: 2103, - DeckList: []uint64{9, 7}, - Characters: []QuestCharacterDesign{{Order: 1, CharacterID: 10383, Level: 15}}, - StoryCostumes: []QuestCostumeDesign{{Order: 3, CostumeID: 3801, UniqueCharacterID: 38}}, - } - if got := formations[29]; !reflect.DeepEqual(got, want) { - t.Fatalf("formation mismatch\n got: %#v\nwant: %#v", got, want) - } -} - -func TestRealQuest21Formations(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - formations, err := LoadQuestFormations(root, "20260923193640", 21) - if err != nil { - t.Fatal(err) - } - quest29 := formations[29] - if quest29.CharGroupID != 2102 || quest29.StoryCharGroupID != 2103 || len(quest29.DeckList) != 0 { - t.Fatalf("unexpected quest 29 groups: %#v", quest29) - } - wantCharacters := []QuestCharacterDesign{ - {Order: 1, CharacterID: 10383, Level: 15}, - {Order: 2, CharacterID: 10372, Level: 15}, - {Order: 3, CharacterID: 10412, Level: 15}, - } - if !reflect.DeepEqual(quest29.Characters, wantCharacters) { - t.Fatalf("quest 29 characters = %#v", quest29.Characters) - } - wantCostumes := []QuestCostumeDesign{ - {Order: 1, CostumeID: 996000, UniqueCharacterID: 9960}, - {Order: 2, CostumeID: 3501, UniqueCharacterID: 35}, - {Order: 3, CostumeID: 3801, UniqueCharacterID: 38}, - {Order: 4, CostumeID: 3701, UniqueCharacterID: 37}, - {Order: 5, CostumeID: 4101, UniqueCharacterID: 41}, - } - if !reflect.DeepEqual(quest29.StoryCostumes, wantCostumes) { - t.Fatalf("quest 29 story costumes = %#v", quest29.StoryCostumes) - } - quest31 := formations[31] - if quest31.StoryCharGroupID != 2107 || len(quest31.StoryCostumes) != 3 { - t.Fatalf("unexpected quest 31 formation: %#v", quest31) - } -} diff --git a/go/internal/server/gamedata/random_box_test.go b/go/internal/server/gamedata/random_box_test.go deleted file mode 100644 index 0a9fee7..0000000 --- a/go/internal/server/gamedata/random_box_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestRandomBoxOpenAggregatesOnlyKnownDeterministicReward(t *testing.T) { - design := &RandomBoxDesign{rewards: map[uint64][]BattleReward{ - 7: {{Type: 8, ID: 704, Count: 1}}, - }} - got, err := design.Open(7, 100000) - if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) { - t.Fatalf("Open deterministic box = %+v, %v", got, err) - } - if _, err := design.Open(8, 1); err == nil { - t.Fatal("accepted an unknown or weighted box") - } -} - -// This locks the actual current-version row behind the installed GameData opt-in, -// rather than replacing GameData lookup with a hand-written item mapping. -func TestLoadRandomBoxDesignInstalledEngravingEssence(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - design, err := LoadRandomBoxDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - got, err := design.Open(433302, 100000) // Essence of Perseverance box. - if err != nil || len(got) != 1 || got[0] != (BattleReward{Type: 8, ID: 704, Count: 100000}) { - t.Fatalf("433302 deterministic contents = %+v, %v", got, err) - } -} diff --git a/go/internal/server/gamedata/recruit_npc_test.go b/go/internal/server/gamedata/recruit_npc_test.go deleted file mode 100644 index 6fe3fcc..0000000 --- a/go/internal/server/gamedata/recruit_npc_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestRecruitNPCResolvesScoutParallelInteractionWithZeroPackField(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err = db.Exec("CREATE TABLE FieldNpcTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - raw := wire.AppendVarint(nil, 8, 7) - raw = wire.AppendVarint(raw, 14, 9) - for _, value := range []uint64{1, 12, 4} { - raw = wire.AppendVarint(raw, 9, value) - } - for _, value := range []uint64{0, 30, 40} { - raw = wire.AppendVarint(raw, 12, value) - } - raw = wire.AppendVarint(raw, 18, 1) - raw = wire.AppendVarint(raw, 19, 2) - if _, err = db.Exec("INSERT INTO FieldNpcTable VALUES(7,?)", raw); err != nil { - t.Fatal(err) - } - npc, err := loadRecruitNPC(db, 7) - if err != nil || npc.ScoutID != 40 || npc.MapID != 9 || npc.QuestEnableTypes[0] != 1 { - t.Fatalf("npc=%+v err=%v", npc, err) - } - if _, err = loadRecruitNPC(db, 8); err == nil { - t.Fatal("accepted absent NPC") - } - if _, err = db.Exec("CREATE TABLE QuestTable1(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(2,?)", wire.AppendVarint(nil, 63, 1)); err != nil { - t.Fatal(err) - } - if err = loadRecruitNPCQuestTypes(db, 1, &npc); err != nil || npc.QuestTypes[2] != 1 { - t.Fatalf("questtypes=%v err=%v", npc.QuestTypes, err) - } -} diff --git a/go/internal/server/gamedata/recruit_test.go b/go/internal/server/gamedata/recruit_test.go deleted file mode 100644 index 526c0f7..0000000 --- a/go/internal/server/gamedata/recruit_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "maps" - "strings" - "testing" -) - -func TestRecruitSpecialDefaultsUseProtoZeroAndValidateConfiguration(t *testing.T) { - fields := map[int][]uint64{1: {2}, 2: {120}, 4: {50}, 6: {3}, 7: {99}} - d := RecruitDesign{} - if e := decodeRecruitSpecialDefaults(friendshipTestProto(fields), &d); e != nil { - t.Fatal(e) - } - if d.AppearCount != 2 || d.AutoResetMinute != 120 || d.ResetCount != 50 || d.ResetType != 3 || d.ResetLimit != 99 { - t.Fatalf("defaults %+v", d) - } - for _, field := range []int{1, 2, 4, 6, 7} { - copyFields := map[int][]uint64{} - maps.Copy(copyFields, fields) - delete(copyFields, field) - if e := decodeRecruitSpecialDefaults(friendshipTestProto(copyFields), &RecruitDesign{}); e == nil { - t.Fatalf("missing configuration field %d accepted", field) - } - } - fields[5] = []uint64{1} - if e := decodeRecruitSpecialDefaults(friendshipTestProto(fields), &RecruitDesign{}); e == nil { - t.Fatal("currency item ID accepted") - } -} - -// Invalid rows must fail before any character/costume lookup can hide the -// actual malformed recruitment table behind a missing unrelated table. -func TestRecruitLoaderRejectsInvalidProtocolRows(t *testing.T) { - for _, tc := range []struct { - name string - fields map[int][]uint64 - }{ - {"identity", map[int][]uint64{2: {101}, 3: {11}, 5: {1}, 6: {71}, 7: {8}}}, - {"partial materials", map[int][]uint64{2: {101}, 3: {10}, 5: {1}, 7: {8}}}, - {"zero cost", map[int][]uint64{2: {101}, 3: {10}, 5: {0}, 6: {71}, 7: {8}}}, - {"unsupported currency", map[int][]uint64{2: {101}, 3: {10}, 5: {1}, 6: {71}, 7: {3}}}, - {"unsupported type", map[int][]uint64{2: {101}, 3: {10}, 5: {1}, 6: {71}, 7: {8}, 9: {2}}}, - } { - t.Run(tc.name, func(t *testing.T) { - db, e := sql.Open("sqlite", ":memory:") - if e != nil { - t.Fatal(e) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, e = db.Exec("CREATE TABLE MercenaryScoutTable(id INTEGER,ProtoBuf BLOB)"); e != nil { - t.Fatal(e) - } - var proto []byte - for field, values := range tc.fields { - for _, v := range values { - proto = wire.AppendVarint(proto, field, v) - } - } - if _, e = db.Exec("INSERT INTO MercenaryScoutTable VALUES(10,?)", proto); e != nil { - t.Fatal(e) - } - _, e = loadRecruitDesign(db) - if e == nil || strings.Contains(e.Error(), "no such table") { - t.Fatalf("bad row not rejected by recruitment decoder: %v", e) - } - }) - } -} diff --git a/go/internal/server/gamedata/reward_costume_test.go b/go/internal/server/gamedata/reward_costume_test.go deleted file mode 100644 index ec61c29..0000000 --- a/go/internal/server/gamedata/reward_costume_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestRewardCostumeCatalogInstalledSmoke(t *testing.T) { - root := os.Getenv("BD2_GAMEDATA_ROOT") - version := os.Getenv("BD2_GAMEDATA_VERSION") - if root == "" || version == "" { - t.Skip("explicit installed GameData integration environment required") - } - d, e := LoadRewardCostumeCatalog(root, version) - if e != nil { - t.Fatal(e) - } - if len(d.characters) == 0 { - t.Fatal("empty catalog") - } - if _, ok := d.Character(60601); !ok { - t.Fatal("installed event costume omitted") - } -} diff --git a/go/internal/server/gamedata/reward_equipment_test.go b/go/internal/server/gamedata/reward_equipment_test.go deleted file mode 100644 index 778c1c6..0000000 --- a/go/internal/server/gamedata/reward_equipment_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestInstalledRewardEquipmentCatalog(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("explicit installed GameData integration environment required") - } - d, err := LoadRewardEquipmentCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - if len(d.equipment) == 0 { - t.Fatal("empty equipment catalog") - } - for id := range d.equipment { - if _, _, _, err := d.RollOptions(id); err != nil { - t.Fatalf("equipment %d: %v", id, err) - } - } -} diff --git a/go/internal/server/gamedata/reward_graph_test.go b/go/internal/server/gamedata/reward_graph_test.go deleted file mode 100644 index e57cd9d..0000000 --- a/go/internal/server/gamedata/reward_graph_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "errors" - "os" - "reflect" - "testing" -) - -func TestGrantedOpenWrappersAndDirectInventoryBoxes(t *testing.T) { - g := &RewardGraph{ - boxes: map[uint64]uint64{1: 1, 2: 2, 3: 3}, - direct: map[uint64]bool{1: false, 2: true, 3: false}, - groups: map[uint64][]byte{ - 1: group(1, 0, []BattleReward{{9, 2, 2}, {9, 3, 1}}, []uint64{100, 100}), - 2: group(1, 0, []BattleReward{{8, 1000, 5}}, []uint64{100}), - 3: group(1, 0, []BattleReward{{3, 0, 10}}, []uint64{100}), - }, - sample: func(uint64) (uint64, error) { return 0, nil }, - } - got, err := g.ResolveGranted([]BattleReward{{9, 1, 2}, {9, 2, 1}}) - want := []BattleReward{{3, 0, 20}, {9, 2, 5}} - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("granted=%+v want=%+v err=%v", got, want, err) - } - // Explicitly opening the root does not consume a nested inventory box. - got, err = g.Resolve([]BattleReward{{9, 1, 1}}) - if err != nil || !reflect.DeepEqual(got, []BattleReward{{3, 0, 10}, {9, 2, 2}}) { - t.Fatalf("open nested=%+v err=%v", got, err) - } - got, err = g.Resolve([]BattleReward{{9, 2, 1}}) - if err != nil || !reflect.DeepEqual(got, []BattleReward{{8, 1000, 5}}) { - t.Fatalf("manual direct root=%+v err=%v", got, err) - } - if _, err := g.ResolveGranted([]BattleReward{{9, 999, 1}}); err == nil { - t.Fatal("unknown box accepted") - } - g.boxes[10], g.direct[10] = 10, true - g.special = map[uint64]bool{10: true} - if got, err := g.ResolveGranted([]BattleReward{{9, 10, 1}}); err != nil || !reflect.DeepEqual(got, []BattleReward{{9, 10, 1}}) { - t.Fatalf("special box grant=%+v err=%v", got, err) - } - if _, err := g.Resolve([]BattleReward{{9, 10, 1}}); err == nil { - t.Fatal("special box used generic random opening") - } - g.sample = func(uint64) (uint64, error) { return 0, errors.New("sample failed") } - if out, err := g.ResolveGranted([]BattleReward{{9, 1, 1}}); err == nil || out != nil { - t.Fatal("failed opening returned partial rewards") - } -} - -func group(drop, count uint64, rs []BattleReward, ws []uint64) []byte { - b := wire.AppendVarint(nil, 2, drop) - b = wire.AppendVarint(b, 1, count) - for _, r := range rs { - b = wire.AppendVarint(b, 6, r.Type) - b = wire.AppendVarint(b, 5, r.ID) - b = wire.AppendVarint(b, 4, r.Count) - } - for _, w := range ws { - b = wire.AppendVarint(b, 8, w) - } - return b -} -func TestRewardGraphWeightedNestedAndEquipmentLeaves(t *testing.T) { - g := &RewardGraph{boxes: map[uint64]uint64{1: 1, 2: 2}, direct: map[uint64]bool{1: false, 2: false}, groups: map[uint64][]byte{1: group(0, 1, []BattleReward{{9, 2, 1}, {8, 8, 2}}, []uint64{1, 3}), 2: group(1, 0, []BattleReward{{10, 10, 1}, {11, 11, 1}}, []uint64{100, 100})}, sample: func(uint64) (uint64, error) { return 0, nil }} - out, e := g.Resolve([]BattleReward{{9, 1, 2}}) - if e != nil || len(out) != 2 || out[0].Type != 10 || out[0].Count != 2 || out[1].Type != 11 { - t.Fatalf("out=%v e=%v", out, e) - } - g.SetSampler(func(n uint64) (uint64, error) { return n - 1, nil }) - out, e = g.Resolve([]BattleReward{{9, 1, 1}}) - if e != nil || len(out) != 1 || out[0].Type != 8 || out[0].Count != 2 { - t.Fatalf("out=%v e=%v", out, e) - } -} -func TestRewardGraphCycleBadRandomAndOverflow(t *testing.T) { - g := &RewardGraph{boxes: map[uint64]uint64{1: 1}, direct: map[uint64]bool{1: false}, groups: map[uint64][]byte{1: group(1, 0, []BattleReward{{9, 1, 1}}, nil)}} - if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil { - t.Fatal("cycle accepted") - } - g.groups[1] = group(0, 1, []BattleReward{{8, 1, 1}}, []uint64{1}) - g.sample = func(n uint64) (uint64, error) { return n, nil } - if _, e := g.Resolve([]BattleReward{{9, 1, 1}}); e == nil { - t.Fatal("bad random accepted") - } - if _, e := g.Resolve([]BattleReward{{8, 1, 2147483647}, {8, 1, 1}}); e == nil { - t.Fatal("overflow accepted") - } -} -func TestRewardGraphInstalledLoad(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not set") - } - g, e := LoadRewardGraph(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - if len(g.boxes) == 0 || len(g.groups) == 0 { - t.Fatal("empty graph") - } - // Real 2.35.10 pass levels 5..20 BASIC/PREMIUM: OPEN wrappers must yield - // actual wallet/material leaves, while nested DIRECT boxes remain owned. - var rewards []BattleReward - for id := uint64(532657); id <= 532672; id++ { - rewards = append(rewards, BattleReward{9, id, 1}) - } - for id := uint64(532677); id <= 532692; id++ { - rewards = append(rewards, BattleReward{9, id, 1}) - } - out, err := g.ResolveGranted(rewards) - if err != nil { - t.Fatal(err) - } - want := []BattleReward{{3, 0, 600}, {4, 0, 70000}, {8, 7, 40}, {8, 8, 35}, {8, 11, 1}, {8, 12, 2}, {8, 13, 8}, {8, 14, 1}, {8, 1000, 16}, {8, 1002, 10}, {8, 1003, 1}, {9, 504143, 13}, {9, 504145, 23}, {12, 0, 1500}} - if !reflect.DeepEqual(out, want) { - t.Fatalf("real pass material grant=%+v want=%+v", out, want) - } -} diff --git a/go/internal/server/gamedata/story_catalog_test.go b/go/internal/server/gamedata/story_catalog_test.go deleted file mode 100644 index b5d4b65..0000000 --- a/go/internal/server/gamedata/story_catalog_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "testing" -) - -func TestStoryCatalogEnumeratesSeparateChainsAndContentTicketRules(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, query := range []string{"CREATE TABLE PackTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE ContentOpenTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)", "CREATE TABLE QuestTable1(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE QuestTable7(id INTEGER PRIMARY KEY,ProtoBuf BLOB)", "CREATE TABLE QuestTable8(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"} { - if _, err = db.Exec(query); err != nil { - t.Fatal(err) - } - } - for _, pack := range []struct{ id, kind, next int }{{1, 0, 0}, {7, 1000, 8}, {8, 1000, 0}, {9, 2, 0}} { - raw := wire.AppendVarint(nil, 25, uint64(pack.id)) - raw = wire.AppendVarint(raw, 55, uint64(pack.kind)) - raw = wire.AppendVarint(raw, 45, uint64(pack.next)) - raw = wire.AppendVarint(raw, 21, uint64(pack.id*10)) - if pack.id == 7 { - raw = wire.AppendVarint(raw, 8, 1) - raw = wire.AppendVarint(raw, 9, 100) - raw = wire.AppendVarint(raw, 10, 19) - // Non-stackable costume instances retain their ID with count zero. - raw = wire.AppendVarint(raw, 8, 0) - raw = wire.AppendVarint(raw, 9, 200) - raw = wire.AppendVarint(raw, 10, 11) - } - if _, err = db.Exec("INSERT INTO PackTable VALUES(?,?)", pack.id, raw); err != nil { - t.Fatal(err) - } - } - raw := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 6, 100) - raw = wire.AppendVarint(raw, 5, 3) - raw = wire.AppendVarint(raw, 4, 99) - if _, err = db.Exec("INSERT INTO ContentOpenTable VALUES(1,1,?)", raw); err != nil { - t.Fatal(err) - } - main := wire.AppendVarint(wire.AppendVarint(nil, 35, 2), 37, 0) - if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(1,?)", main); err != nil { - t.Fatal(err) - } - terminal := wire.AppendVarint(nil, 37, 1) - if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(2,?)", terminal); err != nil { - t.Fatal(err) - } - if _, err = db.Exec("INSERT INTO QuestTable1 VALUES(3,?)", wire.AppendVarint(nil, 63, 1)); err != nil { - t.Fatal(err) - } - for _, id := range []int{7, 8} { - query := "INSERT INTO QuestTable7 VALUES(1,?)" - if id == 8 { - query = "INSERT INTO QuestTable8 VALUES(1,?)" - } - raw = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 46, 1), 51, 101), 56, 19) - if _, err = db.Exec(query, raw); err != nil { - t.Fatal(err) - } - } - d, err := loadStoryCatalog(db) - if err != nil { - t.Fatal(err) - } - if len(d.Packs) != 3 || d.Packs[8].NextPackID != 0 || d.Packs[7].NextPackID != 8 { - t.Fatalf("catalog=%+v", d.Packs) - } - if open := d.Packs[1].Open; open == nil || open.TicketID != 100 || open.SquadLevel != 3 || open.TutorialID != 99 { - t.Fatalf("open=%+v", open) - } - if d.Packs[7].Open != nil { - t.Fatal("invented missing content-open permission rule") - } - if main := d.Packs[1].MainQuestIDs; len(main) != 2 || main[0] != 1 || main[1] != 2 { - t.Fatalf("sidequest in chapter completion=%v", main) - } - if d.Packs[1].Quests[1].NextQuestID != 2 || d.Packs[1].Quests[2].PriorQuestID != 1 || d.Packs[1].Quests[3].Type != 1 { - t.Fatal("quest edges/type were lost") - } - if rewards := d.Packs[7].BuyRewards; len(rewards) != 2 || rewards[0].Type != 19 || rewards[0].ID != 100 || rewards[1].Type != 11 || rewards[1].ID != 200 || rewards[1].Count != 0 { - t.Fatalf("buy rewards=%+v", rewards) - } - if len(d.TicketSources[101]) != 2 { - t.Fatalf("ticket quest sources=%+v", d.TicketSources) - } -} diff --git a/go/internal/server/gamedata/story_characters_test.go b/go/internal/server/gamedata/story_characters_test.go deleted file mode 100644 index 9eeeb9a..0000000 --- a/go/internal/server/gamedata/story_characters_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestInstalledStoryCharacterCatalogUsesAuthoredRows(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - catalog, err := LoadStoryCharacterCatalog(root, "20260923193640", []int{1, 21}) - if err != nil { - t.Fatal(err) - } - chars, err := catalog.Characters(1, 1) - if err != nil { - t.Fatal(err) - } - if len(chars) != 0 { - t.Fatalf("pack1 quest1 fabricated temporary characters: %v", chars) - } - chars, err = catalog.Characters(21, 29) - if err != nil { - t.Fatal(err) - } - if len(chars) == 0 { - t.Fatal("pack21 quest29 omitted authored characters") - } - for _, c := range chars { - if c.CharacterID == 0 || c.Level == 0 || c.HP == 0 || c.CostumeID == 996000 { - t.Fatalf("invalid story character %v", c) - } - } -} - -func TestInstalledAllStoryCharacterDesigns(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - story, err := LoadStoryCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - var packs []int - for id := range story.Packs { - packs = append(packs, id) - } - catalog, err := LoadStoryCharacterCatalog(root, "20260923193640", packs) - if err != nil { - t.Fatal(err) - } - count := 0 - for _, formations := range catalog.formations { - for _, f := range formations { - count += len(f.Characters) - } - } - if count == 0 { - t.Fatal("all story catalog omitted character rows") - } - t.Logf("validated %d packs and %d authored character rows", len(packs), count) -} diff --git a/go/internal/server/gamedata/talent_growth_test.go b/go/internal/server/gamedata/talent_growth_test.go deleted file mode 100644 index 0dc4be4..0000000 --- a/go/internal/server/gamedata/talent_growth_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package gamedata - -import ( - "database/sql" - "os" - "testing" - - "bd2server/internal/server/wire" -) - -func TestTalentGrowthDesignJoinsCharacterAndUsesCumulativeExperience(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil { - t.Fatal(err) - } - talent := testVarintField(testVarintField(nil, 6, 904), 11, 5) - character := testVarintField(nil, 18, 904) - if _, err := db.Exec("INSERT INTO TalentTable VALUES (?,?)", 904, talent); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", 140, character); err != nil { - t.Fatal(err) - } - needs := []uint64{14, 28, 404, 1008} - gold := []uint64{1000, 2000, 4000, 10000} - for level, need := range needs { - growth := testPackedField(nil, 2, 1, gold[level]) - growth = testPackedField(growth, 3, uint64(level+3), 0) - growth = testPackedField(growth, 4, 8, 4) - growth = testVarintField(growth, 6, need) - if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 904, level+1, growth); err != nil { - t.Fatal(err) - } - } - if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 904, 5, testVarintField(nil, 5, 5)); err != nil { - t.Fatal(err) - } - design, err := loadTalentGrowthDesign(db) - if err != nil { - t.Fatal(err) - } - rule, err := design.UpgradeRule(140, 3) - if err != nil { - t.Fatal(err) - } - if rule.TalentID != 904 || rule.GrowthGroup != 904 || rule.MaxLevel != 5 || rule.RequiredTotalExp != 446 { - t.Fatalf("rule=%+v", rule) - } - want := []PromotionCost{{Type: 8, ID: 5, Count: 1}, {Type: 4, Count: 4000}} - if len(rule.Costs) != len(want) || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] { - t.Fatalf("costs=%+v want=%+v", rule.Costs, want) - } - if _, err := design.UpgradeRule(140, 5); err == nil { - t.Fatal("max-level talent was upgradeable") - } -} - -func TestTalentGrowthDesignRejectsMismatchedCostArrays(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil { - t.Fatal(err) - } - talent := testVarintField(testVarintField(nil, 6, 9), 11, 2) - character := testVarintField(nil, 18, 9) - broken := testPackedField(testPackedField(nil, 2, 1, 1000), 3, 3) - broken = testPackedField(broken, 4, 8, 4) - broken = wire.AppendVarint(broken, 6, 10) - if _, err := db.Exec("INSERT INTO TalentTable VALUES (?,?)", 9, talent); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", 1, character); err != nil { - t.Fatal(err) - } - if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 9, 1, broken); err != nil { - t.Fatal(err) - } - if _, err := loadTalentGrowthDesign(db); err == nil { - t.Fatal("mismatched talent costs were accepted") - } -} - -func TestTalentGrowthAgainstInstalledCurrentVersion(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") - } - design, err := LoadTalentGrowthDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - rule, err := design.UpgradeRule(140, 1) - if err != nil { - t.Fatal(err) - } - want := []PromotionCost{{Type: 8, ID: 3, Count: 1}, {Type: 4, Count: 1000}} - if rule.TalentID != 904 || rule.MaxLevel != 5 || rule.RequiredTotalExp != 14 || len(rule.Costs) != 2 || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] { - t.Fatalf("installed rule=%+v", rule) - } - if _, err := design.UpgradeRule(10140, 1); err == nil { - t.Fatal("the current max-level-one talent was upgradeable") - } -} diff --git a/go/internal/server/gamedata/talent_use_test.go b/go/internal/server/gamedata/talent_use_test.go deleted file mode 100644 index 997bd98..0000000 --- a/go/internal/server/gamedata/talent_use_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package gamedata - -import ( - "bd2server/internal/server/wire" - "database/sql" - "encoding/binary" - "math" - "testing" -) - -func TestTalentUseLoaderJoinsCharacterLevelRewardsAndFood(t *testing.T) { - db, err := sql.Open("sqlite", ":memory:") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := db.Close(); err != nil { - t.Error(err) - } - }() - for _, table := range []string{"TalentTable", "CharTable", "TalentRewardTable", "FoodBuffTable", "FoodTable"} { - if _, err = db.Exec("CREATE TABLE " + table + "(id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - } - if _, err = db.Exec("CREATE TABLE TalentSkillTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"); err != nil { - t.Fatal(err) - } - talent := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 18, 42), 11, 5), 1, 22) - for _, v := range []struct { - table string - id uint64 - b []byte - }{{"TalentTable", 8, talent}, {"CharTable", 350, wire.AppendVarint(nil, 18, 8)}, {"TalentRewardTable", 9, wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 3), 3, 11), 4, 8)}, {"FoodBuffTable", 7, wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 3, 42)}, {"FoodTable", 22, wire.AppendVarint(nil, 3, 7)}} { - if _, err = db.Exec("INSERT INTO "+v.table+" VALUES(?,?)", v.id, v.b); err != nil { - t.Fatal(err) - } - } - var values []byte - values = binary.LittleEndian.AppendUint32(values, math.Float32bits(0.75)) - values = binary.LittleEndian.AppendUint32(values, math.Float32bits(5)) - skill := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 10), 2, 13), 5, 2) - skill = wire.AppendBytes(skill, 14, values) - if _, err = db.Exec("INSERT INTO TalentSkillTable VALUES(?,?,?)", 42, 1, skill); err != nil { - t.Fatal(err) - } - d, err := loadTalentUseDesign(db) - if err != nil { - t.Fatal(err) - } - meta := d.Characters[350] - r := d.Rules[[2]uint64{42, 1}] - if meta.Group != 42 || !meta.BannedPacks[22] || r.Class != 13 || r.Catalyst != 10 || r.Values[0] != 0.75 || r.Values[1] != 5 || d.Foods[22] != 42 || d.Rewards[9][0].Count != 3 { - t.Fatal("talent loader lost static rule joins") - } - if _, err = db.Exec("UPDATE TalentSkillTable SET ProtoBuf=?", wire.AppendBytes(wire.AppendVarint(nil, 2, 13), 14, []byte{1, 2, 3})); err != nil { - t.Fatal(err) - } - if _, err = loadTalentUseDesign(db); err == nil { - t.Fatal("malformed float rule accepted") - } -} diff --git a/go/internal/server/gamedata/today_quests_test.go b/go/internal/server/gamedata/today_quests_test.go deleted file mode 100644 index 57a8dd8..0000000 --- a/go/internal/server/gamedata/today_quests_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package gamedata - -import ( - "os" - "testing" -) - -func TestInstalledTodayQuestChains(t *testing.T) { - root := "../../../../data/resources/GameData" - if _, err := os.Stat(root); err != nil { - t.Skip("installed GameData absent") - } - c, err := LoadTodayQuests(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - if len(c.Quests) != 397 || c.Limit != 3 || c.PostCount != 3 || c.AchievementScore != 7 { - t.Fatalf("unexpected current defaults: %+v", c) - } - roots, finals := 0, 0 - for _, q := range c.Quests { - if q.PriorID == 0 { - roots++ - } - if q.NextID == 0 { - finals++ - if len(q.Rewards) != 1 || q.Rewards[0].Type != 4 || q.ReputationCompleteID != 1 { - t.Fatalf("missing commissioned reward %d", q.ID) - } - } - } - if roots != 121 || finals != 121 { - t.Fatalf("roots=%d finals=%d", roots, finals) - } -} diff --git a/go/internal/server/gamedata/validate_test.go b/go/internal/server/gamedata/validate_test.go deleted file mode 100644 index 0f12d9f..0000000 --- a/go/internal/server/gamedata/validate_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package gamedata - -import ( - "archive/zip" - "context" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strconv" - "testing" -) - -func TestValidate(t *testing.T) { - root := t.TempDir() - release := filepath.Join(root, "123", "release") - if err := os.MkdirAll(release, 0o755); err != nil { - t.Fatal(err) - } - archive := filepath.Join(release, ArchiveName) - f, err := os.Create(archive) - if err != nil { - t.Fatal(err) - } - w := zip.NewWriter(f) - entry, err := w.Create("common-hash") - if err != nil { - t.Fatal(err) - } - if _, err := entry.Write([]byte("verified payload")); err != nil { - t.Fatal(err) - } - if err := w.Close(); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - stat, err := os.Stat(archive) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(release, InfoName), []byte(strconv.FormatInt(stat.Size(), 10)), 0o644); err != nil { - t.Fatal(err) - } - - result, err := Validate(root, "123") - if err != nil { - t.Fatal(err) - } - if result.EntryCount != 1 || result.UncompressedSize != int64(len("verified payload")) { - t.Fatalf("unexpected result: %+v", result) - } -} - -func TestValidateRejectsSizeMismatch(t *testing.T) { - root := t.TempDir() - release := filepath.Join(root, "123", "release") - if err := os.MkdirAll(release, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(release, ArchiveName), []byte("not a zip"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(release, InfoName), []byte("999"), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Validate(root, "123"); err == nil { - t.Fatal("expected validation failure") - } -} - -func TestEnsureDownloadsAndPreservesBrokenArchive(t *testing.T) { - served := t.TempDir() - writeTestArchive(t, served, "123", []byte("healthy archive payload")) - origin := httptest.NewServer(http.FileServer(http.Dir(served))) - defer origin.Close() - - root := t.TempDir() - release := filepath.Join(root, "123", "release") - if err := os.MkdirAll(release, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(release, ArchiveName), []byte("broken"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(release, InfoName), []byte("6"), 0o644); err != nil { - t.Fatal(err) - } - - result, downloaded, err := Ensure(context.Background(), origin.Client(), root, "123", origin.URL) - if err != nil { - t.Fatal(err) - } - if !downloaded || result.EntryCount != 1 { - t.Fatalf("unexpected ensure result: downloaded=%v result=%+v", downloaded, result) - } - broken, err := filepath.Glob(filepath.Join(release, ArchiveName+".broken.*")) - if err != nil || len(broken) != 1 { - t.Fatalf("broken archive was not preserved: %v %v", broken, err) - } -} - -func writeTestArchive(t *testing.T, root, version string, payload []byte) { - t.Helper() - release := filepath.Join(root, version, "release") - if err := os.MkdirAll(release, 0o755); err != nil { - t.Fatal(err) - } - archive := filepath.Join(release, ArchiveName) - f, err := os.Create(archive) - if err != nil { - t.Fatal(err) - } - w := zip.NewWriter(f) - entry, err := w.Create("common-hash") - if err != nil { - t.Fatal(err) - } - if _, err := entry.Write(payload); err != nil { - t.Fatal(err) - } - if err := w.Close(); err != nil { - t.Fatal(err) - } - if err := f.Close(); err != nil { - t.Fatal(err) - } - stat, err := os.Stat(archive) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(release, InfoName), []byte(strconv.FormatInt(stat.Size(), 10)), 0o644); err != nil { - t.Fatal(err) - } -} diff --git a/go/internal/server/gateway/auth/service.go b/go/internal/server/gateway/auth/service.go new file mode 100644 index 0000000..9f9d26f --- /dev/null +++ b/go/internal/server/gateway/auth/service.go @@ -0,0 +1,521 @@ +package auth + +import ( + "bd2server/internal/server/domain/identity" + "bd2server/internal/server/gateway/authconfig" + "bd2server/internal/server/protocol/wire" + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +type Service struct { + config authconfig.Runtime + identity *identity.Service + client *http.Client + limits requestLimiter +} +type limitWindow struct { + started time.Time + count int +} +type requestLimiter struct { + mu sync.Mutex + windows map[string]limitWindow + lastSweep time.Time +} + +func New(config authconfig.Runtime, store identity.Repository) (*Service, error) { + if config.Mode != "oauth" || store == nil { + return nil, errors.New("auth: OAuth service requires oauth configuration and store") + } + clear(config.MasterKey) + config.MasterKey = nil + providers := map[string]string{} + for name, p := range config.Providers { + providers[name] = p.ClientID + } + service, err := identity.New(identity.Config{Providers: providers, AccessTTL: config.AccessTTL, RefreshTTL: config.RefreshTTL, DeviceTTL: config.DeviceTTL}, store) + if err != nil { + return nil, err + } + return &Service{config: config, identity: service, client: &http.Client{Timeout: 15 * time.Second}, limits: requestLimiter{windows: map[string]limitWindow{}}}, nil +} +func (s *Service) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/device", s.createDevice) + mux.HandleFunc("GET /auth/{provider}/start", s.start) + mux.HandleFunc("GET /auth/{provider}/callback", s.callback) + mux.HandleFunc("POST /auth/device/{id}/poll", s.poll) + mux.HandleFunc("POST /auth/session/refresh", s.refresh) + mux.HandleFunc("POST /auth/session/revoke", s.revoke) + return securityHeaders(mux) +} +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") + w.Header().Set("X-Frame-Options", "DENY") + next.ServeHTTP(w, r) + }) +} +func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool { + defer func() { _ = r.Body.Close() }() + data, err := io.ReadAll(io.LimitReader(r.Body, 16<<10+1)) + if err != nil || len(data) > 16<<10 { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return false + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err = decoder.Decode(target); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return false + } + var trailing any + if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return false + } + return true +} +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} +func tokenResponse(r identity.TokenSet) map[string]any { + return map[string]any{"provider": r.Provider, "access_token": r.AccessToken, "access_expires_in": r.AccessExpiresIn, "refresh_token": r.RefreshToken, "refresh_expires_in": r.RefreshExpiresIn} +} +func writeFailure(w http.ResponseWriter, err error, fallback string) { + status := http.StatusInternalServerError + message := fallback + var failure *identity.Failure + if errors.As(err, &failure) { + message = failure.Message + if failure.RefreshInvalid { + w.Header().Set("X-BD2-Refresh-Invalid", "1") + } + switch failure.Kind { + case identity.Invalid: + status = http.StatusBadRequest + case identity.Forbidden: + status = http.StatusForbidden + case identity.Expired: + status = http.StatusGone + case identity.Conflict: + status = http.StatusConflict + case identity.Unauthorized: + status = http.StatusUnauthorized + case identity.TooManyPending: + status = http.StatusTooManyRequests + } + } + http.Error(w, message, status) +} +func (s *Service) createDevice(w http.ResponseWriter, r *http.Request) { + clientIP := remoteIP(r.RemoteAddr) + if !s.limits.allow("create:"+clientIP, s.identity.Now(), time.Minute, 10) { + w.Header().Set("Retry-After", "60") + http.Error(w, "too many login attempts", http.StatusTooManyRequests) + return + } + var request struct { + Provider string `json:"provider"` + } + if !decodeJSON(w, r, &request) { + return + } + result, err := s.identity.CreateDevice(request.Provider, clientIP) + if err != nil { + var failure *identity.Failure + if errors.As(err, &failure) && failure.Kind == identity.TooManyPending { + w.Header().Set("Retry-After", strconv.FormatInt(int64(s.config.DeviceTTL.Seconds()), 10)) + } + writeFailure(w, err, "could not create transaction") + return + } + start := *s.config.PublicURLParsed + start.Path = "/auth/" + request.Provider + "/start" + query := start.Query() + query.Set("transaction_id", result.ID) + query.Set("ticket", result.StartTicket) + start.RawQuery = query.Encode() + writeJSON(w, http.StatusCreated, map[string]any{"transaction_id": result.ID, "device_secret": result.Secret, "start_url": start.String(), "expires_in": int64(s.config.DeviceTTL.Seconds()), "poll_interval": 2}) +} +func (s *Service) start(w http.ResponseWriter, r *http.Request) { + provider := r.PathValue("provider") + if _, ok := s.config.Providers[provider]; !ok { + http.Error(w, "provider is not enabled", http.StatusNotFound) + return + } + auth, err := s.identity.Start(provider, r.URL.Query().Get("transaction_id"), r.URL.Query().Get("ticket")) + if err != nil { + writeFailure(w, err, "could not start authorization") + return + } + challenge := sha256.Sum256([]byte(auth.Verifier)) + values := url.Values{"client_id": {s.config.Providers[provider].ClientID}, "redirect_uri": {s.redirectURL(provider)}, "response_type": {"code"}, "scope": {providerScope(provider)}, "state": {auth.State}, "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, "code_challenge_method": {"S256"}} + if provider == "google" { + values.Set("nonce", auth.Nonce) + } + http.Redirect(w, r, providerAuthorizeURL(provider)+"?"+values.Encode(), http.StatusFound) +} +func (s *Service) callback(w http.ResponseWriter, r *http.Request) { + provider, state, code := r.PathValue("provider"), r.URL.Query().Get("state"), r.URL.Query().Get("code") + if _, ok := s.config.Providers[provider]; !ok { + http.Error(w, "provider is not enabled", http.StatusNotFound) + return + } + if state == "" { + http.Error(w, "authorization was not completed", http.StatusBadRequest) + return + } + if r.URL.Query().Get("error") != "" { + if err := s.identity.CancelAuthorization(provider, state); err != nil { + writeFailure(w, err, "authorization state unavailable") + return + } + http.Error(w, "authorization was cancelled", http.StatusBadRequest) + return + } + if code == "" { + http.Error(w, "authorization was not completed", http.StatusBadRequest) + return + } + auth, err := s.identity.Authorization(provider, state) + if err != nil { + writeFailure(w, err, "authorization state unavailable") + return + } + id, err := s.exchangeIdentity(r.Context(), provider, code, auth.Verifier, auth.Nonce) + auth.Verifier, auth.Nonce = "", "" + if err != nil { + s.identity.RejectProvider(auth.ID) + var failure *providerFailure + if errors.As(err, &failure) { + slog.Warn("OAuth provider authorization failed", "provider", failure.Provider, "stage", failure.Stage, "reason", failure.Reason, "http_status", failure.HTTPStatus, "oauth_error", failure.OAuthError) + if failure.OAuthError == "invalid_client" { + http.Error(w, "server OAuth configuration is invalid; contact the server administrator", http.StatusBadGateway) + return + } + } else { + slog.Warn("OAuth provider authorization failed", "provider", provider, "reason", "internal_error") + } + http.Error(w, "provider authorization failed", http.StatusBadGateway) + return + } + if err = s.identity.CompleteDevice(auth.ID, provider, id); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, identity.ErrNotAllowed) { + status = http.StatusForbidden + } else if errors.Is(err, identity.ErrConsumed) { + status = http.StatusConflict + } + http.Error(w, "authorization could not be completed", status) + return + } + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `BD2 login

Login complete. You can return to the game.

`) +} +func (s *Service) poll(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if !s.limits.allow("poll:"+remoteIP(r.RemoteAddr)+":"+id, s.identity.Now(), time.Minute, 60) { + w.Header().Set("Retry-After", "2") + http.Error(w, "poll rate exceeded", http.StatusTooManyRequests) + return + } + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Device ") { + http.Error(w, "invalid device transaction", http.StatusForbidden) + return + } + result, err := s.identity.Poll(id, strings.TrimPrefix(auth, "Device ")) + if err != nil { + writeFailure(w, err, "login result unavailable") + return + } + switch result.Status { + case "pending": + writeJSON(w, http.StatusAccepted, map[string]any{"status": "pending", "retry_after": 2}) + case "failed": + writeJSON(w, http.StatusForbidden, map[string]string{"status": "failed", "error": result.ErrorCode}) + case "complete": + writeJSON(w, http.StatusOK, tokenResponse(result.Tokens)) + } +} +func (s *Service) refresh(w http.ResponseWriter, r *http.Request) { + var request struct { + RefreshToken string `json:"refresh_token"` + AttemptID string `json:"attempt_id"` + } + if !decodeJSON(w, r, &request) { + return + } + tokens, err := s.identity.Refresh(request.RefreshToken, request.AttemptID) + if err != nil { + writeFailure(w, err, "refresh unavailable") + return + } + writeJSON(w, http.StatusOK, tokenResponse(tokens)) +} +func (s *Service) revoke(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") == "" { + http.Error(w, "access token required", http.StatusUnauthorized) + return + } + if err := s.identity.Revoke(strings.TrimPrefix(auth, "Bearer ")); err != nil { + writeFailure(w, err, "revocation unavailable") + return + } + w.WriteHeader(http.StatusNoContent) +} +func (s *Service) ValidateAccess(token string) (string, error) { + return s.identity.ValidateAccess(token) +} +func (s *Service) AuthenticateLogin(request []byte) (string, error) { + token, found, err := wire.Bytes(request, 2) + if err != nil || !found { + return "", identity.ErrUnauthorized + } + return s.identity.ValidateAccess(string(token)) +} + +type providerFailure struct { + Provider string + Stage string + Reason string + HTTPStatus int + OAuthError string +} + +func (e *providerFailure) Error() string { + return fmt.Sprintf("provider=%s stage=%s reason=%s status=%d oauth_error=%s", e.Provider, e.Stage, e.Reason, e.HTTPStatus, e.OAuthError) +} + +func networkProviderFailure(ctx context.Context, provider, stage string, err error) error { + reason := "network_error" + if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) { + reason = "timeout" + } else if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) { + reason = "cancelled" + } else { + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + reason = "timeout" + } + } + return &providerFailure{Provider: provider, Stage: stage, Reason: reason} +} + +func rejectedProviderFailure(provider, stage string, response *http.Response) error { + failure := &providerFailure{Provider: provider, Stage: stage, Reason: "http_rejected", HTTPStatus: response.StatusCode} + var body struct { + Error string `json:"error"` + } + decoder := json.NewDecoder(io.LimitReader(response.Body, 8<<10)) + if decoder.Decode(&body) == nil { + failure.OAuthError = safeOAuthError(body.Error) + } + return failure +} + +func invalidProviderResponse(provider, stage string) error { + return &providerFailure{Provider: provider, Stage: stage, Reason: "invalid_response", HTTPStatus: http.StatusOK} +} + +func safeOAuthError(value string) string { + switch value { + case "invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", + "unsupported_grant_type", "invalid_scope", "access_denied", "server_error", "temporarily_unavailable": + return value + default: + return "unknown" + } +} + +func (s *Service) exchangeIdentity(ctx context.Context, provider, code, verifier, nonce string) (identity.ProviderIdentity, error) { + values := url.Values{"client_id": {s.config.Providers[provider].ClientID}, "client_secret": {s.config.ProviderSecrets[provider]}, "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {s.redirectURL(provider)}, "code_verifier": {verifier}} + request, _ := http.NewRequestWithContext(ctx, http.MethodPost, providerTokenURL(provider), strings.NewReader(values.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response, err := s.client.Do(request) + if err != nil { + return identity.ProviderIdentity{}, networkProviderFailure(ctx, provider, "token_exchange", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return identity.ProviderIdentity{}, rejectedProviderFailure(provider, "token_exchange", response) + } + var token struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + } + if err := decodeProviderJSON(response.Body, &token); err != nil || token.AccessToken == "" { + return identity.ProviderIdentity{}, invalidProviderResponse(provider, "token_exchange") + } + if provider == "google" { + if token.IDToken == "" { + return identity.ProviderIdentity{}, invalidProviderResponse(provider, "token_exchange") + } + identity, err := s.verifyGoogleIDToken(ctx, token.IDToken, nonce) + token.AccessToken, token.IDToken = "", "" + return identity, err + } + userinfo, _ := http.NewRequestWithContext(ctx, http.MethodGet, providerUserURL(provider), nil) + userinfo.Header.Set("Authorization", "Bearer "+token.AccessToken) + response, err = s.client.Do(userinfo) + token.AccessToken = "" + if err != nil { + return identity.ProviderIdentity{}, networkProviderFailure(ctx, provider, "userinfo", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return identity.ProviderIdentity{}, rejectedProviderFailure(provider, "userinfo", response) + } + var user struct { + ID string `json:"id"` + Sub string `json:"sub"` + } + if err := decodeProviderJSON(response.Body, &user); err != nil { + return identity.ProviderIdentity{}, invalidProviderResponse(provider, "userinfo") + } + if provider == "discord" && user.ID != "" { + return identity.ProviderIdentity{Issuer: "https://discord.com", Subject: user.ID}, nil + } + return identity.ProviderIdentity{}, invalidProviderResponse(provider, "userinfo") +} + +// verifyGoogleIDToken delegates signature and standard-claim verification to +// Google's HTTPS tokeninfo endpoint, then independently verifies this server's +// audience, nonce and expiry. The raw ID token is never persisted or logged. +func (s *Service) verifyGoogleIDToken(ctx context.Context, idToken, nonce string) (identity.ProviderIdentity, error) { + endpoint := "https://oauth2.googleapis.com/tokeninfo?id_token=" + url.QueryEscape(idToken) + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + response, err := s.client.Do(request) + if err != nil { + return identity.ProviderIdentity{}, networkProviderFailure(ctx, "google", "id_token_verify", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + return identity.ProviderIdentity{}, rejectedProviderFailure("google", "id_token_verify", response) + } + var claims struct { + Issuer string `json:"iss"` + Audience string `json:"aud"` + Subject string `json:"sub"` + Nonce string `json:"nonce"` + Expires string `json:"exp"` + } + if err := decodeProviderJSON(response.Body, &claims); err != nil { + return identity.ProviderIdentity{}, invalidProviderResponse("google", "id_token_verify") + } + expires, err := strconv.ParseInt(claims.Expires, 10, 64) + + if err != nil || !s.identity.ValidateGoogleClaims(claims.Issuer, claims.Audience, claims.Subject, claims.Nonce, nonce, expires) { + return identity.ProviderIdentity{}, &providerFailure{Provider: "google", Stage: "id_token_verify", Reason: "invalid_claims"} + } + return identity.ProviderIdentity{Issuer: "https://accounts.google.com", Subject: claims.Subject}, nil +} + +func decodeProviderJSON(reader io.Reader, target any) error { + data, err := io.ReadAll(io.LimitReader(reader, 1<<20+1)) + if err != nil { + return err + } + if len(data) > 1<<20 { + return errors.New("auth: provider response is too large") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(target); err != nil { + return err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("auth: provider response has trailing JSON") + } + return nil +} + +func (l *requestLimiter) allow(key string, now time.Time, duration time.Duration, maximum int) bool { + l.mu.Lock() + defer l.mu.Unlock() + if l.windows == nil { + l.windows = make(map[string]limitWindow) + } + if l.lastSweep.IsZero() || now.Sub(l.lastSweep) >= time.Minute { + for candidate, window := range l.windows { + if now.Sub(window.started) >= duration { + delete(l.windows, candidate) + } + } + l.lastSweep = now + } + window, exists := l.windows[key] + if !exists || now.Sub(window.started) >= duration { + if !exists && len(l.windows) >= 4096 { + return false + } + l.windows[key] = limitWindow{started: now, count: 1} + return true + } + if window.count >= maximum { + return false + } + window.count++ + l.windows[key] = window + return true +} + +func remoteIP(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err == nil && host != "" { + return host + } + return remoteAddr +} + +func (s *Service) redirectURL(provider string) string { + return s.config.PublicURL + "/auth/" + provider + "/callback" +} +func providerScope(provider string) string { + if provider == "discord" { + return "identify" + } + return "openid" +} +func providerAuthorizeURL(provider string) string { + if provider == "discord" { + return "https://discord.com/oauth2/authorize" + } + return "https://accounts.google.com/o/oauth2/v2/auth" +} +func providerTokenURL(provider string) string { + if provider == "discord" { + return "https://discord.com/api/v10/oauth2/token" + } + return "https://oauth2.googleapis.com/token" +} +func providerUserURL(provider string) string { + return "https://discord.com/api/v10/users/@me" +} + +func (s *Service) AuthorizeAdministrator(token string) (string, error) { + return s.identity.AuthorizeAdministrator(token) +} diff --git a/go/internal/server/authconfig/config.go b/go/internal/server/gateway/authconfig/config.go similarity index 99% rename from go/internal/server/authconfig/config.go rename to go/internal/server/gateway/authconfig/config.go index eb374ad..f111f80 100644 --- a/go/internal/server/authconfig/config.go +++ b/go/internal/server/gateway/authconfig/config.go @@ -13,7 +13,7 @@ import ( "strings" "time" - "bd2server/internal/server/configfile" + "bd2server/internal/server/platform/configfile" ) const FileName = "authentication.json" diff --git a/go/internal/server/bootstrap/bootstrap.go b/go/internal/server/gateway/bootstrap/bootstrap.go similarity index 98% rename from go/internal/server/bootstrap/bootstrap.go rename to go/internal/server/gateway/bootstrap/bootstrap.go index 6c623e9..d8dbb10 100644 --- a/go/internal/server/bootstrap/bootstrap.go +++ b/go/internal/server/gateway/bootstrap/bootstrap.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" ) type Config struct { diff --git a/go/internal/server/gateway/session/routing_test.go b/go/internal/server/gateway/session/routing_test.go new file mode 100644 index 0000000..d62ae3d --- /dev/null +++ b/go/internal/server/gateway/session/routing_test.go @@ -0,0 +1,264 @@ +package session_test + +import ( + "bd2server/internal/server/gateway/session" + "bd2server/internal/server/gateway/transport" + "bd2server/internal/server/runtime/player" + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +// This client implements the documented base64/protobuf/AES transport itself. +// It obtains each key from LoginUser, never through an internal test key API. +func clientEncrypt(t *testing.T, plain, key []byte, payload bool) []byte { + t.Helper() + if payload { + plain = []byte(base64.StdEncoding.EncodeToString(plain)) + } + block, e := aes.NewCipher(key) + if e != nil { + t.Fatal(e) + } + pad := aes.BlockSize - len(plain)%aes.BlockSize + plain = append(plain, bytes.Repeat([]byte{byte(pad)}, pad)...) + out := make([]byte, len(plain)) + cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(out, plain) + return []byte(base64.StdEncoding.EncodeToString(out)) +} +func clientDecrypt(t *testing.T, encoded string, key []byte) []byte { + t.Helper() + raw, e := base64.StdEncoding.DecodeString(encoded) + if e != nil { + t.Fatal(e) + } + block, e := aes.NewCipher(key) + if e != nil { + t.Fatal(e) + } + plain := make([]byte, len(raw)) + cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize)).CryptBlocks(plain, raw) + pad := int(plain[len(plain)-1]) + plain = plain[:len(plain)-pad] + decoded, e := base64.StdEncoding.DecodeString(string(plain)) + if e != nil { + t.Fatal(e) + } + return decoded +} +func scalar(n int, v uint64) []byte { + return binary.AppendUvarint(binary.AppendUvarint(nil, uint64(n<<3)), v) +} +func message(n int, b []byte) []byte { + out := binary.AppendUvarint(nil, uint64(n<<3|2)) + out = binary.AppendUvarint(out, uint64(len(b))) + return append(out, b...) +} +func field(proto []byte, wanted int) []byte { + for len(proto) > 0 { + k, n := binary.Uvarint(proto) + if n <= 0 { + return nil + } + proto = proto[n:] + if k&7 == 0 { + _, n = binary.Uvarint(proto) + if n <= 0 { + return nil + } + proto = proto[n:] + continue + } + if k&7 != 2 { + return nil + } + size, n := binary.Uvarint(proto) + if n <= 0 || size > uint64(len(proto)-n) { + return nil + } + value := proto[n : n+int(size)] + if int(k>>3) == wanted { + return value + } + proto = proto[n+int(size):] + } + return nil +} + +type authenticator struct{} + +func (authenticator) AuthenticateLogin(p []byte) (string, error) { + id := string(field(p, 2)) + if id != "A" && id != "B" { + return "", errors.New("unknown identity") + } + return id, nil +} + +type executor struct { + account string + blocked chan struct{} + release chan struct{} + once sync.Once + executions atomic.Int32 +} + +func (e *executor) Recover(context.Context) error { return nil } + +func (e *executor) Execute(ctx context.Context, c player.Command) (player.Reply, error) { + if c.Requests[0].Path == "/LoginUser" { + user := message(3, c.LoginSessionKey) + return player.Reply{Responses: []player.Response{{PacketCode: 3, Body: message(1, user)}}}, nil + } + if e.account == "A" { + e.once.Do(func() { close(e.blocked); <-e.release }) + } + if ctx.Err() != nil { + return player.Reply{}, ctx.Err() + } + e.executions.Add(1) + var reply player.Reply + for _, r := range c.Requests { + reply.Responses = append(reply.Responses, player.Response{PacketCode: 1, Body: message(1, []byte(e.account+r.Path)), Notification: message(1, []byte(r.Path))}) + } + return reply, nil +} + +type router map[string]*player.Runtime + +func (r router) Acquire(_ context.Context, id string) (*player.Runtime, func(), error) { + owner := r[id] + if owner == nil { + return nil, nil, errors.New("unknown player") + } + return owner, func() {}, nil +} +func TestCancelledClientRetryAndTwoAccountsDoNotShareExecution(t *testing.T) { + a := &executor{account: "A", blocked: make(chan struct{}), release: make(chan struct{})} + b := &executor{account: "B"} + owners := router{} + for id, e := range map[string]*executor{"A": a, "B": b} { + owner, err := player.New(id, e, player.Limits{}) + if err != nil { + t.Fatal(err) + } + owners[id] = owner + defer func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = owner.Close(ctx) + }() + } + server, err := session.NewServer(owners, authenticator{}) + if err != nil { + t.Fatal(err) + } + fixed := []byte("abcdefghijkrstuv024680wxyzlmnopq") + login := func(id string) (string, []byte) { + t.Helper() + request := append(scalar(1, 1), message(2, []byte(id))...) + reply, err := server.DispatchRaw(context.Background(), "/LoginUser", clientEncrypt(t, request, fixed, true), "") + if err != nil { + t.Fatal(err) + } + var envelope struct{ Data string } + if err = json.Unmarshal(reply.Body, &envelope); err != nil { + t.Fatal(err) + } + key := field(field(clientDecrypt(t, envelope.Data, fixed), 1), 3) + if len(key) != 32 { + t.Fatal("client received invalid login key") + } + return "s=" + reply.Cookie, key + } + aCookie, aKey := login("A") + bCookie, bKey := login("B") + if bytes.Equal(aKey, bKey) { + t.Fatal("accounts share encryption key") + } + request := scalar(1, 2) + aBody := clientEncrypt(t, request, aKey, true) + ctx, cancel := context.WithCancel(context.Background()) + pending := make(chan error, 1) + go func() { _, e := server.DispatchRaw(ctx, "/ItemInfo", aBody, aCookie); pending <- e }() + select { + case <-a.blocked: + case <-time.After(time.Second): + t.Fatal("A did not enter owner") + } + bDone := make(chan error, 1) + go func() { + _, e := server.DispatchRaw(context.Background(), "/ItemInfo", clientEncrypt(t, request, bKey, true), bCookie) + bDone <- e + }() + select { + case e := <-bDone: + if e != nil { + t.Fatal(e) + } + case <-time.After(time.Second): + t.Fatal("blocked A serialized unrelated B") + } + cancel() + select { + case e := <-pending: + if !errors.Is(e, context.Canceled) { + t.Fatal("HTTP cancellation not propagated", e) + } + case <-time.After(time.Second): + t.Fatal("cancelled client stayed blocked") + } + close(a.release) + if _, err = server.DispatchRaw(context.Background(), "/ItemInfo", aBody, aCookie); err != nil { + t.Fatal(err) + } + if a.executions.Load() != 1 || b.executions.Load() != 1 { + t.Fatal("retry duplicated accepted operation") + } + changed := clientEncrypt(t, append(request, scalar(2, 99)...), aKey, true) + if _, err = server.DispatchRaw(context.Background(), "/ItemInfo", changed, aCookie); !errors.Is(err, player.ErrIdentityConflict) { + t.Fatal("same operation identity accepted modified asset request", err) + } + if _, err = server.DispatchRaw(context.Background(), "/ItemInfo", aBody, bCookie); err == nil { + t.Fatal("one player's ciphertext accepted with another cookie") + } + // A restored actor has no unfinished battle/preview state. Old encrypted + // traffic must reconnect instead of interpreting a cached transient reply. + if err := owners["B"].Recover(context.Background()); err != nil { + t.Fatal(err) + } + if _, err = server.DispatchRaw(context.Background(), "/ItemInfo", clientEncrypt(t, scalar(1, 8), bKey, true), bCookie); !errors.Is(err, transport.ErrGameSessionExpired) { + t.Fatal("recovered owner accepted old game session", err) + } + bCookie, bKey = login("B") + oldCookie, oldKey := bCookie, bKey + bCookie, bKey = login("B") + if _, err = server.DispatchRaw(context.Background(), "/ItemInfo", clientEncrypt(t, scalar(1, 9), oldKey, true), oldCookie); !errors.Is(err, transport.ErrGameSessionExpired) { + t.Fatal("superseded login retained mutable player session", err) + } + if _, err = server.DispatchRaw(context.Background(), "/ItemInfo", clientEncrypt(t, scalar(1, 9), bKey, true), bCookie); err != nil { + t.Fatal("new game login rejected", err) + } + if err := owners["B"].Close(context.Background()); err != nil { + t.Fatal(err) + } + replacement, err := player.New("B", b, player.Limits{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = replacement.Close(context.Background()) }() + owners["B"] = replacement + if _, err = server.DispatchRaw(context.Background(), "/ItemInfo", clientEncrypt(t, scalar(1, 10), bKey, true), bCookie); !errors.Is(err, transport.ErrGameSessionExpired) { + t.Fatal("unloaded actor's key reused by fresh state generation", err) + } + +} diff --git a/go/internal/server/gateway/session/server.go b/go/internal/server/gateway/session/server.go new file mode 100644 index 0000000..8c5d73a --- /dev/null +++ b/go/internal/server/gateway/session/server.go @@ -0,0 +1,366 @@ +// Package session authenticates game sessions and routes commands to their account owner. +package session + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/gateway/transport" + "bd2server/internal/server/protocol" + "bd2server/internal/server/protocol/cryptox" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/runtime/player" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +const ( + gameSessionTTL = 24 * time.Hour + maxGameSessions = 1024 + maxCookieHeaderLen = 8 << 10 +) + +var errSessionRequired = errors.New("session login required") + +type LoginAuthenticator interface{ AuthenticateLogin([]byte) (string, error) } +type RuntimeRouter interface { + Acquire(context.Context, string) (*player.Runtime, func(), error) +} +type Handler interface { + Handle(command.Context, string, []byte) (int, []byte, bool, error) +} +type ResponseObserver interface { + BeforeDispatch(command.Context, string, []byte) error + AfterDispatch(command.Context, string, []byte, []byte) ([]byte, error) +} +type LoginService interface { + Login(command.Context, []byte, []byte) ([]byte, error) +} +type gameSession struct { + key []byte + accountID, id string + expiresAt, lastUsed time.Time + generation uint64 +} +type Server struct { + mu sync.Mutex + sessions map[[sha256.Size]byte]*gameSession + router RuntimeRouter + auth LoginAuthenticator + now func() time.Time + sessionTTL time.Duration + maxSessions int +} + +func NewServer(router RuntimeRouter, auth LoginAuthenticator) (*Server, error) { + if router == nil { + return nil, errors.New("session runtime router is nil") + } + return &Server{sessions: map[[sha256.Size]byte]*gameSession{}, router: router, auth: auth, now: time.Now, sessionTTL: gameSessionTTL, maxSessions: maxGameSessions}, nil +} +func (s *Server) DispatchRaw(ctx context.Context, path string, body []byte, cookie string) (reply transport.RawReply, err error) { + started := time.Now() + timing := requestTiming{} + completed := false + defer func() { timing.log(path, time.Since(started), len(reply.Body), err, completed) }() + reply, err = s.dispatchRaw(ctx, path, body, cookie, &timing) + completed = true + return reply, err +} +func (s *Server) dispatchRaw(ctx context.Context, path string, body []byte, cookie string, timing *requestTiming) (transport.RawReply, error) { + if err := ctx.Err(); err != nil { + return transport.RawReply{}, err + } + if path == "/LoginUser" { + return s.login(ctx, body, timing) + } + started := time.Now() + game, err := s.authorize(cookie) + timing.auth += time.Since(started) + if err != nil { + return transport.RawReply{}, err + } + defer clear(game.key) + started = time.Now() + var requests []player.Request + if path == "/BatchRequest" { + batch, decoded, err := protocol.DecodeBatchRequest(body, game.key) + if err != nil { + return transport.RawReply{}, err + } + for i, item := range batch { + requests = append(requests, player.Request{Path: item.Path, Body: decoded[i]}) + } + } else { + plain, err := cryptox.DecryptBase64Payload(string(body), game.key) + if err != nil { + return transport.RawReply{}, fmt.Errorf("%s decrypt: %w", path, err) + } + requests = []player.Request{{Path: path, Body: plain}} + } + timing.decode += time.Since(started) + timing.items = len(requests) + identity, digest, err := requestIdentity(game, requests) + if err != nil { + return transport.RawReply{}, err + } + response, _, err := s.execute(ctx, player.Command{Identity: identity, Digest: digest, Requests: requests}, game.generation, timing) + if err != nil { + return transport.RawReply{}, err + } + if len(response.Responses) != len(requests) { + return transport.RawReply{}, errors.New("player response count does not match command") + } + started = time.Now() + defer func() { timing.encode += time.Since(started) }() + if path != "/BatchRequest" { + item := response.Responses[0] + encoded, err := protocol.EncodeWithNotify(item.PacketCode, item.Body, game.key, s.now().UnixMilli(), item.Notification) + return transport.RawReply{Body: encoded}, err + } + items := make([]protocol.BatchResponse, 0, len(requests)) + for i, item := range response.Responses { + envelope, err := protocol.EnvelopeWithNotify(item.PacketCode, item.Body, game.key, s.now().UnixMilli(), item.Notification) + if err != nil { + return transport.RawReply{}, err + } + items = append(items, protocol.BatchResponse{Path: requests[i].Path, ResponseData: envelope}) + } + encoded, err := json.Marshal(items) + return transport.RawReply{Body: encoded}, err +} +func (s *Server) execute(ctx context.Context, c player.Command, expectedGeneration uint64, timing *requestTiming) (player.Reply, uint64, error) { + owner, release, err := s.router.Acquire(ctx, c.Identity.AccountID) + if err != nil { + return player.Reply{}, 0, err + } + if release == nil { + return player.Reply{}, 0, errors.New("player runtime lease has no release") + } + defer release() + generation := owner.Generation() + if expectedGeneration != 0 && generation != expectedGeneration { + return player.Reply{}, 0, transport.ErrGameSessionExpired + } + c.ExpectedGeneration = expectedGeneration + future, err := owner.Submit(ctx, c) + if err != nil { + if errors.Is(err, player.ErrGenerationExpired) { + return player.Reply{}, 0, transport.ErrGameSessionExpired + } + return player.Reply{}, 0, err + } + response, err := future.Wait(ctx) + timing.queue += response.Timing.Queue + timing.execute += response.Timing.Execute + timing.observer += response.Timing.Observer + timing.begin += response.Timing.Begin + timing.commit += response.Timing.Commit + timing.rollback += response.Timing.Rollback + if expectedGeneration != 0 { + var recovery interface{ RequiresRecovery() bool } + if errors.Is(err, player.ErrGenerationExpired) || owner.Generation() != generation || (errors.As(err, &recovery) && recovery.RequiresRecovery()) { + return player.Reply{}, 0, fmt.Errorf("%w: player state generation changed", transport.ErrGameSessionExpired) + } + } + return response, response.Generation, err +} +func (s *Server) login(ctx context.Context, body []byte, timing *requestTiming) (transport.RawReply, error) { + started := time.Now() + request, err := cryptox.DecryptBase64Payload(string(body), cryptox.Key()) + timing.decode += time.Since(started) + if err != nil { + return transport.RawReply{}, fmt.Errorf("LoginUser decrypt: %w", err) + } + accountID := "local-owner" + if s.auth != nil { + started = time.Now() + accountID, err = s.auth.AuthenticateLogin(request) + timing.auth += time.Since(started) + if err != nil { + return transport.RawReply{}, fmt.Errorf("%w: %v", transport.ErrAccessCredentialInvalid, err) + } + if accountID == "" { + return transport.RawReply{}, errors.New("LoginUser authentication returned an empty account ID") + } + } + game, token, err := newGameSession(accountID, s.now(), s.sessionTTL) + if err != nil { + return transport.RawReply{}, err + } + requests := []player.Request{{Path: "/LoginUser", Body: request}} + identity, digest, err := requestIdentity(game, requests) + if err != nil { + clear(game.key) + return transport.RawReply{}, err + } + response, generation, err := s.execute(ctx, player.Command{Identity: identity, Digest: digest, Requests: requests, LoginSessionKey: game.key}, 0, timing) + if err != nil { + clear(game.key) + return transport.RawReply{}, err + } + if len(response.Responses) != 1 { + clear(game.key) + return transport.RawReply{}, errors.New("login player response count invalid") + } + started = time.Now() + encoded, err := protocol.Encode(3, response.Responses[0].Body, cryptox.Key(), s.now().UnixMilli()) + timing.encode += time.Since(started) + if err != nil { + clear(game.key) + return transport.RawReply{}, err + } + s.mu.Lock() + s.pruneSessions(s.now()) + for existingToken, existing := range s.sessions { + if existing.accountID == accountID { + s.deleteSession(existingToken, existing) + } + } + s.makeSessionRoom() + game.generation = generation + s.sessions[sessionTokenKey(token)] = game + s.mu.Unlock() + return transport.RawReply{Body: encoded, Cookie: token}, nil +} +func requestIdentity(game *gameSession, requests []player.Request) (command.Identity, [32]byte, error) { + sequences := sha256.New() + content := sha256.New() + var size [8]byte + for _, req := range requests { + seq, present, err := wire.Varint(req.Body, 1) + if err != nil || !present || seq == 0 { + return command.Identity{}, [32]byte{}, fmt.Errorf("%s has no request sequence", req.Path) + } + binary.BigEndian.PutUint64(size[:], uint64(len(req.Path))) + _, _ = sequences.Write(size[:]) + _, _ = sequences.Write([]byte(req.Path)) + binary.BigEndian.PutUint64(size[:], seq) + _, _ = sequences.Write(size[:]) + binary.BigEndian.PutUint64(size[:], uint64(len(req.Path))) + _, _ = content.Write(size[:]) + _, _ = content.Write([]byte(req.Path)) + binary.BigEndian.PutUint64(size[:], uint64(len(req.Body))) + _, _ = content.Write(size[:]) + _, _ = content.Write(req.Body) + } + var digest [32]byte + copy(digest[:], content.Sum(nil)) + return command.Identity{AccountID: game.accountID, SessionID: game.id, RequestID: hex.EncodeToString(sequences.Sum(nil))}, digest, nil +} +func (s *Server) authorize(cookie string) (*gameSession, error) { + token, err := parseSessionCookie(cookie) + if err != nil { + if errors.Is(err, errSessionRequired) { + return nil, fmt.Errorf("%w: %v", transport.ErrGameSessionExpired, err) + } + return nil, err + } + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + s.pruneSessions(now) + game, ok := s.sessions[sessionTokenKey(token)] + if !ok { + return nil, fmt.Errorf("%w: cookie no longer names a live session", transport.ErrGameSessionExpired) + } + game.lastUsed = now + return &gameSession{key: append([]byte(nil), game.key...), accountID: game.accountID, id: game.id, expiresAt: game.expiresAt, lastUsed: game.lastUsed, generation: game.generation}, nil +} +func newGameSession(accountID string, now time.Time, ttl time.Duration) (*gameSession, string, error) { + keyBytes := make([]byte, 16) + if _, err := rand.Read(keyBytes); err != nil { + return nil, "", fmt.Errorf("create game session key: %w", err) + } + tokenBytes := make([]byte, 24) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, "", fmt.Errorf("create game session token: %w", err) + } + idBytes := make([]byte, 12) + if _, err := rand.Read(idBytes); err != nil { + return nil, "", fmt.Errorf("create login request identity: %w", err) + } + return &gameSession{ + key: []byte(hex.EncodeToString(keyBytes)), + accountID: accountID, + id: hex.EncodeToString(idBytes), + expiresAt: now.Add(ttl), + lastUsed: now, + }, hex.EncodeToString(tokenBytes) + "|1", nil +} + +func sessionTokenKey(token string) [sha256.Size]byte { return sha256.Sum256([]byte(token)) } + +func (s *Server) pruneSessions(now time.Time) { + for token, game := range s.sessions { + if !now.Before(game.expiresAt) { + s.deleteSession(token, game) + } + } +} + +func (s *Server) makeSessionRoom() { + for len(s.sessions) >= s.maxSessions { + var oldestToken [sha256.Size]byte + var oldest *gameSession + for token, game := range s.sessions { + if oldest == nil || game.lastUsed.Before(oldest.lastUsed) { + oldestToken, oldest = token, game + } + } + if oldest == nil { + return + } + s.deleteSession(oldestToken, oldest) + } +} + +func (s *Server) deleteSession(token [sha256.Size]byte, game *gameSession) { + delete(s.sessions, token) + for i := range game.key { + game.key[i] = 0 + } + game.accountID = "" + game.id = "" +} + +func parseSessionCookie(cookie string) (string, error) { + if cookie == "" { + return "", errSessionRequired + } + if len(cookie) > maxCookieHeaderLen { + return "", errors.New("game session cookie header is too large") + } + var token string + seen := false + for value := range strings.SplitSeq(cookie, ";") { + name, candidate, found := strings.Cut(strings.TrimSpace(value), "=") + if !found || name != "s" { + continue + } + if seen { + return "", errors.New("duplicate game session cookie") + } + seen = true + token = candidate + } + if !seen { + return "", errSessionRequired + } + if len(token) != 50 || token[48:] != "|1" { + return "", errors.New("invalid game session cookie") + } + for _, char := range token[:48] { + if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') { //nolint:staticcheck // QF1001 + return "", errors.New("invalid game session cookie") + } + } + return token, nil +} diff --git a/go/internal/server/gateway/session/timing.go b/go/internal/server/gateway/session/timing.go new file mode 100644 index 0000000..9276411 --- /dev/null +++ b/go/internal/server/gateway/session/timing.go @@ -0,0 +1,33 @@ +package session + +import ( + "log/slog" + "time" +) + +type requestTiming struct { + queue, decode, auth, execute, observer time.Duration + begin, commit, rollback, encode time.Duration + items int +} + +func (t *requestTiming) log(path string, total time.Duration, responseBytes int, err error, completed bool) { + status := "ok" + if !completed { + status = "panic" + } else if err != nil { + status = "error" + } + other := total - t.queue - t.decode - t.auth - t.execute - t.observer - t.begin - t.commit - t.rollback - t.encode + slog.Info("session request phases", "path", path, "status", status, + "queue_ms", milliseconds(t.queue), "decode_ms", milliseconds(t.decode), + "auth_ms", milliseconds(t.auth), "execute_ms", milliseconds(t.execute), + "observer_ms", milliseconds(t.observer), "transaction_begin_ms", milliseconds(t.begin), + "commit_ms", milliseconds(t.commit), "rollback_ms", milliseconds(t.rollback), + "encode_ms", milliseconds(t.encode), "other_ms", milliseconds(other), + "duration_ms", milliseconds(total), "items", t.items, "response_bytes", responseBytes) +} + +func milliseconds(duration time.Duration) float64 { + return float64(duration) / float64(time.Millisecond) +} diff --git a/go/internal/server/transport/commerce.go b/go/internal/server/gateway/transport/commerce.go similarity index 100% rename from go/internal/server/transport/commerce.go rename to go/internal/server/gateway/transport/commerce.go diff --git a/go/internal/server/transport/http.go b/go/internal/server/gateway/transport/http.go similarity index 92% rename from go/internal/server/transport/http.go rename to go/internal/server/gateway/transport/http.go index 26f86a8..95346ad 100644 --- a/go/internal/server/transport/http.go +++ b/go/internal/server/gateway/transport/http.go @@ -2,6 +2,7 @@ package transport import ( + "context" "encoding/base64" "encoding/json" "errors" @@ -12,10 +13,11 @@ import ( "strings" "time" - "bd2server/internal/server/authconfig" - "bd2server/internal/server/bootstrap" - "bd2server/internal/server/resourcepolicy" - "bd2server/internal/server/stateio" + "bd2server/internal/server/gateway/authconfig" + "bd2server/internal/server/gateway/bootstrap" + "bd2server/internal/server/resources/policy" + "bd2server/internal/server/runtime/player" + "bd2server/internal/server/storage/stateio" ) type Envelope struct { @@ -50,7 +52,7 @@ type RawReply struct { // RawDispatcher handles encrypted/session packets whose outer representation // is not always an Envelope (BatchRequest returns a JSON array). type RawDispatcher interface { - DispatchRaw(path string, wireBody []byte, cookie string) (RawReply, error) + DispatchRaw(ctx context.Context, path string, wireBody []byte, cookie string) (RawReply, error) } type Availability interface { @@ -280,10 +282,22 @@ func (h HTTP) game(w http.ResponseWriter, r *http.Request) { http.Error(w, "authenticated packet not yet implemented", http.StatusNotImplemented) return } - reply, err := h.Raw.DispatchRaw(path, body, r.Header.Get("Cookie")) + reply, err := h.Raw.DispatchRaw(r.Context(), path, body, r.Header.Get("Cookie")) if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + if errors.Is(err, player.ErrMailboxFull) { + w.Header().Set("Retry-After", "1") + http.Error(w, "player command capacity exhausted", http.StatusServiceUnavailable) + return + } + if errors.Is(err, player.ErrClosed) || errors.Is(err, player.ErrUnavailable) { + h.reconnect(w, "player-unavailable") + return + } if errors.Is(err, stateio.ErrStateRecoveryRequired) { - h.logger().Error("account state requires process recovery", "path", path, "duration_ms", elapsedMilliseconds(started), "error", err) + h.logger().Error("account state isolated for recovery", "path", path, "duration_ms", elapsedMilliseconds(started), "error", err) h.reconnect(w, "state-recovery") return } diff --git a/go/internal/server/hunting/dispatch.go b/go/internal/server/hunting/dispatch.go deleted file mode 100644 index 1980cf4..0000000 --- a/go/internal/server/hunting/dispatch.go +++ /dev/null @@ -1,402 +0,0 @@ -package hunting - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "bytes" - "crypto/rand" - "encoding/json" - "fmt" - "math/big" - "sort" - "strconv" - "strings" - "time" -) - -type dispatchReceipt struct { - Request, Response []byte - Code int -} -type dispatchJob struct { - Group, ID, Count, Start, End, Free, Bonus uint64 - Rewards []gamedata.BattleReward - Runs [][]gamedata.BattleReward -} -type dispatchState struct { - Version string `json:"version"` - Jobs map[string]dispatchJob `json:"jobs"` - Receipts map[string]dispatchReceipt `json:"receipts"` -} - -func (s *Service) AttachDispatchEligibility(check func(*gamedata.DispatchDesign) error) { - s.mu.Lock() - defer s.mu.Unlock() - s.dispatchEligibility = check -} -func (s *Service) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { - if strings.HasPrefix(path, "/HuntDispatch") { - return s.handleDispatch(path, req, session) - } - return s.handleGroundSession(path, req, session) -} -func (s *Service) handleDispatch(path string, req []byte, session string) (int, []byte, bool, error) { - code := map[string]int{"/HuntDispatchInfo": 189, "/HuntDispatchStart": 190, "/HuntDispatchEnd": 191, "/HuntDispatchRewardPreview": 194, "/HuntDispatch": 0}[path] - if path != "/HuntDispatchInfo" && path != "/HuntDispatchStart" && path != "/HuntDispatchEnd" && path != "/HuntDispatchRewardPreview" && path != "/HuntDispatch" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - if e := s.refreshAP(); e != nil { - return code, nil, true, e - } - seq, found, err := wire.Varint(req, 1) - if err != nil || !found || seq == 0 { - return code, nil, true, fmt.Errorf("hunting: missing sequence") - } - ds := dispatchState{Version: versionconfig.State(), Jobs: map[string]dispatchJob{}, Receipts: map[string]dispatchReceipt{}} - raw, err := s.storage.Load("huntdispatch") - if err != nil { - return code, nil, true, err - } - if raw != nil { - if err = stateio.RequireExactJSONObject(raw, "version", "jobs", "receipts"); err != nil { - return code, nil, true, err - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if err = decoder.Decode(&ds); err != nil { - return code, nil, true, err - } - if ds.Version != versionconfig.State() || ds.Jobs == nil || ds.Receipts == nil { - return code, nil, true, fmt.Errorf("hunting: invalid dispatch state") - } - } - if path == "/HuntDispatchInfo" { - var out []byte - keys := make([]string, 0, len(ds.Jobs)) - for k := range ds.Jobs { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - out = wire.AppendBytes(out, 1, dispatchJobWire(ds.Jobs[k])) - } - // A client without a remembered dispatch pack needs a default. This - // server selects the smallest cleared ordinary hunting pack. - var minimum uint64 - for key, state := range s.state.Packs { - if state.Highest == 0 { - continue - } - pack, err := strconv.ParseUint(key, 10, 32) - if err != nil || pack == 0 { - return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch pack") - } - if minimum == 0 || pack < minimum { - minimum = pack - } - } - if minimum != 0 { - out = wire.AppendVarint(out, 2, minimum) - } - return code, out, true, nil - } - for k, j := range ds.Jobs { - if k != fmt.Sprintf("%d:%d", j.Group, j.ID) || j.Group == 0 || j.ID == 0 || j.Count == 0 || j.Count > 20 || j.Start == 0 || j.End < j.Start || len(j.Runs) != int(j.Count) || j.Free > 2147483647 || j.Bonus > 2147483647 { - return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch job") - } - } - for k, r := range ds.Receipts { - if k == "" || len(r.Request) == 0 || r.Response == nil { - return code, nil, true, fmt.Errorf("hunting: invalid saved dispatch receipt") - } - } - key := session + ":" + strconv.FormatUint(seq, 10) - if r, ok := ds.Receipts[key]; ok { - if !bytes.Equal(r.Request, append([]byte(path), req...)) { - return code, nil, true, fmt.Errorf("hunting: sequence reused with different dispatch request") - } - return r.Code, r.Response, true, nil - } - group, _, e := wire.Varint(req, 2) - id, _, e2 := wire.Varint(req, 3) - if e != nil || e2 != nil || group == 0 || id == 0 { - return code, nil, true, fmt.Errorf("hunting: invalid dispatch ID") - } - jobKey := fmt.Sprintf("%d:%d", group, id) - job, exists := ds.Jobs[jobKey] - d, err := s.dispatchLoad(group, id) - if err != nil { - return code, nil, true, err - } - var out []byte - if path == "/HuntDispatchRewardPreview" { - if !exists { - return code, nil, true, fmt.Errorf("hunting: dispatch not started") - } - played := dispatchPlayed(job, d.ClearTime) - out = wire.AppendVarint(out, 1, played) - preview := dispatchCompletedRewards(job, played) - remaining := d.AP * (job.Count - played) - bonus := min(remaining, job.Bonus) - free := remaining - bonus - if free > 0 { - preview = append(preview, gamedata.BattleReward{Type: 21, Count: free}) - } - if bonus > 0 { - preview = append(preview, gamedata.BattleReward{Type: 23, Count: bonus}) - } - bundle := dispatchPreview(preview) - for _, r := range preview { - v := wire.AppendVarint(nil, 2, r.ID) - v = wire.AppendVarint(v, 3, r.Type) - v = wire.AppendVarint(v, 4, r.Count) - bundle = wire.AppendBytes(bundle, 1, v) - } - out = wire.AppendBytes(out, 2, bundle) - return code, out, true, nil - } - if path == "/HuntDispatchEnd" { - if !exists { - return code, nil, true, fmt.Errorf("hunting: dispatch not started") - } - played := dispatchPlayed(job, d.ClearTime) - completed := dispatchCompletedRewards(job, played) - remaining := d.AP * (job.Count - played) - refundBonus := min(remaining, job.Bonus) - refundFree := remaining - refundBonus - next := s.clone() - next.Free += refundFree - next.Bonus += refundBonus - if e := s.persist(next); e != nil { - return code, nil, true, e - } - if refundFree > 0 { - completed = append(completed, gamedata.BattleReward{Type: 21, Count: refundFree}) - } - if refundBonus > 0 { - completed = append(completed, gamedata.BattleReward{Type: 23, Count: refundBonus}) - } - bundle, e := s.dispatchGrant("dispatch:"+key, completed) - if e != nil { - return code, nil, true, e - } - out = wire.AppendBytes(out, 1, bundle) - delete(ds.Jobs, jobKey) - } else { - count, _, e := wire.Varint(req, 4) - if e != nil || count == 0 || count > 20 { - return code, nil, true, fmt.Errorf("hunting: dispatch count must be 1..20") - } - if exists { - return code, nil, true, fmt.Errorf("hunting: dispatch already running") - } - for _, active := range ds.Jobs { - design, e := s.dispatchLoad(active.Group, active.ID) - if e != nil { - return code, nil, true, e - } - if design.TypeGroup == d.TypeGroup { - return code, nil, true, fmt.Errorf("hunting: dispatch category already running") - } - } - if s.dispatchEligibility != nil { - if e = s.dispatchEligibility(d); e != nil { - return code, nil, true, e - } - } else { - if d.TypeGroup != 0 { - return code, nil, true, fmt.Errorf("hunting: SkyWay dispatch eligibility unavailable") - } - st := s.state.Packs[strconv.Itoa(int(d.Pack))] - if st.Highest < d.GroundID { - return code, nil, true, fmt.Errorf("hunting: dispatch requires cleared difficulty") - } - } - if d.AP > uint64(2147483647)/count { - return code, nil, true, fmt.Errorf("hunting: dispatch AP overflow") - } - cost := d.AP * count - if s.state.Free < cost && s.state.Bonus < cost-s.state.Free { - return code, nil, true, fmt.Errorf("hunting: insufficient hunting AP") - } - rewards, e := d.Roll(count, func(n uint64) (uint64, error) { - v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) - if e != nil { - return 0, e - } - return v.Uint64(), nil - }) - if e != nil { - return code, nil, true, e - } - next := s.clone() - free := min(cost, next.Free) - bonus := cost - free - next.Free -= free - next.Bonus -= bonus - if path == "/HuntDispatchStart" { - now := uint64(time.Now().UnixMilli()) - runs := make([][]gamedata.BattleReward, count) - for i := range runs { - runs[i], e = d.Roll(1, func(n uint64) (uint64, error) { - v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) - if e != nil { - return 0, e - } - return v.Uint64(), nil - }) - if e != nil { - return code, nil, true, e - } - } - job = dispatchJob{Group: group, ID: id, Count: count, Start: now, End: now + d.ClearTime*count*1000, Free: free, Bonus: bonus, Rewards: rewards, Runs: runs} - ds.Jobs[jobKey] = job - out = wire.AppendBytes(out, 1, dispatchJobWire(job)) - } else { - beforeFree, beforeBonus := s.state.Free, s.state.Bonus - bundle, e := s.dispatchGrant("dispatch:"+key, rewards) - if e != nil { - return code, nil, true, e - } - out = wire.AppendBytes(out, 1, bundle) - if s.state.Free >= beforeFree { - next.Free += s.state.Free - beforeFree - } else { - delta := beforeFree - s.state.Free - if next.Free < delta { - return code, nil, true, fmt.Errorf("hunting: AP grant conflict") - } - next.Free -= delta - } - if s.state.Bonus >= beforeBonus { - next.Bonus += s.state.Bonus - beforeBonus - } else { - delta := beforeBonus - s.state.Bonus - if next.Bonus < delta { - return code, nil, true, fmt.Errorf("hunting: AP grant conflict") - } - next.Bonus -= delta - } - } - if e = s.persist(next); e != nil { - return code, nil, true, e - } - } - ds.Receipts[key] = dispatchReceipt{append([]byte(path), req...), out, code} - b, e := json.Marshal(ds) - if e != nil { - return code, nil, true, e - } - if e = s.storage.Save("huntdispatch", b); e != nil { - return code, nil, true, e - } - return code, out, true, nil -} -func dispatchPlayed(j dispatchJob, seconds uint64) uint64 { - if seconds == 0 { - return j.Count - } - now := uint64(time.Now().UnixMilli()) - if now <= j.Start { - return 0 - } - n := min((now-j.Start)/(seconds*1000), j.Count) - return n -} -func dispatchJobWire(j dispatchJob) []byte { - var out []byte - for n, v := range map[int]uint64{1: j.Group, 2: j.ID, 3: j.Count, 4: j.Start, 5: j.End, 6: j.Free, 7: j.Bonus} { - out = wire.AppendVarint(out, n, v) - } - return out -} -func dispatchPreview(rs []gamedata.BattleReward) []byte { - var out []byte - for _, r := range rs { - v := wire.AppendVarint(nil, 2, r.ID) - v = wire.AppendVarint(v, 3, r.Type) - v = wire.AppendVarint(v, 4, r.Count) - out = wire.AppendBytes(out, 6, v) - } - return out -} -func (s *Service) dispatchGrant(identity string, rs []gamedata.BattleReward) ([]byte, error) { - if s.grant != nil { - var rewards []gamedata.Reward - var ap []gamedata.BattleReward - for _, r := range rs { - if r.Type == 21 || r.Type == 23 { - ap = append(ap, r) - } else { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - } - } - s.mu.Unlock() - bundle, err := s.grant(identity, rewards) - s.mu.Lock() - if err != nil { - return nil, err - } - bundle = append(bundle, dispatchPreview(ap)...) - for _, r := range ap { - v := wire.AppendVarint(nil, 3, r.Type) - v = wire.AppendVarint(v, 4, r.Count) - bundle = wire.AppendBytes(bundle, 1, v) - } - return bundle, nil - } - - var currency []gamedata.Reward - var items []gamedata.BattleReward - for _, r := range rs { - switch r.Type { - case 21, 23: - continue - case 2, 3, 4, 12, 20: - currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - default: - items = append(items, r) - } - } - if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil { - return nil, err - } - granted, err := s.inventory.GrantOnce(identity+":items", items) - if err != nil { - return nil, err - } - if len(granted) == 0 { - granted = s.inventory.GrantedItems(identity + ":items") - } - out := dispatchPreview(rs) - for _, r := range rs { - if r.Type == 21 || r.Type == 23 { - v := wire.AppendVarint(nil, 3, r.Type) - v = wire.AppendVarint(v, 4, r.Count) - out = wire.AppendBytes(out, 1, v) - } - } - for _, r := range currency { - v := wire.AppendVarint(nil, 2, r.ID) - v = wire.AppendVarint(v, 3, r.Type) - v = wire.AppendVarint(v, 4, r.Count) - out = wire.AppendBytes(out, 1, v) - } - for _, item := range granted { - out = wire.AppendBytes(out, 1, player.ItemWire(item)) - } - return out, nil -} - -func dispatchCompletedRewards(j dispatchJob, n uint64) []gamedata.BattleReward { - var out []gamedata.BattleReward - for i := uint64(0); i < n && i < uint64(len(j.Runs)); i++ { - out = append(out, j.Runs[i]...) - } - return out -} diff --git a/go/internal/server/hunting/dispatch_test.go b/go/internal/server/hunting/dispatch_test.go deleted file mode 100644 index 6bbbbde..0000000 --- a/go/internal/server/hunting/dispatch_test.go +++ /dev/null @@ -1,124 +0,0 @@ -package hunting - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "testing" -) - -func TestDispatchSweepReplayAndValidation(t *testing.T) { - store := stateio.NewMemory() - inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s, err := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 100, 10) - if err != nil { - t.Fatal(err) - } - s.dispatchLoad = func(g, id uint64) (*gamedata.DispatchDesign, error) { - return &gamedata.DispatchDesign{Group: g, ID: id, Pack: 1, GroundID: 1, AP: 6, ClearTime: 1200, Rewards: []gamedata.BattleReward{{Type: 4, Count: 10}, {Type: 8, ID: 1000, Count: 1}}}, nil - } - s.state.Packs["1"] = packState{Highest: 1} - req := wire.AppendVarint(nil, 1, 1) - req = wire.AppendVarint(req, 2, 1) - req = wire.AppendVarint(req, 3, 1) - req = wire.AppendVarint(req, 4, 2) - _, a, _, err := s.HandleSession("/HuntDispatch", req, "s1") - if err != nil { - t.Fatal(err) - } - _, b, _, err := s.HandleSession("/HuntDispatch", req, "s1") - if err != nil || !bytes.Equal(a, b) { - t.Fatalf("replay %v", err) - } - free, bonus, _ := s.HuntingAP() - if free != 88 || bonus != 10 || wallet.Snapshot().Gold != 20 { - t.Fatalf("AP/currency %d/%d %+v", free, bonus, wallet.Snapshot()) - } - req = wire.AppendVarint(req, 4, 3) - if _, _, _, err = s.HandleSession("/HuntDispatch", req, "s1"); err == nil { - t.Fatal("sequence conflict accepted") - } - s.state.Packs["1"] = packState{} - if _, _, _, err = s.HandleSession("/HuntDispatch", req, "s2"); err == nil { - t.Fatal("uncleared ground accepted") - } -} -func TestDispatchCancelRefundsUnplayedAP(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 10) - s.dispatchLoad = func(g, id uint64) (*gamedata.DispatchDesign, error) { - return &gamedata.DispatchDesign{Group: g, ID: id, Pack: 1, GroundID: 1, AP: 6, ClearTime: 1200, Rewards: []gamedata.BattleReward{{Type: 4, Count: 10}}}, nil - } - s.state.Packs["1"] = packState{Highest: 1} - req := wire.AppendVarint(nil, 1, 1) - req = wire.AppendVarint(req, 2, 1) - req = wire.AppendVarint(req, 3, 1) - req = wire.AppendVarint(req, 4, 2) - if _, _, _, e := s.HandleSession("/HuntDispatchStart", req, "session"); e != nil { - t.Fatal(e) - } - end := wire.AppendVarint(nil, 1, 2) - end = wire.AppendVarint(end, 2, 1) - end = wire.AppendVarint(end, 3, 1) - if _, _, _, e := s.HandleSession("/HuntDispatchEnd", end, "session"); e != nil { - t.Fatal(e) - } - free, bonus, _ := s.HuntingAP() - if free != 10 || bonus != 10 || wallet.Snapshot().Gold != 0 { - t.Fatalf("cancel %d %d %+v", free, bonus, wallet.Snapshot()) - } -} - -func TestDispatchPreviewHasAPItemInfo(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 10) - s.dispatchLoad = func(g, id uint64) (*gamedata.DispatchDesign, error) { - return &gamedata.DispatchDesign{Group: g, ID: id, Pack: 1, GroundID: 1, AP: 6, ClearTime: 1200, Rewards: []gamedata.BattleReward{{Type: 4, Count: 10}}}, nil - } - s.state.Packs["1"] = packState{Highest: 1} - req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 1), 4, 2) - if _, _, _, e := s.HandleSession("/HuntDispatchStart", req, "session"); e != nil { - t.Fatal(e) - } - preview := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1), 3, 1) - _, out, _, e := s.HandleSession("/HuntDispatchRewardPreview", preview, "session") - if e != nil { - t.Fatal(e) - } - totals := map[uint64]uint64{} - if err := wire.Walk(out, func(f wire.Field) error { - if f.Number == 2 { - return wire.Walk(f.Value, func(item wire.Field) error { - if item.Number == 1 { - typ, _, _ := wire.Varint(item.Value, 3) - n, _, _ := wire.Varint(item.Value, 4) - totals[typ] += n - } - return nil - }) - } - return nil - }); err != nil { - t.Fatal(err) - } - if totals[21] != 10 || totals[23] != 2 || totals[4] != 0 { - t.Fatalf("preview refund ItemInfo %+v", totals) - } - free, bonus, _ := s.HuntingAP() - if free != 0 || bonus != 8 { - t.Fatal("preview changed AP") - } -} diff --git a/go/internal/server/hunting/dispatch_transaction_test.go b/go/internal/server/hunting/dispatch_transaction_test.go deleted file mode 100644 index 55a5dfc..0000000 --- a/go/internal/server/hunting/dispatch_transaction_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package hunting - -import ( - "errors" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type failedDispatchStore struct{ stateio.Store } - -func (s failedDispatchStore) Save(name string, payload []byte) error { - if name == "huntdispatch" { - return errors.New("injected dispatch receipt write failure") - } - return s.Store.Save(name, payload) -} - -func TestDispatchReceiptFailureRollsBackAPAndRewards(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12}) - if err != nil { - t.Fatal(err) - } - if err = inv.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - s, err := Open(failedDispatchStore{repo}, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 4) - if err != nil { - t.Fatal(err) - } - s.state.Packs["1"] = packState{Highest: 1} - s.dispatchLoad = func(group, id uint64) (*gamedata.DispatchDesign, error) { - return &gamedata.DispatchDesign{Group: group, ID: id, Pack: 1, GroundID: 1, AP: 2, Rewards: []gamedata.BattleReward{{Type: 4, Count: 100}, {Type: 8, ID: 987, Count: 3}}}, nil - } - operation, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 1), 4, 2) - if _, _, _, err = s.HandleSession("/HuntDispatch", req, "session-A"); err == nil { - _ = operation.Rollback() - t.Fatal("receipt failure accepted") - } - _ = operation.Rollback() - if err = repo.Close(); err != nil { - t.Fatal(err) - } - reopened, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := reopened.Close(); err != nil { - t.Error(err) - } - }() - for _, name := range []string{"hunting", "huntdispatch"} { - raw, err := reopened.Load(name) - if err != nil || raw != nil { - t.Fatalf("%s survived rollback: %s %v", name, raw, err) - } - } - inv, err = player.OpenInventory(reopened, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err = player.OpenWallet(reopened, player.Currency{}) - if err != nil { - t.Fatal(err) - } - if len(inv.All()) != 0 || wallet.Snapshot().Gold != 12 { - t.Fatalf("dispatch rewards survived rollback: %+v %+v", inv.All(), wallet.Snapshot()) - } -} diff --git a/go/internal/server/hunting/hunting.go b/go/internal/server/hunting/hunting.go deleted file mode 100644 index fba117e..0000000 --- a/go/internal/server/hunting/hunting.go +++ /dev/null @@ -1,516 +0,0 @@ -// Package hunting owns ordinary HuntingGround progress and repeatable rewards. -package hunting - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "encoding/binary" - "encoding/json" - "fmt" - "maps" - "math" - "slices" - "strconv" - "sync" - "time" -) - -const BattleMode uint64 = 5 - -type packState struct { - Current, Highest uint64 - Auto bool - Defeated []uint64 -} -type snapshot struct { - Version string `json:"version"` - Free uint64 `json:"free"` - Bonus uint64 `json:"bonus"` - Packs map[string]packState `json:"packs"` - Receipts map[string]bool `json:"receipts"` -} -type Service struct { - mu sync.Mutex - storage stateio.Store - inventory *player.Inventory - wallet *player.Wallet - state snapshot - currentPack func() (int, error) - load func(int) (*gamedata.HuntingPack, error) - dispatchLoad func(uint64, uint64) (*gamedata.DispatchDesign, error) - dispatchEligibility func(*gamedata.DispatchDesign) error - eligibility func(int, uint64) error - grant func(string, []gamedata.Reward) ([]byte, error) - apDesign *gamedata.HuntingAPDesign - now func() time.Time -} - -func Open(store stateio.Store, root, version string, inventory *player.Inventory, wallet *player.Wallet, currentPack func() (int, error), free, bonus uint64) (*Service, error) { - if store == nil || inventory == nil || wallet == nil || currentPack == nil { - return nil, fmt.Errorf("hunting: invalid configuration") - } - s := &Service{storage: store, inventory: inventory, wallet: wallet, currentPack: currentPack, state: snapshot{versionconfig.State(), free, bonus, map[string]packState{}, map[string]bool{}}} - s.dispatchLoad = func(group, id uint64) (*gamedata.DispatchDesign, error) { - return gamedata.LoadDispatchDesign(root, version, group, id) - } - s.load = func(pack int) (*gamedata.HuntingPack, error) { return gamedata.LoadHuntingPack(root, version, pack) } - b, err := store.Load("hunting") - if err != nil { - return nil, err - } - if b != nil { - if err := stateio.RequireExactJSONObject(b, "version", "free", "bonus", "packs", "receipts"); err != nil { - return nil, err - } - if err := json.Unmarshal(b, &s.state); err != nil { - return nil, err - } - if s.state.Version != versionconfig.State() || s.state.Packs == nil || s.state.Receipts == nil { - return nil, fmt.Errorf("hunting: incompatible state") - } - } - if s.state.Free > math.MaxInt32 || s.state.Bonus > math.MaxInt32 { - return nil, fmt.Errorf("hunting: AP exceeds protocol range") - } - for key, st := range s.state.Packs { - pack, err := strconv.Atoi(key) - if err != nil || pack <= 0 || strconv.Itoa(pack) != key { - return nil, fmt.Errorf("hunting: invalid saved pack") - } - d, err := s.load(pack) - if err != nil { - return nil, err - } - if _, ok := ground(d, st.Current); !ok { - return nil, fmt.Errorf("hunting: saved current ground missing from GameData") - } - if st.Highest != 0 { - if _, ok := ground(d, st.Highest); !ok { - return nil, fmt.Errorf("hunting: saved highest ground missing from GameData") - } - } - g, _ := ground(d, st.Current) - seen := map[uint64]bool{} - for _, id := range st.Defeated { - member := id == g.BossID - for _, candidate := range g.Monsters { - member = member || id == candidate - } - if !member || seen[id] { - return nil, fmt.Errorf("hunting: invalid defeated monster") - } - seen[id] = true - } - } - for key, claimed := range s.state.Receipts { - if key == "" || !claimed { - return nil, fmt.Errorf("hunting: invalid receipt") - } - } - return s, nil -} -func (s *Service) HuntingAP() (uint64, uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - if e := s.refreshAP(); e != nil { - return 0, 0, e - } - return s.state.Free, s.state.Bonus, nil -} -func (s *Service) Handle(path string, req []byte) (int, []byte, bool, error) { - if path == "/HuntDispatch" || path == "/HuntDispatchInfo" || path == "/HuntDispatchStart" || path == "/HuntDispatchEnd" || path == "/HuntDispatchRewardPreview" { - return s.handleDispatch(path, req, "local") - } - if path != "/HuntingGroundInfo" && path != "/HuntingGroundInfoList" && path != "/HuntingGroundEnter" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - seq, found, err := wire.Varint(req, 1) - if err != nil || !found || seq == 0 { - return 0, nil, true, fmt.Errorf("hunting: missing sequence") - } - if path == "/HuntingGroundInfoList" { - ids, err := packed(req, 2) - if err != nil { - return 387, nil, true, err - } - var response []byte - seen := map[uint64]bool{} - for _, id := range ids { - if id == 0 || id > math.MaxInt32 || seen[id] { - return 387, nil, true, fmt.Errorf("hunting: invalid pack list") - } - seen[id] = true - d, err := s.load(int(id)) - if err != nil { - return 387, nil, true, err - } - if len(d.Grounds) == 0 { - response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 5, id)) - continue - } - info, err := s.info(int(id), d) - if err != nil { - return 387, nil, true, err - } - response = wire.AppendBytes(response, 1, info) - } - return 387, response, true, nil - } - pack, found, err := wire.Varint(req, 2) - if err != nil || !found || pack == 0 || pack > math.MaxInt32 { - return 134, nil, true, fmt.Errorf("hunting: invalid pack") - } - d, err := s.load(int(pack)) - if err != nil { - return 134, nil, true, err - } - if path == "/HuntingGroundInfo" { - // A catalog entry is not an account's active hunting run. The detail - // endpoint preserves an explicitly present empty message until entry. - if s.state.Packs[strconv.Itoa(int(pack))].Current == 0 { - return 134, wire.AppendBytes(nil, 1, nil), true, nil - } - b, err := s.info(int(pack), d) - return 134, wire.AppendBytes(nil, 1, b), true, err - } - if len(d.Grounds) == 0 { - return 110, nil, true, fmt.Errorf("hunting: pack has no hunting ground") - } - current, err := s.currentPack() - if err != nil || current != int(pack) { - return 110, nil, true, fmt.Errorf("hunting: enter pack is not current") - } - id, _, err := wire.Varint(req, 3) - if err != nil { - return 110, nil, true, err - } - auto, _, err := wire.Varint(req, 4) - if err != nil || auto > 1 { - return 110, nil, true, fmt.Errorf("hunting: invalid auto flag") - } - st := s.state.Packs[strconv.Itoa(int(pack))] - g, ok := ground(d, id) - if !ok { - return 110, nil, true, fmt.Errorf("hunting: unknown ground") - } - if s.eligibility != nil { - if err := s.eligibility(int(pack), g.Difficulty); err != nil { - return 110, nil, true, err - } - } else if id != d.Grounds[0].ID { - return 110, nil, true, fmt.Errorf("hunting: main quest difficulty eligibility unavailable") - } - if st.Current != id { - st.Defeated = nil - } - st.Current, st.Auto = id, auto != 0 - next := s.clone() - next.Packs[strconv.Itoa(int(pack))] = st - if err := s.persist(next); err != nil { - return 110, nil, true, err - } - var out []byte - for _, m := range monstersWithState(d, g, st.Defeated) { - out = wire.AppendBytes(out, 1, m) - } - return 110, out, true, nil -} -func (s *Service) info(pack int, d *gamedata.HuntingPack) ([]byte, error) { - st := s.state.Packs[strconv.Itoa(pack)] - initial := st.Current == 0 - if st.Current == 0 { - st.Current = d.Grounds[0].ID - } - g, ok := ground(d, st.Current) - if !ok { - return nil, fmt.Errorf("hunting: saved ground missing from GameData") - } - b := wire.AppendVarint(nil, 2, st.Current) - if st.Highest != 0 { - b = wire.AppendVarint(b, 3, st.Highest) - } - b = wire.AppendVarint(b, 5, uint64(pack)) - if st.Auto { - b = wire.AppendVarint(b, 1, 1) - } - var monsterInfos [][]byte - if initial { - // The initial catalog lists ordinary monsters only. A boss is not an - // active account encounter simply because its design row exists. - for _, id := range g.Monsters { - monsterInfos = append(monsterInfos, monsterWire(d.Monsters[id], true)) - } - } else { - monsterInfos = monstersWithState(d, g, st.Defeated) - } - for _, m := range monsterInfos { - b = wire.AppendBytes(b, 4, m) - } - return b, nil -} -func ground(d *gamedata.HuntingPack, id uint64) (gamedata.HuntingGround, bool) { - for _, g := range d.Grounds { - if g.ID == id { - return g, true - } - } - return gamedata.HuntingGround{}, false -} -func monsterWire(m gamedata.HuntingMonster, active bool) []byte { - b := wire.AppendVarint(nil, 1, m.ID) - b = wire.AppendVarint(b, 2, m.Decks[0]) - if active { - b = wire.AppendVarint(b, 6, 1) - } - return b -} -func monstersWithState(d *gamedata.HuntingPack, g gamedata.HuntingGround, defeated []uint64) [][]byte { - dead := map[uint64]bool{} - for _, id := range defeated { - dead[id] = true - } - boss := len(g.Monsters) == 0 - all := true - for _, id := range g.Monsters { - all = all && dead[id] - } - boss = boss || all - out := make([][]byte, 0, len(g.Monsters)+1) - for _, id := range g.Monsters { - out = append(out, monsterWire(d.Monsters[id], !dead[id])) - } - if boss { - out = append(out, monsterWire(d.Monsters[g.BossID], true)) - } - return out -} -func (s *Service) ValidateBattle(pack int, mode, monster, deck uint64) error { - if mode != BattleMode { - return nil - } - s.mu.Lock() - defer s.mu.Unlock() - if e := s.refreshAP(); e != nil { - return e - } - _, _, _, err := s.validate(pack, monster, deck) - return err -} -func (s *Service) validate(pack int, monster, deck uint64) (*gamedata.HuntingPack, gamedata.HuntingGround, gamedata.HuntingMonster, error) { - d, err := s.load(pack) - if err != nil { - return nil, gamedata.HuntingGround{}, gamedata.HuntingMonster{}, err - } - st := s.state.Packs[strconv.Itoa(pack)] - g, ok := ground(d, st.Current) - if !ok { - return nil, g, gamedata.HuntingMonster{}, fmt.Errorf("hunting: battle before ground enter") - } - member := monster == g.BossID - for _, id := range g.Monsters { - member = member || id == monster - } - m := d.Monsters[monster] - if monster == g.BossID { - for _, id := range g.Monsters { - done := false - for _, dead := range st.Defeated { - done = done || dead == id - } - if !done { - return nil, g, m, fmt.Errorf("hunting: boss locked until ordinary monsters are defeated") - } - } - } - if slices.Contains(st.Defeated, monster) { - return nil, g, m, fmt.Errorf("hunting: defeated monster requires reentry") - } - known := false - for _, id := range m.Decks { - known = known || id == deck - } - if !member || !known { - return nil, g, m, fmt.Errorf("hunting: monster/deck does not belong to selected ground") - } - cost := d.NormalAP - if m.Type == 1 { - cost = d.BossAP - } - if s.state.Free < cost && s.state.Bonus < cost-s.state.Free { - return nil, g, m, fmt.Errorf("hunting: insufficient hunting AP") - } - return d, g, m, nil -} -func (s *Service) CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) { - if mode != BattleMode { - return nil, nil, nil - } - s.mu.Lock() - defer s.mu.Unlock() - if receipt == "" { - return nil, nil, fmt.Errorf("hunting: missing battle receipt") - } - fingerprint := fmt.Sprintf("%d:%d:%d:%d", pack, mode, monster, deck) - ledger, err := s.loadBattleReceipts() - if err != nil { - return nil, nil, err - } - if saved, ok := ledger[receipt]; ok { - if saved.Fingerprint != fingerprint { - return nil, nil, fmt.Errorf("hunting: battle receipt conflict") - } - return saved.Bundle, saved.Monsters, nil - } - d, g, m, err := s.validate(pack, monster, deck) - if err != nil { - return nil, nil, err - } - rewards := m.Rewards[deck] - identity := "hunting:" + receipt - currency := make([]gamedata.Reward, 0) - stack := make([]gamedata.BattleReward, 0) - for _, r := range rewards { - if r.Type == 2 || r.Type == 3 || r.Type == 4 || r.Type == 12 || r.Type == 20 { - currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - } else { - stack = append(stack, r) - } - } - var grantedBundle []byte - var items []player.Item - if s.grant != nil { - rs := make([]gamedata.Reward, len(rewards)) - for i, r := range rewards { - rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 - } - s.mu.Unlock() - grantedBundle, err = s.grant(identity, rs) - s.mu.Lock() - if err != nil { - return nil, nil, err - } - } else { - if _, err := s.wallet.GrantQuestOnce(identity+":currency", currency); err != nil { - return nil, nil, err - } - items, err = s.inventory.GrantOnce(identity+":items", stack) - if err != nil { - return nil, nil, err - } - if len(items) == 0 { - items = s.inventory.GrantedItems(identity + ":items") - } - } - next := s.clone() - cost := d.NormalAP - if m.Type == 1 { - cost = d.BossAP - } - if next.Free >= cost { - next.Free -= cost - } else { - next.Bonus -= cost - next.Free - next.Free = 0 - } - st := next.Packs[strconv.Itoa(pack)] - if monster == g.BossID { - if st.Highest < g.ID { - st.Highest = g.ID - } - st.Defeated = nil - } else { - st.Defeated = append(append([]uint64(nil), st.Defeated...), monster) - } - next.Packs[strconv.Itoa(pack)] = st - next.Receipts[receipt] = true - var bundle []byte - if s.grant != nil { - bundle = grantedBundle - } - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - view := wire.AppendVarint(nil, 2, item.ID) - view = wire.AppendVarint(view, 3, item.Type) - view = wire.AppendVarint(view, 4, item.Count) - bundle = wire.AppendBytes(bundle, 6, view) - } - for _, r := range currency { - if s.grant != nil { - break - } - b := wire.AppendVarint(nil, 3, r.Type) - b = wire.AppendVarint(b, 4, r.Count) - bundle = wire.AppendBytes(bundle, 1, b) - } - updates := [][]byte{monsterWire(m, false)} - if monster == g.BossID { - for _, id := range g.Monsters { - updates = append(updates, monsterWire(d.Monsters[id], true)) - } - } else if len(st.Defeated) == len(g.Monsters) { - updates = append(updates, monsterWire(d.Monsters[g.BossID], true)) - } - if err := s.persist(next); err != nil { - return nil, nil, err - } - latest, err := s.loadBattleReceipts() - if err != nil { - return nil, nil, err - } - maps.Copy(ledger, latest) - ledger[receipt] = battleReceipt{Fingerprint: fingerprint, Bundle: bundle, Monsters: updates} - if err := s.saveBattleReceipts(ledger); err != nil { - return nil, nil, err - } - return bundle, updates, nil -} -func (s *Service) clone() snapshot { - next := s.state - next.Packs = map[string]packState{} - maps.Copy(next.Packs, s.state.Packs) - next.Receipts = map[string]bool{} - maps.Copy(next.Receipts, s.state.Receipts) - return next -} -func (s *Service) persist(next snapshot) error { - b, err := json.Marshal(next) - if err != nil { - return err - } - if err := s.storage.Save("hunting", b); err != nil { - return err - } - s.state = next - return nil -} -func packed(req []byte, n int) ([]uint64, error) { - var out []uint64 - err := wire.Walk(req, func(f wire.Field) error { - if f.Number != n { - return nil - } - if f.Type == 0 { - v, _ := binary.Uvarint(f.Value) - out = append(out, v) - return nil - } - if f.Type != 2 { - return fmt.Errorf("hunting: invalid repeated integer") - } - for raw := f.Value; len(raw) > 0; { - v, n := binary.Uvarint(raw) - if n <= 0 { - return fmt.Errorf("hunting: invalid packed integer") - } - out = append(out, v) - raw = raw[n:] - } - return nil - }) - return out, err -} diff --git a/go/internal/server/hunting/hunting_test.go b/go/internal/server/hunting/hunting_test.go deleted file mode 100644 index a226198..0000000 --- a/go/internal/server/hunting/hunting_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package hunting - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "fmt" - "testing" - "time" -) - -func TestHuntingCatalogPlaceholdersAndDetailRequireEnteredRun(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, err := Open(store, "", "", inv, wallet, func() (int, error) { return 73, nil }, 20, 5) - if err != nil { - t.Fatal(err) - } - d := &gamedata.HuntingPack{Grounds: []gamedata.HuntingGround{{ID: 9, MapID: 123, BossID: 41, Monsters: []uint64{31, 32, 33, 34, 35}}}, Monsters: map[uint64]gamedata.HuntingMonster{}} - for _, id := range []uint64{31, 32, 33, 34, 35, 41} { - d.Monsters[id] = gamedata.HuntingMonster{ID: id, Decks: []uint64{id + 100}} - } - s.load = func(pack int) (*gamedata.HuntingPack, error) { - switch pack { - case 73: - return d, nil - case 84, 85: - return &gamedata.HuntingPack{}, nil - default: - return nil, fmt.Errorf("unknown pack") - } - } - list := wire.AppendVarint(nil, 1, 1) - for _, id := range []uint64{84, 73, 85} { - list = wire.AppendVarint(list, 2, id) - } - code, response, ok, err := s.Handle("/HuntingGroundInfoList", list) - if err != nil || !ok || code != 387 { - t.Fatalf("list %d %v %v", code, ok, err) - } - var entries [][]byte - if err := wire.Walk(response, func(f wire.Field) error { - if f.Number == 1 { - entries = append(entries, f.Value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(entries) != 3 || !bytes.Equal(entries[0], wire.AppendVarint(nil, 5, 84)) || !bytes.Equal(entries[2], wire.AppendVarint(nil, 5, 85)) { - t.Fatalf("catalog lost ordered pack placeholders: %x", entries) - } - current, _, _ := wire.Varint(entries[1], 2) - monsters := 0 - _ = wire.Walk(entries[1], func(f wire.Field) error { - if f.Number == 4 { - monsters++ - } - return nil - }) - if current != 9 || monsters != 5 { - t.Fatalf("catalog current=%d monsters=%d", current, monsters) - } - for _, id := range []uint64{73, 84} { - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, id) - _, detail, _, err := s.Handle("/HuntingGroundInfo", req) - if err != nil || !bytes.Equal(detail, wire.AppendBytes(nil, 1, nil)) { - t.Fatalf("unentered pack %d returned active state: %x %v", id, detail, err) - } - } - if len(s.state.Packs) != 0 { - t.Fatal("query created hunting progress") - } - enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 73), 3, 9) - if _, _, _, err := s.Handle("/HuntingGroundEnter", enter); err != nil { - t.Fatal(err) - } - _, detail, _, err := s.Handle("/HuntingGroundInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, 73)) - active, found, _ := wire.Bytes(detail, 1) - if err != nil || !found || len(active) == 0 { - t.Fatalf("entered detail missing: %x %v", detail, err) - } -} - -func TestHuntingRepeatEnterReceiptAPAndDefeated(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, err := Open(store, "", "", inv, wallet, func() (int, error) { return 73, nil }, 20, 5) - if err != nil { - t.Fatal(err) - } - d := &gamedata.HuntingPack{NormalAP: 3, BossAP: 7, Grounds: []gamedata.HuntingGround{{ID: 9, MapID: 123, BossID: 41, Monsters: []uint64{31}}, {ID: 15, MapID: 123, BossID: 51, Monsters: []uint64{32}}}, Monsters: map[uint64]gamedata.HuntingMonster{31: {ID: 31, Decks: []uint64{81}, Rewards: map[uint64][]gamedata.BattleReward{81: {{Type: 8, ID: 999, Count: 2}, {Type: 4, Count: 10}}}}, 41: {ID: 41, Type: 1, Decks: []uint64{91}, Rewards: map[uint64][]gamedata.BattleReward{91: {{Type: 8, ID: 998, Count: 4}}}}}} - s.load = func(int) (*gamedata.HuntingPack, error) { return d, nil } - enter := func(id uint64) error { - r := wire.AppendVarint(nil, 1, 1) - r = wire.AppendVarint(r, 2, 73) - r = wire.AppendVarint(r, 3, id) - _, _, _, err := s.Handle("/HuntingGroundEnter", r) - return err - } - if err := enter(15); err == nil { - t.Fatal("higher difficulty unlocked") - } - if err := enter(9); err != nil { - t.Fatal(err) - } - if err := s.ValidateBattle(73, 5, 31, 91); err == nil { - t.Fatal("wrong deck accepted") - } - if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionA:2"); err != nil { - t.Fatal(err) - } - if err := s.ValidateBattle(73, 5, 31, 81); err == nil { - t.Fatal("defeated monster replay accepted") - } - if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionA:2"); err != nil { - t.Fatal(err) - } - free, bonus, _ := s.HuntingAP() - if free != 17 || bonus != 5 { - t.Fatalf("AP=%d/%d", free, bonus) - } - info, err := s.info(73, d) - if err != nil { - t.Fatal(err) - } - var inactive bool - if err := wire.Walk(info, func(f wire.Field) error { - if f.Number == 4 { - id, _, _ := wire.Varint(f.Value, 1) - active, _, _ := wire.Varint(f.Value, 6) - if id == 31 { - inactive = active == 0 - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if !inactive { - t.Fatal("info resurrected defeated monster") - } - if err := enter(9); err != nil { - t.Fatal(err) - } - if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionB:2"); err == nil { - t.Fatal("same difficulty entry reset round") - } - if _, _, err := s.CompleteBattle(73, 5, 41, 91, "sessionB:boss"); err != nil { - t.Fatal(err) - } - if _, _, err := s.CompleteBattle(73, 5, 31, 81, "sessionB:2"); err != nil { - t.Fatal(err) - } - var normal uint64 - for _, item := range inv.All() { - if item.ID == 999 { - normal += item.Count - } - } - if normal != 4 { - t.Fatalf("repeated rewards=%+v", inv.All()) - } - free, _, _ = s.HuntingAP() - if free != 7 { - t.Fatalf("repeat AP=%d", free) - } - if _, _, err := s.CompleteBattle(73, 5, 41, 91, "sessionB:3"); err != nil { - t.Fatal(err) - } - if s.state.Packs["73"].Highest != 9 { - t.Fatal("boss did not unlock next ground") - } -} - -func TestHuntingStageLoopAndBattleReplay(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 100, 0) - d := &gamedata.HuntingPack{NormalAP: 1, BossAP: 1, Grounds: []gamedata.HuntingGround{{ID: 1, MapID: 13, BossID: 3, Monsters: []uint64{1, 2}}}, Monsters: map[uint64]gamedata.HuntingMonster{}} - for _, id := range []uint64{1, 2, 3} { - typ := uint64(0) - if id == 3 { - typ = 1 - } - d.Monsters[id] = gamedata.HuntingMonster{ID: id, Type: typ, Decks: []uint64{id}, Rewards: map[uint64][]gamedata.BattleReward{id: {{Type: 4, Count: 10}}}} - } - s.load = func(int) (*gamedata.HuntingPack, error) { return d, nil } - if _, e := s.EnsureForPack(1); e != nil { - t.Fatal(e) - } - if e := s.ValidateBattle(1, 5, 3, 3); e == nil { - t.Fatal("early boss accepted") - } - if _, _, e := s.CompleteBattle(1, 5, 1, 1, "a:1"); e != nil { - t.Fatal(e) - } - req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1), 3, 1) - if _, _, _, e := s.HandleSession("/HuntingGroundEnter", req, "a"); e != nil { - t.Fatal(e) - } - if e := s.ValidateBattle(1, 5, 1, 1); e == nil { - t.Fatal("enter reset partial stage") - } - bundle, updates, e := s.CompleteBattle(1, 5, 2, 2, "a:3") - if e != nil || len(updates) != 2 { - t.Fatalf("boss transition %v %+v", e, updates) - } - again, replay, e := s.CompleteBattle(1, 5, 2, 2, "a:3") - if e != nil || !bytes.Equal(bundle, again) || len(replay) != 2 { - t.Fatal("battle replay lost response") - } - if _, _, e = s.CompleteBattle(1, 5, 3, 3, "a:3"); e == nil { - t.Fatal("battle receipt conflict accepted") - } - _, updates, e = s.CompleteBattle(1, 5, 3, 3, "a:4") - if e != nil || len(updates) != 3 || len(s.state.Packs["1"].Defeated) != 0 { - t.Fatalf("round reset %v %v", e, updates) - } - if e = s.ValidateBattle(1, 5, 1, 1); e != nil { - t.Fatal(e) - } - free, _, _ := s.HuntingAP() - if free != 97 || wallet.Snapshot().Gold != 30 { - t.Fatal("duplicate AP/reward") - } -} -func TestHuntingAPExchangeReplayAndOverflow(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 2) - cost := []gamedata.Reward{{Type: 21, Count: 4}} - reward := []gamedata.Reward{{Type: 23, Count: 3}} - if e := s.ExchangeAPOnce("x", cost, reward); e != nil { - t.Fatal(e) - } - if e := s.ExchangeAPOnce("x", cost, reward); e != nil { - t.Fatal(e) - } - free, bonus, _ := s.HuntingAP() - if free != 6 || bonus != 5 { - t.Fatal("AP replay") - } - if e := s.ExchangeAPOnce("x", nil, reward); e == nil { - t.Fatal("identity conflict") - } - if e := s.CanExchangeAP(nil, []gamedata.Reward{{Type: 21, Count: 2147483647}}); e == nil { - t.Fatal("AP overflow") - } -} - -func TestHuntingRewardAPCallbackDoesNotDeadlock(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 0) - s.load = func(int) (*gamedata.HuntingPack, error) { - return &gamedata.HuntingPack{NormalAP: 1, Grounds: []gamedata.HuntingGround{{ID: 1, BossID: 2, Monsters: []uint64{1}}}, Monsters: map[uint64]gamedata.HuntingMonster{1: {ID: 1, Decks: []uint64{1}, Rewards: map[uint64][]gamedata.BattleReward{1: {{Type: 21, Count: 3}}}}, 2: {ID: 2, Decks: []uint64{2}}}}, nil - } - s.AttachRewards(func(id string, _ []gamedata.Reward) ([]byte, error) { - return nil, s.ExchangeAPOnce(id, nil, []gamedata.Reward{{Type: 21, Count: 3}}) - }) - if _, err := s.EnsureForPack(1); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { _, _, e := s.CompleteBattle(1, 5, 1, 1, "apreward"); done <- e }() - select { - case e := <-done: - if e != nil { - t.Fatal(e) - } - case <-time.After(2 * time.Second): - t.Fatal("reward callback deadlocked") - } - free, _, _ := s.HuntingAP() - if free != 12 { - t.Fatalf("AP grant lost %d", free) - } -} - -func TestHuntingAPDailyRefreshPreservesBonus(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - s, _ := Open(store, "", "", inv, wallet, func() (int, error) { return 1, nil }, 4, 7) - if e := s.AttachAPRefresh(gamedata.HuntingAPDesign{Max: 90, ResetSeconds: 9 * 3600}); e != nil { - t.Fatal(e) - } - now := time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC) - s.now = func() time.Time { return now } - if _, _, err := s.HuntingAP(); err != nil { - t.Fatal(err) - } - now = now.Add(2 * time.Minute) - free, bonus, e := s.HuntingAP() - if e != nil || free != 90 || bonus != 7 { - t.Fatalf("refresh %d %d %v", free, bonus, e) - } - s.state.Free = 12 - free, _, _ = s.HuntingAP() - if free != 12 { - t.Fatal("refreshed same day twice") - } -} diff --git a/go/internal/server/hunting/progress.go b/go/internal/server/hunting/progress.go deleted file mode 100644 index dc00a01..0000000 --- a/go/internal/server/hunting/progress.go +++ /dev/null @@ -1,224 +0,0 @@ -package hunting - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "bytes" - "encoding/json" - "fmt" - "strconv" -) - -type battleReceipt struct { - Fingerprint string - Bundle []byte - Monsters [][]byte -} -type battleLedger struct { - Version string `json:"version"` - Receipts map[string]battleReceipt `json:"receipts"` -} - -func (s *Service) loadBattleReceipts() (map[string]battleReceipt, error) { - b, e := s.storage.Load("huntingbattle") - if e != nil { - return nil, e - } - if b == nil { - return map[string]battleReceipt{}, nil - } - if e = stateio.RequireExactJSONObject(b, "version", "receipts"); e != nil { - return nil, e - } - var l battleLedger - d := json.NewDecoder(bytes.NewReader(b)) - d.DisallowUnknownFields() - if e = d.Decode(&l); e != nil { - return nil, e - } - if l.Version != versionconfig.State() || l.Receipts == nil { - return nil, fmt.Errorf("hunting: invalid battle ledger") - } - return l.Receipts, nil -} -func (s *Service) saveBattleReceipts(rs map[string]battleReceipt) error { - b, e := json.Marshal(battleLedger{versionconfig.State(), rs}) - if e != nil { - return e - } - return s.storage.Save("huntingbattle", b) -} -func (s *Service) AttachEligibility(check func(int, uint64) error) { - s.mu.Lock() - defer s.mu.Unlock() - s.eligibility = check -} -func (s *Service) AttachRewards(grant func(string, []gamedata.Reward) ([]byte, error)) { - s.mu.Lock() - defer s.mu.Unlock() - s.grant = grant -} - -// EnsureForPack initializes the ordinary stage when the world loads a pack. -func (s *Service) EnsureForPack(pack int) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - d, e := s.load(pack) - if e != nil { - return nil, e - } - if len(d.Grounds) == 0 { - return nil, nil - } - st := s.state.Packs[strconv.Itoa(pack)] - if st.Current == 0 { - st.Current = d.Grounds[0].ID - next := s.clone() - next.Packs[strconv.Itoa(pack)] = st - if e = s.persist(next); e != nil { - return nil, e - } - } - return s.info(pack, d) -} -func (s *Service) SnapshotForPack(pack int) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - d, e := s.load(pack) - if e != nil { - return nil, e - } - if len(d.Grounds) == 0 { - return nil, nil - } - return s.info(pack, d) -} - -// Ground mutations have their own persisted session+sequence replay ledger. -func (s *Service) handleGroundSession(path string, req []byte, session string) (int, []byte, bool, error) { - if path != "/HuntingGroundEnter" { - return s.Handle(path, req) - } - seq, found, e := wire.Varint(req, 1) - if e != nil || !found || seq == 0 { - return 110, nil, true, fmt.Errorf("hunting: missing sequence") - } - key := "enter:" + session + ":" + strconv.FormatUint(seq, 10) - rs, e := s.loadBattleReceipts() - if e != nil { - return 110, nil, true, e - } - fp := fmt.Sprintf("%x", req) - if r, ok := rs[key]; ok { - if r.Fingerprint != fp { - return 110, nil, true, fmt.Errorf("hunting: enter sequence conflict") - } - return 110, r.Bundle, true, nil - } - code, out, ok, e := s.Handle(path, req) - if e != nil || !ok { - return code, out, ok, e - } - rs[key] = battleReceipt{Fingerprint: fp, Bundle: out} - if e = s.saveBattleReceipts(rs); e != nil { - return code, nil, true, e - } - return code, out, ok, nil -} -func (s *Service) reload() error { - b, e := s.storage.Load("hunting") - if e != nil { - return e - } - if b == nil { - return nil - } - var st snapshot - if e = json.Unmarshal(b, &st); e != nil { - return e - } - s.state = st - return nil -} -func (s *Service) CanExchangeAP(costs, rewards []gamedata.Reward) error { - s.mu.Lock() - defer s.mu.Unlock() - if e := s.reload(); e != nil { - return e - } - if e := s.refreshAP(); e != nil { - return e - } - _, e := s.exchangeAP(costs, rewards) - return e -} -func (s *Service) exchangeAP(costs, rewards []gamedata.Reward) (snapshot, error) { - st := s.clone() - apply := func(rs []gamedata.Reward, cost bool) error { - for _, r := range rs { - if (r.Type != 21 && r.Type != 23) || r.ID != 0 || r.Count == 0 { - return fmt.Errorf("hunting: invalid AP exchange") - } - p := &st.Free - if r.Type == 23 { - p = &st.Bonus - } - if cost { - if *p < r.Count { - return fmt.Errorf("hunting: insufficient AP") - } - *p -= r.Count - } else { - if r.Count > 2147483647-*p { - return fmt.Errorf("hunting: AP overflow") - } - *p += r.Count - } - } - return nil - } - if e := apply(costs, true); e != nil { - return st, e - } - return st, apply(rewards, false) -} -func (s *Service) ExchangeAPOnce(identity string, costs, rewards []gamedata.Reward) error { - s.mu.Lock() - defer s.mu.Unlock() - if identity == "" { - return fmt.Errorf("hunting: missing AP identity") - } - if e := s.reload(); e != nil { - return e - } - key := "apexchange:" + identity - fpb, e := json.Marshal([][]gamedata.Reward{costs, rewards}) - if e != nil { - return e - } - rs, e := s.loadBattleReceipts() - if e != nil { - return e - } - fp := string(fpb) - if r, ok := rs[key]; ok { - if r.Fingerprint != fp { - return fmt.Errorf("hunting: AP identity conflict") - } - return nil - } - if e := s.refreshAP(); e != nil { - return e - } - st, e := s.exchangeAP(costs, rewards) - if e != nil { - return e - } - if e = s.persist(st); e != nil { - return e - } - rs[key] = battleReceipt{Fingerprint: fp} - return s.saveBattleReceipts(rs) -} diff --git a/go/internal/server/hunting/transaction_test.go b/go/internal/server/hunting/transaction_test.go deleted file mode 100644 index 3984ba2..0000000 --- a/go/internal/server/hunting/transaction_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package hunting - -import ( - "encoding/json" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -func TestHuntingSettlementRollsBackAPWalletInventoryAndProgress(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12}) - if err != nil { - t.Fatal(err) - } - if err := inv.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - load := func(int) (*gamedata.HuntingPack, error) { - return &gamedata.HuntingPack{NormalAP: 1, BossAP: 2, - Grounds: []gamedata.HuntingGround{{ID: 1, MapID: 13, BossID: 11}}, - Monsters: map[uint64]gamedata.HuntingMonster{11: {ID: 11, Type: 1, Decks: []uint64{21}, Rewards: map[uint64][]gamedata.BattleReward{21: {{Type: 4, Count: 100}, {Type: 8, ID: 987, Count: 3}}}}}}, nil - } - s, err := Open(repo, "", "", inv, wallet, func() (int, error) { return 1, nil }, 10, 4) - if err != nil { - t.Fatal(err) - } - s.load = load - enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 1) - if _, _, _, err := s.Handle("/HuntingGroundEnter", enter); err != nil { - t.Fatal(err) - } - before, err := repo.Load("hunting") - if err != nil { - t.Fatal(err) - } - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, err := s.CompleteBattle(1, BattleMode, 11, 21, "login-A:2"); err != nil { - _ = op.Rollback() - t.Fatal(err) - } - // Model a later domain failure in the same request/batch. A dirty rollback - // fences in-memory domains, requiring restart before they can serve again. - _ = op.Rollback() - if repo.Check() == nil { - t.Fatal("dirty rollback did not fence stale domains") - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - reopened, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := reopened.Close(); err != nil { - t.Error(err) - } - }() - after, err := reopened.Load("hunting") - if err != nil || string(after) != string(before) { - t.Fatalf("hunting progress survived rollback: %s %v", after, err) - } - inv, err = player.OpenInventory(reopened, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err = player.OpenWallet(reopened, player.Currency{}) - if err != nil { - t.Fatal(err) - } - var persisted snapshot - if err := json.Unmarshal(after, &persisted); err != nil { - t.Fatal(err) - } - free, bonus := persisted.Free, persisted.Bonus - if err != nil || free != 10 || bonus != 4 || wallet.Snapshot().Gold != 12 || len(inv.All()) != 0 { - t.Fatalf("settlement survived rollback: AP=%d/%d wallet=%+v items=%+v err=%v", free, bonus, wallet.Snapshot(), inv.All(), err) - } -} diff --git a/go/internal/server/lifecycle/gate_test.go b/go/internal/server/lifecycle/gate_test.go deleted file mode 100644 index 1d40823..0000000 --- a/go/internal/server/lifecycle/gate_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package lifecycle - -import ( - "context" - "testing" - "time" -) - -func TestDrainRejectsNewRequestsAndWaitsForAdmittedRequest(t *testing.T) { - gate := NewGate() - done, ok := gate.BeginRequest() - if !ok || !gate.Ready() { - t.Fatal("ready gate rejected request") - } - gate.Drain() - if gate.Ready() { - t.Fatal("draining gate remained ready") - } - if _, ok := gate.BeginRequest(); ok { - t.Fatal("draining gate admitted new request") - } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - if err := gate.Wait(ctx); err == nil { - t.Fatal("drain completed before admitted request") - } - done() - if err := gate.Wait(context.Background()); err != nil { - t.Fatal(err) - } -} diff --git a/go/internal/server/logging/logging_test.go b/go/internal/server/logging/logging_test.go deleted file mode 100644 index 83c96b9..0000000 --- a/go/internal/server/logging/logging_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package logging - -import ( - "bytes" - "context" - "io" - "log/slog" - "os" - "strings" - "sync" - "testing" -) - -func TestLevelsAndColors(t *testing.T) { - for _, test := range []struct { - name, ansi string - level slog.Level - }{ - {"TRACE", "90", LevelTrace}, {"DEBUG", "36", slog.LevelDebug}, - {"INFO", "32", slog.LevelInfo}, {"WARN", "33", slog.LevelWarn}, {"ERROR", "31", slog.LevelError}, - } { - t.Run(test.name, func(t *testing.T) { - var out bytes.Buffer - h, err := NewHandler(&out, Options{Level: LevelTrace, Color: ColorAlways}) - if err != nil { - t.Fatal(err) - } - slog.New(h).Log(context.Background(), test.level, "hello", "level", "ERROR", "text", "a\nb") - want := "level=\x1b[" + test.ansi + "m" + test.name + "\x1b[0m msg=hello level=ERROR text=\"a\\nb\"" - if !strings.Contains(out.String(), want) { - t.Fatalf("output=%q want fragment=%q", out.String(), want) - } - }) - } -} - -func TestFilterAndDynamicLevel(t *testing.T) { - var out bytes.Buffer - var level slog.LevelVar - h, _ := NewHandler(&out, Options{Level: &level, Color: ColorNever}) - logger := slog.New(h) - logger.Log(context.Background(), LevelTrace, "hidden") - logger.Debug("hidden") - logger.Info("visible") - if strings.Contains(out.String(), "hidden") { - t.Fatal(out.String()) - } - level.Set(LevelTrace) - logger.Log(context.Background(), LevelTrace, "trace visible") - if !strings.Contains(out.String(), "level=TRACE msg=\"trace visible\"") { - t.Fatal(out.String()) - } -} - -func TestGroupsAndConcurrentDerivedLoggers(t *testing.T) { - var out bytes.Buffer - h, _ := NewHandler(&out, Options{Color: ColorAlways}) - logger := slog.New(h).With("service", "server").WithGroup("request").With("id", 7) - var workers sync.WaitGroup - for range 50 { - workers.Go(func() { logger.Info("handled", slog.Group("result", "ok", true)) }) - } - workers.Wait() - lines := strings.Split(strings.TrimSpace(out.String()), "\n") - if len(lines) != 50 { - t.Fatalf("lines=%d", len(lines)) - } - for _, line := range lines { - if !strings.Contains(line, "service=server request.id=7 request.result.ok=true") || strings.Count(line, "\x1b[0m") != 1 { - t.Fatalf("damaged record %q", line) - } - } -} - -func TestAutoRedirectedOutputIsPlain(t *testing.T) { - file, err := os.CreateTemp(t.TempDir(), "log") - if err != nil { - t.Fatal(err) - } - defer func() { - if err := file.Close(); err != nil { - t.Error(err) - } - }() - h, _ := NewHandler(file, Options{}) - slog.New(h).Warn("redirected") - if _, err := file.Seek(0, 0); err != nil { - t.Fatal(err) - } - data, err := io.ReadAll(file) - if err != nil { - t.Fatal(err) - } - if bytes.Contains(data, []byte("\x1b")) { - t.Fatalf("ANSI in redirected log %q", data) - } - var out bytes.Buffer - h, _ = NewHandler(&out, Options{}) - slog.New(h).Info("buffer") - if strings.Contains(out.String(), "\x1b") { - t.Fatal(out.String()) - } -} - -func TestEnvironmentAndValidation(t *testing.T) { - t.Setenv("BD2_LOG_LEVEL", "trace") - t.Setenv("BD2_LOG_COLOR", "never") - options, err := OptionsFromEnv() - if err != nil || options.Level.Level() != LevelTrace || options.Color != ColorNever { - t.Fatalf("options=%+v err=%v", options, err) - } - t.Setenv("BD2_LOG_LEVEL", "invalid") - if _, err := OptionsFromEnv(); err == nil { - t.Fatal("invalid level accepted") - } - if _, err := ParseColorMode("invalid"); err == nil { - t.Fatal("invalid color accepted") - } - if _, err := NewHandler(nil, Options{}); err == nil { - t.Fatal("nil writer accepted") - } -} - -func TestAutoColorEnvironment(t *testing.T) { - t.Setenv("TERM", "xterm-256color") - t.Setenv("NO_COLOR", "") - if autoColorAllowed() { - t.Fatal("NO_COLOR presence must suppress auto color") - } - if err := os.Unsetenv("NO_COLOR"); err != nil { - t.Fatal(err) - } - if !autoColorAllowed() { - t.Fatal("ordinary terminal must allow auto color") - } - t.Setenv("TERM", "dumb") - if autoColorAllowed() { - t.Fatal("dumb terminal must suppress auto color") - } -} diff --git a/go/internal/server/mail/attendance_test.go b/go/internal/server/mail/attendance_test.go deleted file mode 100644 index 4059fb5..0000000 --- a/go/internal/server/mail/attendance_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package mail - -import ( - "bytes" - "testing" - "time" - - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type attendanceDesign struct{ selections int } - -func (*attendanceDesign) Character(uint64) (gamedata.CharacterDesign, bool) { - return gamedata.CharacterDesign{}, false -} -func (*attendanceDesign) RollOptions(uint64) ([]gamedata.EquipmentOptionChoice, []gamedata.EquipmentOptionChoice, *gamedata.EquipmentOptionChoice, error) { - return nil, nil, nil, nil -} -func (d *attendanceDesign) Resolve(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - return d.ResolveGranted(r) -} -func (d *attendanceDesign) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) { - var out []gamedata.BattleReward - for _, reward := range r { - if reward.Type == 9 && reward.ID == 100 { - d.selections++ - out = append(out, gamedata.BattleReward{Type: 4, Count: 10 * reward.Count}, gamedata.BattleReward{Type: 8, ID: 1000, Count: 2 * reward.Count}, gamedata.BattleReward{Type: 9, ID: 200, Count: reward.Count}) - } else { - out = append(out, reward) - } - } - return out, nil -} - -func attendanceMailFixture(t *testing.T, store stateio.Store, seed *Starter, d *attendanceDesign) (*Service, *player.Inventory, *player.Wallet) { - t.Helper() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(store, nil) - if err != nil { - t.Fatal(err) - } - equipment, err := player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, equipment.EnsurePersisted} { - if err := persist(); err != nil { - t.Fatal(err) - } - } - economy, err := events.NewEconomy(store, items, wallet, collection, equipment, d, d, d, nil) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(store, seed, items, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachAttendanceRewardEconomy(economy); err != nil { - t.Fatal(err) - } - return service, items, wallet -} - -func TestAttendanceMailDefersRewardsAndReplaysAcrossRestart(t *testing.T) { - store := stateio.NewMemory() - seed := &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100} - design := &attendanceDesign{} - s, items, wallet := attendanceMailFixture(t, store, seed, design) - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - s.now = func() time.Time { return now } - reward := []gamedata.Reward{{Type: 9, ID: 100, Count: 1}} - if err := s.IssueAttachmentsOnce("daily:1", "签到奖励", "请领取", reward, now); err != nil { - t.Fatal(err) - } - if err := s.IssueAttachmentsOnce("daily:1", "签到奖励", "请领取", reward, now.Add(time.Minute)); err != nil { - t.Fatal(err) - } - if err := s.IssueAttachmentsOnce("daily:1", "签到奖励", "请领取", []gamedata.Reward{{Type: 4, Count: 999}}, now); err == nil { - t.Fatal("identity accepted changed attachments") - } - if len(s.dynamic) != 1 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 || design.selections != 0 { - t.Fatal("issuing mail granted rewards") - } - if err := s.IssueAttachmentsOnce("daily:2", "签到奖励", "请领取", reward, now); err != nil { - t.Fatal(err) - } - if s.issued["attendance:daily:2"] != 102 { - t.Fatal("mail ID did not increase") - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 101) - request = wire.AppendVarint(request, 2, 102) - _, response, _, err := s.Handle("/MailOpen", request) - if err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 20 || design.selections != 2 || len(items.All()) != 4 || len(s.history) != 2 { - t.Fatalf("claim missing domains/history: gold=%d selections=%d items=%d", wallet.Snapshot().Gold, design.selections, len(items.All())) - } - for _, item := range items.All() { - if item.Type == 9 && item.ID != 200 { - t.Fatal("OPEN wrapper stored instead of DIRECT child") - } - } - reopened, items, wallet := attendanceMailFixture(t, store, seed, design) - reopened.now = s.now - _, replay, _, err := reopened.Handle("/MailOpen", request) - if err != nil || !bytes.Equal(response, replay) || design.selections != 2 || wallet.Snapshot().Gold != 20 || len(items.All()) != 4 { - t.Fatal("restart replay changed or repeated grants", err) - } - if err := reopened.IssueAttachmentsOnce("expired", "签到奖励", "请领取", reward, now.Add(-31*24*time.Hour)); err != nil { - t.Fatal(err) - } - expired := wire.AppendVarint(nil, 1, 2) - expired = wire.AppendVarint(expired, 2, 103) - if _, _, _, err := reopened.Handle("/MailOpen", expired); err == nil || design.selections != 2 || len(reopened.history) != 2 { - t.Fatal("expired mail granted") - } -} - -func TestAttendanceEconomyDoesNotRegrantExistingOpenedMail(t *testing.T) { - store := stateio.NewMemory() - seed := &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100} - design := &attendanceDesign{} - s, _, _ := attendanceMailFixture(t, store, seed, design) - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - s.now = func() time.Time { return now } - if err := s.EnqueueCompensation("existing:grant", "奖励", "请领取", []gamedata.Reward{{Type: 4, Count: 7}}, now); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 101) - if _, _, _, err := s.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - reopened, _, wallet := attendanceMailFixture(t, store, seed, design) - reopened.now = s.now - if _, _, _, err := reopened.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 7 || design.selections != 0 { - t.Fatal("existing opened mail routed to new attendance economy") - } -} diff --git a/go/internal/server/mail/cash.go b/go/internal/server/mail/cash.go deleted file mode 100644 index 8b29d55..0000000 --- a/go/internal/server/mail/cash.go +++ /dev/null @@ -1,111 +0,0 @@ -package mail - -import ( - "errors" - "fmt" - "maps" - "math" - "reflect" - "sort" - "strings" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -// Cash attachments are already resolved at purchase, so manual boxes must not -// be expanded again and selected random rewards must not be rolled on claim. -type CashRewardEconomy interface { - ApplyResolved(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} - -func (s *Service) AttachCashRewards(e CashRewardEconomy, templates map[uint64]bool) error { - if e == nil || len(templates) == 0 { - return errors.New("mail: missing cash economy or templates") - } - s.cashEconomy = e - s.cashTemplates = make(map[uint64]bool, len(templates)) - maps.Copy(s.cashTemplates, templates) - return nil -} -func (s *Service) IssueCashOnce(identity string, template uint64, rewards []gamedata.Reward, sentAt time.Time) error { - s.mu.Lock() - defer s.mu.Unlock() - if s.cashEconomy == nil || !s.cashTemplates[template] { - return fmt.Errorf("mail: cash template %d or economy unavailable", template) - } - if identity == "" || strings.TrimSpace(identity) != identity || len(identity) > 450 || sentAt.UnixMilli() <= 0 || sentAt.UnixMilli() >= 253402300799000 { - return errors.New("mail: invalid cash mail identity or time") - } - if err := validateCashAttachments(rewards); err != nil { - return err - } - key := "cash:" + identity - if id, ok := s.issued[key]; ok { - previous := s.dynamic[id] - if !previous.IsCash || previous.TemplateID != template || !reflect.DeepEqual(mailAttachments(previous), rewards) { - return errors.New("mail: cash identity reused with different attachments") - } - return nil - } - return s.enqueueCompensations([]compensation{{identity: key, templateID: template, isCash: true, rewards: rewards, sentAt: sentAt}}) -} -func validateCashAttachments(rewards []gamedata.Reward) error { - if len(rewards) == 0 { - return errors.New("mail: empty cash attachments") - } - for _, r := range rewards { - if r.Type < 2 || r.Type > 72 || r.Count == 0 || r.Count > math.MaxInt32 || r.ID > math.MaxInt32 { - return fmt.Errorf("mail: invalid cash attachment %+v", r) - } - } - return nil -} -func (s *Service) cashInfo(request []byte) ([]byte, error) { - start, _, err := wire.Varint(request, 2) - if err != nil || start > math.MaxInt64 { - return nil, errors.New("mail: invalid cash cursor") - } - count, present, err := wire.Varint(request, 3) - if err != nil || !present || count == 0 || count > math.MaxInt32 { - return nil, errors.New("mail: invalid cash select count") - } - if count > mailHistoryPageMax { - count = mailHistoryPageMax - } - entries := map[uint64]MailDBInfo{} - for _, entry := range s.Starter.Mails { - if entry.IsCash && !containsID(s.state.Opened, entry.MailID) { - entries[entry.MailID] = entry - } - } - for id, entry := range s.dynamic { - if entry.IsCash && !containsID(s.state.Opened, id) { - entries[id] = entry - } - } - ids := make([]uint64, 0, len(entries)) - var max uint64 - for id := range entries { - ids = append(ids, id) - if id > max { - max = id - } - } - sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] }) - var result []byte - var selected uint64 - for _, id := range ids { - if start != 0 && id >= start { - continue - } - if selected >= count { - break - } - result = wire.AppendBytes(result, 1, entries[id].encode()) - selected++ - } - result = wire.AppendVarint(result, 2, uint64(len(entries))) - return wire.AppendVarint(result, 3, max), nil -} diff --git a/go/internal/server/mail/content_ticket_test.go b/go/internal/server/mail/content_ticket_test.go deleted file mode 100644 index a791515..0000000 --- a/go/internal/server/mail/content_ticket_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package mail - -import ( - "bd2server/internal/server/gamedata" - "testing" -) - -func TestContentTicketsUseCatalogAndRequireSingleUse(t *testing.T) { - s := &Service{} - if err := s.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{660003: true}}); err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - id, count uint64 - want bool - }{{660003, 1, true}, {450030, 1, false}, {660003, 2, false}, {660003, 0, false}} { - if got := s.supportedItemDBInfoReward(gamedata.Reward{Type: 19, ID: tc.id, Count: tc.count}); got != tc.want { - t.Fatalf("ticket=%+v supported=%v", tc, got) - } - } -} diff --git a/go/internal/server/mail/grant_spool_test.go b/go/internal/server/mail/grant_spool_test.go deleted file mode 100644 index ca3a7e7..0000000 --- a/go/internal/server/mail/grant_spool_test.go +++ /dev/null @@ -1,475 +0,0 @@ -package mail - -import ( - "bd2server/internal/server/gamedata" - "encoding/json" - "errors" - "math" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func spoolTestService(t *testing.T, store stateio.Store) (*Service, *player.Inventory, *player.Wallet) { - t.Helper() - inventory, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(store, &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100}, inventory, wallet) - if err != nil { - t.Fatal(err) - } - return service, inventory, wallet -} - -func spoolGrant(identity string, rewards ...GrantReward) Grant { - return Grant{Identity: identity, Title: "系统奖励", Body: "请领取邮件附件。", SentAt: time.Date(2026, 10, 3, 1, 0, 0, 0, time.UTC).UnixMilli(), Rewards: rewards} -} - -func writeSpool(t *testing.T, path string, grants ...Grant) { - t.Helper() - data, err := json.Marshal(GrantSpool{Version: 1, Grants: grants}) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0o600); err != nil { - t.Fatal(err) - } -} - -func TestGrantSpoolImportsDynamicMailOnceAcrossOpenAndRestart(t *testing.T) { - store := stateio.NewMemory() - service, _, wallet := spoolTestService(t, store) - path := filepath.Join(t.TempDir(), "grants.json") - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - if _, _, _, err := service.Handle("/MailInfo", request); err != nil { - t.Fatal(err) - } - writeSpool(t, path, - spoolGrant("dev-mail:a", GrantReward{Type: 4, Count: 123}), - spoolGrant("dev-mail:b", GrantReward{Type: 8, ID: 9, Count: 10}), - ) - // Spool attachment never changes the versioned starter mailbox. - if len(service.Starter.Mails) != 0 { - t.Fatal("starter changed") - } - for range 2 { - code, response, handled, err := service.Handle("/MailInfo", request) - if err != nil || !handled || code != packetCode { - t.Fatalf("code=%d handled=%v err=%v", code, handled, err) - } - count, _, _ := wire.Varint(response, 2) - max, _, _ := wire.Varint(response, 3) - if count != 3 || max != 102 { - t.Fatalf("count=%d max=%d", count, max) - } - } - if wallet.Snapshot().Gold != 0 { - t.Fatal("unopened mail granted currency") - } - open := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 101) - if _, _, _, err := service.Handle("/MailOpen", open); err != nil { - t.Fatal(err) - } - reopened, inventory, wallet := spoolTestService(t, store) - if err := reopened.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - _, response, _, err := reopened.Handle("/MailInfo", request) - if err != nil { - t.Fatal(err) - } - if count, _, _ := wire.Varint(response, 2); count != 2 { - t.Fatalf("count=%d", count) - } - if len(reopened.dynamic) != 2 || reopened.state.NextDynamicMailID != 103 { - t.Fatalf("reissued mail: %+v", reopened.state) - } - open = wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 102) - if _, _, _, err := reopened.Handle("/MailOpen", open); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 123 || len(inventory.All()) != 1 || inventory.All()[0].Count != 10 { - t.Fatalf("wallet=%+v inventory=%+v", wallet.Snapshot(), inventory.All()) - } - if _, _, _, err := reopened.Handle("/MailInfo", request); err != nil { - t.Fatal(err) - } - if reopened.state.NextDynamicMailID != 103 { - t.Fatal("opened grants were reissued") - } -} - -func TestGrantSpoolPersistsIssuedIdentityInSQLiteRequestTransaction(t *testing.T) { - dir := t.TempDir() - database := filepath.Join(dir, "state.db") - path := filepath.Join(dir, "grants.json") - writeSpool(t, path, spoolGrant("dev-mail:sqlite", GrantReward{Type: 4, Count: 123})) - repository, err := accountstate.Open(database) - if err != nil { - t.Fatal(err) - } - service, _, _ := spoolTestService(t, repository) - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - operation, err := repository.BeginOperation() - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - if _, _, _, err := service.Handle("/MailInfo", request); 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 = accountstate.Open(database) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repository.Close(); err != nil { - t.Error(err) - } - }() - service, _, _ = spoolTestService(t, repository) - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - operation, err = repository.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", request); err != nil { - t.Fatal(err) - } - if err := operation.Commit(); err != nil { - t.Fatal(err) - } - if len(service.dynamic) != 1 || service.issued["dev-mail:sqlite"] != 101 || service.state.NextDynamicMailID != 102 { - t.Fatalf("SQLite restart reissued grant: state=%+v issued=%+v", service.state, service.issued) - } -} - -func TestGrantSpoolPaidJewelryAndDrawTicketsClaimAndRestart(t *testing.T) { - store := stateio.NewMemory() - service, _, _ := spoolTestService(t, store) - path := filepath.Join(t.TempDir(), "grants.json") - writeSpool(t, path, spoolGrant("paid-and-draw-tickets", - GrantReward{Type: 2, Count: 100000000}, - GrantReward{Type: 8, ID: 1000, Count: 100000000}, - GrantReward{Type: 8, ID: 1104, Count: 100000000}, - GrantReward{Type: 4, Count: 1000000000}, - )) - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 101) - _, response, _, err := service.Handle("/MailOpen", request) - if err != nil { - t.Fatal(err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("reward bundle missing: %v", err) - } - items := map[[2]uint64]uint64{} - views := map[[2]uint64]uint64{} - if err := wire.Walk(bundle, func(field wire.Field) error { - if field.Number != 1 && field.Number != 6 { - t.Fatalf("unexpected reward bundle field %d", field.Number) - } - id, _, err := wire.Varint(field.Value, 2) - if err != nil { - return err - } - typ, _, err := wire.Varint(field.Value, 3) - if err != nil { - return err - } - count, _, err := wire.Varint(field.Value, 4) - if err != nil { - return err - } - index, _, err := wire.Varint(field.Value, 1) - if err != nil { - return err - } - if field.Number == 1 { - if typ == 8 && index == 0 || typ != 8 && index != 0 { - t.Fatalf("incorrect item instance for type=%d index=%d", typ, index) - } - items[[2]uint64{typ, id}] = count - } else { - views[[2]uint64{typ, id}] = count - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(items) != 4 || items[[2]uint64{2, 0}] != 100000000 || items[[2]uint64{4, 0}] != 1000000000 || - items[[2]uint64{8, 1000}] != 100000000 || items[[2]uint64{8, 1104}] != 100000000 || - len(views) != 2 || views[[2]uint64{8, 1000}] != 100000000 || views[[2]uint64{8, 1104}] != 100000000 { - t.Fatalf("items=%v views=%v", items, views) - } - service, inventory, wallet := spoolTestService(t, store) - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3)); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - if got := wallet.Snapshot(); got.Jewelry != 100000000 || got.Gold != 1000000000 || len(inventory.All()) != 2 || service.state.NextDynamicMailID != 102 { - t.Fatalf("grant duplicated after restart: wallet=%+v items=%+v state=%+v", got, inventory.All(), service.state) - } -} - -func TestGrantSpoolEnforcesMailInt32IDAndCountBoundaries(t *testing.T) { - for _, reward := range []GrantReward{ - {Type: 8, ID: math.MaxInt32, Count: math.MaxInt32}, - {Type: 2, Count: math.MaxInt32}, - {Type: 19, ID: 450030, Count: 1}, - } { - input, err := json.Marshal(GrantSpool{Version: 1, Grants: []Grant{spoolGrant("boundary", reward)}}) - if err != nil { - t.Fatal(err) - } - if _, err := decodeGrantSpool(input); err != nil { - t.Fatalf("valid boundary reward rejected: %+v: %v", reward, err) - } - } - for _, reward := range []GrantReward{ - {Type: 8, ID: math.MaxInt32 + 1, Count: 1}, - {Type: 8, ID: 1000, Count: math.MaxInt32 + 1}, - {Type: 2, ID: 1, Count: 1}, - {Type: 2, Count: math.MaxInt32 + 1}, - {Type: 19, ID: 450030, Count: 0}, - {Type: 19, ID: 450030, Count: 2}, - } { - input, err := json.Marshal(GrantSpool{Version: 1, Grants: []Grant{spoolGrant("boundary", reward)}}) - if err != nil { - t.Fatal(err) - } - if _, err := decodeGrantSpool(input); err == nil { - t.Fatalf("invalid boundary reward accepted: %+v", reward) - } - } -} - -func TestGrantSpoolOneUseContentTicketItemDBInfoPersistsAndIsIdempotent(t *testing.T) { - // In 2.35.10 CommonPacket.AddItemInfo dispatches ElementType 19 to - // AddContentTicketItem; it consumes a normal ItemDBInfo with an instance ID. - store := stateio.NewMemory() - service, inventory, _ := spoolTestService(t, store) - if err := service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}}); err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "grants.json") - writeSpool(t, path, spoolGrant("full-moon-one-use", GrantReward{Type: 19, ID: 450030, Count: 1})) - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 101) - _, response, _, err := service.Handle("/MailOpen", request) - if err != nil { - t.Fatal(err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("reward bundle missing: %v", err) - } - item, found, err := wire.Bytes(bundle, 1) - if err != nil || !found { - t.Fatalf("ItemDBInfo missing: %v", err) - } - for field, want := range map[int]uint64{2: 450030, 3: 19, 4: 1} { - if got, present, err := wire.Varint(item, field); err != nil || !present || got != want { - t.Fatalf("ItemDBInfo field %d=%d present=%v err=%v", field, got, present, err) - } - } - index, _, err := wire.Varint(item, 1) - if err != nil || index == 0 { - t.Fatalf("content ticket instance=%d err=%v", index, err) - } - if got := inventory.All(); len(got) != 1 || got[0].Type != 19 || got[0].ID != 450030 || got[0].Count != 1 || got[0].InvenIndex != index { - t.Fatalf("content ticket not stored: %+v", got) - } - service, inventory, _ = spoolTestService(t, store) - if err := service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}}); err != nil { - t.Fatal(err) - } - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3)); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - if got := inventory.All(); len(got) != 1 || got[0].Count != 1 || got[0].InvenIndex != index || service.state.NextDynamicMailID != 102 { - t.Fatalf("content ticket reissued: items=%+v state=%+v", got, service.state) - } -} - -func TestGrantSpoolValidatesEntireBatchBeforeIssuing(t *testing.T) { - store := stateio.NewMemory() - service, _, _ := spoolTestService(t, store) - path := filepath.Join(t.TempDir(), "grants.json") - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - good := spoolGrant("a", GrantReward{Type: 4, Count: 10}) - bad := spoolGrant("b", GrantReward{Type: 6, ID: 1, Count: 1}) - writeSpool(t, path, good, bad) - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err == nil { - t.Fatal("accepted unsupported second grant") - } - assertNoSpoolMail(t, service, store) - writeSpool(t, path, good, good) - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 2)); err == nil { - t.Fatal("accepted duplicate identity") - } - assertNoSpoolMail(t, service, store) - writeSpool(t, path, good) - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3)); err != nil { - t.Fatal(err) - } - if service.issued["a"] != 101 { - t.Fatal("valid replacement did not import") - } -} - -func assertNoSpoolMail(t *testing.T, service *Service, store stateio.EntryStore) { - t.Helper() - if len(service.dynamic) != 0 || len(service.issued) != 0 || service.state.NextDynamicMailID != 101 { - t.Fatalf("partial memory update: %+v", service.state) - } - for _, bucket := range []string{"dynamic", "issued"} { - entries, err := store.ListEntries("mail", bucket) - if err != nil || len(entries) != 0 { - t.Fatalf("partial storage %s: entries=%v err=%v", bucket, entries, err) - } - } -} - -type failingSpoolStore struct { - *stateio.Memory - fail bool - writes int -} - -func (s *failingSpoolStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { - if domain == "mail" && len(changes) != 0 { - s.writes++ - if s.fail { - return errors.New("injected spool save failure") - } - } - return s.Memory.SaveWithEntries(domain, core, changes) -} - -func TestGrantSpoolBatchWriteFailureLeavesMemoryAndStorageUnchanged(t *testing.T) { - store := &failingSpoolStore{Memory: stateio.NewMemory(), fail: true} - service, _, _ := spoolTestService(t, store) - path := filepath.Join(t.TempDir(), "grants.json") - writeSpool(t, path, spoolGrant("a", GrantReward{Type: 4, Count: 10}), spoolGrant("b", GrantReward{Type: 4, Count: 20})) - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - if _, _, _, err := service.Handle("/MailInfo", request); err == nil { - t.Fatal("save failure ignored") - } - assertNoSpoolMail(t, service, store) - if store.writes != 1 { - t.Fatalf("batch used %d writes", store.writes) - } - store.fail = false - if _, _, _, err := service.Handle("/MailInfo", request); err != nil { - t.Fatal(err) - } - if len(service.dynamic) != 2 || service.state.NextDynamicMailID != 103 { - t.Fatal("retry did not issue complete batch") - } -} - -func TestGrantSpoolBatchIDExhaustionDoesNotIssueFirstMail(t *testing.T) { - store := stateio.NewMemory() - service, _, _ := spoolTestService(t, store) - service.state.NextDynamicMailID = ^uint64(0) - 1 - path := filepath.Join(t.TempDir(), "grants.json") - writeSpool(t, path, spoolGrant("a", GrantReward{Type: 4, Count: 10}), spoolGrant("b", GrantReward{Type: 4, Count: 20})) - if err := service.AttachGrantSpoolPath(path); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err == nil { - t.Fatal("accepted overflowing batch") - } - if len(service.dynamic) != 0 || service.state.NextDynamicMailID != ^uint64(0)-1 { - t.Fatal("partially imported overflowing batch") - } -} - -func TestGrantSpoolRejectsMalformedSchema(t *testing.T) { - valid := `{"version":1,"grants":[{"identity":"a","title":"t","body":"b","sent_at":1790989200000,"rewards":[{"type":4,"id":0,"count":10}]}]}` - if _, err := decodeGrantSpool([]byte(valid)); err != nil { - t.Fatal(err) - } - invalid := []string{ - `null`, `{}`, `{"version":1,"grants":null}`, `{"version":1,"grants":[],"extra":0}`, - strings.Replace(valid, `"version":1`, `"version":2`, 1), - strings.Replace(valid, `"version":1`, `"version":1,"version":1`, 1), - strings.Replace(valid, `"title":"t"`, `"title":"t","title":"u"`, 1), - strings.Replace(valid, `"title":"t"`, `"Title":"t"`, 1), - strings.Replace(valid, `"body":"b"`, `"body":null`, 1), - strings.Replace(valid, `"identity":"a"`, `"identity":" "`, 1), - strings.Replace(valid, `"sent_at":1790989200000`, `"sent_at":0`, 1), - strings.Replace(valid, `"sent_at":1790989200000`, `"sent_at":9223372036854775807`, 1), - strings.Replace(valid, `"count":10`, `"count":0`, 1), - strings.Replace(valid, `"count":10`, `"count":1.5`, 1), - strings.Replace(valid, `"count":10`, `"count":null`, 1), - strings.Replace(valid, `"count":10`, `"count":10,"count":10`, 1), - strings.Replace(valid, `"count":10`, `"count":10,"extra":0`, 1), - strings.Replace(valid, `"id":0,`, ``, 1), - strings.Replace(valid, `"id":0`, `"id":1`, 1), - strings.Replace(valid, `"type":4`, `"type":8`, 1), - valid + `{}`, - } - for _, input := range invalid { - if _, err := decodeGrantSpool([]byte(input)); err == nil { - t.Errorf("accepted malformed spool: %s", input) - } - } - if _, err := decodeGrantSpool([]byte(`{"version":1,"grants":[]}`)); err != nil { - t.Fatal(err) - } -} diff --git a/go/internal/server/mail/mail_test.go b/go/internal/server/mail/mail_test.go deleted file mode 100644 index 39cd026..0000000 --- a/go/internal/server/mail/mail_test.go +++ /dev/null @@ -1,750 +0,0 @@ -package mail - -import ( - "bytes" - "os" - "path/filepath" - "testing" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestStarterAnswersMailInfoWithoutCapture(t *testing.T) { - seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "mail.json")) - if err != nil { - t.Fatal(err) - } - code, proto, ok, err := seed.Handle("/MailInfo", wire.AppendVarint(nil, 1, 249)) - if err != nil || !ok || code != packetCode || len(proto) == 0 { - t.Fatalf("MailInfo: code=%d bytes=%d ok=%t err=%v", code, len(proto), ok, err) - } - if _, _, ok, err := seed.Handle("/MailOpen", wire.AppendVarint(nil, 1, 1)); ok || err != nil { - t.Fatal("seed claimed an endpoint it does not own") - } - if _, _, ok, err := seed.Handle("/MailInfo", nil); !ok || err == nil { - t.Fatal("missing sequence was accepted") - } -} - -func TestDynamicCompensationMailPersistsAndIsIdempotent(t *testing.T) { - seed := &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100} - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 24, 0, 0, 0, 0, time.UTC) - rewards := []gamedata.Reward{{Type: 4, Count: 123}} - if err := service.EnqueueCompensation("daily:2026-09-23:1", "日常任务到期补发", "任务已完成但未领取。", rewards, now); err != nil { - t.Fatal(err) - } - if err := service.EnqueueCompensation("daily:2026-09-23:1", "日常任务到期补发", "任务已完成但未领取。", rewards, now); err != nil { - t.Fatal(err) - } - reopened, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - if len(reopened.dynamic) != 1 || reopened.issued["daily:2026-09-23:1"] != 101 { - t.Fatalf("dynamic=%+v issued=%+v", reopened.dynamic, reopened.issued) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 101) - if _, _, _, err := reopened.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 123 { - t.Fatalf("gold=%d", wallet.Snapshot().Gold) - } -} - -func TestMailOpenGrantsItemsAndCurrencyAndPersists(t *testing.T) { - seed := &Starter{Version: "2.35.10", MailCount: 3, MaxMailID: 12, Mails: []MailDBInfo{ - {MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{3, 4}, RewardIDs: []uint64{0, 0}, RewardCounts: []uint64{70, 123456789}}, - {MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{8}, RewardIDs: []uint64{9}, RewardCounts: []uint64{10}}, - }} - starter := &player.Starter{Version: "2.35.10"} - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Gold: 321}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendBytes(request, 2, packed([]uint64{11, 12})) - code, response, ok, err := service.Handle("/MailOpen", request) - if err != nil || !ok || code != 132 { - t.Fatalf("code=%d ok=%v err=%v", code, ok, err) - } - if wallet.Snapshot().FreeJewelry != 70 { - t.Fatalf("wallet=%+v", wallet.Snapshot()) - } - if wallet.Snapshot().Gold != 123457110 { - t.Fatalf("gold did not stack into wallet: %+v", wallet.Snapshot()) - } - if len(inv.All()) != 1 || inv.All()[0].ID != 9 || inv.All()[0].Count != 10 { - t.Fatalf("items=%+v", inv.All()) - } - bundle, found, _ := wire.Bytes(response, 1) - if !found || len(bundle) == 0 { - t.Fatal("reward bundle missing") - } - infoReq := wire.AppendVarint(nil, 1, 2) - _, info, _, err := service.Handle("/MailInfo", infoReq) - if err != nil { - t.Fatal(err) - } - if _, found, _ := wire.Bytes(info, 1); found { - t.Fatal("opened mail remained visible") - } - count, found, _ := wire.Varint(info, 2) - if !found || count != 1 { - t.Fatalf("mail count=%d found=%v", count, found) - } - service, err = OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - _, info, _, _ = service.Handle("/MailInfo", infoReq) - if _, found, _ := wire.Bytes(info, 1); found { - t.Fatal("opened mail was not persisted") - } -} - -func TestMailHistoryPersistsPagesAndKeepsFirstOpenTime(t *testing.T) { - seed := &Starter{Version: "2.35.10", MailCount: 4, MaxMailID: 13, Mails: []MailDBInfo{ - {MailID: 11, MailType: 2, Title: "first", ExpiresAt: 100, SentAt: 10, RewardTypes: []uint64{4}, RewardIDs: []uint64{0}, RewardCounts: []uint64{1}}, - {MailID: 12, MailType: 2, Title: "second", ExpiresAt: 200, SentAt: 20, RewardTypes: []uint64{4}, RewardIDs: []uint64{0}, RewardCounts: []uint64{2}}, - {MailID: 13, MailType: 2, Title: "third", ExpiresAt: 300, SentAt: 30, RewardTypes: []uint64{4}, RewardIDs: []uint64{0}, RewardCounts: []uint64{3}}, - }} - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - openedAt := time.Date(2026, 10, 3, 5, 6, 7, 8_000_000, time.UTC) - service.now = func() time.Time { return openedAt } - open := wire.AppendVarint(nil, 1, 1) - open = wire.AppendBytes(open, 2, packed([]uint64{11, 12, 13})) - if _, _, _, err := service.Handle("/MailOpen", open); err != nil { - t.Fatal(err) - } - - request := wire.AppendVarint(nil, 1, 2) - request = wire.AppendVarint(request, 2, 0) - request = wire.AppendVarint(request, 3, 2) - code, response, handled, err := service.Handle("/MailHistoryInfo", request) - if err != nil || !handled || code != historyPacketCode { - t.Fatalf("code=%d handled=%v err=%v", code, handled, err) - } - entries := historyEntries(t, response) - if len(entries) != 2 || historyID(t, entries[0]) != 13 || historyID(t, entries[1]) != 12 { - t.Fatalf("first page IDs=%v", historyIDs(t, entries)) - } - total, found, err := wire.Varint(response, 2) - if err != nil || !found || total != 3 { - t.Fatalf("total=%d found=%v err=%v", total, found, err) - } - if isOpen, found, _ := wire.Varint(entries[0], 11); !found || isOpen != 1 { - t.Fatalf("history is_open=%d found=%v", isOpen, found) - } - if got, found, _ := wire.Varint(entries[0], 12); !found || got != uint64(openedAt.UnixMilli()) { - t.Fatalf("open_time=%d found=%v", got, found) - } - if got, found, _ := wire.Varint(entries[0], 14); !found || got != uint64(openedAt.Add(mailHistoryPeriod).UnixMilli()) { - t.Fatalf("history_delete_time=%d found=%v", got, found) - } - - next := wire.AppendVarint(nil, 1, 3) - next = wire.AppendVarint(next, 2, 12) - next = wire.AppendVarint(next, 3, 2) - _, response, _, err = service.Handle("/MailHistoryInfo", next) - if err != nil { - t.Fatal(err) - } - entries = historyEntries(t, response) - if len(entries) != 1 || historyID(t, entries[0]) != 11 { - t.Fatalf("second page IDs=%v", historyIDs(t, entries)) - } - - service.now = func() time.Time { return openedAt.Add(24 * time.Hour) } - if _, _, _, err := service.Handle("/MailOpen", wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, 13)); err != nil { - t.Fatal(err) - } - reopened, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - reopened.now = service.now - _, response, _, err = reopened.Handle("/MailHistoryInfo", request) - if err != nil { - t.Fatal(err) - } - entries = historyEntries(t, response) - if got, _, _ := wire.Varint(entries[0], 12); got != uint64(openedAt.UnixMilli()) { - t.Fatalf("retry changed first open time to %d", got) - } - - reopened.now = func() time.Time { return openedAt.Add(mailHistoryPeriod) } - _, response, _, err = reopened.Handle("/MailHistoryInfo", request) - if err != nil { - t.Fatal(err) - } - if entries := historyEntries(t, response); len(entries) != 0 { - t.Fatalf("expired history remains: %v", historyIDs(t, entries)) - } - if total, _, _ := wire.Varint(response, 2); total != 0 { - t.Fatalf("expired total=%d", total) - } -} - -func TestMailHistoryRejectsInvalidPagination(t *testing.T) { - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, &Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 10}, inv, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - if _, _, handled, err := service.Handle("/MailHistoryInfo", request); !handled || err == nil { - t.Fatalf("handled=%v err=%v", handled, err) - } -} - -type mailCostumeDesign map[uint64]gamedata.CharacterDesign - -func (d mailCostumeDesign) Character(costumeID uint64) (gamedata.CharacterDesign, bool) { - value, ok := d[costumeID] - return value, ok -} - -func TestMailCostumeRewardCreatesEnhancementFiveIdempotently(t *testing.T) { - seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{ - MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10, - RewardTypes: []uint64{11}, RewardIDs: []uint64{206}, RewardCounts: []uint64{6}, - }}} - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if err := collection.BindBaseCharacters(nil); err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachCostumeRewards(collection, mailCostumeDesign{206: { - ID: 20, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 10, - }}); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 11) - _, response, _, err := service.Handle("/MailOpen", request) - if err != nil { - t.Fatal(err) - } - if got := collection.Costumes(); len(got) != 1 || got[0].ID != 206 || got[0].Level != 5 { - t.Fatalf("costumes=%+v", got) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("bundle: found=%v err=%v", found, err) - } - var characters, costumes, upgrades int - if err := wire.Walk(bundle, func(field wire.Field) error { - switch field.Number { - case 2: - characters++ - case 3: - costumes++ - case 9: - upgrades++ - } - return nil - }); err != nil { - t.Fatal(err) - } - if characters != 1 || costumes != 1 || upgrades != 5 { - t.Fatalf("characters=%d costumes=%d upgrades=%d", characters, costumes, upgrades) - } - if _, _, _, err := service.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - if got := collection.Costumes(); len(got) != 1 || got[0].Level != 5 { - t.Fatalf("retry costumes=%+v", got) - } -} - -func TestEnsureStarterLimitedCostumesIsDurablyIdempotent(t *testing.T) { - storage := stateio.NewMemory() - service, _, _ := spoolTestService(t, storage) - design := mailCostumeDesign{ - 206: {ID: 20, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 10}, - 306: {ID: 30, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 10}, - } - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if err := service.AttachCostumeRewards(collection, design); err != nil { - t.Fatal(err) - } - firstTime := time.Date(2026, 10, 3, 8, 0, 0, 0, time.UTC) - if err := service.EnsureStarterLimitedCostumes([]uint64{206}, firstTime); err != nil { - t.Fatal(err) - } - firstID := service.issued[starterLimitedCostumeIdentity] - if firstID == 0 || len(service.dynamic) != 1 { - t.Fatalf("first issue: id=%d dynamic=%v", firstID, service.dynamic) - } - // A retry after the schedule changed must preserve the originally frozen - // mail rather than allocating a second ID or replacing its attachments. - if err := service.EnsureStarterLimitedCostumes([]uint64{306}, firstTime.Add(24*time.Hour)); err != nil { - t.Fatal(err) - } - if service.issued[starterLimitedCostumeIdentity] != firstID || len(service.dynamic) != 1 || service.dynamic[firstID].RewardIDs[0] != 206 { - t.Fatalf("in-process retry changed gift: issued=%v dynamic=%v", service.issued, service.dynamic) - } - - reopened, _, _ := spoolTestService(t, storage) - if err := reopened.AttachCostumeRewards(collection, design); err != nil { - t.Fatal(err) - } - if err := reopened.EnsureStarterLimitedCostumes([]uint64{306}, firstTime.Add(48*time.Hour)); err != nil { - t.Fatal(err) - } - if reopened.issued[starterLimitedCostumeIdentity] != firstID || len(reopened.dynamic) != 1 || reopened.dynamic[firstID].RewardIDs[0] != 206 { - t.Fatalf("restart retry changed gift: issued=%v dynamic=%v", reopened.issued, reopened.dynamic) - } -} - -func TestStarterPrestigeSkinMailClaimsAndPersistsOwnershipOnce(t *testing.T) { - storage := stateio.NewMemory() - service, _, _ := spoolTestService(t, storage) - sentAt := time.Date(2026, 10, 5, 8, 0, 0, 0, time.UTC) - skins := []uint64{101, 202} - if err := service.EnsureStarterPrestigeSkins(skins, sentAt); err != nil { - t.Fatal(err) - } - id := service.issued[starterPrestigeSkinIdentity] - if id == 0 || len(service.dynamic) != 1 { - t.Fatalf("gift missing: issued=%v dynamic=%v", service.issued, service.dynamic) - } - original := append([]byte(nil), service.dynamic[id].encode()...) - if err := service.EnsureStarterPrestigeSkins(skins, sentAt.Add(time.Hour)); err != nil { - t.Fatal(err) - } - service, inventory, _ := spoolTestService(t, storage) - if service.issued[starterPrestigeSkinIdentity] != id || len(service.dynamic) != 1 || !bytes.Equal(original, service.dynamic[id].encode()) { - t.Fatal("retry or restart changed the unclaimed gift") - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, id) - code, response, handled, err := service.Handle("/MailOpen", request) - if err != nil || !handled || code != 132 { - t.Fatalf("MailOpen: code=%d handled=%v err=%v", code, handled, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("reward bundle: found=%v err=%v", found, err) - } - // Check the actual client-visible ItemDBInfo, including the inventory - // indices which must remain stable when this mail is claimed again. - assertSkins := func(payload []byte) map[uint64]uint64 { - t.Helper() - indices := make(map[uint64]uint64) - for _, item := range historyEntries(t, payload) { - index, _, _ := wire.Varint(item, 1) - design, _, _ := wire.Varint(item, 2) - typ, _, _ := wire.Varint(item, 3) - count, _, _ := wire.Varint(item, 4) - if index == 0 || typ != 45 || count != 1 || (design != skins[0] && design != skins[1]) || indices[design] != 0 { - t.Fatalf("invalid or duplicate skin ItemDBInfo: index=%d design=%d type=%d count=%d", index, design, typ, count) - } - indices[design] = index - } - if len(indices) != len(skins) { - t.Fatalf("skin ownership=%v", indices) - } - return indices - } - granted := assertSkins(bundle) - assertOwnership := func(inv *player.Inventory) { - t.Helper() - _, info, handled, err := inv.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 2)) - if err != nil || !handled { - t.Fatalf("ItemInfo: handled=%v err=%v", handled, err) - } - owned := assertSkins(info) - for design, index := range granted { - if owned[design] != index { - t.Fatalf("skin %d inventory index changed: got=%d want=%d", design, owned[design], index) - } - } - } - assertOwnership(inventory) - if _, _, _, err := service.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - assertOwnership(inventory) - if err := service.EnsureStarterPrestigeSkins([]uint64{303}, sentAt.Add(24*time.Hour)); err != nil { - t.Fatal(err) - } - service, inventory, _ = spoolTestService(t, storage) - if err := service.EnsureStarterPrestigeSkins([]uint64{404}, sentAt.Add(48*time.Hour)); err != nil { - t.Fatal(err) - } - if service.issued[starterPrestigeSkinIdentity] != id || len(service.dynamic) != 1 || !bytes.Equal(original, service.dynamic[id].encode()) || !containsID(service.state.Opened, id) { - t.Fatal("restart lost the claimed gift ledger or replaced its frozen attachments") - } - if _, _, _, err := service.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - assertOwnership(inventory) -} - -func historyEntries(t *testing.T, response []byte) [][]byte { - t.Helper() - var entries [][]byte - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 { - entries = append(entries, append([]byte(nil), field.Value...)) - } - return nil - }); err != nil { - t.Fatal(err) - } - return entries -} - -func historyID(t *testing.T, entry []byte) uint64 { - t.Helper() - id, found, err := wire.Varint(entry, 1) - if err != nil || !found { - t.Fatalf("history ID: found=%v err=%v", found, err) - } - return id -} - -func historyIDs(t *testing.T, entries [][]byte) []uint64 { - t.Helper() - ids := make([]uint64, 0, len(entries)) - for _, entry := range entries { - ids = append(ids, historyID(t, entry)) - } - return ids -} - -func TestStarterContentTicketOnlyAllowsAuditedSingleUseReward(t *testing.T) { - for _, reward := range []struct { - id, count uint64 - valid bool - }{{450030, 1, true}, {450029, 1, true}, {450030, 0, false}, {450030, 2, false}} { - seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{ - MailID: 13, MailType: 2, ExpiresAt: 100, SentAt: 10, - RewardTypes: []uint64{19}, RewardIDs: []uint64{reward.id}, RewardCounts: []uint64{reward.count}, - }}} - if err := seed.Validate(); (err == nil) != reward.valid { - t.Fatalf("id=%d count=%d valid=%v err=%v", reward.id, reward.count, reward.valid, err) - } - } -} - -func TestMailOpenGrantsNonResourceItemDBInfoType(t *testing.T) { - seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{ - MailID: 13, MailType: 2, ExpiresAt: 100, SentAt: 10, - RewardTypes: []uint64{14}, RewardIDs: []uint64{1}, RewardCounts: []uint64{2}, - }}} - starter := &player.Starter{Version: "2.35.10"} - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendBytes(request, 2, packed([]uint64{13})) - if _, _, _, err := service.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - items := inv.All() - if len(items) != 1 || items[0].ID != 1 || items[0].Type != 14 || items[0].Count != 2 { - t.Fatalf("items=%+v", items) - } -} - -func TestMailOpenGrantsCatalystAndMileageCurrenciesAndPersists(t *testing.T) { - seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 14, Mails: []MailDBInfo{{ - MailID: 14, MailType: 2, ExpiresAt: 100, SentAt: 10, - RewardTypes: []uint64{12, 20}, RewardIDs: []uint64{0, 0}, RewardCounts: []uint64{250, 300}, - }}} - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{Catalyst: 10, Mileage: 20}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, seed, inv, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendBytes(request, 2, packed([]uint64{14})) - _, response, _, err := service.Handle("/MailOpen", request) - if err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Catalyst != 260 || wallet.Snapshot().Mileage != 320 || len(inv.All()) != 0 { - t.Fatalf("wallet=%+v items=%+v", wallet.Snapshot(), inv.All()) - } - bundle, found, _ := wire.Bytes(response, 1) - if !found { - t.Fatal("reward bundle missing") - } - gotCurrencies := map[uint64]uint64{} - if err := wire.Walk(bundle, func(field wire.Field) error { - if field.Number != 1 { - return nil - } - typ, _, err := wire.Varint(field.Value, 3) - if err != nil { - return err - } - count, _, err := wire.Varint(field.Value, 4) - if err != nil { - return err - } - gotCurrencies[typ] = count - return nil - }); err != nil { - t.Fatal(err) - } - if gotCurrencies[12] != 250 || gotCurrencies[20] != 300 { - t.Fatalf("currency rewards=%v", gotCurrencies) - } - if _, _, _, err := service.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Catalyst != 260 || wallet.Snapshot().Mileage != 320 { - t.Fatalf("replay wallet=%+v", wallet.Snapshot()) - } - reopened, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - if reopened.Snapshot().Catalyst != 260 || reopened.Snapshot().Mileage != 320 { - t.Fatalf("persisted wallet=%+v", reopened.Snapshot()) - } -} - -func TestWatchedSeedReloadsOnlyValidAtomicReplacement(t *testing.T) { - dir := t.TempDir() - seedPath := filepath.Join(dir, "seed.json") - first := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}}} - if err := first.Write(seedPath); err != nil { - t.Fatal(err) - } - starter, err := Load(seedPath) - if err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, starter, inv, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.AttachSeedPath(seedPath); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - if _, response, _, err := service.Handle("/MailInfo", request); err != nil { - t.Fatal(err) - } else if id, _, _ := wire.Varint(mustFirstMail(t, response), 1); id != 11 { - t.Fatalf("initial mail ID=%d", id) - } - second := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 12, Mails: []MailDBInfo{{MailID: 12, MailType: 2, ExpiresAt: 100, SentAt: 10}}} - if err := second.Write(seedPath); err != nil { - t.Fatal(err) - } - if _, response, _, err := service.Handle("/MailInfo", request); err != nil { - t.Fatal(err) - } else if id, _, _ := wire.Varint(mustFirstMail(t, response), 1); id != 12 { - t.Fatalf("reloaded mail ID=%d", id) - } - if err := os.WriteFile(seedPath, []byte("not json"), 0o600); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", request); err == nil { - t.Fatal("accepted malformed watched seed") - } - // The malformed file did not replace the last known-good mailbox. - if service.Starter.Mails[0].MailID != 12 { - t.Fatalf("last known-good seed lost: %+v", service.Starter.Mails) - } -} - -func TestExpandedWatchedSeedAdvancesDynamicMailAllocator(t *testing.T) { - dir := t.TempDir() - seedPath := filepath.Join(dir, "seed.json") - first := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 11, Mails: []MailDBInfo{{MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}}} - if err := first.Write(seedPath); err != nil { - t.Fatal(err) - } - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := OpenService(storage, first, inv, wallet) - if err != nil { - t.Fatal(err) - } - if err := service.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := service.AttachSeedPath(seedPath); err != nil { - t.Fatal(err) - } - second := &Starter{Version: "2.35.10", MailCount: 3, MaxMailID: 101, Mails: []MailDBInfo{ - {MailID: 11, MailType: 2, ExpiresAt: 100, SentAt: 10}, - {MailID: 101, MailType: 2, ExpiresAt: 100, SentAt: 10}, - }} - if err := second.Write(seedPath); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1)); err != nil { - t.Fatal(err) - } - if service.state.NextDynamicMailID != 102 { - t.Fatalf("next dynamic mail ID=%d", service.state.NextDynamicMailID) - } - reopened, err := OpenService(storage, second, inv, wallet) - if err != nil { - t.Fatal(err) - } - if reopened.state.NextDynamicMailID != 102 { - t.Fatalf("reopened next dynamic mail ID=%d", reopened.state.NextDynamicMailID) - } -} - -func mustFirstMail(t *testing.T, response []byte) []byte { - t.Helper() - entry, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("missing first mail: found=%v err=%v", found, err) - } - return entry -} - -func TestMailOpenAcceptsOfficialPackedRequest(t *testing.T) { - request := []byte{0x08, 0xef, 0x01, 0x12, 0x0f, 0x85, 0xc1, 0xe1, 0xce, 0x30, 0x88, 0xc1, 0xe1, 0xce, 0x30, 0x8a, 0xc1, 0xe1, 0xce, 0x30} - ids, err := requestMailIDs(request) - if err != nil { - t.Fatal(err) - } - want := []uint64{13050077317, 13050077320, 13050077322} - if len(ids) != len(want) { - t.Fatalf("ids=%v", ids) - } - for i := range want { - if ids[i] != want[i] { - t.Fatalf("ids=%v", ids) - } - } -} - -func TestStarterMailProtocolEncodingAndRoundTrip(t *testing.T) { - seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 7, Mails: []MailDBInfo{{ - MailID: 7, MailType: 2, Title: "Welcome", Body: "Rewards", ExpiresAt: 1000, SentAt: 10, - RewardTypes: []uint64{3, 8}, RewardIDs: []uint64{0, 17}, RewardCounts: []uint64{5, 300}, - }}} - path := filepath.Join(t.TempDir(), "mail.json") - if err := seed.Write(path); err != nil { - t.Fatal(err) - } - loaded, err := Load(path) - if err != nil { - t.Fatal(err) - } - // MailInfoResponse contains MailDBInfo field 1 plus count and maximum ID. - // Reward arrays are packed protobuf varints; default ID 0 remains a slot. - wantMail := []byte{0x08, 7, 0x10, 2, 0x2a, 7, 'W', 'e', 'l', 'c', 'o', 'm', 'e', 0x32, 7, 'R', 'e', 'w', 'a', 'r', 'd', 's', 0x38, 0xe8, 7, 0x42, 2, 3, 8, 0x4a, 2, 0, 17, 0x52, 3, 5, 0xac, 2, 0x68, 10} - want := append([]byte{0x0a, byte(len(wantMail))}, wantMail...) - want = append(want, 0x10, 2, 0x18, 7) - code, actual, handled, err := loaded.Handle("/MailInfo", wire.AppendVarint(nil, 1, 249)) - if err != nil || !handled || code != 131 || !bytes.Equal(actual, want) { - t.Fatalf("code=%d proto=%x handled=%v err=%v", code, actual, handled, err) - } -} - -func TestValidateRejectsRewardLengthMismatch(t *testing.T) { - seed := &Starter{Version: "2.35.10", Mails: []MailDBInfo{{MailID: 1, MailType: 2, ExpiresAt: 1, SentAt: 1, RewardTypes: []uint64{8}, RewardIDs: []uint64{1}}}, MailCount: 2, MaxMailID: 1} - if seed.Validate() == nil { - t.Fatal("accepted bad rewards") - } -} diff --git a/go/internal/server/mail/notifications.go b/go/internal/server/mail/notifications.go deleted file mode 100644 index 403002a..0000000 --- a/go/internal/server/mail/notifications.go +++ /dev/null @@ -1,29 +0,0 @@ -package mail - -import "bd2server/internal/server/wire" - -// BeforeDispatch and AfterDispatch observe newly allocated mail inside the -// account request transaction, including mail issued by other domains. -func (s *Service) BeforeDispatch(string, []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - s.beforeMailID = s.state.NextDynamicMailID - return nil -} - -func (s *Service) AfterDispatch(string, []byte, []byte) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - // Allocation is monotonic. Without a new ID, no existing row can match - // the notification window, so read-only packets need not scan the inbox. - if s.state.NextDynamicMailID == s.beforeMailID { - return nil, nil - } - now := uint64(s.now().UnixMilli()) - for id, entry := range s.dynamic { - if id >= s.beforeMailID && !containsID(s.state.Opened, id) && entry.ExpiresAt > now { - return wire.AppendVarint(nil, 1, 1), nil // Notify.IsNewMail - } - } - return nil, nil -} diff --git a/go/internal/server/mail/notifications_test.go b/go/internal/server/mail/notifications_test.go deleted file mode 100644 index 5c98b09..0000000 --- a/go/internal/server/mail/notifications_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package mail - -import ( - "testing" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestNewMailNotificationDoesNotRepeatOnUnchangedRequests(t *testing.T) { - s, _, _ := attendanceMailFixture(t, stateio.NewMemory(), &Starter{Version: "2.35.10", MailCount: 1}, &attendanceDesign{}) - now := time.Now().UTC() - if err := s.BeforeDispatch("/Attendance", nil); err != nil { - t.Fatal(err) - } - if err := s.IssueAttachmentsOnce("today", "Attendance Rewards", "Claim your rewards.", []gamedata.Reward{{Type: 9, ID: 100, Count: 1}}, now); err != nil { - t.Fatal(err) - } - notice, err := s.AfterDispatch("/Attendance", nil, nil) - if v, ok, parseErr := wire.Varint(notice, 1); err != nil || parseErr != nil || !ok || v != 1 { - t.Fatal("new mail did not notify", err, parseErr) - } - if err := s.BeforeDispatch("/read", nil); err != nil { - t.Fatal(err) - } - if err := s.IssueAttachmentsOnce("today", "Attendance Rewards", "Claim your rewards.", []gamedata.Reward{{Type: 9, ID: 100, Count: 1}}, now); err != nil { - t.Fatal(err) - } - if notice, err := s.AfterDispatch("/read", nil, nil); err != nil || len(notice) != 0 { - t.Fatal("unchanged mail notified again", err) - } -} diff --git a/go/internal/server/missions/achievement_progress_test.go b/go/internal/server/missions/achievement_progress_test.go deleted file mode 100644 index 22e9304..0000000 --- a/go/internal/server/missions/achievement_progress_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package missions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -type testAchievementProgress uint64 - -func (p *testAchievementProgress) AchievementValue(uint64) (uint64, error) { return uint64(*p), nil } -func TestAchievementClearRequiresProgressBeforeAnyBatchReward(t *testing.T) { - store := stateio.NewMemory() - inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - design := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{GroupID: 987, ID: 1}: {Target: 5, CounterGroup: 987, AddExp: 7, Rewards: []gamedata.Reward{{Type: 8, ID: 123, Count: 1}}}, {GroupID: 987, ID: 2}: {Target: 10, CounterGroup: 987, AddExp: 9, Rewards: []gamedata.Reward{{Type: 8, ID: 123, Count: 2}}}}} - s, err := Open(store, design, inv) - if err != nil { - t.Fatal(err) - } - progress := testAchievementProgress(5) - if err := s.AttachAchievementProgress(&progress); err != nil { - t.Fatal(err) - } - info := wire.AppendVarint(nil, 1, 987) - info = wire.AppendVarint(info, 2, 1) - info = wire.AppendVarint(info, 2, 2) - req := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 3, info) - if _, _, _, err := s.Handle("/AchievementClear", req); err == nil { - t.Fatal("unearned batch claimed") - } - if len(inv.All()) != 0 || len(s.state.Claimed) != 0 { - t.Fatal("earlier batch reward mutated") - } - progress = 10 - if _, _, _, err := s.Handle("/AchievementClear", req); err != nil { - t.Fatal(err) - } - if exp, err := s.AchievementExperience(); err != nil || exp != 16 { - t.Fatalf("exp=%d err=%v", exp, err) - } - if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 3 { - t.Fatalf("rewards=%+v", inv.All()) - } - if _, _, _, err := s.Handle("/AchievementClear", req); err != nil { - t.Fatal(err) - } - if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 3 { - t.Fatal("replay granted twice") - } -} - -func TestAchievementCurrencyRewardsCreditWalletAndReplay(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - wallet, _ := player.OpenWallet(store, player.Currency{}) - d := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{GroupID: 987, ID: 1}: {Target: 1, AddExp: 9, Rewards: []gamedata.Reward{{Type: 3, Count: 50}, {Type: 4, Count: 100}}}}} - s, err := Open(store, d, inv) - if err != nil { - t.Fatal(err) - } - if err := s.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - p := testAchievementProgress(1) - if err := s.AttachAchievementProgress(&p); err != nil { - t.Fatal(err) - } - req := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 987), 2, 1)) - for range 2 { - if _, _, _, err := s.Handle("/AchievementClear", req); err != nil { - t.Fatal(err) - } - } - if c := wallet.Snapshot(); c.Gold != 100 || c.FreeJewelry != 50 { - t.Fatalf("wallet=%+v", c) - } - if len(inv.All()) != 0 { - t.Fatal("currency stored in items") - } -} - -func TestAchievementTierOrderUsesActualIDsAndAllowsUnorderedBatch(t *testing.T) { - for _, content := range []uint64{0, 1} { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - first, last := uint64(17), uint64(93) - if content == 1 { - first, last = 1017, 1093 - } - d := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{ContentsGroup: content, GroupID: 987, ID: first}: {Target: 1, AddExp: 7}, {ContentsGroup: content, GroupID: 987, ID: last}: {Target: 2, AddExp: 9}}} - s, err := Open(store, d, inv) - if err != nil { - t.Fatal(err) - } - p := testAchievementProgress(2) - if err := s.AttachAchievementProgress(&p); err != nil { - t.Fatal(err) - } - request := func(ids ...uint64) []byte { - info := wire.AppendVarint(nil, 1, 987) - for _, id := range ids { - info = wire.AppendVarint(info, 2, id) - } - return wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, content), 3, info) - } - if _, _, _, err := s.Handle("/AchievementClear", request(last)); err == nil { - t.Fatal("skipped earlier tier") - } - if len(s.state.Claimed) != 0 { - t.Fatal("invalid jump mutated") - } - if _, _, _, err := s.Handle("/AchievementClear", request(last, first)); err != nil { - t.Fatal(err) - } - if exp, err := s.AchievementExperience(); err != nil || exp != 16 { - t.Fatalf("exp=%d err=%v", exp, err) - } - s.state.Claimed = append(s.state.Claimed, "achievement:0/12345/1") - if _, err := s.AchievementExperience(); err == nil { - t.Fatal("unknown historical claim omitted") - } - } -} diff --git a/go/internal/server/missions/events_test.go b/go/internal/server/missions/events_test.go deleted file mode 100644 index c693afa..0000000 --- a/go/internal/server/missions/events_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package missions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "testing" - "time" -) - -func TestEventsFollowConditionsAndLoginPersistsAcrossRestart(t *testing.T) { - daily := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 9876} - weekly := gamedata.MissionKey{GroupType: 1, GroupID: 66, ID: 7654} - gacha := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 8765} - locked := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 8654} - design := &gamedata.MissionDesign{Missions: map[gamedata.MissionKey][]gamedata.Reward{daily: nil, weekly: nil, gacha: nil, locked: nil}, Conditions: map[gamedata.MissionKey]gamedata.MissionCondition{ - daily: {Type: ConditionConnect, TargetValue: 1}, weekly: {Type: ConditionConnect, TargetValue: 3}, gacha: {Type: ConditionGachaBuy, SubType: 5, SubTypeComparison: 1, TargetValue: 7}, locked: {Type: ConditionGachaBuy, UnlockPack: 21, UnlockQuest: 4, TargetValue: 1}, - }} - store := stateio.NewMemory() - inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s, err := Open(store, design, inv) - if err != nil { - t.Fatal(err) - } - now := time.Now().UTC() - s.now = func() time.Time { return now } - if err := s.RecordLogin(nil); err != nil { - t.Fatal(err) - } - s, err = Open(store, design, inv) - if err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return now } - if err := s.RecordLogin(nil); err != nil { - t.Fatal(err) - } - if s.state.Progress[missionName(weekly)] != 1 { - t.Fatal("reconnect incremented weekly login") - } - if err := s.RecordEvent(ConditionGachaBuy, 4, 20, nil); err != nil { - t.Fatal(err) - } - if s.state.Progress[missionName(gacha)] != 0 || s.state.Progress[missionName(locked)] != 0 { - t.Fatal("subtype/locked mission matched") - } - if err := s.RecordEvent(ConditionGachaBuy, 6, 3, nil); err != nil { - t.Fatal(err) - } - if s.state.Progress[missionName(gacha)] != 3 { - t.Fatal("event did not use count") - } - if err := s.RecordEvent(ConditionGachaBuy, 6, 100, func(pack, quest uint64) bool { return pack == 21 && quest == 4 }); err != nil { - t.Fatal(err) - } - if s.state.Progress[missionName(gacha)] != 7 || s.state.Progress[missionName(locked)] != 1 { - t.Fatal("target cap/unlock did not apply") - } - wallet, err := player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MaxMailID: 100, MailCount: 1}, inv, wallet) - if err != nil { - t.Fatal(err) - } - if err := s.AttachMail(mailbox); err != nil { - t.Fatal(err) - } - now = now.AddDate(0, 0, 1) - if err := s.RecordLogin(nil); err != nil { - t.Fatal(err) - } - // Monday resets weekly; other days increment its connection count. - want := uint64(2) - if now.Weekday() == time.Monday { - want = 1 - } - if s.state.Progress[missionName(weekly)] != want { - t.Fatalf("weekly progress=%d want=%d", s.state.Progress[missionName(weekly)], want) - } -} diff --git a/go/internal/server/missions/missions.go b/go/internal/server/missions/missions.go deleted file mode 100644 index d076f58..0000000 --- a/go/internal/server/missions/missions.go +++ /dev/null @@ -1,1181 +0,0 @@ -// Package missions owns local completion eligibility and claim state for the -// three regular (non-scheduled-event) mission endpoints. Static definitions -// come from GameData; this package never replays an HTTP capture. -package missions - -import ( - "encoding/json" - "errors" - "fmt" - "maps" - "slices" - "sort" - "strconv" - "strings" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -const ( - missionClearPacket = 120 - missionSectionPacket = 122 - achievementClearPacket = 168 -) - -var ErrInvalidRequest = errors.New("missions: invalid request") - -// Service is deliberately given completion signals by authoritative gameplay -// code. A request cannot manufacture a mission completion merely by naming a -// table row. AchievementClear is different: its request carries the exact -// client-calculated completed achievement ids, as in the official protocol. -type AchievementProgressSource interface { - AchievementValue(groupID uint64) (uint64, error) -} - -func (s *Service) AttachAchievementProgress(source AchievementProgressSource) error { - if source == nil { - return errors.New("missions: nil achievement progress") - } - s.achievementProgress = source - return nil -} - -type Service struct { - eventHandler interface { - Handle(string, []byte) (int, []byte, bool, error) - } - levelRewards *gamedata.AchievementLevelDesign - levelReward uint64 - achievementProgress AchievementProgressSource - mu sync.Mutex - storage stateio.Store - design *gamedata.MissionDesign - inventory *player.Inventory - wallet *player.Wallet - mail *mail.Service - state snapshot - now func() time.Time -} - -func (s *Service) AttachWallet(wallet *player.Wallet) error { - if wallet == nil { - return errors.New("missions: nil wallet") - } - s.wallet = wallet - return nil -} - -type snapshot struct { - Version string `json:"version"` - DailyPeriod string `json:"daily_period"` - WeeklyPeriod string `json:"weekly_period"` - Completed []string `json:"completed"` - Claimed []string `json:"claimed"` - Progress map[string]uint64 `json:"progress"` -} - -func Open(storage stateio.Store, design *gamedata.MissionDesign, inventory *player.Inventory) (*Service, error) { - if storage == nil || design == nil || inventory == nil { - return nil, errors.New("missions: invalid service configuration") - } - now := time.Now().UTC() - s := &Service{storage: storage, design: design, inventory: inventory, now: time.Now, state: snapshot{ - Version: versionconfig.State(), DailyPeriod: dailyPeriod(now), WeeklyPeriod: weeklyPeriod(now), Progress: map[string]uint64{}, - }} - b, err := storage.Load("missions") - if err != nil { - return nil, fmt.Errorf("missions: load state: %w", err) - } - if b == nil { - return s, nil - } - if err := stateio.RequireExactJSONObject(b, "version", "daily_period", "weekly_period", "completed", "claimed", "progress"); err != nil { - return nil, fmt.Errorf("missions: incompatible state layout: %w", err) - } - if err := json.Unmarshal(b, &s.state); err != nil || s.state.Version != versionconfig.State() || s.state.DailyPeriod == "" || s.state.WeeklyPeriod == "" { - return nil, errors.New("missions: malformed state") - } - if s.state.Progress == nil { - return nil, errors.New("missions: progress must be an object") - } - return s, nil -} - -func (s *Service) AttachMail(mailbox *mail.Service) error { - if mailbox == nil { - return errors.New("missions: nil compensation mailbox") - } - s.mu.Lock() - defer s.mu.Unlock() - s.mail = mailbox - return nil -} - -func (s *Service) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - b, err := s.storage.Load("missions") - if err != nil { - return err - } - if b != nil { - return nil - } - return s.persist(cloneSnapshot(s.state)) -} - -// CompleteMission is the only way normal mission eligibility enters this -// package. It is persistent and idempotent. Gameplay/event handlers should -// call it only after they have independently verified the table condition. -func (s *Service) CompleteMission(key gamedata.MissionKey) error { - s.mu.Lock() - defer s.mu.Unlock() - if err := s.rolloverLocked(); err != nil { - return err - } - if _, ok := s.design.Missions[key]; !ok { - return fmt.Errorf("missions: unknown mission %+v", key) - } - condition := s.design.Conditions[key] - value := condition.TargetValue - if value == 0 { - value = 1 - } - return s.setProgressLocked(key, value) -} - -func (s *Service) SetProgress(key gamedata.MissionKey, value uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - if err := s.rolloverLocked(); err != nil { - return err - } - return s.setProgressLocked(key, value) -} - -func (s *Service) setProgressLocked(key gamedata.MissionKey, value uint64) error { - if _, ok := s.design.Missions[key]; !ok { - return fmt.Errorf("missions: unknown mission %+v", key) - } - name := missionName(key) - if s.state.Progress[name] >= value { - return nil - } - next := cloneSnapshot(s.state) - next.Progress[name] = value - condition := s.design.Conditions[key] - target := condition.TargetValue - if target == 0 { - target = 1 - } - if s.state.Progress[name] < target && value >= target { - s.applyCompletionDependencies(&next, key) - } - return s.commit(next) -} - -func (s *Service) applyCompletionDependencies(next *snapshot, completed gamedata.MissionKey) { - queue := []gamedata.MissionKey{completed} - seen := map[string]bool{} - for len(queue) != 0 { - current := queue[0] - queue = queue[1:] - currentName := missionName(current) - if seen[currentName] { - continue - } - seen[currentName] = true - for key, condition := range s.design.Conditions { - if key.GroupType == 2 { - continue - } - matched := condition.Type == 30 && key.GroupType == current.GroupType - matched = matched || (condition.Type == 31 && condition.SubType == current.GroupType) - if !matched { - continue - } - name := missionName(key) - target := condition.TargetValue - if target == 0 { - target = 1 - } - before := next.Progress[name] - if before >= target { - continue - } - next.Progress[name] = min(before+1, target) - if before < target && next.Progress[name] >= target { - queue = append(queue, key) - } - } - } -} - -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - if s.eventHandler != nil && path == "/MissionUpdate" { - regular, event, err := splitMissionUpdates(request) - if err != nil { - return 119, nil, true, err - } - if len(event) > 0 { - if len(regular) > 0 { - s.mu.Lock() - err = s.validateRegularUpdates(regular) - s.mu.Unlock() - if err != nil { - return 119, nil, true, err - } - } - if _, _, handled, err := s.eventHandler.Handle(path, event); err != nil || !handled { - if err == nil { - err = ErrInvalidRequest - } - return 119, nil, true, err - } - if len(regular) == 0 { - return 119, nil, true, nil - } - request = regular - } - } - if s.eventHandler != nil && path == "/MissionClear" { - if code, response, handled, err := s.eventHandler.Handle(path, request); handled { - return code, response, handled, err - } - } - s.mu.Lock() - defer s.mu.Unlock() - if err := s.rolloverLocked(); err != nil { - return 0, nil, true, err - } - switch path { - case "/UserLevelReward": - response, err := s.userLevelReward(request) - return 73, response, true, err - case "/MissionInfo": - if err := requireSeq(request); err != nil { - return 118, nil, true, err - } - return 118, s.missionInfo(), true, nil - case "/MissionUpdate": - if err := s.update(request); err != nil { - return 119, nil, true, err - } - return 119, nil, true, nil - case "/AchievementInfo": - if err := requireSeq(request); err != nil { - return 166, nil, true, err - } - return 166, s.achievementInfo(), true, nil - case "/MissionClear": - response, err := s.clearMission(request) - return missionClearPacket, response, true, err - case "/MissionSectionReward": - response, err := s.clearSection(request) - return missionSectionPacket, response, true, err - case "/AchievementClear": - response, err := s.clearAchievements(request) - return achievementClearPacket, response, true, err - default: - return 0, nil, false, nil - } -} - -// Each update carries its own event identity; a client can acknowledge regular -// and scheduled missions together in one request. -func splitMissionUpdates(request []byte) (regular, event []byte, err error) { - if err = requireSeq(request); err != nil { - return - } - seq, _, _ := wire.Varint(request, 1) - err = wire.Walk(request, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 2 { - return ErrInvalidRequest - } - uid, e := scalar(f.Value, 4) - if e != nil { - return e - } - if uid == 0 { - if len(regular) == 0 { - regular = wire.AppendVarint(nil, 1, seq) - } - regular = wire.AppendBytes(regular, 2, f.Value) - } else { - if len(event) == 0 { - event = wire.AppendVarint(nil, 1, seq) - } - event = wire.AppendBytes(event, 2, f.Value) - } - return nil - }) - return -} - -func (s *Service) validateRegularUpdates(request []byte) error { - return wire.Walk(request, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 2 { - return ErrInvalidRequest - } - group, err := scalar(f.Value, 1) - if err != nil || group == 0 { - return ErrInvalidRequest - } - id, err := scalar(f.Value, 2) - if err != nil || id == 0 { - return ErrInvalidRequest - } - if _, err = scalar(f.Value, 3); err != nil { - return ErrInvalidRequest - } - matches := 0 - for key := range s.design.Missions { - if key.GroupType != 2 && key.GroupID == group && key.ID == id { - matches++ - } - } - if matches != 1 { - return ErrInvalidRequest - } - return nil - }) -} - -// AttachEventHandler delegates scheduled event operations before regular -// mission processing. The event service validates calendar and player state. -func (s *Service) AttachEventHandler(handler interface { - Handle(string, []byte) (int, []byte, bool, error) -}) error { - if handler == nil { - return errors.New("missions: nil event handler") - } - s.eventHandler = handler - return nil -} - -func dailyPeriod(now time.Time) string { return now.UTC().Format("2006-01-02") } - -func weeklyPeriod(now time.Time) string { - now = now.UTC() - daysSinceMonday := (int(now.Weekday()) + 6) % 7 - return now.AddDate(0, 0, -daysSinceMonday).Format("2006-01-02") -} - -func (s *Service) rolloverLocked() error { - if s.now == nil { - return errors.New("missions: missing clock") - } - now := s.now().UTC() - daily, weekly := dailyPeriod(now), weeklyPeriod(now) - if daily == s.state.DailyPeriod && weekly == s.state.WeeklyPeriod { - return nil - } - if s.mail == nil { - return errors.New("missions: compensation mailbox is not attached") - } - next := cloneSnapshot(s.state) - if daily != next.DailyPeriod { - if err := s.compensatePeriodLocked(0, next.DailyPeriod, now); err != nil { - return err - } - clearMissionPeriod(&next, 0) - next.DailyPeriod = daily - } - if weekly != next.WeeklyPeriod { - if err := s.compensatePeriodLocked(1, next.WeeklyPeriod, now); err != nil { - return err - } - clearMissionPeriod(&next, 1) - next.WeeklyPeriod = weekly - } - return s.commit(next) -} - -func (s *Service) compensatePeriodLocked(groupType uint64, period string, now time.Time) error { - label := "日常" - if groupType == 1 { - label = "周常" - } - for _, key := range s.completedUnclaimedForType(groupType) { - rewards := s.design.Missions[key] - if len(rewards) == 0 { - continue - } - identity := fmt.Sprintf("expired-mission:%d:%s:%s", groupType, period, missionName(key)) - title := label + "任务到期补发" - body := fmt.Sprintf("%s周期 %s 已结束。任务 %d/%d 已完成但未领取,奖励由系统自动补发。", label, period, key.GroupID, key.ID) - if err := s.mail.EnqueueCompensation(identity, title, body, rewards, now); err != nil { - return fmt.Errorf("missions: enqueue expired mission %+v: %w", key, err) - } - } - for _, key := range s.eligibleUnclaimedSectionsForType(groupType) { - rewards := s.design.Sections[key].Rewards - if len(rewards) == 0 { - continue - } - identity := fmt.Sprintf("expired-section:%d:%s:%d", groupType, period, key.ID) - title := label + "阶段奖励到期补发" - body := fmt.Sprintf("%s周期 %s 已结束。阶段奖励 %d 已达成但未领取,奖励由系统自动补发。", label, period, key.ID) - if err := s.mail.EnqueueCompensation(identity, title, body, rewards, now); err != nil { - return fmt.Errorf("missions: enqueue expired section %+v: %w", key, err) - } - } - return nil -} - -func (s *Service) completedUnclaimedForType(groupType uint64) []gamedata.MissionKey { - var result []gamedata.MissionKey - for key, condition := range s.design.Conditions { - if key.GroupType != groupType { - continue - } - target := condition.TargetValue - if target == 0 { - target = 1 - } - if s.state.Progress[missionName(key)] >= target && !contains(s.state.Claimed, "mission:"+missionName(key)) { - result = append(result, key) - } - } - sort.Slice(result, func(i, j int) bool { - if result[i].GroupID != result[j].GroupID { - return result[i].GroupID < result[j].GroupID - } - return result[i].ID < result[j].ID - }) - return result -} - -func (s *Service) eligibleUnclaimedSectionsForType(groupType uint64) []gamedata.SectionRewardKey { - completed := uint64(len(s.completedForType(groupType))) - var result []gamedata.SectionRewardKey - for key, section := range s.design.Sections { - identity := fmt.Sprintf("section:%d/%d", key.GroupType, key.ID) - if key.GroupType == groupType && section.SectionValue != 0 && completed >= section.SectionValue && !contains(s.state.Claimed, identity) { - result = append(result, key) - } - } - sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) - return result -} - -func (s *Service) completedForType(groupType uint64) []gamedata.MissionKey { - var result []gamedata.MissionKey - for key, condition := range s.design.Conditions { - if key.GroupType != groupType { - continue - } - target := condition.TargetValue - if target == 0 { - target = 1 - } - if s.state.Progress[missionName(key)] >= target { - result = append(result, key) - } - } - return result -} - -func clearMissionPeriod(state *snapshot, groupType uint64) { - prefix := strconv.FormatUint(groupType, 10) + "/" - for name := range state.Progress { - if strings.HasPrefix(name, prefix) { - delete(state.Progress, name) - } - } - state.Completed = filterPeriodIdentities(state.Completed, groupType) - state.Claimed = filterPeriodIdentities(state.Claimed, groupType) -} - -func filterPeriodIdentities(values []string, groupType uint64) []string { - missionPrefix := "mission:" + strconv.FormatUint(groupType, 10) + "/" - sectionPrefix := "section:" + strconv.FormatUint(groupType, 10) + "/" - result := values[:0] - for _, value := range values { - if !strings.HasPrefix(value, missionPrefix) && !strings.HasPrefix(value, sectionPrefix) { - result = append(result, value) - } - } - return result -} - -func (s *Service) missionInfo() []byte { - var response []byte - for _, key := range s.progressMissionKeys() { - entry := wire.AppendVarint(nil, 1, key.GroupID) - entry = wire.AppendVarint(entry, 2, key.ID) - if key.GroupType != 0 { - entry = wire.AppendVarint(entry, 3, key.GroupType) - } - entry = wire.AppendVarint(entry, 4, s.state.Progress[missionName(key)]) - if contains(s.state.Claimed, "mission:"+missionName(key)) { - entry = wire.AppendVarint(entry, 5, 1) - } - response = wire.AppendBytes(response, 1, entry) - } - for _, identity := range s.state.Claimed { - var groupType, id uint64 - if _, err := fmt.Sscanf(identity, "section:%d/%d", &groupType, &id); err != nil { - continue - } - entry := wire.AppendVarint(nil, 1, groupType) - entry = wire.AppendVarint(entry, 2, id) - response = wire.AppendBytes(response, 2, entry) - } - now := s.now().UTC() - daily := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) - daysUntilMonday := (8 - int(now.Weekday())) % 7 - if daysUntilMonday == 0 { - daysUntilMonday = 7 - } - weekly := time.Date(now.Year(), now.Month(), now.Day()+daysUntilMonday, 0, 0, 0, 0, time.UTC) - response = wire.AppendVarint(response, 3, uint64(daily.UnixMilli())) - response = wire.AppendVarint(response, 4, uint64(weekly.UnixMilli())) - return response -} - -func (s *Service) update(request []byte) error { - if err := requireSeq(request); err != nil { - return err - } - var updates int - err := wire.Walk(request, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - if field.Type != 2 { - return ErrInvalidRequest - } - groupID, err := scalar(field.Value, 1) - if err != nil || groupID == 0 { - return ErrInvalidRequest - } - id, err := scalar(field.Value, 2) - if err != nil || id == 0 { - return ErrInvalidRequest - } - value, err := scalar(field.Value, 3) - if err != nil { - return ErrInvalidRequest - } - var key gamedata.MissionKey - found := false - for candidate := range s.design.Missions { - if candidate.GroupID == groupID && candidate.ID == id && candidate.GroupType != 2 { - if found { - return ErrInvalidRequest - } - key, found = candidate, true - } - } - if !found { - return ErrInvalidRequest - } - if err := s.setProgressLocked(key, value); err != nil { - return err - } - updates++ - return nil - }) - if err != nil { - return err - } - if updates == 0 { - return ErrInvalidRequest - } - return nil -} - -func (s *Service) achievementInfo() []byte { - // AchievementInfo contains progress and last completed IDs. This local - // account currently records acknowledgement/claim state through the - // inventory idempotency key, but deliberately does not fabricate progress. - return nil -} - -func (s *Service) clearMission(request []byte) ([]byte, error) { - if err := requireSeq(request); err != nil { - return nil, err - } - all, err := boolean(request, 2) - if err != nil { - return nil, err - } - groupType, err := scalar(request, 3) - if err != nil { - return nil, err - } - groupID, err := scalar(request, 4) - if err != nil { - return nil, err - } - id, err := scalar(request, 5) - if err != nil { - return nil, err - } - if groupType == 2 { - return nil, errors.New("missions: scheduled event missions require an event service") - } - var keys []gamedata.MissionKey - if all { - if groupType != 0 || groupID != 0 || id != 0 { - return nil, fmt.Errorf("%w: bulk MissionClear has identifiers", ErrInvalidRequest) - } - keys = s.completedMissionKeys() - } else { - if groupID == 0 || id == 0 { - return nil, fmt.Errorf("%w: MissionClear identity", ErrInvalidRequest) - } - keys = []gamedata.MissionKey{{GroupType: groupType, GroupID: groupID, ID: id}} - } - items, err := s.claimMissions(keys) - if err != nil { - return nil, err - } - bundle := rewardBundle(items) - for _, key := range keys { - for _, reward := range s.design.Missions[key] { - if reward.Type == 3 || reward.Type == 4 { - entry := wire.AppendVarint(nil, 3, reward.Type) - entry = wire.AppendVarint(entry, 4, reward.Count) - bundle = wire.AppendBytes(bundle, 1, entry) - } - } - } - return bundle, nil -} - -func (s *Service) clearSection(request []byte) ([]byte, error) { - if err := requireSeq(request); err != nil { - return nil, err - } - groupType, err := scalar(request, 2) - if err != nil { - return nil, fmt.Errorf("%w: section group type", ErrInvalidRequest) - } - all, err := boolean(request, 3) - if err != nil { - return nil, err - } - id, err := scalar(request, 4) - if err != nil { - return nil, err - } - var keys []gamedata.SectionRewardKey - if all { - if id != 0 { - return nil, fmt.Errorf("%w: bulk section id", ErrInvalidRequest) - } - keys = s.eligibleSections(groupType) - } else { - if id == 0 { - return nil, fmt.Errorf("%w: section id", ErrInvalidRequest) - } - key := gamedata.SectionRewardKey{GroupType: groupType, ID: id} - if !s.sectionEligible(key) { - return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest) - } - keys = []gamedata.SectionRewardKey{key} - } - items, err := s.claimSections(keys) - if err != nil { - return nil, err - } - var response []byte - for _, item := range items { - response = wire.AppendBytes(response, 1, player.ItemWire(item)) - } - for _, key := range keys { - for _, reward := range s.design.Sections[key].Rewards { - if reward.Type == 3 || reward.Type == 4 { - entry := wire.AppendVarint(nil, 3, reward.Type) - entry = wire.AppendVarint(entry, 4, reward.Count) - response = wire.AppendBytes(response, 1, entry) - } - } - } - return response, nil -} - -func (s *Service) clearAchievements(request []byte) ([]byte, error) { - if err := requireSeq(request); err != nil { - return nil, err - } - contents, err := scalar(request, 2) - if err != nil { - return nil, fmt.Errorf("%w: achievement contents group", ErrInvalidRequest) - } - claims, err := achievementClaims(request) - if err != nil || len(claims) == 0 { - return nil, fmt.Errorf("%w: achievement clear info", ErrInvalidRequest) - } - requested := map[gamedata.AchievementKey]bool{} - for _, claim := range claims { - for _, id := range claim.IDs { - key, _, ok := s.achievementDesign(contents, claim.GroupID, id) - if !ok { - return nil, fmt.Errorf("%w: unknown achievement", ErrInvalidRequest) - } - requested[key] = true - } - } - for key := range requested { - if contains(s.state.Claimed, "achievement:"+achievementName(key)) { - continue - } - for earlier := range s.design.Achievements { - if earlier.ContentsGroup != key.ContentsGroup || earlier.GroupID != key.GroupID || earlier.ID >= key.ID { - continue - } - if !requested[earlier] && !contains(s.state.Claimed, "achievement:"+achievementName(earlier)) { - return nil, fmt.Errorf("missions: achievement tier %v requires earlier tier %v to be claimed", key, earlier) - } - } - } - // Validate the whole batch before granting any reward. - for _, claim := range claims { - for _, id := range claim.IDs { - key, d, ok := s.achievementDesign(contents, claim.GroupID, id) - if !ok { - return nil, fmt.Errorf("%w: unknown achievement", ErrInvalidRequest) - } - if contains(s.state.Claimed, "achievement:"+achievementName(key)) { - continue - } - if d.Target > 0 { - if s.achievementProgress == nil { - return nil, errors.New("missions: achievement progress unavailable") - } - group := d.CounterGroup - if group == 0 { - group = key.GroupID - } - value, err := s.achievementProgress.AchievementValue(group) - if err != nil { - return nil, err - } - if float64(value) < d.Target { - return nil, fmt.Errorf("missions: achievement %v requires %g progress, got %d", key, d.Target, value) - } - } - } - } - var allItems []player.Item - var addExp uint64 - var currencyRewards []gamedata.Reward - next := cloneSnapshot(s.state) - for _, claim := range claims { - for _, id := range claim.IDs { - key, design, ok := s.achievementDesign(contents, claim.GroupID, id) - if !ok { - return nil, fmt.Errorf("%w: unknown achievement %+v", ErrInvalidRequest, key) - } - identity := "achievement:" + achievementName(key) - if contains(next.Claimed, identity) { - continue - } - items, err := s.grantRewards(identity, design.Rewards) - if err != nil { - return nil, err - } - if ^uint64(0)-addExp < design.AddExp { - return nil, errors.New("missions: achievement exp overflow") - } - addExp += design.AddExp - for _, reward := range design.Rewards { - if reward.Type == 2 || reward.Type == 3 || reward.Type == 4 || reward.Type == 12 || reward.Type == 20 { - currencyRewards = append(currencyRewards, reward) - } - } - allItems = append(allItems, items...) - next.Claimed = append(next.Claimed, identity) - } - } - if addExp > uint64(^uint(0)>>1) { - return nil, errors.New("missions: achievement exp overflow") - } - if err := s.commit(next); err != nil { - return nil, err - } - response := wire.AppendVarint(nil, 1, addExp) - bundle := rewardBundle(allItems) - for _, reward := range currencyRewards { - item := wire.AppendVarint(nil, 3, reward.Type) - item = wire.AppendVarint(item, 4, reward.Count) - bundle = wire.AppendBytes(bundle, 1, item) - } - return wire.AppendBytes(response, 2, bundle), nil -} - -// The official all-clear request omits ContentsGroup (protobuf value zero). -// In that form GroupID+ID must identify exactly one static row; ambiguity is -// rejected rather than resolved by map iteration order. -func (s *Service) achievementDesign(contents, groupID, id uint64) (gamedata.AchievementKey, gamedata.AchievementDesign, bool) { - if contents != 0 { - key := gamedata.AchievementKey{ContentsGroup: contents, GroupID: groupID, ID: id} - design, ok := s.design.Achievements[key] - return key, design, ok - } - var foundKey gamedata.AchievementKey - var found gamedata.AchievementDesign - matched := false - for key, design := range s.design.Achievements { - if key.GroupID != groupID || key.ID != id { - continue - } - if matched { - return gamedata.AchievementKey{}, gamedata.AchievementDesign{}, false - } - foundKey, found, matched = key, design, true - } - return foundKey, found, matched -} - -func (s *Service) progressMissionKeys() []gamedata.MissionKey { - result := make([]gamedata.MissionKey, 0, len(s.state.Progress)) - for name, value := range s.state.Progress { - if value == 0 { - continue - } - for key := range s.design.Missions { - if missionName(key) == name { - result = append(result, key) - break - } - } - } - sort.Slice(result, func(i, j int) bool { - a, b := result[i], result[j] - if a.GroupType != b.GroupType { - return a.GroupType < b.GroupType - } - if a.GroupID != b.GroupID { - return a.GroupID < b.GroupID - } - return a.ID < b.ID - }) - return result -} - -func (s *Service) completedMissionKeys() []gamedata.MissionKey { - var result []gamedata.MissionKey - for key, condition := range s.design.Conditions { - target := condition.TargetValue - if target == 0 { - target = 1 - } - name := missionName(key) - if s.state.Progress[name] >= target && !contains(s.state.Claimed, "mission:"+name) { - result = append(result, key) - } - } - sort.Slice(result, func(i, j int) bool { - a, b := result[i], result[j] - if a.GroupType != b.GroupType { - return a.GroupType < b.GroupType - } - if a.GroupID != b.GroupID { - return a.GroupID < b.GroupID - } - return a.ID < b.ID - }) - return result -} - -func (s *Service) claimMissions(keys []gamedata.MissionKey) ([]player.Item, error) { - next := cloneSnapshot(s.state) - var result []player.Item - for _, key := range keys { - condition := s.design.Conditions[key] - target := condition.TargetValue - if target == 0 { - target = 1 - } - if s.state.Progress[missionName(key)] < target { - return nil, fmt.Errorf("%w: mission is not complete", ErrInvalidRequest) - } - rewards, ok := s.design.Missions[key] - if !ok { - return nil, fmt.Errorf("%w: unknown mission %+v", ErrInvalidRequest, key) - } - identity := "mission:" + missionName(key) - items, err := s.grantRewards(identity, rewards) - if err != nil { - return nil, err - } - result = append(result, items...) - if !contains(next.Claimed, identity) { - next.Claimed = append(next.Claimed, identity) - } - } - if err := s.commit(next); err != nil { - return nil, err - } - return result, nil -} - -func (s *Service) eligibleSections(groupType uint64) []gamedata.SectionRewardKey { - var result []gamedata.SectionRewardKey - for key := range s.design.Sections { - // Proto3 omits group_type for the official bulk request. Zero means - // all mission groups there, not an invalid group. - if (groupType == 0 || key.GroupType == groupType) && s.sectionEligible(key) { - result = append(result, key) - } - } - sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) - return result -} - -// Section reward thresholds are authoritative static values. The completed -// set, rather than the claimed set, is deliberately counted as the client does. -func (s *Service) sectionEligible(key gamedata.SectionRewardKey) bool { - section, ok := s.design.Sections[key] - if !ok { - return false - } - if section.SectionValue == 0 { - return false - } - var count uint64 - for _, identity := range s.state.Claimed { - var group, missionGroup, missionID uint64 - if _, err := fmt.Sscanf(identity, "mission:%d/%d/%d", &group, &missionGroup, &missionID); err == nil && group == key.GroupType { - count++ - } - } - return count >= section.SectionValue -} - -func (s *Service) claimSections(keys []gamedata.SectionRewardKey) ([]player.Item, error) { - next := cloneSnapshot(s.state) - var result []player.Item - for _, key := range keys { - section, ok := s.design.Sections[key] - if !ok || !s.sectionEligible(key) { - return nil, fmt.Errorf("%w: section is not complete", ErrInvalidRequest) - } - identity := fmt.Sprintf("section:%d/%d", key.GroupType, key.ID) - items, err := s.grantRewards(identity, section.Rewards) - if err != nil { - return nil, err - } - result = append(result, items...) - if !contains(next.Claimed, identity) { - next.Claimed = append(next.Claimed, identity) - } - } - if err := s.commit(next); err != nil { - return nil, err - } - return result, nil -} - -func (s *Service) grantRewards(identity string, rewards []gamedata.Reward) ([]player.Item, error) { - if s.wallet != nil { - if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { - return nil, err - } - } - var stack []gamedata.BattleReward - for _, reward := range rewards { - if reward.Type == 2 || reward.Type == 3 || reward.Type == 4 || reward.Type == 12 || reward.Type == 20 { - if s.wallet == nil { - return nil, errors.New("missions: currency reward wallet unavailable") - } - continue - } - if reward.ID == 0 || reward.Count == 0 { - // Type 12 is an account resource whose full/overflow conversion is - // server-dynamic. It is intentionally not fabricated as ItemDBInfo. - continue - } - stack = append(stack, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 - } - if len(stack) == 0 { - return nil, nil - } - items, err := s.inventory.GrantOnce(identity+":items", stack) - if err != nil { - return nil, err - } - if len(items) == 0 { - items = s.inventory.GrantedItems(identity + ":items") - } - return items, nil -} - -func rewardBundle(items []player.Item) []byte { - var bundle []byte - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - view := wire.AppendVarint(nil, 2, item.ID) - view = wire.AppendVarint(view, 3, item.Type) - view = wire.AppendVarint(view, 4, item.Count) - bundle = wire.AppendBytes(bundle, 6, view) - } - return bundle -} - -func (s *Service) commit(next snapshot) error { - sort.Strings(next.Completed) - sort.Strings(next.Claimed) - if next.DailyPeriod == s.state.DailyPeriod && next.WeeklyPeriod == s.state.WeeklyPeriod && equalStrings(next.Completed, s.state.Completed) && equalStrings(next.Claimed, s.state.Claimed) && equalProgress(next.Progress, s.state.Progress) { - return nil - } - return s.persist(next) -} - -func (s *Service) persist(next snapshot) error { - b, err := json.Marshal(next) - if err != nil { - return err - } - if err := s.storage.Save("missions", b); err != nil { - return err - } - s.state = next - return nil -} -func equalProgress(a, b map[string]uint64) bool { - if len(a) != len(b) { - return false - } - for key, value := range a { - if b[key] != value { - return false - } - } - return true -} - -func cloneSnapshot(in snapshot) snapshot { - progress := make(map[string]uint64, len(in.Progress)) - maps.Copy(progress, in.Progress) - return snapshot{Version: in.Version, DailyPeriod: in.DailyPeriod, WeeklyPeriod: in.WeeklyPeriod, Completed: append([]string(nil), in.Completed...), Claimed: append([]string(nil), in.Claimed...), Progress: progress} -} -func contains(values []string, want string) bool { - return slices.Contains(values, want) -} -func equalStrings(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} -func missionName(k gamedata.MissionKey) string { - return fmt.Sprintf("%d/%d/%d", k.GroupType, k.GroupID, k.ID) -} -func achievementName(k gamedata.AchievementKey) string { - return fmt.Sprintf("%d/%d/%d", k.ContentsGroup, k.GroupID, k.ID) -} - -func requireSeq(b []byte) error { - seq, err := scalar(b, 1) - if err != nil || seq == 0 { - return ErrInvalidRequest - } - return nil -} -func scalar(b []byte, number int) (uint64, error) { - value, found, err := wire.Varint(b, number) - if err != nil { - return 0, ErrInvalidRequest - } - if !found { - return 0, nil - } - return value, nil -} -func boolean(b []byte, number int) (bool, error) { - value, err := scalar(b, number) - if err != nil || value > 1 { - return false, ErrInvalidRequest - } - return value == 1, nil -} - -type achievementClaim struct { - GroupID uint64 - IDs []uint64 -} - -func achievementClaims(b []byte) ([]achievementClaim, error) { - var claims []achievementClaim - err := wire.Walk(b, func(field wire.Field) error { - if field.Number != 3 { - return nil - } - if field.Type != 2 { - return ErrInvalidRequest - } - group, err := scalar(field.Value, 1) - if err != nil || group == 0 { - return ErrInvalidRequest - } - ids, err := packed(field.Value, 2) - if err != nil || len(ids) == 0 { - return ErrInvalidRequest - } - claims = append(claims, achievementClaim{group, ids}) - return nil - }) - return claims, err -} -func packed(b []byte, number int) ([]uint64, error) { - var values []uint64 - err := wire.Walk(b, func(field wire.Field) error { - if field.Number != number { - return nil - } - if field.Type == 0 { - value, _ := decode(field.Value) - values = append(values, value) - return nil - } - if field.Type != 2 { - return ErrInvalidRequest - } - for remaining := field.Value; len(remaining) > 0; { - value, n := decode(remaining) - if n == 0 { - return ErrInvalidRequest - } - values = append(values, value) - remaining = remaining[n:] - } - return nil - }) - return values, err -} -func decode(b []byte) (uint64, int) { - var value uint64 - for i, x := range b { - value |= uint64(x&127) << (7 * i) - if x < 128 { - return value, i + 1 - } - if i == 9 { - return 0, 0 - } - } - return 0, 0 -} diff --git a/go/internal/server/missions/missions_test.go b/go/internal/server/missions/missions_test.go deleted file mode 100644 index c12208d..0000000 --- a/go/internal/server/missions/missions_test.go +++ /dev/null @@ -1,213 +0,0 @@ -package missions - -import ( - "testing" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestAchievementClaimsAcceptPackedAndUnpackedIDs(t *testing.T) { - info := wire.AppendVarint(nil, 1, 7) - packed := []byte{1, 2, 3} - info = wire.AppendBytes(info, 2, packed) - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 1) - request = wire.AppendBytes(request, 3, info) - claims, err := achievementClaims(request) - if err != nil { - t.Fatal(err) - } - if len(claims) != 1 || claims[0].GroupID != 7 || len(claims[0].IDs) != 3 || claims[0].IDs[2] != 3 { - t.Fatalf("claims=%+v", claims) - } -} - -func TestDailyRolloverMailsCompletedMissionAndSectionRewards(t *testing.T) { - mission := gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 101} - section := gamedata.SectionRewardKey{GroupType: 0, ID: 10} - design := &gamedata.MissionDesign{ - Missions: map[gamedata.MissionKey][]gamedata.Reward{mission: {{Type: 4, Count: 100}}}, - Conditions: map[gamedata.MissionKey]gamedata.MissionCondition{mission: {TargetValue: 1}}, - Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{section: { - SectionValue: 1, Rewards: []gamedata.Reward{{Type: 3, Count: 20}}, - }}, - Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, - } - storage := stateio.NewMemory() - starter := &player.Starter{Version: "2.35.10"} - inv, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - mailbox, err := mail.OpenService(storage, &mail.Starter{Version: "2.35.10", MailCount: 1, MaxMailID: 100}, inv, wallet) - if err != nil { - t.Fatal(err) - } - service, err := Open(storage, design, inv) - if err != nil { - t.Fatal(err) - } - if err := service.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - if err := service.AttachMail(mailbox); err != nil { - t.Fatal(err) - } - service.state.DailyPeriod = "2026-09-23" - service.state.WeeklyPeriod = "2026-09-21" - service.now = func() time.Time { return time.Date(2026, 9, 24, 0, 1, 0, 0, time.UTC) } - service.state.Progress[missionName(mission)] = 1 - if _, _, _, err := service.Handle("/MissionInfo", wire.AppendVarint(nil, 1, 1)); err != nil { - t.Fatal(err) - } - _, mailInfo, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 2)) - if err != nil { - t.Fatal(err) - } - mailCount := 0 - if err := wire.Walk(mailInfo, func(field wire.Field) error { - if field.Number == 1 { - mailCount++ - } - return nil - }); err != nil { - t.Fatal(err) - } - if service.state.DailyPeriod != "2026-09-24" || len(service.state.Progress) != 0 || mailCount != 2 { - t.Fatalf("mission=%+v mailCount=%d", service.state, mailCount) - } - for id := uint64(101); id <= 102; id++ { - request := wire.AppendVarint(nil, 1, id) - request = wire.AppendVarint(request, 2, id) - if _, _, _, err := mailbox.Handle("/MailOpen", request); err != nil { - t.Fatal(err) - } - } - if got := wallet.Snapshot(); got.Gold != 100 || got.FreeJewelry != 20 { - t.Fatalf("wallet=%+v", got) - } -} - -func TestRequireSeqRejectsMissingSequence(t *testing.T) { - if err := requireSeq(nil); err == nil { - t.Fatal("expected missing sequence error") - } -} - -func TestOfficialBulkAchievementRequestMayOmitContentsGroup(t *testing.T) { - design := &gamedata.MissionDesign{ - Missions: map[gamedata.MissionKey][]gamedata.Reward{}, Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, - Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{ - {ContentsGroup: 7, GroupID: 1, ID: 1}: {AddExp: 3}, - }, - } - starter := &player.Starter{Version: "2.35.10"} - inventory, err := player.OpenInventory(stateio.NewMemory(), starter) - if err != nil { - t.Fatal(err) - } - service, err := Open(stateio.NewMemory(), design, inventory) - if err != nil { - t.Fatal(err) - } - info := wire.AppendVarint(nil, 1, 1) - info = wire.AppendVarint(info, 2, 1) - request := wire.AppendVarint(nil, 1, 242) - request = wire.AppendBytes(request, 3, info) - code, response, handled, err := service.Handle("/AchievementClear", request) - if err != nil { - t.Fatal(err) - } - if !handled || code != 168 { - t.Fatalf("handled=%v code=%d", handled, code) - } - exp, found, err := wire.Varint(response, 1) - if err != nil || !found || exp != 3 { - t.Fatalf("exp=%d found=%v err=%v", exp, found, err) - } -} - -func TestOfficialBulkSectionRequestAcceptsDefaultGroupType(t *testing.T) { - design := &gamedata.MissionDesign{ - Missions: map[gamedata.MissionKey][]gamedata.Reward{}, Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, - Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, - } - starter := &player.Starter{Version: "2.35.10"} - inventory, err := player.OpenInventory(stateio.NewMemory(), starter) - if err != nil { - t.Fatal(err) - } - service, err := Open(stateio.NewMemory(), design, inventory) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 233) - request = wire.AppendVarint(request, 3, 1) - code, _, handled, err := service.Handle("/MissionSectionReward", request) - if err != nil { - t.Fatal(err) - } - if !handled || code != 122 { - t.Fatalf("handled=%v code=%d", handled, code) - } -} - -func TestMissionProgressIsNotClaimedAndDailyTypeZeroIsValid(t *testing.T) { - key := gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 101} - design := &gamedata.MissionDesign{ - Missions: map[gamedata.MissionKey][]gamedata.Reward{key: {{Type: 4, Count: 1000}}}, - Conditions: map[gamedata.MissionKey]gamedata.MissionCondition{key: {TargetValue: 1}}, - Sections: map[gamedata.SectionRewardKey]gamedata.SectionRewardDesign{}, Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{}, - } - starter := &player.Starter{Version: "2.35.10"} - storage := stateio.NewMemory() - inv, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - service, err := Open(storage, design, inv) - if err != nil { - t.Fatal(err) - } - if err := service.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - if err := service.CompleteMission(key); err != nil { - t.Fatal(err) - } - _, response, _, err := service.Handle("/MissionInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - entry, found, _ := wire.Bytes(response, 1) - if !found { - t.Fatal("mission missing") - } - value, _, _ := wire.Varint(entry, 4) - claimed, claimedFound, _ := wire.Varint(entry, 5) - if value != 1 || claimedFound || claimed != 0 { - t.Fatalf("value=%d claimed=%d/%v", value, claimed, claimedFound) - } - clear := wire.AppendVarint(nil, 1, 2) - clear = wire.AppendVarint(clear, 4, 1) - clear = wire.AppendVarint(clear, 5, 101) - if _, _, _, err := service.Handle("/MissionClear", clear); err != nil { - t.Fatal(err) - } - if got := wallet.Snapshot().Gold; got != 1000 { - t.Fatalf("gold=%d", got) - } -} diff --git a/go/internal/server/missions/mixed_update_test.go b/go/internal/server/missions/mixed_update_test.go deleted file mode 100644 index 7e8c492..0000000 --- a/go/internal/server/missions/mixed_update_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package missions - -import ( - "bd2server/internal/server/wire" - "testing" -) - -func TestSplitMixedMissionUpdates(t *testing.T) { - regular := wire.AppendVarint(nil, 1, 1) - regular = wire.AppendVarint(regular, 2, 101) - regular = wire.AppendVarint(regular, 3, 2) - event := wire.AppendVarint(nil, 1, 9) - event = wire.AppendVarint(event, 2, 10) - event = wire.AppendVarint(event, 3, 1) - event = wire.AppendVarint(event, 4, 77) - req := wire.AppendVarint(nil, 1, 40) - req = wire.AppendBytes(req, 2, regular) - req = wire.AppendBytes(req, 2, event) - r, e, err := splitMissionUpdates(req) - if err != nil { - t.Fatal(err) - } - for _, part := range [][]byte{r, e} { - seq, _, _ := wire.Varint(part, 1) - if seq != 40 { - t.Fatal("lost sequence") - } - } - rr, _, _ := wire.Bytes(r, 2) - ee, _, _ := wire.Bytes(e, 2) - rid, _, _ := wire.Varint(rr, 2) - eid, _, _ := wire.Varint(ee, 4) - if rid != 101 || eid != 77 { - t.Fatal("mixed update was not split by event identity") - } -} diff --git a/go/internal/server/missions/user_level_reward.go b/go/internal/server/missions/user_level_reward.go deleted file mode 100644 index a137f56..0000000 --- a/go/internal/server/missions/user_level_reward.go +++ /dev/null @@ -1,148 +0,0 @@ -package missions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "encoding/json" - "fmt" -) - -type levelRewardSnapshot struct { - Version string `json:"version"` - LevelReward uint64 `json:"level_reward"` -} - -// AttachUserLevelRewards reads the dedicated claim bucket in the same store as missions. -func (s *Service) AttachUserLevelRewards(d *gamedata.AchievementLevelDesign) error { - if err := d.Validate(); err != nil { - return err - } - s.mu.Lock() - defer s.mu.Unlock() - entries, ok := s.storage.(stateio.EntryStore) - if !ok { - return fmt.Errorf("missions: user level rewards require entry storage") - } - raw, _, err := entries.LoadEntry("missions", "user_level_rewards", "state") - if err != nil { - return err - } - var state levelRewardSnapshot - if raw != nil { - if err := stateio.RequireExactJSONObject(raw, "version", "level_reward"); err != nil { - return err - } - if err := json.Unmarshal(raw, &state); err != nil { - return err - } - if state.Version != versionconfig.State() { - return fmt.Errorf("missions: incompatible user level reward version") - } - if state.LevelReward != 0 { - found := false - for _, l := range d.Levels { - found = found || l.ID == state.LevelReward - } - if !found { - return fmt.Errorf("missions: claimed user level missing from GameData") - } - } - } - s.levelRewards = d - s.levelReward = state.LevelReward - return nil -} -func (s *Service) LevelRewardCount() (uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.levelRewards == nil { - return 0, fmt.Errorf("missions: user level rewards unavailable") - } - return s.levelReward, nil -} - -func (s *Service) userLevelReward(request []byte) ([]byte, error) { - if s.levelRewards == nil { - return nil, fmt.Errorf("missions: user level rewards unavailable") - } - if err := requireSeq(request); err != nil { - return nil, err - } - ids, err := packed(request, 2) - if err != nil || len(ids) == 0 { - return nil, ErrInvalidRequest - } - exp, err := s.achievementExperienceLocked() - if err != nil { - return nil, err - } - level := s.levelRewards.Level(exp) - next := s.levelReward - var fresh []gamedata.AchievementLevel - var previous uint64 - for _, id := range ids { - if id == 0 || id <= previous { - return nil, ErrInvalidRequest - } - previous = id - index := -1 - for i, l := range s.levelRewards.Levels { - if l.ID == id { - index = i - break - } - } - if index < 0 || id > level { - return nil, ErrInvalidRequest - } - if id <= s.levelReward { - continue - } - expected := uint64(0) - for _, l := range s.levelRewards.Levels { - if l.ID > next { - expected = l.ID - break - } - } - if id != expected { - return nil, fmt.Errorf("%w: user level reward requires preceding claims", ErrInvalidRequest) - } - fresh = append(fresh, s.levelRewards.Levels[index]) - next = id - } - var items []player.Item - var currencies []gamedata.Reward - for _, l := range fresh { - granted, err := s.grantRewards(fmt.Sprintf("user-level:%d", l.ID), l.Rewards) - if err != nil { - return nil, err - } - items = append(items, granted...) - for _, r := range l.Rewards { - if r.Type == 2 || r.Type == 3 || r.Type == 4 || r.Type == 12 || r.Type == 20 { - currencies = append(currencies, r) - } - } - } - if next != s.levelReward { - raw, err := json.Marshal(levelRewardSnapshot{versionconfig.State(), next}) - if err != nil { - return nil, err - } - if err := s.storage.(stateio.EntryStore).PutEntry("missions", "user_level_rewards", "state", raw); err != nil { - return nil, err - } - s.levelReward = next - } - bundle := rewardBundle(items) - for _, r := range currencies { - item := wire.AppendVarint(nil, 3, r.Type) - item = wire.AppendVarint(item, 4, r.Count) - bundle = wire.AppendBytes(bundle, 1, item) - } - return wire.AppendBytes(nil, 1, bundle), nil -} diff --git a/go/internal/server/missions/user_level_reward_test.go b/go/internal/server/missions/user_level_reward_test.go deleted file mode 100644 index 71ebcd6..0000000 --- a/go/internal/server/missions/user_level_reward_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package missions - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -func TestUserLevelRewardEligibilityBatchPersistenceAndRetry(t *testing.T) { - store := stateio.NewMemory() - inv, _ := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - d := &gamedata.MissionDesign{Achievements: map[gamedata.AchievementKey]gamedata.AchievementDesign{{GroupID: 9, ID: 1}: {AddExp: 10}}} - s, _ := Open(store, d, inv) - levels := &gamedata.AchievementLevelDesign{Levels: []gamedata.AchievementLevel{{ID: 17, NeedEXP: 4, Rewards: []gamedata.Reward{{Type: 8, ID: 987, Count: 3}}}, {ID: 23, NeedEXP: 8, Rewards: []gamedata.Reward{{Type: 8, ID: 988, Count: 7}}}, {ID: 41, NeedEXP: 10, Rewards: []gamedata.Reward{{Type: 8, ID: 989, Count: 9}}}}} - if err := s.AttachUserLevelRewards(levels); err != nil { - t.Fatal(err) - } - req := func(ids ...uint64) []byte { - r := wire.AppendVarint(nil, 1, 1) - for _, id := range ids { - r = wire.AppendVarint(r, 2, id) - } - return r - } - for _, ids := range [][]uint64{{23}, {17, 23}, {17, 17}, {99}} { - if _, _, _, err := s.Handle("/UserLevelReward", req(ids...)); err == nil { - t.Fatalf("accepted %v", ids) - } - } - if len(inv.All()) != 0 { - t.Fatal("invalid batch granted rewards") - } - s.state.Claimed = []string{"achievement:" + achievementName(gamedata.AchievementKey{GroupID: 9, ID: 1})} - if _, _, _, err := s.Handle("/UserLevelReward", req(17, 23, 41)); err == nil { - t.Fatal("accepted unearned level") - } - if _, _, _, err := s.Handle("/UserLevelReward", req(17, 23)); err != nil { - t.Fatal(err) - } - if count, _ := s.LevelRewardCount(); count != 23 { - t.Fatalf("count=%d", count) - } - if len(inv.All()) != 2 || inv.All()[0].Count+inv.All()[1].Count != 10 { - t.Fatalf("items=%+v", inv.All()) - } - reopened, _ := Open(store, d, inv) - if err := reopened.AttachUserLevelRewards(levels); err != nil { - t.Fatal(err) - } - reopened.state.Claimed = s.state.Claimed - if _, _, _, err := reopened.Handle("/UserLevelReward", req(17, 23)); err != nil { - t.Fatal(err) - } - if len(inv.All()) != 2 { - t.Fatal("retry granted twice") - } - if n, _ := reopened.LevelRewardCount(); n != 23 { - t.Fatalf("reopened=%d", n) - } -} diff --git a/go/internal/server/monsterhunt/calendar_test.go b/go/internal/server/monsterhunt/calendar_test.go deleted file mode 100644 index 300c6cc..0000000 --- a/go/internal/server/monsterhunt/calendar_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package monsterhunt - -import ( - "testing" - "time" - - "bd2server/internal/server/readonly" - "bd2server/internal/server/wire" -) - -func TestFutureSeasonDoesNotReplaceActiveOrRewardSeason(t *testing.T) { - rows := []season{ - {ID: 5, Start: 100, End: 200}, - {ID: 3, Start: 300, End: 400}, - {ID: 999, Start: 500, End: 600}, - } - for _, tc := range []struct{ now, want uint64 }{ - {50, 5}, {100, 5}, {199, 5}, {200, 5}, {299, 5}, - {300, 3}, {400, 3}, {500, 999}, - } { - if got := selectSeason(rows, tc.now); got.ID != tc.want { - t.Fatalf("time=%d season=%d want=%d", tc.now, got.ID, tc.want) - } - } -} - -func TestSchedulePreservesAllCalendarsAndPlacesSelectedCategoriesLast(t *testing.T) { - field := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} } - row := func(id uint64, independent bool) readonly.Field { - flag := uint64(0) - if independent { - flag = 1 - } - return readonly.Field{Number: 1, Type: 2, Fields: []readonly.Field{ - {Number: 1, Type: 2, Fields: []readonly.Field{field(1, id)}}, field(6, flag), - }} - } - s := &Service{now: func() time.Time { return time.UnixMilli(150) }, - seasons: []season{{ID: 1, Start: 100, End: 200}, {ID: 2, Start: 300, End: 400}, {ID: 3, Start: 100, End: 200, Independent: true}, {ID: 4, Start: 300, End: 400, Independent: true}}, - seed: &readonly.Seed{Responses: map[string]readonly.Response{"/MonsterHuntScheduleInfo": { - Fields: []readonly.Field{row(1, false), row(2, false), row(3, true), row(4, true), field(2, 5), {Number: 3, Type: 2, Fields: []readonly.Field{field(1, 77)}}}, - }}}, - } - for _, now := range []int64{50, 150, 250, 350, 450} { - s.now = func() time.Time { return time.UnixMilli(now) } - _, response, _, err := s.scheduleInfo(wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - var ids []uint64 - var lastRegular, lastIndependent uint64 - history := 0 - if err := wire.Walk(response, func(f wire.Field) error { - if f.Number == 1 { - nested, _, _ := wire.Bytes(f.Value, 1) - id, _, _ := wire.Varint(nested, 1) - ids = append(ids, id) - flag, _, _ := wire.Varint(f.Value, 6) - if flag == 0 { - lastRegular = id - } else { - lastIndependent = id - } - } - if f.Number == 3 { - history++ - } - return nil - }); err != nil { - t.Fatal(err) - } - want := uint64(1) - if now >= 300 { - want = 2 - } - seen := map[uint64]bool{} - for _, id := range ids { - seen[id] = true - } - if len(ids) != 4 || len(seen) != 4 || !seen[1] || !seen[2] || !seen[3] || !seen[4] || lastRegular != want || lastIndependent != want+2 || history != 1 { - t.Fatalf("time=%d rows=%v history=%d", now, ids, history) - } - if len(s.seed.Responses["/MonsterHuntScheduleInfo"].Fields) != 6 { - t.Fatal("projection mutated published calendars") - } - } -} diff --git a/go/internal/server/monsterhunt/monsterhunt_test.go b/go/internal/server/monsterhunt/monsterhunt_test.go deleted file mode 100644 index 84d99d1..0000000 --- a/go/internal/server/monsterhunt/monsterhunt_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package monsterhunt - -import ( - "bd2server/internal/server/calendar" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/readonly" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "os" - "path/filepath" - "strconv" - "testing" - "time" -) - -func installed(t *testing.T) (*Service, *stateio.Memory) { - t.Helper() - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - seed, e := readonly.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json")) - if e != nil { - t.Fatal(e) - } - calendars, e := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", "20260923193640") - if e != nil { - t.Fatal(e) - } - seed, e = calendars.ApplyReadonly(seed) - if e != nil { - t.Fatal(e) - } - store := stateio.NewMemory() - inv, e := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - wallet, e := player.OpenWallet(store, player.Currency{Gold: 100000}) - if e != nil { - t.Fatal(e) - } - s, e := Open(store, root, "20260923193640", seed, inv, wallet) - if e != nil { - t.Fatal(e) - } - c := s.current() - s.now = func() time.Time { return time.UnixMilli(int64(c.Start + 1000)) } - return s, store -} -func battleRequest(id, deck, mode uint64) []byte { - b := wire.AppendVarint(nil, 1, 1) - b = wire.AppendVarint(b, 4, deck) - b = wire.AppendVarint(b, 5, mode) - b = wire.AppendVarint(b, 6, id) - return wire.AppendVarint(b, 10, 1) -} -func TestBattleSessionProgressRewardsAndPersistentRetry(t *testing.T) { - s, store := installed(t) - c := s.current() - d, e := s.load(c.Hunt) - if e != nil { - t.Fatal(e) - } - req := battleRequest(c.Hunt, d.DeckID, BattleMode) - if _, e = s.EnterBattle(req, "session:1"); e != nil { - t.Fatal(e) - } - s.BeginSession("session") - end := wire.AppendVarint(nil, 1, 2) - end = wire.AppendVarint(end, 2, 1) - out, e := s.CompleteBattle(end, "session:1") - if e != nil { - t.Fatal(e) - } - if _, ok, _ := wire.Bytes(out, 7); !ok { - t.Fatal("clear reward missing") - } - u := s.state.Users[strconv.FormatUint(c.ID, 10)] - if u.ClearLevel != 1 || u.Level != 2 || !u.Played { - t.Fatalf("progress=%+v", u) - } - retry, e := s.CompleteBattle(end, "session:1") - if e != nil || !bytes.Equal(out, retry) { - t.Fatalf("retry=%x err=%v", retry, e) - } - bad := wire.AppendVarint(end, 7, 1) - if _, e = s.CompleteBattle(bad, "session:1"); e == nil { - t.Fatal("changed retry accepted") - } - restored, e := Open(store, s.root, s.version, s.seed, s.inventory, s.wallet) - if e != nil { - t.Fatal(e) - } - retry, e = restored.CompleteBattle(end, "session:1") - if e != nil || !bytes.Equal(out, retry) { - t.Fatalf("restored retry failed %v", e) - } -} -func TestPracticeHasNoRewardsAndRejectsInvalidHP(t *testing.T) { - s, _ := installed(t) - c := s.current() - d, e := s.load(c.Hunt) - if e != nil { - t.Fatal(e) - } - if _, e = s.EnterBattle(battleRequest(c.Hunt, d.DeckID, PracticeMode), "p:1"); e != nil { - t.Fatal(e) - } - hp, _ := d.HP(1) - bad := wire.AppendVarint(nil, 7, hp+1) - if _, e = s.CompleteBattle(bad, "p:1"); e == nil { - t.Fatal("impossible HP accepted") - } - if b, e := s.CompleteBattle(nil, "p:1"); e != nil || len(b) != 0 { - t.Fatalf("practice=%x err=%v", b, e) - } - if len(s.state.Users) != 0 { - t.Fatal("practice changed competitive progress") - } -} -func TestPresetSlotCurrencyAndIdempotency(t *testing.T) { - s, _ := installed(t) - req := wire.AppendVarint(nil, 1, 41) - req = wire.AppendVarint(req, 2, 1) - before := s.state.Slots - for range 2 { - if _, _, _, e := s.HandleSession("/MonsterHuntPresetSlotAdd", req, "session"); e != nil { - t.Fatal(e) - } - } - if s.state.Slots != before+1 { - t.Fatal("slot retry doubled purchase") - } - changed := wire.AppendVarint(nil, 1, 41) - changed = wire.AppendVarint(changed, 2, 2) - if _, _, _, e := s.HandleSession("/MonsterHuntPresetSlotAdd", changed, "session"); e == nil { - t.Fatal("changed slot retry accepted") - } -} -func TestDeckShapeRejectsRepeatedPositionAndAcceptsTeamThree(t *testing.T) { - s := &Service{} - char := wire.AppendVarint(nil, 1, 1) - char = wire.AppendVarint(char, 3, 1) - deck := wire.AppendVarint(nil, 1, 3) - deck = wire.AppendBytes(deck, 2, char) - if e := s.validateDecks([][]byte{deck}); e != nil { - t.Fatal(e) - } - dup := wire.AppendVarint(nil, 1, 2) - dup = wire.AppendVarint(dup, 3, 2) - deck = wire.AppendBytes(deck, 2, dup) - if e := s.validateDecks([][]byte{deck}); e == nil { - t.Fatal("duplicate position accepted") - } -} -func TestRankRewardUsesActualLocalPercentThreshold(t *testing.T) { - s := &Service{} - d := &gamedata.MonsterHunt{Ranks: map[uint64][]gamedata.MonsterHuntRankReward{1: {{Type: 1, Ranking: 10, Rewards: []gamedata.BattleReward{{Count: 10}}}, {Type: 1, Ranking: 100, Rewards: []gamedata.BattleReward{{Count: 1}}}}}} - got := s.rankRewards(d, 1) - if len(got) != 1 || got[0].Count != 1 { - t.Fatalf("rewards=%+v", got) - } -} - -func TestDailyRewardUpgradePaysDifference(t *testing.T) { - d := &gamedata.MonsterHunt{Rewards: map[uint64]gamedata.MonsterHuntRewards{1: {Daily: []gamedata.BattleReward{{Type: 4, Count: 100}}}, 2: {Daily: []gamedata.BattleReward{{Type: 4, Count: 150}, {Type: 8, ID: 1, Count: 2}}}}} - if got := dailyDifference(d, 1, 1); len(got) != 0 { - t.Fatalf("same level repeated payout=%v", got) - } - got := dailyDifference(d, 1, 2) - if len(got) != 2 || got[0].Count != 50 || got[1].Count != 2 { - t.Fatalf("upgrade=%v", got) - } -} -func TestSessionRetryRejectsChangedBodyWithoutGameData(t *testing.T) { - s := &Service{state: snapshot{Replies: map[string]reply{"sid:/MonsterHuntDeckSave:2": {Request: wire.AppendVarint(nil, 1, 2), Response: []byte{}}}}} - req := wire.AppendVarint(nil, 1, 2) - if _, _, ok, e := s.HandleSession("/MonsterHuntDeckSave", req, "sid"); e != nil || !ok { - t.Fatal(e) - } - changed := wire.AppendVarint(req, 4, 1) - if _, _, _, e := s.HandleSession("/MonsterHuntDeckSave", changed, "sid"); e == nil { - t.Fatal("changed same-sequence request accepted") - } -} -func TestRequestMalformedAndUnsignedWrapRejected(t *testing.T) { - s := &Service{} - for _, req := range [][]byte{{0xff}, wire.AppendVarint(nil, 1, ^uint64(0))} { - if _, _, ok, e := s.HandleSession("/MonsterHuntUserInfo", req, "sid"); e == nil || !ok { - t.Fatalf("bad request accepted %x", req) - } - } -} diff --git a/go/internal/server/monsterhunt/preset.go b/go/internal/server/monsterhunt/preset.go deleted file mode 100644 index ad4b9c9..0000000 --- a/go/internal/server/monsterhunt/preset.go +++ /dev/null @@ -1,124 +0,0 @@ -package monsterhunt - -import ( - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "fmt" -) - -func (s *Service) presetBindings(p []byte) (map[uint64]uint64, []player.PresetEquipmentBinding, error) { - assignments := map[uint64]uint64{} - var bindings []player.PresetEquipmentBinding - entries, e := messages(p, 5) - if e != nil { - return nil, nil, e - } - for _, entry := range entries { - base, _, e := wire.Bytes(entry, 1) - if e != nil { - return nil, nil, e - } - index, _ := scalar(base, 1) - costume, _ := scalar(entry, 2) - if _, duplicate := assignments[index]; duplicate { - return nil, nil, fmt.Errorf("monsterhunt: duplicate preset character") - } - assignments[index] = costume - if costume != 0 && s.collection != nil { - c, found := s.collection.CostumeByIndex(costume) - if !found || c.UseChar != index { - return nil, nil, fmt.Errorf("monsterhunt: preset costume not owned by character") - } - } - binding := player.PresetEquipmentBinding{CharacterIndex: index, Equipment: make([]uint64, 5)} - equipment, e := messages(entry, 3) - if e != nil { - return nil, nil, e - } - seen := map[uint64]bool{} - for _, item := range equipment { - t, _ := scalar(item, 1) - id, _ := scalar(item, 2) - if t >= 5 || seen[t] { - return nil, nil, fmt.Errorf("monsterhunt: invalid preset equipment slot") - } - seen[t] = true - binding.Equipment[t] = id - } - bindings = append(bindings, binding) - } - if len(bindings) > 0 && s.equipment != nil { - if e = s.equipment.ValidatePresetEquipment(bindings); e != nil { - return nil, nil, e - } - } - return assignments, bindings, nil -} -func (s *Service) applyPreset(p []byte) ([]byte, error) { - if s.characters == nil || s.equipment == nil || s.collection == nil { - return nil, fmt.Errorf("monsterhunt: preset ownership runtime unavailable") - } - assignments, bindings, e := s.presetBindings(p) - if e != nil { - return nil, e - } - if _, e = s.characters.ApplyPresetCostumes(assignments); e != nil { - return nil, e - } - if len(bindings) > 0 { - if _, e = s.equipment.ApplyPresetEquipment(bindings); e != nil { - return nil, e - } - } - var out []byte - for _, binding := range bindings { - c, ok := s.characters.Find(binding.CharacterIndex) - if ok { - out = wire.AppendBytes(out, 2, player.CharacterWire(c)) - } - b := wire.AppendVarint(nil, 1, binding.CharacterIndex) - for _, id := range binding.Equipment { - b = wire.AppendVarint(b, 2, id) - } - out = wire.AppendBytes(out, 3, b) - } - return out, nil -} - -func (s *Service) validateSettings(settings [][]byte) error { - for _, setting := range settings { - index, e := scalar(setting, 1) - if e != nil || index == 0 { - return fmt.Errorf("monsterhunt: invalid costume setting character") - } - if s.characters != nil { - if _, ok := s.characters.Find(index); !ok { - return fmt.Errorf("monsterhunt: setting character not owned") - } - } - seq, e := messages(setting, 2) - if e != nil { - return e - } - mode, _ := scalar(setting, 3) - if mode != BattleMode && mode != PracticeMode { - return fmt.Errorf("monsterhunt: invalid setting battle mode") - } - for _, item := range seq { - costume, _, e := wire.Varint(item, 1) - if e != nil { - return e - } - if costume == 0 || costume == ^uint64(0) { - continue - } - if s.collection != nil { - c, ok := s.collection.CostumeByIndex(costume) - if !ok || c.UseChar != index { - return fmt.Errorf("monsterhunt: setting costume not owned") - } - } - } - } - return nil -} diff --git a/go/internal/server/monsterhunt/rewards.go b/go/internal/server/monsterhunt/rewards.go deleted file mode 100644 index 0dbbcb9..0000000 --- a/go/internal/server/monsterhunt/rewards.go +++ /dev/null @@ -1,99 +0,0 @@ -package monsterhunt - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -// Daily payout is the best reward achieved that UTC day. Improvements pay -// only positive per-item differences from the already paid level. -func dailyDifference(d *gamedata.MonsterHunt, previous, next uint64) []gamedata.BattleReward { - paid := map[[2]uint64]uint64{} - for _, r := range d.Rewards[previous].Daily { - paid[[2]uint64{r.Type, r.ID}] += r.Count - } - var out []gamedata.BattleReward - for _, r := range d.Rewards[next].Daily { - n := paid[[2]uint64{r.Type, r.ID}] - if r.Count > n { - r.Count -= n - out = append(out, r) - } - } - return out -} - -// This server has one persistent account state: a submitted participant ranks -// first among the local participants. No official leaderboard is imported. -func (s *Service) score(u user) float64 { - progress := float64(0) - if u.HighestHP > 0 { - progress = float64(u.HighestHP-u.StartHP) / float64(u.HighestHP) * 10000 - } - return float64(u.ClearLevel*1000000) + progress -} -func (s *Service) rankWire(u user) []byte { - b := wire.AppendVarint(nil, 7, 1) - b = wire.AppendDouble(b, 8, s.score(u)) - return wire.AppendDouble(b, 10, 100) -} -func (s *Service) rankRewards(d *gamedata.MonsterHunt, group uint64) []gamedata.BattleReward { - var selected *gamedata.MonsterHuntRankReward - for _, r := range d.Ranks[group] { - threshold := float64(1) - if r.Type == 1 { - threshold = 100 - } - if r.Ranking >= threshold && (selected == nil || r.Type < selected.Type || r.Type == selected.Type && r.Ranking < selected.Ranking) { - selected = &r - } - } - if selected == nil { - return nil - } - return selected.Rewards -} -func (s *Service) grant(identity string, rewards []gamedata.BattleReward) ([]byte, error) { - if s.rewardGrant != nil { - rs := make([]gamedata.Reward, len(rewards)) - for i, r := range rewards { - rs[i] = gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016 - } - return s.rewardGrant(identity, rs) - } - var currency []gamedata.Reward - var stack []gamedata.BattleReward - for _, r := range rewards { - switch r.Type { - case 2, 3, 4, 12, 20: - currency = append(currency, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - default: - stack = append(stack, r) - } - } - if _, e := s.wallet.GrantQuestOnce(identity+":currency", currency); e != nil { - return nil, e - } - items, e := s.inventory.GrantOnce(identity+":items", stack) - if e != nil { - return nil, e - } - if len(items) == 0 { - items = s.inventory.GrantedItems(identity + ":items") - } - var bundle []byte - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - v := wire.AppendVarint(nil, 2, item.ID) - v = wire.AppendVarint(v, 3, item.Type) - v = wire.AppendVarint(v, 4, item.Count) - bundle = wire.AppendBytes(bundle, 6, v) - } - for _, r := range currency { - v := wire.AppendVarint(nil, 3, r.Type) - v = wire.AppendVarint(v, 4, r.Count) - bundle = wire.AppendBytes(bundle, 1, v) - } - return bundle, nil -} diff --git a/go/internal/server/monsterhunt/transaction_test.go b/go/internal/server/monsterhunt/transaction_test.go deleted file mode 100644 index d115f6a..0000000 --- a/go/internal/server/monsterhunt/transaction_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package monsterhunt - -import ( - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -func TestPresetPurchaseRollsBackCurrencyAndSlotTogether(t *testing.T) { - s, _ := installed(t) - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - wallet, err := player.OpenWallet(repo, player.Currency{Gold: 100000}) - if err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - s.storage = repo - s.wallet = wallet - before := s.state.Slots - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1) - if _, _, _, err = s.HandleSession("/MonsterHuntPresetSlotAdd", req, "session-A"); err != nil { - _ = op.Rollback() - t.Fatal(err) - } - _ = op.Rollback() - if err = repo.Close(); err != nil { - t.Fatal(err) - } - reopened, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := reopened.Close(); err != nil { - t.Error(err) - } - }() - if raw, err := reopened.Load("monsterhunt"); err != nil || raw != nil { - t.Fatalf("slot survived rollback: %s %v", raw, err) - } - wallet, err = player.OpenWallet(reopened, player.Currency{}) - if err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 100000 { - t.Fatalf("gold survived rollback: %+v", wallet.Snapshot()) - } - if s.state.Slots != before+1 || repo.Check() == nil { - t.Fatal("dirty domain memory was not fenced") - } -} diff --git a/go/internal/server/monsterhunt/validation.go b/go/internal/server/monsterhunt/validation.go deleted file mode 100644 index 44c5659..0000000 --- a/go/internal/server/monsterhunt/validation.go +++ /dev/null @@ -1,37 +0,0 @@ -package monsterhunt - -import ( - "bd2server/internal/server/wire" - "encoding/binary" - "fmt" - "math" -) - -func validateRequest(req []byte) error { - return wire.Walk(req, func(f wire.Field) error { - if f.Type == 0 { - v, _ := binary.Uvarint(f.Value) - if v > math.MaxInt32 { - return fmt.Errorf("monsterhunt: integer outside protocol range") - } - } - return nil - }) -} - -func (s *Service) ValidatePack(pack int, req []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - id, e := scalar(req, 6) - if e != nil { - return e - } - d, e := s.load(id) - if e != nil { - return e - } - if pack <= 0 || uint64(pack) != d.PackID { - return fmt.Errorf("monsterhunt: hunt does not belong to current pack") - } - return nil -} diff --git a/go/internal/server/npcinn/inn_test.go b/go/internal/server/npcinn/inn_test.go deleted file mode 100644 index 5cd7ed9..0000000 --- a/go/internal/server/npcinn/inn_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package npcinn - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "errors" - "path/filepath" - "testing" -) - -func innTestService(t *testing.T, gold, level, state uint64) (*Service, *player.CharacterStore, *player.Wallet, stateio.Store) { - t.Helper() - store := stateio.NewMemory() - return innTestServiceWithStore(t, store, gold, level, state) -} - -func innTestServiceWithStore(t *testing.T, store stateio.Store, gold, level, state uint64) (*Service, *player.CharacterStore, *player.Wallet, stateio.Store) { - t.Helper() - inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - chars, err := player.OpenCharacterStore(store, []player.Character{{InvenIndex: 71, ID: 350, Level: 1, HP: 10}, {InvenIndex: 72, ID: 360, Level: 1, HP: 10}}, inv, "", "") - if err != nil { - t.Fatal(err) - } - if err := chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err = chars.EnsurePersisted(); err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(store, player.Currency{Gold: gold}) - if err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - s, err := New(store, chars, wallet, func(pack, npc uint64) (gamedata.InnRule, uint64, error) { - return gamedata.InnRule{NPCID: 7, MapID: 33, ItemCount: 2, Currency: 4, FreeSquadLevel: 20, GoodDiscount: 10}, state, nil - }, func() (uint64, error) { return level, nil }, func() bool { return false }) - if err != nil { - t.Fatal(err) - } - s.BeginSession("test") - return s, chars, wallet, store -} - -type innReceiptFailStore struct{ stateio.Store } - -func (s innReceiptFailStore) Save(name string, b []byte) error { - if name == "npcinn" { - return errors.New("receipt write failed") - } - return s.Store.Save(name, b) -} - -func TestInnReceiptFailureRollsBackGoldAndHealthTogether(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s, _, _, _ := innTestServiceWithStore(t, repo, 10, 21, 1) - s.store = innReceiptFailStore{repo} - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/CharAllRevival", recoveryRequest(1, 71)); err == nil { - t.Fatal("receipt failure ignored") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - next, chars, wallet, _ := innTestServiceWithStore(t, repo, 0, 21, 1) - hp, _ := chars.CurrentHealth(71) - if hp != 10 || wallet.Snapshot().Gold != 10 { - t.Fatal("failed inn request committed health or charge") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = next.Handle("/CharAllRevival", recoveryRequest(1, 71)); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - hp, _ = chars.CurrentHealth(71) - if hp != 100 || wallet.Snapshot().Gold != 5 { - t.Fatal("retry did not commit recovery once") - } -} -func recoveryRequest(seq uint64, indices ...uint64) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 2, 1) - b = wire.AppendVarint(b, 3, 7) - for _, i := range indices { - b = wire.AppendVarint(b, 4, i) - } - return b -} - -func TestInnPartialRecoveryAndReplayUsePersistedHealth(t *testing.T) { - s, chars, wallet, store := innTestService(t, 2, 21, 1) - b := recoveryRequest(1, 71, 72) - code, response, handled, err := s.Handle("/CharAllRevival", b) - if err != nil || !handled || code != 11 { - t.Fatal(err) - } - hp, _ := chars.CurrentHealth(71) - second, _ := chars.CurrentHealth(72) - if hp != 50 || second != 10 || wallet.Snapshot().Gold != 0 { - t.Fatalf("partial recovery hp=%d second=%d wallet=%+v", hp, second, wallet.Snapshot()) - } - next, err := New(store, chars, wallet, s.context, s.level, s.battle) - if err != nil { - t.Fatal(err) - } - next.BeginSession("test") - _, replay, _, err := next.Handle("/CharAllRevival", b) - if err != nil || !bytes.Equal(response, replay) { - t.Fatal("receipt lost on reopening") - } - changed := recoveryRequest(1, 72) - if _, _, _, err = next.Handle("/CharAllRevival", changed); err == nil { - t.Fatal("changed recovery replay accepted") - } -} - -func TestInnFreeLevelDeadRevivalAndDiscountPrice(t *testing.T) { - s, chars, wallet, _ := innTestService(t, 0, 20, 1) - if err := chars.SetCurrentHealth(72, 0); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/CharAllRevival", recoveryRequest(1, 71, 72)); err != nil { - t.Fatal(err) - } - a, _ := chars.CurrentHealth(71) - b, _ := chars.CurrentHealth(72) - if a != 100 || b != 1 || wallet.Snapshot().Gold != 0 { - t.Fatal("free squad recovery or free 1 HP resurrection incorrect") - } - s, chars, wallet, _ = innTestService(t, 10, 21, 2) - if _, _, _, err := s.Handle("/CharAllRevival", recoveryRequest(1, 71)); err != nil { - t.Fatal(err) - } - a, _ = chars.CurrentHealth(71) - if a != 100 || wallet.Snapshot().Gold != 5 { - t.Fatal("discount recovery must ceil 90 * 2 /40 *90%=4.05 to 5 gold") - } - if _, _, _, err := s.Handle("/CharAllRevival", recoveryRequest(2, 71, 71)); err == nil { - t.Fatal("duplicate targets accepted") - } -} diff --git a/go/internal/server/npcshop/sale_validation_test.go b/go/internal/server/npcshop/sale_validation_test.go deleted file mode 100644 index 3268e8a..0000000 --- a/go/internal/server/npcshop/sale_validation_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package npcshop - -import ( - "bytes" - "encoding/json" - "errors" - "path/filepath" - "strconv" - "testing" - "time" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -func saleLine(index, product, n, rate uint64) []byte { - b := wire.AppendVarint(nil, 1, index) - b = wire.AppendVarint(b, 2, product) - b = wire.AppendVarint(b, 3, n) - return wire.AppendVarint(b, 4, rate) -} -func saleRequest(seq uint64, rows ...[]byte) []byte { - b := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, 71) - for _, row := range rows { - b = wire.AppendBytes(b, 3, row) - } - return b -} - -func TestSaleAllClientTypesAndWholeBatchValidation(t *testing.T) { - s, e, store := fixture(t) - s.design.Sell[23] = gamedata.NPCProduct{ID: 23, Reward: gamedata.Reward{Type: 8, ID: 401, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 9}} - items, err := s.items.GrantOnce("sale-types", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}, {Type: 8, ID: 401, Count: 10}}) - if err != nil { - t.Fatal(err) - } - food, resource := items[0], items[1] - good := saleLine(resource.InvenIndex, 23, 4, 100) - for _, bad := range [][]byte{ - saleLine(resource.InvenIndex, 19, 1, 100), - saleLine(resource.InvenIndex, 23, 0, 100), - saleLine(resource.InvenIndex, 23, 11, 100), - saleLine(resource.InvenIndex, 23, 1<<31, 100), - saleLine(resource.InvenIndex, 23, 1, 0), - wire.AppendVarint(good, 3, 1), - wire.AppendBytes(good, 4, nil), - } { - if _, _, _, err = s.Handle("/ShopSell", saleRequest(1, saleLine(food.InvenIndex, 19, 3, 100), bad)); err == nil || e.calls != 0 { - t.Fatalf("invalid batch mutated economy: %x %v", bad, err) - } - } - // KeepFlag is the actual ItemDBInfo protection flag; these two types have - // no binding/equipment LockFlag or UseChar fields in the current protocol. - resource.KeepFlag = 1 - payload, _ := json.Marshal(resource) - if err = store.SaveWithEntries("items", nil, []stateio.EntryMutation{{Bucket: "items", Key: strconv.FormatUint(resource.InvenIndex, 10), Payload: payload}}); err != nil { - t.Fatal(err) - } - s.items, err = player.OpenInventory(store, &player.Starter{Version: versionconfig.State()}) - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/ShopSell", saleRequest(2, good)); err == nil || e.calls != 0 { - t.Fatal("protected resource sold", err) - } - resource.KeepFlag = 0 - payload, _ = json.Marshal(resource) - if err = store.SaveWithEntries("items", nil, []stateio.EntryMutation{{Bucket: "items", Key: strconv.FormatUint(resource.InvenIndex, 10), Payload: payload}}); err != nil { - t.Fatal(err) - } - s.items, err = player.OpenInventory(store, &player.Starter{Version: versionconfig.State()}) - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/ShopSell", saleRequest(3, saleLine(food.InvenIndex, 19, 3, 100), good)); err != nil { - t.Fatal(err) - } - if e.calls != 1 || len(e.rewards) != 2 || e.rewards[0].Count != 21 || e.rewards[1].Count != 36 { - t.Fatal("wrong mixed sale", e) - } - remaining := s.items.All() - if len(remaining) != 2 || remaining[0].Count != 5 || remaining[1].Count != 6 { - t.Fatal("wrong mixed inventory debit", remaining) - } -} - -func TestSaleQuoteSeedRemainsUntilShopInfoRefresh(t *testing.T) { - s, _, _ := fixture(t) - p := gamedata.NPCProduct{Reward: gamedata.Reward{Type: 8, ID: 401, Count: 1}, Discount: 20, Premium: 19} - // Independent vectors from the client's ulong WELL512 operations. - for _, row := range []struct{ seed, rate uint64 }{{0, 117}, {20731, 80}, {20732, 101}} { - s.quotedSeed = &row.seed - if got := s.rate(p, 71, 2); got != row.rate { - t.Fatal("client WELL512 mismatch", row, got) - } - } - s.quotedSeed = nil - s.now = func() time.Time { return time.Unix(20731*86400, 0) } - _, info, _, err := s.Handle("/ShopInfo", wire.AppendVarint(nil, 1, 1)) - seed, _, _ := wire.Varint(info, 2) - if err != nil || seed != 20731 { - t.Fatal(seed, err) - } - s.now = func() time.Time { return time.Unix(20732*86400, 0) } - s.BeginSession("login") // The router activates the same session on each request. - if s.rate(p, 71, 2) != 80 { - t.Fatal("clock change invalidated displayed sale price") - } - if _, _, _, err = s.Handle("/ShopInfo", wire.AppendVarint(nil, 1, 2)); err != nil || s.rate(p, 71, 2) != 101 { - t.Fatal("new quote failed to refresh seed", err) - } -} - -func TestShopPriceUsesUnsignedWELLMaskForFoodResourceAndBuy(t *testing.T) { - s, _, _ := fixture(t) - seed := uint64(20732) - s.quotedSeed = &seed - for _, row := range []struct { - typ, item, tab, expected uint64 - }{ - // Current SellItemTable[2], also observed in the client's request: - // ShopInfo seed=20732; shop=1; Food=5; item=2002; Sell=2; Rate=98. - {5, 2002, 2, 98}, - {8, 101, 2, 93}, - {5, 2002, 1, 110}, - } { - p := gamedata.NPCProduct{Reward: gamedata.Reward{Type: row.typ, ID: row.item, Count: 1}, Discount: 20, Premium: 19} - if got := s.rate(p, 1, row.tab); got != row.expected { - t.Fatalf("unsigned WELL mask type=%d item=%d tab=%d got=%d want=%d", row.typ, row.item, row.tab, got, row.expected) - } - } -} - -func TestSaleClientObservedFoodRateCreditsDerivedGold(t *testing.T) { - s, economy, _ := fixture(t) - s.design.Shops[1] = gamedata.NPCShop{ID: 1, PackID: 91} - s.design.Sell[2] = gamedata.NPCProduct{ID: 2, Reward: gamedata.Reward{Type: 5, ID: 2002, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 28}, Discount: 20, Premium: 19, PremiumPriceType: 1, HighDay: 1, HighShop: 14, HighPremium: 20} - seed := uint64(20732) - s.quotedSeed = &seed - items, err := s.items.GrantOnce("observed-food", []gamedata.BattleReward{{Type: 5, ID: 2002, Count: 2}}) - if err != nil { - t.Fatal(err) - } - request := func(rate uint64) []byte { - b := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1) - return wire.AppendBytes(b, 3, saleLine(items[0].InvenIndex, 2, 1, rate)) - } - if _, _, _, err = s.Handle("/ShopSell", request(93)); err == nil || economy.calls != 0 { - t.Fatal("incorrect sign-extended rate accepted", err) - } - if _, _, _, err = s.Handle("/ShopSell", request(98)); err != nil { - t.Fatal("actual client rate rejected", err) - } - if economy.calls != 1 || len(economy.rewards) != 1 || economy.rewards[0].Type != 4 || economy.rewards[0].Count != 27 || s.items.All()[0].Count != 1 { - t.Fatal("wrong derived sale credit/debit", economy.rewards, s.items.All()) - } -} - -type saleWalletEconomy struct{ wallet *player.Wallet } - -func (e saleWalletEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - if err := e.wallet.ExchangeOnce(identity, costs, rewards); err != nil { - return nil, err - } - return wire.AppendVarint(nil, 1, e.wallet.Snapshot().Gold), nil -} - -func TestSQLiteMixedSaleWalletInventoryReceiptRollbackAndRestartRetry(t *testing.T) { - base, _, _ := fixture(t) - base.design.Sell[23] = gamedata.NPCProduct{ID: 23, Reward: gamedata.Reward{Type: 8, ID: 401, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 9}} - path := filepath.Join(t.TempDir(), "state.db") - starter := &player.Starter{Version: versionconfig.State()} - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = repo.Close() }() - items, err := player.OpenInventory(repo, starter) - if err != nil { - t.Fatal(err) - } - granted, err := items.GrantOnce("initial", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}, {Type: 8, ID: 401, Count: 10}}) - if err != nil { - t.Fatal(err) - } - req := saleRequest(1, saleLine(granted[0].InvenIndex, 19, 3, 100), saleLine(granted[1].InvenIndex, 23, 4, 100)) - var wallet *player.Wallet - var response []byte - for phase := range 3 { - items, err = player.OpenInventory(repo, starter) - if err != nil { - t.Fatal(err) - } - wallet, err = player.OpenWallet(repo, player.Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - var store stateio.Store = repo - if phase == 0 { - store = failedReceiptStore{repo} - } - s, err := New(base.design, store, saleWalletEconomy{wallet}, items, base.available) - if err != nil { - t.Fatal(err) - } - s.now, s.session = base.now, base.session - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, body, _, handleErr := s.Handle("/ShopSell", req) - if phase == 0 { - if handleErr == nil { - t.Fatal("injected receipt failure accepted") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - } else { - if handleErr != nil { - t.Fatal(handleErr) - } - if phase == 1 { - response = body - } else if !bytes.Equal(response, body) { - t.Fatal("restart replay changed response") - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - checkItems, err := player.OpenInventory(repo, starter) - if err != nil { - t.Fatal(err) - } - checkWallet, err := player.OpenWallet(repo, player.Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - wantGold, wantFood, wantResource := uint64(157), uint64(5), uint64(6) - if phase == 0 { - wantGold, wantFood, wantResource = 100, 8, 10 - if raw, err := repo.Load("npc_shop"); err != nil || raw != nil { - t.Fatal("sale receipt survived rollback", err) - } - } - rows := checkItems.All() - if checkWallet.Snapshot().Gold != wantGold || len(rows) != 2 || rows[0].Count != wantFood || rows[1].Count != wantResource { - t.Fatal("SQLite atomic/replay state mismatch", phase, checkWallet.Snapshot(), rows) - } - } -} diff --git a/go/internal/server/npcshop/shop_test.go b/go/internal/server/npcshop/shop_test.go deleted file mode 100644 index 439a959..0000000 --- a/go/internal/server/npcshop/shop_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package npcshop - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "bytes" - "errors" - "fmt" - "path/filepath" - "testing" - "time" -) - -type economy struct { - calls int - costs, rewards []gamedata.Reward - fail bool -} -type failedReceiptStore struct{ stateio.Store } - -func (s failedReceiptStore) Save(name string, b []byte) error { - if name == "npc_shop" { - return fmt.Errorf("receipt disk failure") - } - return s.Store.Save(name, b) -} - -type storedEconomy struct{ store stateio.Store } - -func (e storedEconomy) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { - if err := e.store.Save("npc_test_grants", []byte{1}); err != nil { - return nil, err - } - return []byte{8, 1}, nil -} -func TestFailedSaleReceiptRollsBackConcreteInventoryAndReward(t *testing.T) { - base, _, _ := fixture(t) - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - starter := &player.Starter{Version: versionconfig.State()} - items, err := player.OpenInventory(repo, starter) - if err != nil { - t.Fatal(err) - } - granted, err := items.GrantOnce("initial-food", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}}) - if err != nil { - t.Fatal(err) - } - s, err := New(base.design, failedReceiptStore{repo}, storedEconomy{repo}, items, base.available) - if err != nil { - t.Fatal(err) - } - s.now = base.now - s.SetSessionSource(base.session) - line := wire.AppendVarint(nil, 1, granted[0].InvenIndex) - line = wire.AppendVarint(line, 2, 19) - line = wire.AppendVarint(line, 3, 3) - line = wire.AppendVarint(line, 4, 100) - req := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 71), 3, line) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/ShopSell", req); err == nil { - t.Fatal("expected failed receipt persistence") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - reopened, err := player.OpenInventory(repo, starter) - if err != nil { - t.Fatal(err) - } - if rows := reopened.All(); len(rows) != 1 || rows[0].Count != 8 { - t.Fatal("inventory removal survived rollback", rows) - } - for _, name := range []string{"npc_shop", "npc_test_grants"} { - raw, err := repo.Load(name) - if err != nil || raw != nil { - t.Fatal("reward or receipt survived rollback", name, err) - } - } -} - -func (e *economy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) { - if e.fail { - return nil, fmt.Errorf("insufficient balance") - } - e.calls++ - e.costs = c - e.rewards = r - return []byte{8, 1}, nil -} -func fixture(t *testing.T) (*Service, *economy, *stateio.Memory) { - t.Helper() - store := stateio.NewMemory() - items, err := player.OpenInventory(store, &player.Starter{Version: versionconfig.State()}) - if err != nil { - t.Fatal(err) - } - d := gamedata.NPCShopDesign{Shops: map[uint64]gamedata.NPCShop{71: {ID: 71, PackID: 91, ResetType: 1}}, Products: map[uint64]map[uint64]gamedata.NPCProduct{71: {3: {ID: 3, GroupID: 71, MaxCount: 3, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}, Price: gamedata.Reward{Type: 4, Count: 25}}}}, Sell: map[uint64]gamedata.NPCProduct{19: {ID: 19, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 1}, Price: gamedata.Reward{Type: 4, Count: 7}}}} - e := &economy{} - s, err := New(d, store, e, items, func(pack uint64) bool { return pack == 91 }) - if err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) } - s.SetSessionSource(func() string { return "login" }) - return s, e, store -} -func buy(seq, n, rate uint64) []byte { - b := wire.AppendVarint(nil, 1, seq) - g := wire.AppendVarint(nil, 1, 71) - p := wire.AppendVarint(nil, 2, 3) - p = wire.AppendVarint(p, 3, n) - p = wire.AppendVarint(p, 4, rate) - return wire.AppendBytes(b, 2, wire.AppendBytes(g, 2, p)) -} -func TestPurchaseReceiptLimitsResetAndWholeBatchValidation(t *testing.T) { - s, e, store := fixture(t) - request := buy(1, 2, 100) - code, response, ok, err := s.Handle("/ShopBuy", request) - if err != nil || !ok || code != 54 { - t.Fatal(code, ok, err) - } - if e.calls != 1 || len(e.costs) != 1 || e.costs[0].Count != 50 || e.rewards[0].Count != 4 { - t.Fatal(e) - } - reopened, err := New(s.design, store, e, s.items, s.available) - if err != nil { - t.Fatal(err) - } - reopened.now = s.now - reopened.SetSessionSource(s.session) - _, replay, _, err := reopened.Handle("/ShopBuy", request) - if err != nil || !bytes.Equal(response, replay) || e.calls != 1 { - t.Fatal("retry did not preserve receipt", err) - } - for _, bad := range [][]byte{buy(1, 1, 100), buy(2, 2, 100), buy(3, 1, 1)} { - if _, _, _, err = s.Handle("/ShopBuy", bad); err == nil { - t.Fatalf("invalid purchase accepted %x", bad) - } - } - batch := buy(4, 1, 100) - g := wire.AppendVarint(nil, 1, 71) - p := wire.AppendVarint(wire.AppendVarint(nil, 2, 999), 3, 1) - batch = wire.AppendBytes(batch, 2, wire.AppendBytes(g, 2, p)) - if _, _, _, err = s.Handle("/ShopBuy", batch); err == nil || e.calls != 1 { - t.Fatal("partially granted invalid batch") - } - s.now = func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) } - if _, _, _, err = s.Handle("/ShopBuy", buy(5, 3, 100)); err != nil { - t.Fatal("daily reset did not reopen stock", err) - } -} -func TestSaleConsumesTheRequestedStackAndRejectsDuplicates(t *testing.T) { - s, e, _ := fixture(t) - granted, err := s.items.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 400, Count: 8}}) - if err != nil { - t.Fatal(err) - } - item := granted[0] - line := wire.AppendVarint(nil, 1, item.InvenIndex) - line = wire.AppendVarint(line, 2, 19) - line = wire.AppendVarint(line, 3, 3) - line = wire.AppendVarint(line, 4, 100) - req := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 71), 3, line) - duplicate := wire.AppendBytes(req, 3, line) - if _, _, _, err = s.Handle("/ShopSell", duplicate); err == nil || e.calls != 0 { - t.Fatal("duplicate stack sale accepted") - } - code, first, _, err := s.Handle("/ShopSell", req) - if err != nil || code != 55 { - t.Fatal(code, err) - } - if len(e.costs) != 0 || e.rewards[0].Count != 21 { - t.Fatal("wrong sale reward", e) - } - var remaining uint64 - for _, v := range s.items.All() { - if v.InvenIndex == item.InvenIndex { - remaining = v.Count - } - } - if remaining != 5 { - t.Fatal("wrong concrete stack consumed", remaining) - } - _, replay, _, err := s.Handle("/ShopSell", req) - if err != nil || !bytes.Equal(first, replay) || e.calls != 1 { - t.Fatal("sale retry spent twice", err) - } -} -func TestResourcePurchaseValidatesAndConsumesExactMaterialRows(t *testing.T) { - s, e, _ := fixture(t) - p := s.design.Products[71][3] - p.Price = gamedata.Reward{Type: 8, ID: 401, Count: 5} - s.design.Products[71][3] = p - granted, err := s.items.GrantOnce("resource", []gamedata.BattleReward{{Type: 8, ID: 401, Count: 18}}) - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/ShopBuy", buy(1, 2, 100)); err == nil || e.calls != 0 { - t.Fatal("missing resource payment accepted") - } - material := wire.AppendVarint(nil, 1, granted[0].InvenIndex) - material = wire.AppendVarint(material, 2, 401) - material = wire.AppendVarint(material, 3, 8) - material = wire.AppendVarint(material, 4, 10) - if _, _, _, err = s.Handle("/ShopBuy", wire.AppendBytes(buy(2, 2, 100), 3, material)); err != nil { - t.Fatal(err) - } - if len(e.costs) != 0 || e.rewards[0].Count != 4 { - t.Fatal("material cost sent to generic stack selection", e) - } - rows := s.items.All() - if len(rows) != 1 || rows[0].Count != 8 { - t.Fatal("resource stack was not spent", rows) - } -} diff --git a/go/internal/server/npcshop/talent_discount_test.go b/go/internal/server/npcshop/talent_discount_test.go deleted file mode 100644 index 4528942..0000000 --- a/go/internal/server/npcshop/talent_discount_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package npcshop - -import ( - "bd2server/internal/server/wire" - "testing" -) - -func TestTalentBargainingUsesBoundNPCBasePriceAndSuppressesReputation(t *testing.T) { - s, e, _ := fixture(t) - s.design.ShopNPCs = map[uint64][]uint64{71: {8801}} - calls := 0 - s.SetTalentDiscountSource(func(pack, npc uint64) (uint64, error) { - if pack != 91 || npc != 8801 { - t.Fatal("wrong NPC binding", pack, npc) - } - calls++ - return 20, nil - }) - s.SetReputationSource(func(uint64) (uint64, uint64, error) { return 2, 10, nil }) - p := s.design.Products[71][3] - p.Discount = 40 - p.Premium = 40 - s.design.Products[71][3] = p - rate := s.rate(p, 71, 1) - _, b, _, err := s.Handle("/ShopOpen", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - on, _, _ := wire.Varint(b, 2) - if on != 1 { - t.Fatal("client bargaining mode not enabled") - } - if _, _, _, err = s.Handle("/ShopBuy", buy(2, 1, rate)); err != nil { - t.Fatal(err) - } - if calls == 0 || len(e.costs) != 1 || e.costs[0].Count != 20 { - t.Fatal("talent price combined with reputation/market", e.costs) - } - s.SetTalentDiscountSource(func(uint64, uint64) (uint64, error) { return 0, nil }) - if _, _, _, err = s.Handle("/ShopBuy", buy(2, 1, rate)); err != nil || e.calls != 1 { - t.Fatal("discount state altered replay price", err, e.calls) - } -} -func TestBargainingNoBargainAndInvalidDiscountRejectBeforeEconomy(t *testing.T) { - s, e, _ := fixture(t) - s.design.ShopNPCs = map[uint64][]uint64{71: {8801}} - s.SetTalentDiscountSource(func(uint64, uint64) (uint64, error) { return 20, nil }) - p := s.design.Products[71][3] - p.NoBargain = 1 - s.design.Products[71][3] = p - if _, _, _, err := s.Handle("/ShopBuy", buy(1, 1, 100)); err == nil || e.calls != 0 { - t.Fatal("NoBargain bought during talent view") - } - s.SetTalentDiscountSource(func(uint64, uint64) (uint64, error) { return 101, nil }) - if _, _, _, err := s.Handle("/ShopBuy", buy(2, 1, 100)); err == nil || e.calls != 0 { - t.Fatal("invalid discount charged") - } -} diff --git a/go/internal/server/pictorial/health_test.go b/go/internal/server/pictorial/health_test.go deleted file mode 100644 index 8d64b05..0000000 --- a/go/internal/server/pictorial/health_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package pictorial - -import ( - "bytes" - "errors" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestMaximumHealthIncludesOwnedEquipmentPotentialAndAwakening(t *testing.T) { - character := player.Character{InvenIndex: 77, ID: 350, Level: 1} - s := &Service{Design: &gamedata.PictorialDesign{}, Owned: &ownedState{}} - s.baseHealth.Store([2]uint64{350, 1}, float64(100)) - s.EquipmentContributions = func(got player.Character) ([]gamedata.StatContribution, error) { - if got.InvenIndex != character.InvenIndex || got.ID != character.ID || got.Level != character.Level { - t.Fatalf("wrong stat character: %+v", got) - } - return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Flat: 20, Percent: .1}}, nil - } - s.PotentialContributions = func(player.Character) ([]gamedata.StatContribution, error) { - return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Percent: .2}}, nil - } - s.AwakeContributions = func(player.Character) ([]gamedata.StatContribution, error) { - return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Flat: 5}}, nil - } - maximum, err := s.MaxHealth(character) - if err != nil || maximum != 162 { - t.Fatalf("maximum=%d err=%v, want trunc((100+20+5)*1.3)=162", maximum, err) - } - s.EquipmentContributions = func(player.Character) ([]gamedata.StatContribution, error) { - return nil, errors.New("equipment unavailable") - } - if _, err := s.MaxHealth(character); err == nil { - t.Fatal("ignored equipment stat failure") - } -} - -func TestPotentialConnectionSwitchRecomputesMaximumWithoutHealingOrChangingCostume(t *testing.T) { - store := stateio.NewMemory() - items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - characters, err := player.OpenCharacterStore(store, []player.Character{{InvenIndex: 77, ID: 350, HP: 80, Level: 1, UseCostume: 1, CostumeID: 100}}, items, "", "") - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(store, []player.Costume{{InvenIndex: 1, ID: 100, UseChar: 77}, {InvenIndex: 2, ID: 200, UseChar: 77}}) - if err != nil { - t.Fatal(err) - } - for _, index := range []uint64{1, 2} { - if err = collection.ActivateCostumePotential(index, []uint64{1, 2}); err != nil { - t.Fatal(err) - } - } - wallet, err := player.OpenWallet(store, player.Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - d := &gamedata.CostumePotentialDesign{CharacterTypes: map[uint64]uint64{350: 0}, CostumeActive: map[uint64]bool{100: true, 200: true}, CharacterUnique: map[uint64]uint64{350: 9}, CostumeUnique: map[uint64]uint64{100: 9, 200: 9}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{ - 100: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 10}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 100}}, - 200: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 5}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 20}}, - }} - potential, err := player.NewCostumePotentialService(d, collection, characters, items, wallet) - if err != nil { - t.Fatal(err) - } - if err = potential.AttachConnectStore(store); err != nil { - t.Fatal(err) - } - potential.BeginSession("health") - stats := &Service{Design: &gamedata.PictorialDesign{}, Owned: &ownedState{}, PotentialContributions: potential.Contributions} - stats.baseHealth.Store([2]uint64{350, 1}, float64(100)) - if err = characters.AttachMaxHealth(stats.MaxHealth); err != nil { - t.Fatal(err) - } - request := func(seq, costume uint64) []byte { - row := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, costume) - return wire.AppendBytes(wire.AppendVarint(nil, 1, seq), 2, row) - } - first := request(1, 100) - if _, _, _, err = potential.Handle("/CostumePotentialConnect", first); err != nil { - t.Fatal(err) - } - if maximum, err := characters.MaxHealth(77); err != nil || maximum != 215 { - t.Fatalf("first maximum=%d err=%v", maximum, err) - } - if hp, err := characters.CurrentHealth(77); err != nil || hp != 80 { - t.Fatal("connecting potential healed damage") - } - if err = characters.SetCurrentHealth(77, 200); err != nil { - t.Fatal(err) - } - second := request(2, 200) - code, out, handled, err := potential.Handle("/CostumePotentialConnect", second) - if err != nil || code != 267 || !handled { - t.Fatal(err) - } - c, ok := characters.Find(77) - if !ok || c.ConnectPotentialCostume != 200 || c.UseCostume != 1 || c.CostumeID != 100 || c.HP != 135 { - t.Fatalf("connection changed worn costume or failed HP clamp: %+v", c) - } - if maximum, err := characters.MaxHealth(77); err != nil || maximum != 135 { - t.Fatalf("switched maximum=%d err=%v", maximum, err) - } - _, again, _, err := potential.Handle("/CostumePotentialConnect", second) - if err != nil || !bytes.Equal(out, again) { - t.Fatal("connection replay changed response") - } - if err = characters.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - if _, _, _, err = potential.Handle("/CostumePotentialConnect", request(3, 100)); err != nil { - t.Fatal(err) - } - if hp, err := characters.CurrentHealth(77); err != nil || hp != 0 { - t.Fatal("potential connection revived dead character") - } - if wallet.Snapshot().Gold != 100 || len(items.All()) != 0 { - t.Fatal("free connection charged materials or currency") - } -} diff --git a/go/internal/server/pictorial/permanent_buffs.go b/go/internal/server/pictorial/permanent_buffs.go deleted file mode 100644 index 90d6a7d..0000000 --- a/go/internal/server/pictorial/permanent_buffs.go +++ /dev/null @@ -1,9 +0,0 @@ -package pictorial - -import "bd2server/internal/server/gamedata" - -// AttachPermanentBuffs makes reward-owned account buffs available to the same -// Snapshot used by AllCharRefresh, login and authoritative stat consumers. -func (s *Service) AttachPermanentBuffs(provider func() ([]gamedata.PictorialBuffStat, error)) { - s.permanentBuffs = provider -} diff --git a/go/internal/server/pictorial/permanent_buffs_test.go b/go/internal/server/pictorial/permanent_buffs_test.go deleted file mode 100644 index ee336f6..0000000 --- a/go/internal/server/pictorial/permanent_buffs_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package pictorial - -import ( - "bd2server/internal/server/events" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -func TestPermanentBuffsRefreshThroughAccountSnapshot(t *testing.T) { - store := stateio.NewMemory() - design := map[uint64]gamedata.PictorialBuffStat{1: {Category: 1, StatType: 9, Value: 0.02}} - rewards, err := events.OpenBuffRewards(store, design) - if err != nil { - t.Fatal(err) - } - service := &Service{Design: &gamedata.PictorialDesign{}, Owned: &ownedState{}} - service.AttachPermanentBuffs(rewards.SnapshotBuffs) - _, before, err := service.Snapshot() - if err != nil || len(before) != 0 { - t.Fatal("buff present before ownership", err) - } - if err := rewards.GrantOnce("mission", []gamedata.Reward{{Type: 63, ID: 1, Count: 1}}); err != nil { - t.Fatal(err) - } - _, after, err := service.Snapshot() - if err != nil || len(after) != 1 || after[0].Value != 0.02 { - t.Fatal("new buff missing from live stats", after, err) - } - code, body, handled, err := service.Handle("/AllCharRefresh", wire.AppendVarint(nil, 1, 1)) - if err != nil || code != 165 || !handled || len(body) == 0 { - t.Fatal("native buff refresh missing", err) - } - restored, err := events.OpenBuffRewards(store, design) - if err != nil { - t.Fatal(err) - } - service.AttachPermanentBuffs(restored.SnapshotBuffs) - _, after, err = service.Snapshot() - if err != nil || len(after) != 1 || after[0].Category != 1 { - t.Fatal("relogin buff lost", after, err) - } -} diff --git a/go/internal/server/pictorial/service_test.go b/go/internal/server/pictorial/service_test.go deleted file mode 100644 index 6326e80..0000000 --- a/go/internal/server/pictorial/service_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package pictorial - -import ( - "bytes" - "encoding/hex" - "encoding/json" - "math" - "os" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -type ownedState struct { - characters []player.Character - costumes []player.Costume - items []player.Item - equipment []player.Equipment - discovered []player.Pictorial -} - -func (s *ownedState) PictorialCharacters() []player.Character { return s.characters } -func (s *ownedState) PictorialCostumes() []player.Costume { return s.costumes } -func (s *ownedState) PictorialItems() []player.Item { return s.items } -func (s *ownedState) PictorialEquipment() []player.Equipment { return s.equipment } -func (s *ownedState) PictorialDiscovered() []player.Pictorial { return s.discovered } - -func TestOfficialPictorialProgression(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA to verify installed 2.35.10 GameData") - } - design, err := gamedata.LoadPictorialDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - starter, err := player.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json")) - if err != nil { - t.Fatal(err) - } - var world struct { - RewardCharacter player.Character `json:"reward_character"` - RewardCostume player.Costume `json:"reward_costume"` - StoryCharacters []player.Character `json:"story_characters"` - } - data, err := os.ReadFile(filepath.Join("..", "..", "..", "seed", "v2_35_10", "world.json")) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(data, &world); err != nil { - t.Fatal(err) - } - owned := &ownedState{ - characters: append(append([]player.Character(nil), starter.Characters...), world.RewardCharacter), - costumes: append(append([]player.Costume(nil), starter.Costumes...), world.RewardCostume), - items: append([]player.Item(nil), starter.Items...), - discovered: append([]player.Pictorial(nil), starter.Pictorialbook...), - } - owned.items = append(owned.items, player.Item{ID: 2101, Type: 17, Count: 1}, player.Item{ID: 2102, Type: 17, Count: 1}) - service := &Service{Design: design, Owned: owned} - assertSnapshot := func(health, attack float64, equip bool, officialHex string) { - t.Helper() - entries, buffs, err := service.Snapshot() - if err != nil { - t.Fatal(err) - } - if !equip && len(entries) != 18 { - t.Fatalf("initial account pictorial entries=%+v, want 18 official entries", entries) - } - if equip && len(entries) != 20 { - t.Fatalf("quest-28 pictorial entries=%+v, want 20", entries) - } - got := map[uint64]float64{} - for _, buff := range buffs { - got[buff.StatType] = buff.Value - } - if math.Abs(got[2]-health) > 1e-9 || math.Abs(got[4]-attack) > 1e-9 { - t.Fatalf("entries=%+v buffs=%+v, want health=%v attack=%v", entries, buffs, health, attack) - } - foundEquip := false - for _, entry := range entries { - if entry.GroupID == gamedata.PictorialEquipment && entry.ID == 1 { - foundEquip = true - } - } - if foundEquip != equip { - t.Fatalf("equipment pictorial=%v, want=%v", foundEquip, equip) - } - if !equip { - _, bookResponse, handled, err := service.Handle("/PictorialBookInfo", wire.AppendVarint(nil, 1, 41)) - if err != nil || !handled { - t.Fatalf("encode pictorial entries: handled=%v err=%v", handled, err) - } - officialBook, err := hex.DecodeString("0a040801101f0a04080110200a04080110210a04080110220a04080110230a040801103c" + - "0a04080210010a04080210090a0408041001" + - "0a06080510c5ea010a05080510b5100a05080510b610" + - "0a040807101c0a040807103d0a040807103e0a040807103f0a04080710400a0408071074") - if err != nil || !bytes.Equal(bookResponse, officialBook) { - t.Fatalf("pictorial response=%x official=%x err=%v", bookResponse, officialBook, err) - } - } - _, actual, ok, err := service.Handle("/AllCharRefresh", wire.AppendVarint(nil, 1, 42)) - if err != nil || !ok { - t.Fatalf("encode account buffs: handled=%v err=%v", ok, err) - } - official, err := hex.DecodeString(officialHex) - if err != nil || !bytes.Equal(actual, official) { - t.Fatalf("account buffs proto=%x, official=%x err=%v", actual, official, err) - } - } - assertSnapshot(.015, .0096, false, "0a0b080211b81e85eb51b88e3f0a0b080411613255302aa9833f") - owned.equipment = append(owned.equipment, player.Equipment{InvenIndex: 910000001, ID: 10010}) - owned.items = append(owned.items, player.Item{ID: 2103, Type: 17, Count: 1}) - assertSnapshot(.0175, .01, true, "0a0b080211ec51b81e85eb913f0a0b0804117b14ae47e17a843f") - owned.equipment = append(owned.equipment, player.Equipment{InvenIndex: 910000002, ID: 10010}) - assertSnapshot(.0175, .01, true, "0a0b080211ec51b81e85eb913f0a0b0804117b14ae47e17a843f") // same entry does not stack - owned.characters[5].Level = 20 - maxHP, err := service.MaxHealth(owned.characters[5]) - if err != nil || maxHP != 513 { - t.Fatalf("official level-20 character HP=%d err=%v, want 513", maxHP, err) - } - code, response, ok, err := service.Handle("/AllCharRefresh", wire.AppendVarint(nil, 1, 42)) - if err != nil || !ok || code != 165 { - t.Fatalf("AllCharRefresh: code=%d handled=%v err=%v", code, ok, err) - } - var count int - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 { - count++ - } - return nil - }); err != nil || count != 2 { - t.Fatalf("buff proto: count=%d err=%v", count, err) - } -} - -func TestSharedBuffIDCountsDistinctEntriesButNotDuplicateInventory(t *testing.T) { - owned := &ownedState{items: []player.Item{{ID: 2101, Type: 17, Count: 1}, {ID: 2101, Type: 17, Count: 1}, {ID: 2102, Type: 17, Count: 1}, {ID: 2103, Type: 8, Count: 99}}} - design := &gamedata.PictorialDesign{ - Items: []gamedata.ItemPictorial{{ID: 2101, ItemID: 2101, BuffID: 2002}, {ID: 2102, ItemID: 2102, BuffID: 2002}}, - Buffs: map[uint64]gamedata.PictorialBuff{2002: {ID: 2002, StatType: 2, Value: .0025}}, - } - entries, buffs, err := (&Service{Design: design, Owned: owned}).Snapshot() - if err != nil || len(entries) != 2 || len(buffs) != 1 || buffs[0].Value != .005 { - t.Fatalf("distinct-entries aggregation entries=%v buffs=%v err=%v", entries, buffs, err) - } -} - -func TestGrowthResourceIDCannotMasqueradeAsCollectionItem(t *testing.T) { - owned := &ownedState{items: []player.Item{{ID: 8, Type: 8, Count: 99}}} - design := &gamedata.PictorialDesign{ - Items: []gamedata.ItemPictorial{{ID: 8, ItemID: 8, BuffID: 2002, Category: 0}}, - Buffs: map[uint64]gamedata.PictorialBuff{2002: {ID: 2002, StatType: 2, Value: .0025}}, - } - entries, buffs, err := (&Service{Design: design, Owned: owned}).Snapshot() - if err != nil || len(entries) != 0 || len(buffs) != 0 { - t.Fatalf("resource incorrectly unlocked pictorial entries=%v buffs=%v err=%v", entries, buffs, err) - } -} - -func TestLearnedRecipePictorialAndBuffSurviveSQLiteRestart(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for current recipe collection regression") - } - design, err := gamedata.LoadPictorialDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - // Current CookingPictorialBookTable row 1 points at recipe 101; the - // client EElementType.CookingRecipe is 7, while 14 is an unrelated use item. - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = repo.Close() }() - items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - _, err = items.GrantOnce("unrelated-use-item", []gamedata.BattleReward{{Type: 14, ID: 101, Count: 1}}) - if err != nil { - t.Fatal(err) - } - owned := &ownedState{items: items.All()} - service := &Service{Design: design, Owned: owned} - before, _, err := service.Snapshot() - if err != nil || len(before) != 0 { - t.Fatalf("use item unlocked a recipe collection: %v %v", before, err) - } - _, err = items.GrantOnce("learned-recipe", []gamedata.BattleReward{{Type: 7, ID: 101, Count: 1}}) - if err != nil { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - items, err = player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - owned.items = items.All() - entries, buffs, err := service.Snapshot() - if err != nil || len(entries) != 1 || entries[0].GroupID != gamedata.PictorialCooking || entries[0].ID != 1 || len(buffs) != 1 { - t.Fatalf("recipe collection after restart: %v buffs=%v err=%v", entries, buffs, err) - } - for _, route := range []string{"/PictorialBookInfo", "/AllCharRefresh"} { - _, body, handled, err := service.Handle(route, wire.AppendVarint(nil, 1, 1)) - if err != nil || !handled || len(body) == 0 { - t.Fatalf("learned recipe projection %s: %x %v", route, body, err) - } - } -} - -func BenchmarkCharInfoMaxHealthLoadedDesign(b *testing.B) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - b.Skip("BD2_REAL_GAMEDATA not configured") - } - design, err := gamedata.LoadPictorialDesign(root, "20260923193640") - if err != nil { - b.Fatal(err) - } - // A large account resolves every character through the same immutable - // design. Repeating a known valid character isolates the /CharInfo scaling - // cost from account-specific inventory fixtures while preserving its call - // shape (one maximum-health calculation per returned character). - characters := make([]player.Character, 100) - for i := range characters { - characters[i] = player.Character{InvenIndex: uint64(i + 1), ID: 350, Level: 20} - } - service := &Service{Design: design, Owned: &ownedState{characters: characters}} - b.ReportAllocs() - b.ResetTimer() - for b.Loop() { - for _, character := range characters { - if _, err := service.MaxHealth(character); err != nil { - b.Fatal(err) - } - } - } -} diff --git a/go/internal/server/configfile/default.go b/go/internal/server/platform/configfile/default.go similarity index 100% rename from go/internal/server/configfile/default.go rename to go/internal/server/platform/configfile/default.go diff --git a/go/internal/server/lifecycle/gate.go b/go/internal/server/platform/lifecycle/gate.go similarity index 100% rename from go/internal/server/lifecycle/gate.go rename to go/internal/server/platform/lifecycle/gate.go diff --git a/go/internal/server/lifecycle/signals.go b/go/internal/server/platform/lifecycle/signals.go similarity index 100% rename from go/internal/server/lifecycle/signals.go rename to go/internal/server/platform/lifecycle/signals.go diff --git a/go/internal/server/lifecycle/signals_unix.go b/go/internal/server/platform/lifecycle/signals_unix.go similarity index 100% rename from go/internal/server/lifecycle/signals_unix.go rename to go/internal/server/platform/lifecycle/signals_unix.go diff --git a/go/internal/server/lifecycle/signals_windows.go b/go/internal/server/platform/lifecycle/signals_windows.go similarity index 100% rename from go/internal/server/lifecycle/signals_windows.go rename to go/internal/server/platform/lifecycle/signals_windows.go diff --git a/go/internal/server/logging/logging.go b/go/internal/server/platform/logging/logging.go similarity index 100% rename from go/internal/server/logging/logging.go rename to go/internal/server/platform/logging/logging.go diff --git a/go/internal/server/logging/terminal_unix.go b/go/internal/server/platform/logging/terminal_unix.go similarity index 100% rename from go/internal/server/logging/terminal_unix.go rename to go/internal/server/platform/logging/terminal_unix.go diff --git a/go/internal/server/logging/terminal_windows.go b/go/internal/server/platform/logging/terminal_windows.go similarity index 100% rename from go/internal/server/logging/terminal_windows.go rename to go/internal/server/platform/logging/terminal_windows.go diff --git a/go/internal/server/versionconfig/config.go b/go/internal/server/platform/versionconfig/config.go similarity index 100% rename from go/internal/server/versionconfig/config.go rename to go/internal/server/platform/versionconfig/config.go diff --git a/go/internal/server/player/auto_recovery.go b/go/internal/server/player/auto_recovery.go deleted file mode 100644 index 0d704dc..0000000 --- a/go/internal/server/player/auto_recovery.go +++ /dev/null @@ -1,105 +0,0 @@ -package player - -import ( - "bd2server/internal/server/wire" - "fmt" - "math" - "sort" -) - -// AutoRecoveryResult uses Define_AutoReviveDisabledType's protocol values. -type AutoRecoveryResult struct { - Caster, Experience, Catalyst, Disabled uint64 - Characters []Character -} - -// AutoRecover selects a living permanent fatigue-recovery caster. The selected -// caster is preferred; another owned caster can take over when it is fatigued. -func (s *TalentUseService) AutoRecover(seq, caster uint64, targets []uint64) (AutoRecoveryResult, error) { - r := AutoRecoveryResult{Caster: caster, Catalyst: s.wallet.CatalystBalance()} - if s.context == nil { - return r, fmt.Errorf("player: automatic recovery context unavailable") - } - pack, _, battle, err := s.context() - if err != nil { - return r, err - } - if battle { - return r, fmt.Errorf("player: automatic recovery during battle") - } - all := s.characters.RawAll() - sort.SliceStable(all, func(i, j int) bool { - left, right := all[i].InvenIndex == caster, all[j].InvenIndex == caster - if left != right { - return left - } - return all[i].InvenIndex < all[j].InvenIndex - }) - var selected Character - var cost uint64 - for _, c := range all { - if IsStoryCharacter(c) || IsCharmCharacter(c) { - continue - } - meta, ok := s.design.Characters[c.ID] - if !ok || meta.BannedPacks[pack] { - continue - } - rule, ok := s.design.Rules[[2]uint64{meta.Group, c.TalentLevel}] - if !ok || rule.Class != 10 { - continue - } - hp, e := s.characters.CurrentHealth(c.InvenIndex) - if e != nil { - return r, e - } - if hp == 0 { - continue - } - selected = c - if len(targets) > 0 && rule.Catalyst > math.MaxUint64/uint64(len(targets)) { - return r, fmt.Errorf("player: automatic recovery cost overflow") - } - cost = rule.Catalyst * uint64(len(targets)) - break - } - if selected.InvenIndex == 0 { - r.Disabled = 1 - return r, nil - } - r.Caster = selected.InvenIndex - if len(targets) == 0 { - return r, nil - } - if cost > r.Catalyst { - r.Disabled = 2 - return r, nil - } - request := wire.AppendVarint(nil, 1, seq) - request = wire.AppendVarint(request, 2, r.Caster) - for _, id := range targets { - request = wire.AppendVarint(request, 3, id) - } - _, body, _, err := s.healing(request, "auto-recovery") - if err != nil { - return r, err - } - r.Experience, _, err = wire.Varint(body, 2) - if err != nil { - return r, err - } - r.Catalyst = s.wallet.CatalystBalance() - for _, id := range targets { - c, ok := s.characters.Find(id) - if !ok { - return r, fmt.Errorf("player: recovered character unavailable") - } - hp, e := s.characters.CurrentHealth(id) - if e != nil { - return r, e - } - c.HP = hp - r.Characters = append(r.Characters, c) - } - return r, nil -} diff --git a/go/internal/server/player/auto_recovery_test.go b/go/internal/server/player/auto_recovery_test.go deleted file mode 100644 index e044460..0000000 --- a/go/internal/server/player/auto_recovery_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package player - -import ( - "bd2server/internal/server/stateio" - "testing" -) - -func TestAutoRecoveryRequiresLivingCasterAndChargesActualTargets(t *testing.T) { - s, chars, wallet, _ := talentTest(t, stateio.NewMemory(), 10) - chars.characters = append(chars.characters, Character{InvenIndex: 88, ID: 350, HP: 100, Level: 1, TalentLevel: 1}) - if e := chars.SetCurrentHealth(77, 0); e != nil { - t.Fatal(e) - } - r, e := s.AutoRecover(1, 77, []uint64{77}) - if e != nil || r.Caster != 88 || r.Disabled != 0 || len(r.Characters) != 1 || r.Characters[0].HP != 100 || r.Experience != 3 || r.Catalyst != 45 { - t.Fatalf("result%+v err%v", r, e) - } - if wallet.CatalystBalance() != 45 { - t.Fatal("wrong catalyst") - } - if e := chars.SetCurrentHealth(77, 0); e != nil { - t.Fatal(e) - } - if e := chars.SetCurrentHealth(88, 0); e != nil { - t.Fatal(e) - } - r, e = s.AutoRecover(2, 88, []uint64{77}) - if e != nil || r.Disabled != 1 || len(r.Characters) != 0 || wallet.CatalystBalance() != 45 { - t.Fatalf("exhaustion%+v %v", r, e) - } - if e := chars.SetCurrentHealth(88, 100); e != nil { - t.Fatal(e) - } - rule := s.design.Rules[[2]uint64{42, 1}] - rule.Catalyst = 46 - s.design.Rules[[2]uint64{42, 1}] = rule - r, e = s.AutoRecover(3, 88, []uint64{77}) - if e != nil || r.Disabled != 2 || len(r.Characters) != 0 || wallet.CatalystBalance() != 45 { - t.Fatalf("insufficient catalyst%+v %v", r, e) - } -} diff --git a/go/internal/server/player/char_awake.go b/go/internal/server/player/char_awake.go deleted file mode 100644 index e8fc075..0000000 --- a/go/internal/server/player/char_awake.go +++ /dev/null @@ -1,300 +0,0 @@ -package player - -import ( - "errors" - "fmt" - "slices" - "strconv" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -type CharAwakeService struct { - design *gamedata.CharAwakeDesign - collection *CollectionStore - characters *CharacterStore - inventory *Inventory - wallet *Wallet -} - -func NewCharAwakeService(design *gamedata.CharAwakeDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CharAwakeService, error) { - if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil { - return nil, errors.New("player: incomplete character awakening service") - } - for uniqueID, progress := range collection.CharAwakeStates() { - if err := design.ValidateProgress(uniqueID, progress.ImprintLevels, progress.IsAwake); err != nil { - return nil, fmt.Errorf("player: invalid saved character awakening progress: %w", err) - } - } - return &CharAwakeService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil -} - -func (s *CharAwakeService) Handle(path string, request []byte) (int, []byte, bool, error) { - switch path { - case "/CharAwakeInfo": - return s.info(request) - case "/CharImprintLevelUp": - return s.imprintLevelUp(request) - case "/CharAwakeActive": - return s.awakeActive(request) - default: - return 0, nil, false, nil - } -} - -func validAwakeSequence(request []byte, path string) error { - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return fmt.Errorf("player: %s missing sequence", path) - } - return nil -} - -func (s *CharAwakeService) info(request []byte) (int, []byte, bool, error) { - if err := validAwakeSequence(request, "CharAwakeInfo"); err != nil { - return 0, nil, true, err - } - states := s.collection.CharAwakeStates() - ids := make([]uint64, 0, len(states)) - for id := range states { - ids = append(ids, id) - } - slices.Sort(ids) - var response []byte - for _, id := range ids { - progress := states[id] - var entry []byte - entry = wire.AppendVarint(entry, 1, id) - for i, level := range progress.ImprintLevels { - if level != 0 { - entry = wire.AppendVarint(entry, 2+i, level) - } - } - if progress.IsAwake { - entry = wire.AppendVarint(entry, 5, 1) - } - response = wire.AppendBytes(response, 1, entry) - } - return 326, response, true, nil -} - -func decodeAwakeMaterials(request []byte, fieldNumber int) ([]Item, error) { - var materials []Item - err := wire.Walk(request, func(field wire.Field) error { - if field.Number != fieldNumber { - return nil - } - if field.Type != 2 { - return errors.New("player: invalid character awakening material field") - } - var item Item - if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { - return err - } - if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) { - return errors.New("player: incomplete character awakening material") - } - materials = append(materials, item) - return nil - }) - return materials, err -} - -func (s *CharAwakeService) imprintLevelUp(request []byte) (int, []byte, bool, error) { - if err := validAwakeSequence(request, "CharImprintLevelUp"); err != nil { - return 0, nil, true, err - } - characterIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: CharImprintLevelUp missing character") - } - var targets []gamedata.CharImprintTarget - err = wire.Walk(request, func(field wire.Field) error { - if field.Number != 3 { - return nil - } - if field.Type != 2 { - return errors.New("player: invalid imprint target field") - } - var target gamedata.CharImprintTarget - if err := decodeVarints(field.Value, map[int]*uint64{1: &target.Slot, 2: &target.TargetLevel}); err != nil { - return err - } - if target.Slot == 0 || target.TargetLevel == 0 { - return errors.New("player: incomplete imprint target") - } - targets = append(targets, target) - return nil - }) - if err != nil || len(targets) == 0 { - if err == nil { - err = errors.New("player: CharImprintLevelUp has no targets") - } - return 0, nil, true, err - } - materials, err := decodeAwakeMaterials(request, 4) - if err != nil || len(materials) == 0 { - if err == nil { - err = errors.New("player: CharImprintLevelUp has no materials") - } - return 0, nil, true, err - } - character, found := s.characters.Find(characterIndex) - if !found { - return 0, nil, true, fmt.Errorf("player: unknown imprint character %d", characterIndex) - } - uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level) - if err != nil { - return 0, nil, true, fmt.Errorf("player: validate imprint character growth: %w", err) - } - current, _ := s.collection.CharAwakeState(uniqueID) - costs, levels, err := s.design.ImprintCosts(uniqueID, current.ImprintLevels, targets) - if err != nil { - return 0, nil, true, fmt.Errorf("player: validate imprint GameData: %w", err) - } - items, gold, err := s.validateCosts(costs, materials) - if err != nil { - return 0, nil, true, fmt.Errorf("player: validate imprint materials: %w", err) - } - if len(items) != 0 { - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: validate imprint inventory: %w", err) - } - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return 0, nil, true, errors.New("player: insufficient gold for character imprint") - } - identity := "char-imprint:" + strconv.FormatUint(uniqueID, 10) - for _, level := range levels { - identity += ":" + strconv.FormatUint(level, 10) //nolint:modernize // stringsbuilder - } - if gold != 0 { - if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { - return 0, nil, true, fmt.Errorf("player: spend imprint gold: %w", err) - } - } - if len(items) != 0 { - if err := s.inventory.Consume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: consume imprint materials: %w", err) - } - } - next := current - next.ImprintLevels = levels - if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil { - return 0, nil, true, fmt.Errorf("player: persist character imprint: %w", err) - } - return 327, nil, true, nil -} - -func (s *CharAwakeService) awakeActive(request []byte) (int, []byte, bool, error) { - if err := validAwakeSequence(request, "CharAwakeActive"); err != nil { - return 0, nil, true, err - } - characterIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: CharAwakeActive missing character") - } - materials, err := decodeAwakeMaterials(request, 3) - if err != nil || len(materials) == 0 { - if err == nil { - err = errors.New("player: CharAwakeActive has no materials") - } - return 0, nil, true, err - } - character, found := s.characters.Find(characterIndex) - if !found { - return 0, nil, true, fmt.Errorf("player: unknown awakening character %d", characterIndex) - } - uniqueID, err := s.design.ValidateGrowthCompleted(character.ID, character.Level) - if err != nil { - return 0, nil, true, fmt.Errorf("player: validate awakening character growth: %w", err) - } - current, _ := s.collection.CharAwakeState(uniqueID) - costs, err := s.design.AwakeCosts(uniqueID, current.ImprintLevels, current.IsAwake) - if err != nil { - return 0, nil, true, fmt.Errorf("player: validate awakening GameData: %w", err) - } - items, gold, err := s.validateCosts(costs, materials) - if err != nil { - return 0, nil, true, fmt.Errorf("player: validate awakening materials: %w", err) - } - if len(items) != 0 { - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: validate awakening inventory: %w", err) - } - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return 0, nil, true, errors.New("player: insufficient gold for character awakening") - } - identity := "char-awake:" + strconv.FormatUint(uniqueID, 10) - if gold != 0 { - if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { - return 0, nil, true, fmt.Errorf("player: spend awakening gold: %w", err) - } - } - if len(items) != 0 { - if err := s.inventory.Consume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: consume awakening materials: %w", err) - } - } - next := current - next.IsAwake = true - if err := s.collection.UpdateCharAwake(uniqueID, current, next); err != nil { - return 0, nil, true, fmt.Errorf("player: persist character awakening: %w", err) - } - return 328, nil, true, nil -} - -func (s *CharAwakeService) validateCosts(costs []gamedata.CharAwakeCost, materials []Item) ([]Item, uint64, error) { - want := make(map[[2]uint64]uint64, len(costs)) - for _, cost := range costs { - key := [2]uint64{cost.Type, cost.ID} - if cost.Count > ^uint64(0)-want[key] { - return nil, 0, errors.New("character awakening cost overflow") - } - want[key] += cost.Count - } - got := make(map[[2]uint64]uint64, len(materials)) - var items []Item - var gold uint64 - for _, material := range materials { - key := [2]uint64{material.Type, material.ID} - switch material.Type { - case 4: - if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { - return nil, 0, errors.New("invalid character awakening currency") - } - gold = material.Count - case 8: - items = append(items, material) - default: - return nil, 0, fmt.Errorf("unsupported character awakening material type %d", material.Type) - } - if material.Count > ^uint64(0)-got[key] { - return nil, 0, errors.New("submitted character awakening material overflow") - } - got[key] += material.Count - } - if len(got) != len(want) { - return nil, 0, fmt.Errorf("material kinds mismatch: request=%v GameData=%v", got, want) - } - for key, count := range want { - if got[key] != count { - return nil, 0, fmt.Errorf("material %d/%d=%d want %d", key[0], key[1], got[key], count) - } - } - return items, gold, nil -} - -// Contributions exposes server-side derived stats for CharInfo, revival and -// battle calculators. The client independently computes the same values from -// CharAwakeInfo, so neither side relies on a persisted derived number. -func (s *CharAwakeService) Contributions(character Character) ([]gamedata.StatContribution, error) { - uniqueID, ok := s.design.CharacterUniqueID(character.ID) - if !ok { - return nil, nil - } - progress, _ := s.collection.CharAwakeState(uniqueID) - return s.design.CharAwakeContributions(uniqueID, progress.ImprintLevels, progress.IsAwake) -} diff --git a/go/internal/server/player/char_awake_test.go b/go/internal/server/player/char_awake_test.go deleted file mode 100644 index a254406..0000000 --- a/go/internal/server/player/char_awake_test.go +++ /dev/null @@ -1,140 +0,0 @@ -package player - -import ( - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func newCharAwakeHarness(t *testing.T) (*CharAwakeService, *CollectionStore, *Inventory, *Wallet, []Item) { - t.Helper() - dir := t.TempDir() - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) - if err != nil { - t.Fatal(err) - } - granted, err := inventory.GrantOnce("awake-test", []gamedata.BattleReward{ - {Type: 8, ID: 701, Count: 1}, {Type: 8, ID: 702, Count: 1}, {Type: 8, ID: 703, Count: 1}, {Type: 8, ID: 705, Count: 20}, - }) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - collection, err := OpenCollectionStore(testStore(filepath.Join(dir, "collection.json")), nil) - if err != nil { - t.Fatal(err) - } - characters := &CharacterStore{characters: []Character{{InvenIndex: 77, ID: 354, Level: 100}}} - character := gamedata.CharAwakeCharacter{UniqueCharID: 35, Active: true} - for i := range 3 { - character.ImprintGrowth[i] = []gamedata.CharAwakeGrowth{{ - ID: uint64(i + 1), StatType: uint64(i*2 + 1), StatValue: float64(i + 1), - Costs: []gamedata.CharAwakeCost{{Type: 8, ID: uint64(701 + i), Count: 1}, {Type: 4, Count: 100}}, - }} - } - character.AwakeGrowth = []gamedata.CharAwakeGrowth{ - {ID: 100, StatType: 4, StatValue: .12, Costs: []gamedata.CharAwakeCost{{Type: 8, ID: 705, Count: 20}, {Type: 4, Count: 500}}}, - {ID: 101, StatType: 14, StatValue: .1}, - } - design := &gamedata.CharAwakeDesign{ - Characters: map[uint64]gamedata.CharAwakeCharacter{35: character}, - Stages: map[uint64]gamedata.CharAwakeCharacterStage{ - 354: {UniqueCharID: 35, Grade: 5, GrowthGrade: 5, MaximumLevel: 100}, - }, - } - service, err := NewCharAwakeService(design, collection, characters, inventory, wallet) - if err != nil { - t.Fatal(err) - } - return service, collection, inventory, wallet, granted -} - -func awakeMaterial(item Item) []byte { return ItemWire(item) } - -func TestCharImprintAndAwakePersistConsumeAndRestoreInfo(t *testing.T) { - service, collection, inventory, wallet, granted := newCharAwakeHarness(t) - request := wire.AppendVarint(nil, 1, 10) - request = wire.AppendVarint(request, 2, 77) - for slot := uint64(1); slot <= 3; slot++ { - target := wire.AppendVarint(wire.AppendVarint(nil, 1, slot), 2, 1) - request = wire.AppendBytes(request, 3, target) - request = wire.AppendBytes(request, 4, awakeMaterial(granted[slot-1])) - } - request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 300})) - code, proto, handled, err := service.Handle("/CharImprintLevelUp", request) - if err != nil || !handled || code != 327 || len(proto) != 0 { - t.Fatalf("imprint code=%d proto=%x handled=%v err=%v", code, proto, handled, err) - } - progress, found := collection.CharAwakeState(35) - if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || progress.IsAwake { - t.Fatalf("imprint progress=%+v found=%v", progress, found) - } - if wallet.Snapshot().Gold != 700 || len(inventory.All()) != 1 { - t.Fatalf("post-imprint wallet=%+v items=%+v", wallet.Snapshot(), inventory.All()) - } - - request = wire.AppendVarint(nil, 1, 11) - request = wire.AppendVarint(request, 2, 77) - request = wire.AppendBytes(request, 3, awakeMaterial(granted[3])) - request = wire.AppendBytes(request, 3, awakeMaterial(Item{Type: 4, Count: 500})) - code, proto, handled, err = service.Handle("/CharAwakeActive", request) - if err != nil || !handled || code != 328 || len(proto) != 0 { - t.Fatalf("awake code=%d proto=%x handled=%v err=%v", code, proto, handled, err) - } - progress, _ = collection.CharAwakeState(35) - if !progress.IsAwake || wallet.Snapshot().Gold != 200 || len(inventory.All()) != 0 { - t.Fatalf("post-awake progress=%+v wallet=%+v items=%+v", progress, wallet.Snapshot(), inventory.All()) - } - restarted, err := OpenCollectionStore(collection.store, nil) - if err != nil { - t.Fatal(err) - } - service.collection = restarted - progress, found = restarted.CharAwakeState(35) - if !found || progress.ImprintLevels != [3]uint64{1, 1, 1} || !progress.IsAwake { - t.Fatalf("restarted awakening progress=%+v found=%v", progress, found) - } - - code, proto, handled, err = service.Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 12)) - if err != nil || !handled || code != 326 { - t.Fatalf("info code=%d handled=%v err=%v", code, handled, err) - } - var entry []byte - if err := wire.Walk(proto, func(field wire.Field) error { - if field.Number == 1 { - entry = append([]byte(nil), field.Value...) - } - return nil - }); err != nil { - t.Fatal(err) - } - for field, want := range map[int]uint64{1: 35, 2: 1, 3: 1, 4: 1, 5: 1} { - got, present, err := wire.Varint(entry, field) - if err != nil || !present || got != want { - t.Fatalf("CharAwakeDBInfo field %d=%d present=%v err=%v want=%d proto=%x", field, got, present, err, want, entry) - } - } -} - -func TestCharImprintRejectsClientCostMismatchBeforeMutation(t *testing.T) { - service, collection, inventory, wallet, granted := newCharAwakeHarness(t) - request := wire.AppendVarint(nil, 1, 20) - request = wire.AppendVarint(request, 2, 77) - request = wire.AppendBytes(request, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)) - bad := granted[0] - bad.Count = 2 - request = wire.AppendBytes(request, 4, awakeMaterial(bad)) - request = wire.AppendBytes(request, 4, awakeMaterial(Item{Type: 4, Count: 100})) - if _, _, handled, err := service.Handle("/CharImprintLevelUp", request); !handled || err == nil { - t.Fatalf("mismatched costs handled=%v err=%v", handled, err) - } - if _, found := collection.CharAwakeState(35); found || wallet.Snapshot().Gold != 1000 || len(inventory.All()) != 4 { - t.Fatalf("rejected request mutated state: found=%v wallet=%+v items=%+v", found, wallet.Snapshot(), inventory.All()) - } -} diff --git a/go/internal/server/player/collection_entries_test.go b/go/internal/server/player/collection_entries_test.go deleted file mode 100644 index ac8f6f6..0000000 --- a/go/internal/server/player/collection_entries_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package player - -import ( - "bytes" - "encoding/json" - "reflect" - "testing" - - "bd2server/internal/server/stateio" -) - -type collectionWriteSpy struct { - *stateio.Memory - coreWrites int - mutations [][]stateio.EntryMutation -} - -func (s *collectionWriteSpy) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { - if core != nil { - s.coreWrites++ - } - s.mutations = append(s.mutations, append([]stateio.EntryMutation(nil), changes...)) - return s.Memory.SaveWithEntries(domain, core, changes) -} - -func TestCollectionPersistsOnlyChangedEntries(t *testing.T) { - storage := &collectionWriteSpy{Memory: stateio.NewMemory()} - collection, err := OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if err := collection.EnsurePersisted(); err != nil { - t.Fatal(err) - } - first := cloneCollection(collection.data) - first.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1}} - first.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, UseChar: 920000001}} - first.Grants["first"] = CollectionGrant{CharacterIndices: []uint64{920000001}, CostumeIndices: []uint64{930000001}} - first.GachaApplied["draw:first"] = true - if err := collection.commit(first); err != nil { - t.Fatal(err) - } - core, err := storage.Load("collection") - if err != nil { - t.Fatal(err) - } - for _, bucket := range collectionEntryBuckets { - if bytes.Contains(core, []byte(`"`+bucket+`"`)) { - t.Fatalf("collection core still contains %s", bucket) - } - } - if storage.coreWrites != 1 || len(storage.mutations[1]) != 4 { - t.Fatalf("initial entry save: core writes=%d mutations=%+v", storage.coreWrites, storage.mutations) - } - - second := cloneCollection(collection.data) - second.Grants["second"] = CollectionGrant{ViewCostumeIDs: []uint64{64902}} - if err := collection.commit(second); err != nil { - t.Fatal(err) - } - if storage.coreWrites != 1 || len(storage.mutations[2]) != 1 || storage.mutations[2][0].Bucket != "grants" || storage.mutations[2][0].Key != "second" { - t.Fatalf("entry-only update rewrote core or unrelated entries: %+v", storage.mutations[2]) - } - loaded, err := OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if len(loaded.Characters()) != 1 || len(loaded.Costumes()) != 1 || !loaded.data.GachaApplied["draw:first"] { - t.Fatalf("reloaded collection lost entities or applied draw: %+v", loaded.data) - } - if _, found := loaded.Grant("second"); !found { - t.Fatal("reloaded collection lost second grant") - } -} - -func TestCollectionRejectsInlineLedgerInFinalCore(t *testing.T) { - storage := stateio.NewMemory() - core := map[string]any{ - "version": "2.35.10", "next_character_index": 920000001, - "next_costume_index": 930000001, "grants": map[string]any{}, - } - encoded, err := json.Marshal(core) - if err != nil { - t.Fatal(err) - } - if err := storage.Save("collection", encoded); err != nil { - t.Fatal(err) - } - if _, err := OpenCollectionStore(storage, nil); err == nil { - t.Fatal("old inline collection ledger was accepted") - } -} - -func TestCollectionEntryBucketsRoundTripAndDelete(t *testing.T) { - storage := stateio.NewMemory() - collection, err := OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - next := cloneCollection(collection.data) - next.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1}} - next.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, UseChar: 920000001}} - next.Grants["draw:1"] = CollectionGrant{ViewCostumeIDs: []uint64{64901}} - next.GachaApplied["draw:1"] = true - next.GachaUsers["1"] = GachaUserState{GroupID: 1, Point: 20} - next.GachaFixed["1:2"] = GachaFixedState{FixedID: 1, Type: 2, Count: 3} - next.StepUpProgress["1"] = 2 - next.GachaPointExchange["exchange:1"] = GachaPointExchange{GroupID: 1, Count: 3} - next.GachaSelections["1"] = []GachaSelection{{GroupID: 1, Slot: 1, ItemID: 64901}} - next.CostumePotential["930000001"] = []uint64{2, 4} - next.CharAwake["6490"] = CharAwakeProgress{ImprintLevels: [3]uint64{1, 0, 0}} - if err := collection.commit(next); err != nil { - t.Fatal(err) - } - loaded, err := OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(loaded.data, next) { - t.Fatalf("collection entries changed across reload: loaded=%+v want=%+v", loaded.data, next) - } - removed := cloneCollection(loaded.data) - delete(removed.Grants, "draw:1") - delete(removed.GachaApplied, "draw:1") - removed.Characters = nil - removed.Costumes = nil - if err := loaded.commit(removed); err != nil { - t.Fatal(err) - } - for bucket, key := range map[string]string{"grants": "draw:1", "gacha_applied": "draw:1", "characters": "920000001", "costumes": "930000001"} { - if _, found, err := storage.LoadEntry("collection", bucket, key); err != nil || found { - t.Fatalf("deleted entry %s/%s found=%v err=%v", bucket, key, found, err) - } - } -} diff --git a/go/internal/server/player/collection_promotion_reward_test.go b/go/internal/server/player/collection_promotion_reward_test.go deleted file mode 100644 index 1696307..0000000 --- a/go/internal/server/player/collection_promotion_reward_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package player - -import ( - "path/filepath" - "reflect" - "testing" - - "bd2server/internal/server/gamedata" -) - -func TestNewCostumeReusesPromotedCharacter(t *testing.T) { - for _, inBase := range []bool{false, true} { - t.Run(map[bool]string{false: "collection", true: "base"}[inBase], func(t *testing.T) { - path := filepath.Join(t.TempDir(), "collection.json") - store, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - owned := Character{InvenIndex: 77, ID: 888, HP: 4000, Level: 100, CostumeID: 76541, UseCostume: 123, ConnectPotentialCostume: 76541, TalentLevel: 4} - if inBase { - if err := store.BindBaseCharacters([]Character{owned}); err != nil { - t.Fatal(err) - } - } else { - next := cloneCollection(store.data) - next.Characters = []Character{owned} - if err := store.commit(next); err != nil { - t.Fatal(err) - } - } - catalog, err := gamedata.NewRegularGachaCatalog( - map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 76543, Weight: 1}}}}, - map[uint64]gamedata.CharacterDesign{76543: {ID: 999, GrowthCharacterIDs: []uint64{999, 888}, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}}, - ) - if err != nil { - t.Fatal(err) - } - before := store.data.NextCharacterIndex - grant, err := store.GrantCostumes("new-costume", []uint64{76543}, catalog) - if err != nil { - t.Fatal(err) - } - if len(grant.CharacterIndices) != 0 || len(grant.CostumeIndices) != 1 || store.data.NextCharacterIndex != before { - t.Fatalf("new character created after promotion: %+v", grant) - } - costume, ok := store.CostumeByIndex(grant.CostumeIndices[0]) - if !ok || costume.UseChar != owned.InvenIndex { - t.Fatalf("costume=%+v", costume) - } - loaded, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - if inBase { - if err := loaded.BindBaseCharacters([]Character{owned}); err != nil { - t.Fatal(err) - } - } else if got, ok := loaded.FindCharacter(owned.InvenIndex); !ok || !reflect.DeepEqual(got, owned) { - t.Fatalf("original character changed: %+v", got) - } - retry, err := loaded.GrantCostumes("new-costume", []uint64{76543}, catalog) - if err != nil || !reflect.DeepEqual(retry, grant) || len(loaded.Costumes()) != 1 { - t.Fatalf("retry=%+v err=%v", retry, err) - } - }) - } -} - -func TestCostumeRejectsAmbiguousPromotedOwnership(t *testing.T) { - _, _, err := findCharacterByDesign(nil, []Character{{InvenIndex: 77, ID: 999}, {InvenIndex: 88, ID: 888}}, gamedata.CharacterDesign{ID: 999, GrowthCharacterIDs: []uint64{999, 888}}) - if err == nil { - t.Fatal("duplicate family silently selected") - } -} diff --git a/go/internal/server/player/collection_test.go b/go/internal/server/player/collection_test.go deleted file mode 100644 index 1fd43d5..0000000 --- a/go/internal/server/player/collection_test.go +++ /dev/null @@ -1,229 +0,0 @@ -package player - -import ( - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" -) - -func TestEarnedQuestCostumeSharesPotentialLedgerAndKeepsSingleOwnedInstance(t *testing.T) { - path := filepath.Join(t.TempDir(), "collection.json") - store, err := OpenCollectionStore(testStore(path), []Costume{{InvenIndex: 123, ID: 60101, UseChar: 44}}) - if err != nil { - t.Fatal(err) - } - reward := Costume{InvenIndex: 609338889, ID: 3501, UseChar: 535607162} - if err := store.AttachRewardCostume(reward); err != nil { - t.Fatal(err) - } - if got := store.Costumes(); len(got) != 2 || got[1].InvenIndex != reward.InvenIndex { - t.Fatalf("earned costume view=%+v", got) - } - if err := store.ActivateCostumePotential(reward.InvenIndex, []uint64{1, 2}); err != nil { - t.Fatal(err) - } - got, found := store.CostumeByIndex(reward.InvenIndex) - if !found || len(got.PotentialIDs) != 2 || got.PotentialIDs[0] != 1 || got.PotentialIDs[1] != 2 { - t.Fatalf("earned costume potential=%+v found=%v", got, found) - } - restarted, err := OpenCollectionStore(testStore(path), []Costume{{InvenIndex: 123, ID: 60101, UseChar: 44}}) - if err != nil { - t.Fatal(err) - } - if err := restarted.AttachRewardCostume(reward); err != nil { - t.Fatal(err) - } - got, found = restarted.CostumeByIndex(reward.InvenIndex) - if !found || len(got.PotentialIDs) != 2 { - t.Fatalf("restarted earned costume potential=%+v found=%v", got, found) - } - if err := restarted.AttachRewardCostume(reward); err == nil { - t.Fatal("duplicate earned quest costume accepted") - } -} - -func TestUpdateCollectionCharacterAcrossPromotionChangesDesignNotInstance(t *testing.T) { - path := filepath.Join(t.TempDir(), "collection.json") - store, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - next := cloneCollection(store.data) - next.Characters = []Character{{InvenIndex: 920000054, ID: 6510, Level: 1}} - if err := store.commit(next); err != nil { - t.Fatal(err) - } - promoted := Character{InvenIndex: 920000054, ID: 6514, Level: 100} - if err := store.CanUpdateCharacter(6510, promoted); err != nil { - t.Fatal(err) - } - if err := store.UpdateCharacter(6510, promoted); err != nil { - t.Fatal(err) - } - loaded, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - if got, found := loaded.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 { - t.Fatalf("persisted promoted collection character=%+v found=%v", got, found) - } - if err := loaded.UpdateCharacter(6510, promoted); err == nil { - t.Fatal("stale old design accepted") - } - if err := loaded.CanUpdateCharacter(6514, Character{InvenIndex: 920000055, ID: 6514, Level: 100}); err == nil { - t.Fatal("unknown instance accepted") - } -} - -func TestMaxCostumeDuplicatePersistsExchangeAndDisplayUpgrade(t *testing.T) { - path := filepath.Join(t.TempDir(), "collection.json") - store, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - next := cloneCollection(store.data) - next.Characters = []Character{{InvenIndex: 920000001, ID: 6490, Level: 1, UseCostume: 930000001, ConnectPotentialCostume: 64901}} - next.Costumes = []Costume{{InvenIndex: 930000001, ID: 64901, Level: 5, UseChar: 920000001}} - if err := store.commit(next); err != nil { - t.Fatal(err) - } - catalog, err := gamedata.NewRegularGachaCatalog( - map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}}, - map[uint64]gamedata.CharacterDesign{64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}}, - ) - if err != nil { - t.Fatal(err) - } - grant, err := store.GrantRegular("draw", []uint64{64901}, catalog) - if err != nil { - t.Fatal(err) - } - if len(grant.Exchanges) != 1 || len(grant.Upgrades) != 1 { - t.Fatalf("grant=%+v", grant) - } - if got := grant.Upgrades[0]; got.InvenIndex != 930000001 || got.CostumeID != 64901 || got.Before != 5 || got.After != 5 || got.SortID != 0 { - t.Fatalf("display upgrade=%+v", got) - } - if got := store.Costumes()[0].Level; got != 5 { - t.Fatalf("costume level=%d want=5", got) - } - restored, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - persisted, ok := restored.Grant("draw") - if !ok || len(persisted.Exchanges) != 1 || len(persisted.Upgrades) != 1 || restored.Costumes()[0].Level != 5 { - t.Fatalf("persisted=%+v costumes=%+v", persisted, restored.Costumes()) - } -} - -func TestGrantDifferentCostumesForSameCharacterReusesCharacter(t *testing.T) { - store, err := OpenCollectionStore(testStore(filepath.Join(t.TempDir(), "collection.json")), nil) - if err != nil { - t.Fatal(err) - } - catalog, err := gamedata.NewRegularGachaCatalog( - map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}}, - map[uint64]gamedata.CharacterDesign{ - 64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5}, - 64902: {ID: 6490, HP: 166, CostumeMaxLevel: 5}, - }, - ) - if err != nil { - t.Fatal(err) - } - first, err := store.GrantRegular("first", []uint64{64901}, catalog) - if err != nil { - t.Fatal(err) - } - second, err := store.GrantRegular("second", []uint64{64902}, catalog) - if err != nil { - t.Fatal(err) - } - if len(first.CharacterIndices) != 1 || len(second.CharacterIndices) != 0 || len(store.Characters()) != 1 { - t.Fatalf("first=%+v second=%+v characters=%+v", first, second, store.Characters()) - } - costumes := store.Costumes() - if len(costumes) != 2 || costumes[0].UseChar != costumes[1].UseChar || costumes[0].UseChar != store.Characters()[0].InvenIndex { - t.Fatalf("costumes=%+v characters=%+v", costumes, store.Characters()) - } - if store.Characters()[0].CostumeID != 64901 { - t.Fatalf("initial character costume_id=%d", store.Characters()[0].CostumeID) - } -} - -func TestFirstGachaCompletionIsExplicitAndAtomic(t *testing.T) { - path := filepath.Join(t.TempDir(), "collection.json") - store, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - if store.FirstGachaCompleted() { - t.Fatal("new collection reports completed first gacha") - } - catalog, err := gamedata.NewRegularGachaCatalog( - map[uint64]gamedata.RegularGacha{20: {ID: 20, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64901, Weight: 1}}}}, - map[uint64]gamedata.CharacterDesign{64901: {ID: 6490, HP: 166, CostumeMaxLevel: 5}}, - ) - if err != nil { - t.Fatal(err) - } - _, err = store.GrantRegularPurchase("regular-gacha:20:seq:1", []uint64{64901}, catalog, GachaPurchase{ - Group: gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3}, - }) - if err != nil { - t.Fatal(err) - } - if !store.FirstGachaCompleted() { - t.Fatal("subtype-3 purchase did not persist first-gacha completion") - } - restored, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - if !restored.FirstGachaCompleted() { - t.Fatal("first-gacha completion did not survive restart") - } -} - -func TestFirstGachaCompletionRejectsRewardPayload(t *testing.T) { - path := filepath.Join(t.TempDir(), "collection.json") - store, err := OpenCollectionStore(testStore(path), nil) - if err != nil { - t.Fatal(err) - } - next := cloneCollection(store.data) - next.Grants[FirstGachaCompletedIdentity] = CollectionGrant{ViewCostumeIDs: []uint64{1}} - if err := store.commit(next); err != nil { - t.Fatal(err) - } - if _, err := OpenCollectionStore(testStore(path), nil); err == nil { - t.Fatal("collection accepted a non-empty first-gacha marker") - } -} - -func TestGrantCostumeForBaseCharacterDoesNotCreateCharacter(t *testing.T) { - store, err := OpenCollectionStore(testStore(filepath.Join(t.TempDir(), "collection.json")), nil) - if err != nil { - t.Fatal(err) - } - base := []Character{{InvenIndex: 535604118, ID: 6490, Level: 1, UseCostume: 635604118}} - if err := store.BindBaseCharacters(base); err != nil { - t.Fatal(err) - } - catalog, err := gamedata.NewRegularGachaCatalog( - map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 64902, Weight: 1}}}}, - map[uint64]gamedata.CharacterDesign{64902: {ID: 6490, HP: 166, CostumeMaxLevel: 5}}, - ) - if err != nil { - t.Fatal(err) - } - grant, err := store.GrantRegular("base-costume", []uint64{64902}, catalog) - if err != nil { - t.Fatal(err) - } - if len(grant.CharacterIndices) != 0 || len(store.Characters()) != 0 || len(store.Costumes()) != 1 || store.Costumes()[0].UseChar != base[0].InvenIndex { - t.Fatalf("grant=%+v characters=%+v costumes=%+v", grant, store.Characters(), store.Costumes()) - } -} diff --git a/go/internal/server/player/commerce_items_test.go b/go/internal/server/player/commerce_items_test.go deleted file mode 100644 index 86ee91e..0000000 --- a/go/internal/server/player/commerce_items_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package player - -import ( - "bd2server/internal/server/stateio" - "testing" -) - -func TestCommerceTimedGrantRenewalAndRetry(t *testing.T) { - inv, err := OpenInventory(stateio.NewMemory(), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - first, err := inv.GrantCommerceOnce("a", []Item{{Type: 19, ID: 38, Count: 1, ExpiryTime: 1000}}) - if err != nil || len(first) != 1 { - t.Fatalf("%+v %v", first, err) - } - second, err := inv.GrantCommerceOnce("b", []Item{{Type: 19, ID: 38, Count: 2, ExpiryTime: 2000}}) - if err != nil || len(second) != 1 || second[0].InvenIndex != first[0].InvenIndex || inv.ContentTicketExpiry(38) != 2000 { - t.Fatalf("renewal %+v %v", second, err) - } - retry, err := inv.GrantCommerceOnce("b", nil) - if err != nil || len(retry) != 1 || len(inv.owned.Items) != 1 { - t.Fatalf("retry %+v %v", retry, err) - } - if _, err = inv.GrantCommerceOnce("bad", []Item{{Type: 19, ID: 38, Count: 1}}); err == nil { - t.Fatal("untimed ticket accepted") - } -} diff --git a/go/internal/server/player/content_open_test.go b/go/internal/server/player/content_open_test.go deleted file mode 100644 index 4a82d29..0000000 --- a/go/internal/server/player/content_open_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package player - -import ( - "bytes" - "errors" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -func contentOpenHarness(t *testing.T, store stateio.Store, prerequisite bool, level uint64) (*ContentOpenService, *Inventory) { - t.Helper() - starter := &Starter{Version: versionconfig.State()} - if prerequisite { - starter.Items = []Item{{InvenIndex: 100, Type: 19, ID: 41, Count: 1}} - } - inventory, err := OpenInventory(store, starter) - if err != nil { - t.Fatal(err) - } - design := &gamedata.ContentOpeningDesign{Prerequisite: gamedata.ContentOpenRule{TicketID: 41, SquadLevel: 3}, Completion: gamedata.ContentOpenRule{TicketID: 42, SquadLevel: 2}} - service, err := NewContentOpenService(design, inventory, store, func() (uint64, error) { return level, nil }) - if err != nil { - t.Fatal(err) - } - service.BeginSession("authenticated-session") - return service, inventory -} - -func contentOpenRequest(seq, kind uint64) []byte { - return wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, kind) -} - -func contentOpenTicketCount(inventory *Inventory, id uint64) uint64 { - var count uint64 - for _, item := range inventory.All() { - if item.Type == 19 && item.ID == id { - count += item.Count - } - } - return count -} - -func assertContentOpenResponse(t *testing.T, body []byte) { - t.Helper() - bundle, present, err := wire.Bytes(body, 1) - if err != nil || !present { - t.Fatalf("missing RewardDBInfoBundle: %x %v", body, err) - } - item, present, err := wire.Bytes(bundle, 1) - if err != nil || !present { - t.Fatalf("missing completion ItemDBInfo: %x %v", bundle, err) - } - for field, want := range map[int]uint64{2: 42, 3: 19, 4: 1} { - got, present, err := wire.Varint(item, field) - if err != nil || !present || got != want { - t.Fatalf("ItemDBInfo field %d=%d, want %d", field, got, want) - } - } -} - -func TestContentOpenTicketOnceAcrossSequencesAndSessions(t *testing.T) { - store := stateio.NewMemory() - service, inventory := contentOpenHarness(t, store, true, 3) - request := contentOpenRequest(1, 1) - code, body, handled, err := service.Handle("/ContentOpen", request) - if err != nil || !handled || code != 622 { - t.Fatalf("open: %d %v %v", code, handled, err) - } - assertContentOpenResponse(t, body) - for _, tc := range []struct { - session string - seq uint64 - }{{"authenticated-session", 1}, {"authenticated-session", 2}, {"another-session", 1}} { - service.BeginSession(tc.session) - _, next, _, err := service.Handle("/ContentOpen", contentOpenRequest(tc.seq, 1)) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(next, body) { - t.Fatal("ticket repair/retry returned a different item") - } - } - if contentOpenTicketCount(inventory, 41) != 1 || contentOpenTicketCount(inventory, 42) != 1 { - t.Fatal("prerequisite consumed or completion duplicated") - } - data, err := store.Load("content_open") - if err != nil || bytes.Contains(data, []byte("authenticated-session")) || bytes.Contains(data, []byte("another-session")) { - t.Fatal("receipt contains session credentials") - } - service.BeginSession("authenticated-session") - changed := wire.AppendVarint(append([]byte(nil), request...), 3, 7) - if _, _, _, err := service.Handle("/ContentOpen", changed); err == nil { - t.Fatal("same sequence accepted different request") - } -} - -func TestContentOpenRequiresSessionAndRepairsOwnedTicket(t *testing.T) { - store := stateio.NewMemory() - service, inventory := contentOpenHarness(t, store, false, 0) - service.BeginSession("") - if _, _, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)); err == nil { - t.Fatal("missing authenticated session accepted") - } - if _, err := inventory.GrantOnce("existing-completion", []gamedata.BattleReward{{ID: 42, Type: 19, Count: 1}}); err != nil { - t.Fatal(err) - } - service.BeginSession("repair-session") - _, body, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)) - if err != nil { - t.Fatal(err) - } - assertContentOpenResponse(t, body) - if contentOpenTicketCount(inventory, 42) != 1 { - t.Fatal("repair duplicated owned completion") - } -} - -func TestContentOpenRejectsCorruptReceiptAtOpenAndAfterConstruction(t *testing.T) { - store := stateio.NewMemory() - service, inventory := contentOpenHarness(t, store, true, 3) - if err := store.Save("content_open", []byte(`{"receipts":{"credentials:1":{"digest":"bad","body":"CgA="}}}`)); err != nil { - t.Fatal(err) - } - if _, _, _, err := service.Handle("/ContentOpen", contentOpenRequest(1, 1)); err == nil { - t.Fatal("corrupt reloaded receipt accepted") - } - if contentOpenTicketCount(inventory, 42) != 0 { - t.Fatal("corrupt receipts granted completion") - } - if _, err := NewContentOpenService(service.design, inventory, store, service.squadLevel); err == nil { - t.Fatal("corrupt receipt accepted during open") - } -} - -func TestContentOpenRejectsUnqualifiedAndMalformedRequests(t *testing.T) { - for _, tc := range []struct { - name string - prerequisite bool - level uint64 - request []byte - }{ - {"missing-prerequisite", false, 3, contentOpenRequest(1, 1)}, - {"level", true, 2, contentOpenRequest(1, 1)}, - {"unknown", true, 3, contentOpenRequest(1, 2)}, - {"zero-type", true, 3, contentOpenRequest(1, 0)}, - {"group-is-not-type", true, 3, contentOpenRequest(1, 23)}, - {"zero-sequence", true, 3, contentOpenRequest(0, 1)}, - {"wrong-wire", true, 3, wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, []byte{1})}, - {"truncated-trailing-wire", true, 3, append(contentOpenRequest(1, 1), 0x1a, 0x02, 0x01)}, - {"duplicate-type", true, 3, wire.AppendVarint(contentOpenRequest(1, 1), 2, 1)}, - } { - t.Run(tc.name, func(t *testing.T) { - store := stateio.NewMemory() - service, inventory := contentOpenHarness(t, store, tc.prerequisite, tc.level) - _, _, handled, err := service.Handle("/ContentOpen", tc.request) - if err == nil || !handled { - t.Fatal("invalid request accepted") - } - if contentOpenTicketCount(inventory, 42) != 0 { - t.Fatal("rejected request granted ticket") - } - state, err := store.Load("content_open") - if err != nil || state != nil { - t.Fatal("rejected request persisted receipt") - } - }) - } -} - -type contentOpenFailStore struct { - stateio.AtomicEntryStore - fail bool -} - -func (s *contentOpenFailStore) Save(name string, payload []byte) error { - if name == "content_open" && s.fail { - return errors.New("injected receipt write failure") - } - return s.AtomicEntryStore.Save(name, payload) -} - -func TestContentOpenSQLiteReceiptRestartAndRollback(t *testing.T) { - for _, fail := range []bool{false, true} { - t.Run(map[bool]string{false: "durable-exact-retry", true: "failed-receipt-rolls-back-ticket"}[fail], func(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store := &contentOpenFailStore{AtomicEntryStore: repo, fail: fail} - service, _ := contentOpenHarness(t, store, true, 3) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - request := contentOpenRequest(1, 1) - _, body, _, err := service.Handle("/ContentOpen", request) - if fail { - if err == nil { - t.Fatal("receipt failure ignored") - } - if err := op.Rollback(); err == nil { - t.Fatal("dirty rollback did not request account recovery") - } - } else { - if err != nil { - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - service, inventory := contentOpenHarness(t, repo, true, 3) - if fail { - if contentOpenTicketCount(inventory, 42) != 0 { - t.Fatal("uncommitted completion survived restart") - } - data, err := repo.Load("content_open") - if err != nil || data != nil { - t.Fatal("failed receipt survived restart") - } - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, replay, _, err := service.Handle("/ContentOpen", request) - if err != nil { - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - if !fail && !bytes.Equal(body, replay) { - t.Fatal("exact retry changed after restart") - } - assertContentOpenResponse(t, replay) - if contentOpenTicketCount(inventory, 42) != 1 || contentOpenTicketCount(inventory, 41) != 1 { - t.Fatal("ticket ownership not preserved exactly once") - } - }) - } -} diff --git a/go/internal/server/player/costume_burst.go b/go/internal/server/player/costume_burst.go deleted file mode 100644 index d3b38c3..0000000 --- a/go/internal/server/player/costume_burst.go +++ /dev/null @@ -1,206 +0,0 @@ -package player - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "math" - "strconv" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -const costumeBurstPacketCode = 578 - -type costumeBurstReply struct { - Digest string - Code int - Body []byte -} - -// CostumeBurstService applies one adjacent costume-burst level from the -// authoritative GameData row. The account request transaction makes the -// wallet, inventory, and collection writes one atomic SQLite operation. -type CostumeBurstService struct { - mu sync.Mutex - design *gamedata.CostumeBurstDesign - collection *CollectionStore - inventory *Inventory - wallet *Wallet - sessionID string - replies map[string]map[uint64]costumeBurstReply -} - -func NewCostumeBurstService(design *gamedata.CostumeBurstDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*CostumeBurstService, error) { - if design == nil || collection == nil || inventory == nil || wallet == nil { - return nil, errors.New("player: incomplete costume burst service") - } - return &CostumeBurstService{ - design: design, collection: collection, inventory: inventory, wallet: wallet, - replies: make(map[string]map[uint64]costumeBurstReply), - }, nil -} - -// BeginSession scopes protobuf sequence replay without forgetting older live -// sessions. Session dispatch calls it before each authenticated request. -func (s *CostumeBurstService) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - if id == "" || id == s.sessionID { - return - } - s.sessionID = id - if s.replies[id] != nil { - return - } - if len(s.replies) >= 1024 { - for oldID := range s.replies { - if oldID != id { - delete(s.replies, oldID) - break - } - } - } - s.replies[id] = make(map[uint64]costumeBurstReply) -} - -func (s *CostumeBurstService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/CostumeBurst" { - return 0, nil, false, nil - } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > math.MaxInt32 { - return 0, nil, true, errors.New("player: CostumeBurst missing sequence") - } - costumeID, found, err := wire.Varint(request, 2) - if err != nil || !found || costumeID == 0 || costumeID > math.MaxInt32 { - return 0, nil, true, errors.New("player: CostumeBurst missing costume") - } - materials, err := equipmentRequestItems(request, 3, "CostumeBurst") - if err != nil { - return 0, nil, true, err - } - for _, material := range materials { - if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 || - material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 || - material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 { - return 0, nil, true, errors.New("player: CostumeBurst material exceeds protocol range") - } - } - - digestBytes := sha256.Sum256(request) - digest := hex.EncodeToString(digestBytes[:]) - s.mu.Lock() - defer s.mu.Unlock() - sessionID := s.sessionID - if sessionID == "" { - sessionID = "__direct__" - } - if s.replies[sessionID] == nil { - s.replies[sessionID] = make(map[uint64]costumeBurstReply) - } - if reply, ok := s.replies[sessionID][seq]; ok { - if reply.Digest != digest { - return 0, nil, true, errors.New("player: CostumeBurst sequence reused with different request") - } - return reply.Code, append([]byte(nil), reply.Body...), true, nil - } - - costume, found := s.collection.CostumeByID(costumeID) - if !found { - return 0, nil, true, fmt.Errorf("player: CostumeBurst costume %d is not owned", costumeID) - } - if costume.BurstLevel != 0 { - if applied, ok := s.collection.CostumeBurstReplay(costume.InvenIndex, costume.BurstLevel); ok && applied.Digest == digest { - reply := costumeBurstReply{Digest: digest, Code: applied.Code, Body: append([]byte(nil), applied.Body...)} - s.replies[sessionID][seq] = reply - return reply.Code, append([]byte(nil), reply.Body...), true, nil - } - } - rule, err := s.design.UpgradeRule(costume.ID, costume.BurstLevel) - if err != nil { - return 0, nil, true, fmt.Errorf("player: resolve CostumeBurst rule: %w", err) - } - items, gold, err := validateCostumeBurstMaterials(rule.Costs, materials) - if err != nil { - return 0, nil, true, err - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return 0, nil, true, errors.New("player: insufficient gold for CostumeBurst") - } - if len(items) != 0 { - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: validate CostumeBurst items: %w", err) - } - } - - identity := "costume-burst:" + strconv.FormatUint(costume.InvenIndex, 10) + ":" + strconv.FormatUint(rule.NextLevel, 10) - if gold != 0 { - if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { - return 0, nil, true, fmt.Errorf("player: consume CostumeBurst gold: %w", err) - } - } - if len(items) != 0 { - if err := s.inventory.Consume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: consume CostumeBurst items: %w", err) - } - } - body := wire.AppendVarint(nil, 1, rule.NextLevel) - record := CostumeBurstUpgradeRecord{CostumeID: costume.ID, Level: rule.NextLevel, Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)} - if err := s.collection.ApplyCostumeBurst(costume.InvenIndex, costume.BurstLevel, rule.NextLevel, record); err != nil { - return 0, nil, true, fmt.Errorf("player: persist CostumeBurst: %w", err) - } - reply := costumeBurstReply{Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)} - s.replies[sessionID][seq] = reply - return reply.Code, append([]byte(nil), reply.Body...), true, nil -} - -func validateCostumeBurstMaterials(costs []gamedata.PromotionCost, materials []Item) ([]Item, uint64, error) { - if len(costs) == 0 || len(materials) == 0 { - return nil, 0, errors.New("player: CostumeBurst has no material") - } - want := make(map[[2]uint64]uint64, len(costs)) - for _, cost := range costs { - if cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type == 8 && cost.ID == 0) || (cost.Type != 4 && cost.Type != 8) { - return nil, 0, errors.New("player: invalid CostumeBurst GameData cost") - } - key := [2]uint64{cost.Type, cost.ID} - if want[key] > math.MaxUint64-cost.Count { - return nil, 0, errors.New("player: CostumeBurst GameData cost overflow") - } - want[key] += cost.Count - } - got := make(map[[2]uint64]uint64, len(materials)) - items := make([]Item, 0, len(materials)) - var gold uint64 - for _, material := range materials { - if material.Type != 4 && material.Type != 8 { - return nil, 0, fmt.Errorf("player: unsupported CostumeBurst material type %d", material.Type) - } - key := [2]uint64{material.Type, material.ID} - if got[key] > math.MaxUint64-material.Count { - return nil, 0, errors.New("player: CostumeBurst submitted material overflow") - } - got[key] += material.Count - if material.Type == 4 { - if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { - return nil, 0, errors.New("player: invalid CostumeBurst currency") - } - gold = material.Count - } else { - items = append(items, material) - } - } - if len(got) != len(want) { - return nil, 0, errors.New("player: CostumeBurst material kinds mismatch") - } - for key, count := range want { - if got[key] != count { - return nil, 0, fmt.Errorf("player: CostumeBurst material %d/%d=%d want %d", key[0], key[1], got[key], count) - } - } - return items, gold, nil -} diff --git a/go/internal/server/player/costume_burst_collection_test.go b/go/internal/server/player/costume_burst_collection_test.go deleted file mode 100644 index d498889..0000000 --- a/go/internal/server/player/costume_burst_collection_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package player - -import ( - "encoding/json" - "strings" - "testing" - - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func testCostumeBurstRecord(costumeID, level uint64) CostumeBurstUpgradeRecord { - return CostumeBurstUpgradeRecord{ - CostumeID: costumeID, - Level: level, - Digest: strings.Repeat("ab", 32), - Code: 578, - Body: wire.AppendVarint(nil, 1, level), - } -} - -func TestCollectionCostumeBurstPersistsOverlayAndReplayForEveryCostumeSource(t *testing.T) { - storage := &collectionWriteSpy{Memory: stateio.NewMemory()} - base := []Costume{{InvenIndex: 11, ID: 101, BurstLevel: 0}} - collection, err := OpenCollectionStore(storage, base) - if err != nil { - t.Fatal(err) - } - if err := collection.EnsurePersisted(); err != nil { - t.Fatal(err) - } - next := cloneCollection(collection.data) - next.Costumes = []Costume{{InvenIndex: 22, ID: 202}} - if err := collection.commit(next); err != nil { - t.Fatal(err) - } - - baseRecord := testCostumeBurstRecord(101, 1) - before := len(storage.mutations) - if err := collection.ApplyCostumeBurst(11, 0, 1, baseRecord); err != nil { - t.Fatal(err) - } - if len(storage.mutations) != before+1 || len(storage.mutations[before]) != 2 { - t.Fatalf("burst level and replay were not one collection write: %+v", storage.mutations[before:]) - } - if storage.mutations[before][0].Bucket != "costume_burst_levels" || storage.mutations[before][1].Bucket != "costume_burst_upgrades" { - t.Fatalf("unexpected burst mutations: %+v", storage.mutations[before]) - } - if err := collection.ApplyCostumeBurst(22, 0, 1, testCostumeBurstRecord(202, 1)); err != nil { - t.Fatal(err) - } - if err := collection.ApplyCostumeBurst(22, 1, 2, testCostumeBurstRecord(202, 2)); err != nil { - t.Fatal(err) - } - - if got, found := collection.CostumeByID(101); !found || got.InvenIndex != 11 || got.BurstLevel != 1 { - t.Fatalf("base costume overlay=%+v found=%v", got, found) - } - if got, found := collection.CostumeByIndex(22); !found || got.ID != 202 || got.BurstLevel != 2 { - t.Fatalf("collection costume overlay=%+v found=%v", got, found) - } - replay, found := collection.CostumeBurstReplay(22, 2) - if !found || replay.CostumeID != 202 || replay.Level != 2 || replay.Code != 578 { - t.Fatalf("replay=%+v found=%v", replay, found) - } - replay.Body[0] = 0 - if again, _ := collection.CostumeBurstReplay(22, 2); len(again.Body) == 0 || again.Body[0] == 0 { - t.Fatal("CostumeBurstReplay returned an aliased response body") - } - - core, err := storage.Load("collection") - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(core), "costume_burst") { - t.Fatalf("burst entries leaked into collection core: %s", core) - } - restarted, err := OpenCollectionStore(storage, base) - if err != nil { - t.Fatal(err) - } - if got, _ := restarted.CostumeByIndex(11); got.BurstLevel != 1 { - t.Fatalf("restarted base burst level=%d", got.BurstLevel) - } - if got, _ := restarted.CostumeByID(202); got.BurstLevel != 2 { - t.Fatalf("restarted collection burst level=%d", got.BurstLevel) - } - if got, ok := restarted.CostumeBurstReplay(22, 1); !ok || got.Level != 1 { - t.Fatalf("restarted first replay=%+v found=%v", got, ok) - } -} - -func TestCollectionCostumeBurstCASAndRecordValidation(t *testing.T) { - collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}}) - if err != nil { - t.Fatal(err) - } - record := testCostumeBurstRecord(101, 1) - if err := collection.ApplyCostumeBurst(11, 0, 1, record); err != nil { - t.Fatal(err) - } - for name, call := range map[string]func() error{ - "stale current": func() error { return collection.ApplyCostumeBurst(11, 0, 1, record) }, - "skipped level": func() error { return collection.ApplyCostumeBurst(11, 1, 3, testCostumeBurstRecord(101, 3)) }, - "wrong costume": func() error { return collection.ApplyCostumeBurst(11, 1, 2, testCostumeBurstRecord(102, 2)) }, - "unknown instance": func() error { return collection.ApplyCostumeBurst(99, 0, 1, record) }, - } { - t.Run(name, func(t *testing.T) { - if err := call(); err == nil { - t.Fatal("invalid costume burst transition was accepted") - } - }) - } - if got, _ := collection.CostumeByIndex(11); got.BurstLevel != 1 { - t.Fatalf("rejected transitions mutated level to %d", got.BurstLevel) - } - if _, found := collection.CostumeBurstReplay(11, 2); found { - t.Fatal("rejected transition wrote a replay") - } -} - -func TestCollectionCostumeByIDRejectsAmbiguousOwnership(t *testing.T) { - collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}, {InvenIndex: 12, ID: 101}}) - if err != nil { - t.Fatal(err) - } - if costume, found := collection.CostumeByID(101); found || costume.InvenIndex != 0 || costume.ID != 0 { - t.Fatalf("ambiguous logical ID resolved to %+v", costume) - } -} - -func TestCollectionRejectsMalformedCostumeBurstEntries(t *testing.T) { - validRecord, err := json.Marshal(testCostumeBurstRecord(101, 1)) - if err != nil { - t.Fatal(err) - } - tests := []struct { - name string - bucket string - key string - payload []byte - seed Costume - prepare func(*stateio.Memory) - }{ - {name: "noncanonical level key", bucket: "costume_burst_levels", key: "011", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}}, - {name: "orphan level", bucket: "costume_burst_levels", key: "99", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}}, - {name: "zero level", bucket: "costume_burst_levels", key: "11", payload: []byte("0"), seed: Costume{InvenIndex: 11, ID: 101}}, - {name: "level decreases seed", bucket: "costume_burst_levels", key: "11", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101, BurstLevel: 2}}, - {name: "noncanonical replay key", bucket: "costume_burst_upgrades", key: "011:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }}, - {name: "replay above current", bucket: "costume_burst_upgrades", key: "11:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}}, - {name: "invalid replay digest", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte { - r := testCostumeBurstRecord(101, 1) - r.Digest = strings.Repeat("zz", 32) - b, _ := json.Marshal(r) - return b - }(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }}, - {name: "mismatched response", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte { - r := testCostumeBurstRecord(101, 1) - r.Body = wire.AppendVarint(nil, 1, 2) - b, _ := json.Marshal(r) - return b - }(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - storage := stateio.NewMemory() - collection, err := OpenCollectionStore(storage, []Costume{test.seed}) - if err != nil { - t.Fatal(err) - } - if err := collection.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if test.prepare != nil { - test.prepare(storage) - } - if err := storage.PutEntry("collection", test.bucket, test.key, test.payload); err != nil { - t.Fatal(err) - } - if _, err := OpenCollectionStore(storage, []Costume{test.seed}); err == nil { - t.Fatal("malformed costume burst storage was accepted") - } - }) - } -} diff --git a/go/internal/server/player/costume_burst_test.go b/go/internal/server/player/costume_burst_test.go deleted file mode 100644 index 263709e..0000000 --- a/go/internal/server/player/costume_burst_test.go +++ /dev/null @@ -1,260 +0,0 @@ -package player - -import ( - "math" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func testCostumeBurstDesign() *gamedata.CostumeBurstDesign { - return &gamedata.CostumeBurstDesign{Levels: map[uint64]map[uint64]gamedata.CostumeBurstLevel{ - 4202: { - 1: {CostumeID: 4202, Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}, {Type: 8, ID: 710, Count: 2}}}, - 2: {CostumeID: 4202, Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}, {Type: 8, ID: 710, Count: 2}}}, - }, - }} -} - -func costumeBurstRequest(seq, costumeID uint64, materials ...Item) []byte { - request := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, costumeID) - for _, material := range materials { - request = wire.AppendBytes(request, 3, ItemWire(material)) - } - return request -} - -func newCostumeBurstFixture(t *testing.T, storePath string, stacks []gamedata.BattleReward) (*CostumeBurstService, *CollectionStore, *Inventory, *Wallet, []Item) { - t.Helper() - store := testStore(storePath) - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(store, starter) - if err != nil { - t.Fatal(err) - } - items, err := inventory.GrantOnce("costume-burst-materials", stacks) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}}) - if err != nil { - t.Fatal(err) - } - service, err := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet) - if err != nil { - t.Fatal(err) - } - return service, collection, inventory, wallet, items -} - -func TestCostumeBurstConsumesExactSplitCostsPersistsAndReplays(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.json") - service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, path, []gamedata.BattleReward{ - {Type: 8, ID: 710, Count: 1}, - {Type: 8, ID: 710, Count: 1}, - }) - service.BeginSession("login-a") - request := costumeBurstRequest(7, 4202, stacks[1], Item{Type: 4, Count: 100}, stacks[0]) - code, body, handled, err := service.Handle("/CostumeBurst", request) - if err != nil || !handled || code != costumeBurstPacketCode { - t.Fatalf("CostumeBurst code=%d handled=%v body=%x err=%v", code, handled, body, err) - } - level, found, err := wire.Varint(body, 1) - if err != nil || !found || level != 1 { - t.Fatalf("response level=%d found=%v err=%v body=%x", level, found, err, body) - } - got, found := collection.CostumeByID(4202) - if !found || got.InvenIndex != 88 || got.DesignID != 9911 || got.BurstLevel != 1 { - t.Fatalf("upgraded costume=%+v found=%v", got, found) - } - if wallet.Snapshot().Gold != 900 { - t.Fatalf("gold=%d", wallet.Snapshot().Gold) - } - for _, stack := range stacks { - if err := inventory.CanConsume([]Item{stack}); err == nil { - t.Fatalf("material stack %d survived", stack.InvenIndex) - } - } - - code2, body2, handled, err := service.Handle("/CostumeBurst", request) - if err != nil || !handled || code2 != code || string(body2) != string(body) { - t.Fatalf("same-session replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err) - } - if wallet.Snapshot().Gold != 900 || collection.Costumes()[0].BurstLevel != 1 { - t.Fatal("same-session replay mutated state") - } - - store := testStore(path) - reloadedInventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - reloadedWallet, err := OpenWallet(store, Currency{}) - if err != nil { - t.Fatal(err) - } - reloadedCollection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}}) - if err != nil { - t.Fatal(err) - } - restarted, err := NewCostumeBurstService(testCostumeBurstDesign(), reloadedCollection, reloadedInventory, reloadedWallet) - if err != nil { - t.Fatal(err) - } - restarted.BeginSession("after-restart") - code2, body2, handled, err = restarted.Handle("/CostumeBurst", request) - if err != nil || !handled || code2 != code || string(body2) != string(body) { - t.Fatalf("durable replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err) - } - if reloadedWallet.Snapshot().Gold != 900 || reloadedCollection.Costumes()[0].BurstLevel != 1 { - t.Fatal("durable replay charged or advanced again") - } -} - -func TestCostumeBurstRequiresLogicalCostumeIDAndProtectsSessionSequence(t *testing.T) { - service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: 4}}) - material := stacks[0] - material.Count = 2 - for _, wrong := range []uint64{88, 9911} { - request := costumeBurstRequest(wrong, wrong, material, Item{Type: 4, Count: 100}) - if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled { - t.Fatalf("non-logical costume ID %d accepted: handled=%v err=%v", wrong, handled, err) - } - } - service.BeginSession("login-a") - request := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 100}) - if _, _, _, err := service.Handle("/CostumeBurst", request); err != nil { - t.Fatal(err) - } - different := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 200}) - if _, _, handled, err := service.Handle("/CostumeBurst", different); err == nil || !handled { - t.Fatalf("sequence reuse accepted: handled=%v err=%v", handled, err) - } - if collection.Costumes()[0].BurstLevel != 1 || wallet.Snapshot().Gold != 900 { - t.Fatal("rejected sequence reuse mutated state") - } - remaining := material - remaining.Count = 2 - if err := inventory.CanConsume([]Item{remaining}); err != nil { - t.Fatalf("rejected request consumed remaining items: %v", err) - } -} - -func TestCostumeBurstRejectsInvalidMaterialsAndProtocolRanges(t *testing.T) { - tests := []struct { - name string - materials func(Item) []Item - }{ - {"short", func(stack Item) []Item { stack.Count = 1; return []Item{stack, {Type: 4, Count: 100}} }}, - {"extra", func(stack Item) []Item { - stack.Count = 2 - return []Item{stack, {Type: 4, Count: 100}, {InvenIndex: 900, Type: 8, ID: 711, Count: 1}} - }}, - {"wrong-gold", func(stack Item) []Item { stack.Count = 2; return []Item{stack, {Type: 4, Count: 99}} }}, - {"duplicate-overdraw", func(stack Item) []Item { stack.Count = 1; return []Item{stack, stack, {Type: 4, Count: 100}} }}, - {"range", func(stack Item) []Item { - stack.Count = 2 - stack.ExpiryTime = math.MaxInt64 + 1 - return []Item{stack, {Type: 4, Count: 100}} - }}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - stackCount := uint64(2) - if test.name == "duplicate-overdraw" { - stackCount = 1 - } - service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: stackCount}}) - request := costumeBurstRequest(1, 4202, test.materials(stacks[0])...) - if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled { - t.Fatalf("invalid request accepted: handled=%v err=%v", handled, err) - } - if collection.Costumes()[0].BurstLevel != 0 || wallet.Snapshot().Gold != 1000 { - t.Fatal("invalid request mutated level or wallet") - } - if err := inventory.CanConsume([]Item{stacks[0]}); err != nil { - t.Fatalf("invalid request consumed material: %v", err) - } - }) - } -} - -func TestCostumeBurstSQLiteRollbackIsAtomic(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repository, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(repository, starter) - if err != nil { - t.Fatal(err) - } - stacks, err := inventory.GrantOnce("rollback-material", []gamedata.BattleReward{{Type: 8, ID: 710, Count: 2}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(repository, Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - collection, err := OpenCollectionStore(repository, []Costume{{InvenIndex: 88, ID: 4202}}) - if err != nil { - t.Fatal(err) - } - for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted} { - if err := ensure(); err != nil { - t.Fatal(err) - } - } - service, _ := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet) - request := costumeBurstRequest(1, 4202, stacks[0], Item{Type: 4, Count: 100}) - operation, err := repository.BeginOperation() - if err != nil { - t.Fatal(err) - } - if code, _, handled, err := service.Handle("/CostumeBurst", request); err != nil || !handled || code != costumeBurstPacketCode { - t.Fatalf("transactional request code=%d handled=%v err=%v", code, handled, err) - } - if err := operation.Rollback(); err == nil { - t.Fatal("dirty request rollback did not fence published in-memory state") - } - if err := repository.Close(); err != nil { - t.Fatal(err) - } - - reopened, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := reopened.Close(); err != nil { - t.Error(err) - } - }() - reloadedInventory, err := OpenInventory(reopened, starter) - if err != nil { - t.Fatal(err) - } - reloadedWallet, err := OpenWallet(reopened, Currency{}) - if err != nil { - t.Fatal(err) - } - reloadedCollection, err := OpenCollectionStore(reopened, []Costume{{InvenIndex: 88, ID: 4202}}) - if err != nil { - t.Fatal(err) - } - if reloadedWallet.Snapshot().Gold != 1000 || reloadedCollection.Costumes()[0].BurstLevel != 0 { - t.Fatalf("rolled back state gold=%d costume=%+v", reloadedWallet.Snapshot().Gold, reloadedCollection.Costumes()[0]) - } - if err := reloadedInventory.CanConsume([]Item{stacks[0]}); err != nil { - t.Fatalf("rolled back inventory did not restore materials: %v", err) - } -} diff --git a/go/internal/server/player/costume_potential.go b/go/internal/server/player/costume_potential.go deleted file mode 100644 index 91121d4..0000000 --- a/go/internal/server/player/costume_potential.go +++ /dev/null @@ -1,248 +0,0 @@ -package player - -import ( - "encoding/binary" - "errors" - "fmt" - "math" - "slices" - "strconv" - "strings" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type CostumePotentialService struct { - design *gamedata.CostumePotentialDesign - collection *CollectionStore - characters *CharacterStore - inventory *Inventory - wallet *Wallet - connectStore stateio.Store - connectSession string - connectMu sync.Mutex -} - -func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CostumePotentialService, error) { - if design == nil || collection == nil || characters == nil || inventory == nil || wallet == nil { - return nil, errors.New("player: incomplete costume potential service") - } - return &CostumePotentialService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil -} - -// Contributions follows CharStatInfo.GetCostumeNodeBuffStat: type 2 nodes -// contribute across every owned costume of the same UniqueCharId, while type -// 1 nodes contribute only from CharDBInfo.ConnectPotentialCostume. Values are -// already fractions for the percent options 2, 4 and 6; they are not equipment -// option percentages and must not be divided by 100 or rounded individually. -func (s *CostumePotentialService) Contributions(character Character) ([]gamedata.StatContribution, error) { - if s == nil || s.design == nil || s.collection == nil { - return nil, errors.New("player: missing costume potential contribution source") - } - unique, ok := s.design.CharacterUnique[character.ID] - if !ok || unique == 0 { - return nil, fmt.Errorf("player: unknown costume potential character %d", character.ID) - } - var result []gamedata.StatContribution - connectedFound := character.ConnectPotentialCostume == 0 - for _, costume := range s.collection.Costumes() { - if s.design.CostumeUnique[costume.ID] != unique { - continue - } - if costume.ID == character.ConnectPotentialCostume { - connectedFound = true - } - seen := map[uint64]bool{} - for _, id := range costume.PotentialIDs { - if seen[id] { - return nil, errors.New("player: duplicate active costume potential stat node") - } - seen[id] = true - node, found := s.design.Nodes[costume.ID][id] - if !found { - return nil, fmt.Errorf("player: unknown active potential node %d/%d", costume.ID, id) - } - if node.NodeType != 2 && (node.NodeType != 1 || costume.ID != character.ConnectPotentialCostume) { - continue - } - if math.IsNaN(node.StatValue) || math.IsInf(node.StatValue, 0) || node.StatValue < 0 { - return nil, errors.New("player: invalid costume potential stat value") - } - contribution := gamedata.StatContribution{Option: node.StatType} - switch node.StatType { - case 1, 2: - contribution.Stat = gamedata.StatHealth - case 3, 4: - contribution.Stat = gamedata.StatAttack - case 5, 6: - contribution.Stat = gamedata.StatMagic - case 7: - contribution.Stat = gamedata.StatDefencePercent - case 8: - contribution.Stat = gamedata.StatMagicResistancePercent - case 9: - contribution.Stat = gamedata.StatCriticalChance - case 10: - contribution.Stat = gamedata.StatCriticalDamage - case 11, 12, 13, 14, 15, 19: - contribution.Stat = gamedata.StatElementDamage - case 16, 17, 18, 20: - contribution.Stat = gamedata.StatElementResistance - default: - return nil, fmt.Errorf("player: unsupported costume potential stat option %d", node.StatType) - } - if node.StatType == 2 || node.StatType == 4 || node.StatType == 6 { - contribution.Percent = node.StatValue - } else { - contribution.Flat = node.StatValue - } - result = append(result, contribution) - } - } - if !connectedFound { - return nil, errors.New("player: connected potential costume is not owned by this character") - } - return result, nil -} - -func (s *CostumePotentialService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path == "/CostumePotentialConnect" { - return s.connect(request) - } - if path != "/CostumeNodeActivation" { - return 0, nil, false, nil - } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 0, nil, true, errors.New("player: CostumeNodeActivation missing sequence") - } - characterIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: CostumeNodeActivation missing character") - } - costumeIndex, found, err := wire.Varint(request, 3) - if err != nil || !found || costumeIndex == 0 { - return 0, nil, true, errors.New("player: CostumeNodeActivation missing costume") - } - var nodes []uint64 - var materials []Item - err = wire.Walk(request, func(field wire.Field) error { - switch field.Number { - case 4: - if field.Type != 0 && field.Type != 2 { - return errors.New("player: invalid potential node field") - } - for data := field.Value; len(data) != 0; { - id, count := binary.Uvarint(data) - if count <= 0 || id == 0 { - return errors.New("player: invalid potential node ID") - } - nodes = append(nodes, id) - data = data[count:] - } - case 5: - if field.Type != 2 { - return errors.New("player: invalid potential material field") - } - var item Item - if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { - return err - } - if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) { - return errors.New("player: incomplete potential material") - } - materials = append(materials, item) - } - return nil - }) - if err != nil { - return 0, nil, true, err - } - if len(nodes) == 0 || len(materials) == 0 { - return 0, nil, true, errors.New("player: CostumeNodeActivation has no nodes or materials") - } - character, found := s.characters.Find(characterIndex) - if !found { - return 0, nil, true, fmt.Errorf("player: unknown potential character %d", characterIndex) - } - costume, found := s.collection.CostumeByIndex(costumeIndex) - if !found || costume.UseChar != characterIndex { - return 0, nil, true, fmt.Errorf("player: potential costume %d is not owned by character %d", costumeIndex, characterIndex) - } - if err := s.collection.ValidateCostumePotentialActivation(costumeIndex, nodes); err != nil { - return 0, nil, true, err - } - costs, err := s.design.Validate(costume.ID, character.ID, 0, costume.PotentialIDs, nodes) - if err != nil { - return 0, nil, true, fmt.Errorf("player: validate costume potential GameData: %w", err) - } - want := make(map[[2]uint64]uint64) - for _, cost := range costs { - key := [2]uint64{cost.Type, cost.ID} - if cost.Count > ^uint64(0)-want[key] { - return 0, nil, true, errors.New("player: costume potential cost overflow") - } - want[key] += cost.Count - } - got := make(map[[2]uint64]uint64) - var items []Item - var gold uint64 - for _, material := range materials { - key := [2]uint64{material.Type, material.ID} - if material.Type == 4 { - if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { - return 0, nil, true, errors.New("player: invalid costume potential currency") - } - gold = material.Count - } else { - if material.Type != 8 { - return 0, nil, true, fmt.Errorf("player: unsupported costume potential material type %d", material.Type) - } - items = append(items, material) - } - if material.Count > ^uint64(0)-got[key] { - return 0, nil, true, errors.New("player: submitted costume potential material overflow") - } - got[key] += material.Count - } - if len(got) != len(want) { - return 0, nil, true, fmt.Errorf("player: costume potential material kinds mismatch: request=%v GameData=%v", got, want) - } - for key, count := range want { - if got[key] != count { - return 0, nil, true, fmt.Errorf("player: costume potential material %d/%d=%d want %d", key[0], key[1], got[key], count) - } - } - if len(items) != 0 { - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: validate costume potential items: %w", err) - } - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return 0, nil, true, errors.New("player: insufficient gold for costume potential") - } - sortedNodes := append([]uint64(nil), nodes...) - slices.Sort(sortedNodes) - parts := make([]string, len(sortedNodes)) - for i, id := range sortedNodes { - parts[i] = strconv.FormatUint(id, 10) - } - identity := "costume-potential:" + strconv.FormatUint(costumeIndex, 10) + ":" + strings.Join(parts, ",") - if gold != 0 { - if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { - return 0, nil, true, fmt.Errorf("player: spend costume potential gold: %w", err) - } - } - if len(items) != 0 { - if err := s.inventory.Consume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: consume costume potential items: %w", err) - } - } - if err := s.collection.ActivateCostumePotential(costumeIndex, nodes); err != nil { - return 0, nil, true, fmt.Errorf("player: persist costume potential: %w", err) - } - return 261, nil, true, nil -} diff --git a/go/internal/server/player/costume_potential_connect.go b/go/internal/server/player/costume_potential_connect.go deleted file mode 100644 index e214666..0000000 --- a/go/internal/server/player/costume_potential_connect.go +++ /dev/null @@ -1,206 +0,0 @@ -package player - -import ( - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "math" -) - -type potentialConnectReceipt struct { - Digest string - Body []byte -} - -func (s *CostumePotentialService) AttachConnectStore(store stateio.Store) error { - if store == nil { - return fmt.Errorf("player: missing potential connect store") - } - s.connectStore = store - return nil -} -func (s *CostumePotentialService) BeginSession(id string) { - s.connectMu.Lock() - defer s.connectMu.Unlock() - s.connectSession = id -} -func (s *CostumePotentialService) connect(request []byte) (int, []byte, bool, error) { - s.connectMu.Lock() - defer s.connectMu.Unlock() - fail := func(e error) (int, []byte, bool, error) { return 267, nil, true, e } - seq, ok, err := wire.Varint(request, 1) - if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.connectStore == nil || s.connectSession == "" { - return fail(fmt.Errorf("player: invalid potential connection session/sequence")) - } - var receipts map[string]potentialConnectReceipt - b, err := s.connectStore.Load("potentialconnect") - if err != nil { - return fail(err) - } - if b != nil { - if err = json.Unmarshal(b, &receipts); err != nil || receipts == nil { - return fail(fmt.Errorf("player: invalid potential connection receipts")) - } - } else { - receipts = map[string]potentialConnectReceipt{} - } - for _, r := range receipts { - hash, e := hex.DecodeString(r.Digest) - if e != nil || len(hash) != sha256.Size { - return fail(fmt.Errorf("player: malformed potential connection receipt digest")) - } - if e = wire.Walk(r.Body, func(wire.Field) error { return nil }); e != nil { - return fail(e) - } - } - seqFields := 0 - if err = wire.Walk(request, func(f wire.Field) error { - if f.Number == 1 { - seqFields++ - if f.Type != 0 { - return fmt.Errorf("player: invalid potential sequence wire") - } - } - return nil - }); err != nil { - return fail(err) - } - if seqFields != 1 { - return fail(fmt.Errorf("player: duplicate potential sequence")) - } - key := fmt.Sprintf("%s:%d", s.connectSession, seq) - digest := fmt.Sprintf("%x", sha256.Sum256(request)) - if prior, ok := receipts[key]; ok { - if prior.Digest != digest { - return fail(fmt.Errorf("player: changed potential connection replay")) - } - return 267, prior.Body, true, nil - } - var characters []Character - seen := map[uint64]bool{} - oldHP := map[uint64]uint64{} - err = wire.Walk(request, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 2 || len(characters) >= 4096 { - return fmt.Errorf("player: invalid potential connection list") - } - fields := map[int]bool{} - if e := wire.Walk(f.Value, func(field wire.Field) error { - if field.Number == 1 || field.Number == 2 { - if field.Type != 0 || fields[field.Number] { - return fmt.Errorf("player: duplicate or invalid connection scalar") - } - fields[field.Number] = true - } - return nil - }); e != nil { - return e - } - index, ok, e := wire.Varint(f.Value, 1) - if e != nil || !ok || index == 0 || index > math.MaxInt64 || seen[index] { - return fmt.Errorf("player: duplicate or invalid potential character") - } - costume, _, e := wire.Varint(f.Value, 2) - if e != nil || costume == 0 || costume > math.MaxInt32 { - return fmt.Errorf("player: potential connection requires a costume design ID") - } - c, owned := s.characters.Find(index) - if !owned || IsStoryCharacter(c) || IsCharmCharacter(c) { - return fmt.Errorf("player: potential character is not permanent owned") - } - unique := s.design.CharacterUnique[c.ID] - characterType, knownType := s.design.CharacterTypes[c.ID] - if unique == 0 || !knownType || characterType != 0 || !s.design.CostumeActive[costume] || s.design.CostumeUnique[costume] != unique || len(s.design.Nodes[costume]) == 0 { - return fmt.Errorf("player: incompatible or unavailable potential costume") - } - ownedCostume := false - for _, v := range s.collection.Costumes() { - if v.ID == costume && v.UseChar == index { - ownedCostume = true - for _, id := range v.PotentialIDs { - if _, ok := s.design.Nodes[costume][id]; !ok { - return fmt.Errorf("player: invalid saved potential node") - } - } - break - } - } - if !ownedCostume { - return fmt.Errorf("player: potential costume not owned by requested character") - } - hp, e := s.characters.CurrentHealth(index) - if e != nil { - return e - } - oldHP[index] = hp - c.ConnectPotentialCostume = costume - characters = append(characters, c) - seen[index] = true - return nil - }) - if err != nil { - return fail(err) - } - if len(characters) == 0 { - return fail(fmt.Errorf("player: empty potential connection list")) - } - // All links are validated before writes. The parent account transaction - // includes both character ownership domains, HP and the response receipt. - for _, c := range characters { - if err = s.characters.setPotentialConnection(c.InvenIndex, c.ConnectPotentialCostume); err != nil { - return fail(err) - } - } - var out []byte - for _, c := range characters { - maximum, e := s.characters.MaxHealth(c.InvenIndex) - if e != nil { - return fail(e) - } - hp := min(oldHP[c.InvenIndex], maximum) - if e = s.characters.SetCurrentHealth(c.InvenIndex, hp); e != nil { - return fail(e) - } - c.HP = hp - out = wire.AppendBytes(out, 1, CharacterWire(c)) - } - receipts[key] = potentialConnectReceipt{Digest: digest, Body: out} - b, err = json.Marshal(receipts) - if err != nil { - return fail(err) - } - if err = s.connectStore.Save("potentialconnect", b); err != nil { - return fail(err) - } - return 267, out, true, nil -} -func (s *CharacterStore) setPotentialConnection(index, costume uint64) error { - s.mu.Lock() - for i, c := range s.characters { - if c.InvenIndex == index { - next := append([]Character(nil), s.characters...) - next[i].ConnectPotentialCostume = costume - if err := s.persist(next); err != nil { - s.mu.Unlock() - return err - } - s.characters = next - s.mu.Unlock() - return nil - } - } - collection := s.collection - s.mu.Unlock() - if collection != nil { - if c, ok := collection.FindCharacter(index); ok { - c.ConnectPotentialCostume = costume - return collection.UpdateCharacter(c.ID, c) - } - } - return fmt.Errorf("player: unknown potential connection character") -} diff --git a/go/internal/server/player/costume_potential_connect_test.go b/go/internal/server/player/costume_potential_connect_test.go deleted file mode 100644 index 82f0325..0000000 --- a/go/internal/server/player/costume_potential_connect_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package player - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "errors" - "path/filepath" - "testing" -) - -func connectTestService(t *testing.T, store stateio.Store) (*CostumePotentialService, *CharacterStore) { - t.Helper() - inv, e := OpenInventory(store, &Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - chars, e := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 91, Level: 1, HP: 80}}, inv, "", "") - if e != nil { - t.Fatal(e) - } - if e = chars.EnsurePersisted(); e != nil { - t.Fatal(e) - } - collection, e := OpenCollectionStore(store, nil) - if e != nil { - t.Fatal(e) - } - if len(collection.Costumes()) == 0 { - v := cloneCollection(collection.data) - v.Costumes = []Costume{{InvenIndex: 88, ID: 100, UseChar: 77}, {InvenIndex: 89, ID: 200, UseChar: 77}, {InvenIndex: 90, ID: 300, UseChar: 77}} - if e = collection.commit(v); e != nil { - t.Fatal(e) - } - } - wallet, e := OpenWallet(store, Currency{Gold: 100}) - if e != nil { - t.Fatal(e) - } - d := &gamedata.CostumePotentialDesign{CharacterUnique: map[uint64]uint64{91: 9}, CostumeUnique: map[uint64]uint64{100: 9, 200: 9, 300: 10}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{100: {1: {ID: 1}}, 200: {1: {ID: 1}}, 300: {1: {ID: 1}}}} - s, e := NewCostumePotentialService(d, collection, chars, inv, wallet) - d.CharacterTypes = map[uint64]uint64{91: 0} - d.CostumeActive = map[uint64]bool{100: true, 200: true, 300: true} - if e != nil { - t.Fatal(e) - } - if err := s.AttachConnectStore(store); err != nil { - t.Fatal(err) - } - s.BeginSession("test") - if err := chars.AttachMaxHealth(func(c Character) (uint64, error) { - if c.ConnectPotentialCostume == 200 { - return 50, nil - } - return 100, nil - }); err != nil { - t.Fatal(err) - } - return s, chars -} -func connectRequest(seq uint64, rows ...[2]uint64) []byte { - b := wire.AppendVarint(nil, 1, seq) - for _, r := range rows { - v := wire.AppendVarint(wire.AppendVarint(nil, 1, r[0]), 2, r[1]) - b = wire.AppendBytes(b, 2, v) - } - return b -} -func TestPotentialConnectFreeZeroNodesSwitchClampsAndReplaysAcrossRestart(t *testing.T) { - store := stateio.NewMemory() - s, chars := connectTestService(t, store) - if _, _, _, e := s.Handle("/CostumePotentialConnect", connectRequest(1, [2]uint64{77, 100})); e != nil { - t.Fatal(e) - } - c, _ := chars.Find(77) - if c.ConnectPotentialCostume != 100 || c.HP != 80 || s.wallet.Snapshot().Gold != 100 { - t.Fatal("auto connection charged or healed") - } - b := connectRequest(2, [2]uint64{77, 200}) - _, reply, _, e := s.Handle("/CostumePotentialConnect", b) - if e != nil { - t.Fatal(e) - } - c, _ = chars.Find(77) - if c.HP != 50 { - t.Fatal("maxHP decrease not clamped") - } - next, _ := connectTestService(t, store) - _, again, _, e := next.Handle("/CostumePotentialConnect", b) - if e != nil || !bytes.Equal(reply, again) { - t.Fatal("restart lost exact connection replay") - } - if _, _, _, e = next.Handle("/CostumePotentialConnect", connectRequest(3, [2]uint64{77, 100})); e != nil { - t.Fatal(e) - } - c, _ = next.characters.Find(77) - if c.HP != 50 { - t.Fatal("maxHP increase healed player") - } - if e = next.characters.SetCurrentHealth(77, 0); e != nil { - t.Fatal(e) - } - if _, _, _, e = next.Handle("/CostumePotentialConnect", connectRequest(4, [2]uint64{77, 200})); e != nil { - t.Fatal(e) - } - c, _ = next.characters.Find(77) - if c.HP != 0 { - t.Fatal("connection revived dead character") - } -} -func TestPotentialConnectRejectsUnownedOtherCharacterAndDuplicateBatch(t *testing.T) { - s, chars := connectTestService(t, stateio.NewMemory()) - for _, b := range [][]byte{connectRequest(1, [2]uint64{77, 300}), connectRequest(2, [2]uint64{77, 88}), connectRequest(3, [2]uint64{77, 0}), connectRequest(4, [2]uint64{77, 100}, [2]uint64{77, 200}), connectRequest(5, [2]uint64{77, 100}, [2]uint64{999, 200})} { - if _, _, _, e := s.Handle("/CostumePotentialConnect", b); e == nil { - t.Fatal("invalid potential connection accepted") - } - } - c, _ := chars.Find(77) - if c.ConnectPotentialCostume != 0 { - t.Fatal("invalid batch partially updated character") - } - s.design.CostumeActive[100] = false - if _, _, _, e := s.Handle("/CostumePotentialConnect", connectRequest(6, [2]uint64{77, 100})); e == nil { - t.Fatal("inactive potential costume connected") - } - s.design.CostumeActive[100] = true - s.design.CharacterTypes[91] = 1 - if _, _, _, e := s.Handle("/CostumePotentialConnect", connectRequest(7, [2]uint64{77, 100})); e == nil { - t.Fatal("temporary design character connected") - } - s.design.CharacterTypes[91] = 0 - duplicate := wire.AppendVarint(connectRequest(8, [2]uint64{77, 100}), 1, 9) - if _, _, _, e := s.Handle("/CostumePotentialConnect", duplicate); e == nil { - t.Fatal("duplicate sequence accepted") - } - row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, 100), 2, 200) - duplicate = wire.AppendBytes(wire.AppendVarint(nil, 1, 10), 2, row) - if _, _, _, e := s.Handle("/CostumePotentialConnect", duplicate); e == nil { - t.Fatal("duplicate nested costume scalar accepted") - } -} - -type connectFailStore struct{ stateio.Store } - -func (s connectFailStore) Save(name string, b []byte) error { - if name == "potentialconnect" { - return errors.New("receipt failed") - } - return s.Store.Save(name, b) -} -func TestPotentialConnectSQLiteFailureRollsBackLinkAndHealth(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, e := accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - s, _ := connectTestService(t, repo) - if err := s.AttachConnectStore(connectFailStore{repo}); err != nil { - t.Fatal(err) - } - op, e := repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - b := connectRequest(1, [2]uint64{77, 200}) - if _, _, _, e = s.Handle("/CostumePotentialConnect", b); e == nil { - t.Fatal("receipt failure ignored") - } - if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) { - t.Fatal(e) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, e = accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - next, chars := connectTestService(t, repo) - c, _ := chars.Find(77) - if c.ConnectPotentialCostume != 0 || c.HP != 80 { - t.Fatal("failed connection persisted link/HP") - } - op, e = repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - if _, _, _, e = next.Handle("/CostumePotentialConnect", b); e != nil { - t.Fatal(e) - } - if e = op.Commit(); e != nil { - t.Fatal(e) - } -} - -func TestPotentialConnectionCollectionCharacterFallbackPersistsAcrossRestart(t *testing.T) { - store := stateio.NewMemory() - s, chars := connectTestService(t, store) - v := cloneCollection(s.collection.data) - v.Characters = []Character{{InvenIndex: 78, ID: 92, Level: 1, HP: 40, CostumeID: 201, UseCostume: 91}} - v.Costumes = append(v.Costumes, Costume{InvenIndex: 91, ID: 201, UseChar: 78}) - if e := s.collection.commit(v); e != nil { - t.Fatal(e) - } - if e := chars.AttachCollection(s.collection); e != nil { - t.Fatal(e) - } - s.design.CharacterUnique[92] = 9 - s.design.CharacterTypes[92] = 0 - s.design.CostumeUnique[201] = 9 - s.design.CostumeActive[201] = true - s.design.Nodes[201] = map[uint64]gamedata.CostumePotentialNode{1: {ID: 1}} - b := connectRequest(1, [2]uint64{78, 201}) - _, response, _, e := s.Handle("/CostumePotentialConnect", b) - if e != nil { - t.Fatal(e) - } - c, ok := chars.Find(78) - if !ok || c.ConnectPotentialCostume != 201 || c.UseCostume != 91 || c.CostumeID != 201 || c.HP != 40 { - t.Fatal("collection connection changed appearance or HP") - } - collection, e := OpenCollectionStore(store, nil) - if e != nil { - t.Fatal(e) - } - c, ok = collection.FindCharacter(78) - if !ok || c.ConnectPotentialCostume != 201 { - t.Fatal("collection connection lost after restart") - } - s.collection = collection - if err := chars.AttachCollection(collection); err != nil { - t.Fatal(err) - } - if err := s.AttachConnectStore(store); err != nil { - t.Fatal(err) - } - s.BeginSession("test") - _, again, _, e := s.Handle("/CostumePotentialConnect", b) - if e != nil || !bytes.Equal(response, again) { - t.Fatal("collection reconnect replay lost") - } -} diff --git a/go/internal/server/player/costume_potential_test.go b/go/internal/server/player/costume_potential_test.go deleted file mode 100644 index fec04d9..0000000 --- a/go/internal/server/player/costume_potential_test.go +++ /dev/null @@ -1,164 +0,0 @@ -package player - -import ( - "encoding/binary" - "math" - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestCostumePotentialContributionsIncludePublicAndConnectedNodes(t *testing.T) { - storage := stateio.NewMemory() - collection, err := OpenCollectionStore(storage, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}}) - if err != nil { - t.Fatal(err) - } - for _, index := range []uint64{1, 2, 3} { - if err := collection.ActivateCostumePotential(index, []uint64{1, 2, 3}); err != nil { - t.Fatal(err) - } - } - design := &gamedata.CostumePotentialDesign{CharacterUnique: map[uint64]uint64{91: 9, 101: 10}, CostumeUnique: map[uint64]uint64{100: 9, 200: 9, 300: 10}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{ - 100: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 7}, 2: {ID: 2, NodeType: 1, StatType: 2, StatValue: .085}, 3: {ID: 3, NodeType: 4}}, - 200: {1: {ID: 1, NodeType: 2, StatType: 2, StatValue: .015}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 20}, 3: {ID: 3, NodeType: 2, StatType: 3, StatValue: 3}}, - 300: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 1000}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 1000}, 3: {ID: 3, NodeType: 4}}, - }} - service := &CostumePotentialService{design: design, collection: collection} - character := Character{ID: 91, ConnectPotentialCostume: 100} - contributions, err := service.Contributions(character) - if err != nil { - t.Fatal(err) - } - stats := gamedata.AggregateStats(gamedata.BaseStats{Health: 100, Attack: 10}, contributions) - if stats.Health != 117 || stats.Attack != 13 { - t.Fatalf("public plus connected stats=%+v contributions=%+v", stats, contributions) - } - character.ConnectPotentialCostume = 200 - contributions, err = service.Contributions(character) - if err != nil { - t.Fatal(err) - } - stats = gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions) - if stats.Health != 128 { - t.Fatalf("connection switch health=%f", stats.Health) - } - character.ConnectPotentialCostume = 0 - contributions, err = service.Contributions(character) - if err != nil { - t.Fatal(err) - } - stats = gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions) - if stats.Health != 108 { - t.Fatalf("unconnected public health=%f", stats.Health) - } - character.ConnectPotentialCostume = 300 - if _, err := service.Contributions(character); err == nil { - t.Fatal("connected another character's costume") - } - character.ConnectPotentialCostume = 100 - reopened, err := OpenCollectionStore(storage, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}}) - if err != nil { - t.Fatal(err) - } - service.collection = reopened - contributions, err = service.Contributions(character) - if err != nil { - t.Fatal(err) - } - if stats := gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions); stats.Health != 117 { - t.Fatalf("restart contribution health=%f", stats.Health) - } - design.Nodes[100][1] = gamedata.CostumePotentialNode{ID: 1, NodeType: 2, StatType: 1, StatValue: math.NaN()} - if _, err := service.Contributions(character); err == nil { - t.Fatal("nonfinite stat accepted") - } -} - -func TestCostumeNodeActivationSupportsSingleAndOneClickSets(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - materials, err := inventory.GrantOnce("potential", []gamedata.BattleReward{{Type: 8, ID: 114, Count: 5}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 300}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 6514, Level: 100}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - collection, err := OpenCollectionStore(testStore(filepath.Join(dir, "collection.json")), nil) - if err != nil { - t.Fatal(err) - } - nextCollection := cloneCollection(collection.data) - nextCollection.Costumes = []Costume{{InvenIndex: 88, ID: 65103, UseChar: 77}} - if err := collection.commit(nextCollection); err != nil { - t.Fatal(err) - } - design := &gamedata.CostumePotentialDesign{ - Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{65103: { - 1: {ID: 1, Costs: []gamedata.CostumePotentialCost{{Type: 4, Count: 100}}}, - 2: {ID: 2, Prerequisites: []uint64{1}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 2}}}, - 3: {ID: 3, Prerequisites: []uint64{2}, Costs: []gamedata.CostumePotentialCost{{Type: 8, ID: 114, Count: 3}, {Type: 4, Count: 200}}}, - }}, - CostumeUnique: map[uint64]uint64{65103: 651}, - CharacterGrade: map[uint64]uint64{6514: 5}, - CharacterUnique: map[uint64]uint64{6514: 651}, - } - service, err := NewCostumePotentialService(design, collection, characters, inventory, wallet) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, 88) - request = wire.AppendVarint(request, 4, 1) - request = wire.AppendBytes(request, 5, ItemWire(Item{Type: 4, Count: 100})) - code, response, handled, err := service.Handle("/CostumeNodeActivation", request) - if err != nil || !handled || code != 261 || len(response) != 0 { - t.Fatalf("single activation code=%d handled=%v response=%x err=%v", code, handled, response, err) - } - oneClick := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77), 3, 88) - oneClick = wire.AppendVarint(wire.AppendVarint(oneClick, 4, 2), 4, 3) - item := materials[0] - oneClick = wire.AppendBytes(oneClick, 5, ItemWire(item)) - oneClick = wire.AppendBytes(oneClick, 5, ItemWire(Item{Type: 4, Count: 200})) - code, _, handled, err = service.Handle("/CostumeNodeActivation", oneClick) - if err != nil || !handled || code != 261 { - t.Fatalf("one-click activation code=%d handled=%v err=%v", code, handled, err) - } - if wallet.Snapshot().Gold != 0 { - t.Fatalf("gold=%d", wallet.Snapshot().Gold) - } - costume, found := collection.CostumeByIndex(88) - if !found || len(costume.PotentialIDs) != 3 || costume.PotentialIDs[0] != 1 || costume.PotentialIDs[2] != 3 { - t.Fatalf("activated costume=%+v found=%v", costume, found) - } - var encoded []uint64 - if err := wire.Walk(CostumeWire(costume), func(field wire.Field) error { - if field.Number == 8 { - v, _ := binary.Uvarint(field.Value) - encoded = append(encoded, v) - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(encoded) != 3 { - t.Fatalf("wire potential IDs=%v", encoded) - } - if _, _, _, err := service.Handle("/CostumeNodeActivation", oneClick); err == nil { - t.Fatal("already active nodes accepted") - } - if wallet.Snapshot().Gold != 0 { - t.Fatal("duplicate request charged wallet") - } -} diff --git a/go/internal/server/player/eat_food.go b/go/internal/server/player/eat_food.go deleted file mode 100644 index 7e94761..0000000 --- a/go/internal/server/player/eat_food.go +++ /dev/null @@ -1,402 +0,0 @@ -package player - -import ( - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math" - "strconv" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -// FoodService participates in the account request transaction: consuming the -// stack, saving health and recording the replay response commit together. -type FoodService struct { - mu sync.Mutex - store stateio.AtomicEntryStore - design *gamedata.FoodDesign - inventory *Inventory - characters *CharacterStore - session string - currentPack func() (int, error) - battleActive func() bool -} - -type foodReply struct { - Digest string `json:"digest"` - Body []byte `json:"body"` -} - -func OpenFoodService(store stateio.Store, design *gamedata.FoodDesign, inventory *Inventory, characters *CharacterStore) (*FoodService, error) { - entries, ok := store.(stateio.AtomicEntryStore) - if !ok || design == nil || inventory == nil || characters == nil { - return nil, errors.New("player: incomplete food configuration") - } - s := &FoodService{store: entries, design: design, inventory: inventory, characters: characters} - health, err := entries.ListEntries("characters", "current_hp") - if err != nil { - return nil, err - } - for key, raw := range health { - index, err := strconv.ParseUint(key, 10, 64) - var hp uint64 - if err != nil || index == 0 || json.Unmarshal(raw, &hp) != nil { - return nil, errors.New("player: malformed saved current health") - } - if _, err := characters.MaxHealth(index); err != nil { - return nil, err - } - } - replies, err := entries.ListEntries("characters", "food_requests") - if err != nil { - return nil, err - } - for key, raw := range replies { - var reply foodReply - decodedKey, keyErr := hex.DecodeString(key) - if keyErr != nil || len(decodedKey) != sha256.Size || json.Unmarshal(raw, &reply) != nil || len(reply.Body) == 0 { - return nil, errors.New("player: malformed saved food replay") - } - digest, err := hex.DecodeString(reply.Digest) - if err != nil || len(digest) != sha256.Size { - return nil, errors.New("player: malformed saved food replay digest") - } - if err := wire.Walk(reply.Body, func(field wire.Field) error { return nil }); err != nil { - return nil, errors.New("player: malformed saved food replay body") - } - } - return s, nil -} - -func (s *FoodService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } - -// AttachContext supplies authoritative pack and active-battle state; the client -// omits PackId in ordinary recovery requests, which means the current pack. -func (s *FoodService) AttachContext(currentPack func() (int, error), battleActive func() bool) error { - if currentPack == nil || battleActive == nil { - return errors.New("player: incomplete food context") - } - s.mu.Lock() - defer s.mu.Unlock() - s.currentPack = currentPack - s.battleActive = battleActive - return nil -} - -func (s *FoodService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path == "/EatFoodAuto" { - return s.eatFoodAuto(request) - } - if path != "/EatFood" { - return 0, nil, false, nil - } - var seq, pack, index uint64 - seenHeaders := map[int]bool{} - parseErr := wire.Walk(request, func(field wire.Field) error { - if field.Number == 4 { - return nil - } - target := map[int]*uint64{1: &seq, 2: &pack, 3: &index}[field.Number] - if target == nil || field.Type != 0 || seenHeaders[field.Number] { - return errors.New("player: invalid EatFood header") - } - seenHeaders[field.Number] = true - *target, _ = binary.Uvarint(field.Value) - return nil - }) - if parseErr != nil || seq == 0 || seq > math.MaxInt32 || pack > math.MaxInt32 || index == 0 || index > math.MaxInt64 { - return 0, nil, true, errors.New("player: invalid EatFood request") - } - items, err := equipmentRequestItems(request, 4, "EatFood") - if err != nil || len(items) == 0 { - return 0, nil, true, errors.New("player: EatFood requires food stacks") - } - s.mu.Lock() - defer s.mu.Unlock() - if s.session == "" || s.currentPack == nil || s.battleActive == nil { - return 0, nil, true, errors.New("player: EatFood session or context unavailable") - } - keyDigest := sha256.Sum256([]byte(s.session + ":EatFood:" + strconv.FormatUint(seq, 10))) - key := hex.EncodeToString(keyDigest[:]) - digest := sha256.Sum256(request) - digestString := hex.EncodeToString(digest[:]) - prior, found, err := s.store.LoadEntry("characters", "food_requests", key) - if err != nil { - return 0, nil, true, err - } - if found { - var reply foodReply - if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString { - return 0, nil, true, errors.New("player: EatFood sequence reused with different request") - } - return 22, append([]byte(nil), reply.Body...), true, nil - } - currentPack, err := s.currentPack() - if err != nil || currentPack <= 0 || (pack != 0 && pack != uint64(currentPack)) { - return 0, nil, true, errors.New("player: EatFood pack unavailable") - } - if s.battleActive() { - return 0, nil, true, errors.New("player: cannot eat food during battle") - } - character, err := s.recoverCharacter(index, items) - if err != nil { - return 0, nil, true, err - } - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, err - } - body := wire.AppendBytes(nil, 1, CharacterWire(character)) - if err := s.inventory.Consume(items); err != nil { - return 0, nil, true, err - } - healthRaw, _ := json.Marshal(character.HP) - replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body}) - if err := s.store.SaveWithEntries("characters", nil, []stateio.EntryMutation{{Bucket: "current_hp", Key: strconv.FormatUint(index, 10), Payload: healthRaw}, {Bucket: "food_requests", Key: key, Payload: replyRaw}}); err != nil { - return 0, nil, true, err - } - return 22, body, true, nil -} - -// MaxHealth reads the shared stat calculator without replacing it with field HP. -func (s *CharacterStore) MaxHealth(index uint64) (uint64, error) { - var character Character - for _, candidate := range s.RawAll() { - if candidate.InvenIndex == index { - character = candidate - break - } - } - if character.InvenIndex == 0 { - return 0, fmt.Errorf("player: unknown health character %d", index) - } - s.mu.Lock() - calculate := s.maxHealth - s.mu.Unlock() - if calculate != nil { - return calculate(character) - } - if character.HP == 0 { - return 0, errors.New("player: maximum health unavailable") - } - return character.HP, nil -} - -// CurrentHealth resolves persisted current HP independently from maximum HP. -// A missing current_hp entry retains the owned character record's HP, including -// zero. Recomputing equipment or other maximum-health stats must never heal it. -func (s *CharacterStore) CurrentHealth(index uint64) (uint64, error) { - var saved Character - for _, character := range s.RawAll() { - if character.InvenIndex == index { - saved = character - break - } - } - if saved.InvenIndex == 0 { - return 0, fmt.Errorf("player: unknown health character %d", index) - } - return s.savedCurrentHealth(saved) -} - -func (s *CharacterStore) savedCurrentHealth(saved Character) (uint64, error) { - if s.store == nil { - return saved.HP, nil - } - data, found, err := s.store.LoadEntry("characters", "current_hp", strconv.FormatUint(saved.InvenIndex, 10)) - if err != nil { - return 0, err - } - if !found { - return saved.HP, nil - } - var hp uint64 - if json.Unmarshal(data, &hp) != nil { - return 0, errors.New("player: invalid saved current health") - } - return hp, nil -} - -// SetCurrentHealth accepts server-validated battle results and recovery only. -// Food requests never accept health from the caller. -func (s *CharacterStore) SetCurrentHealth(index, hp uint64) error { - maximum, err := s.MaxHealth(index) - if err != nil { - return err - } - if hp > maximum { - return errors.New("player: current health exceeds maximum") - } - raw, _ := json.Marshal(hp) - return s.store.PutEntry("characters", "current_hp", strconv.FormatUint(index, 10), raw) -} - -func (s *CharacterStore) resetCurrentHealth(index uint64) error { - _, err := s.store.DeleteEntry("characters", "current_hp", strconv.FormatUint(index, 10)) - return err -} - -func (s *FoodService) eatFoodAuto(request []byte) (int, []byte, bool, error) { - var seq uint64 - var targets []struct { - index uint64 - items []Item - } - seenSequence := false - err := wire.Walk(request, func(field wire.Field) error { - switch field.Number { - case 1: - if field.Type != 0 || seenSequence { - return errors.New("player: invalid EatFoodAuto sequence") - } - seenSequence = true - seq, _ = binary.Uvarint(field.Value) - case 2: - if field.Type != 2 { - return errors.New("player: invalid EatFoodAuto target") - } - var index uint64 - seenIndex := false - if err := wire.Walk(field.Value, func(inner wire.Field) error { - if inner.Number == 2 { - return nil - } - if inner.Number != 1 || inner.Type != 0 || seenIndex { - return errors.New("player: invalid EatFoodAuto character") - } - seenIndex = true - index, _ = binary.Uvarint(inner.Value) - return nil - }); err != nil { - return err - } - if index == 0 || index > math.MaxInt64 { - return errors.New("player: invalid EatFoodAuto character index") - } - items, err := equipmentRequestItems(field.Value, 2, "EatFoodAuto") - if err != nil { - return err - } - if len(items) == 0 { - return errors.New("player: EatFoodAuto character requires food stacks") - } - targets = append(targets, struct { - index uint64 - items []Item - }{index, items}) - default: - return errors.New("player: unknown EatFoodAuto field") - } - return nil - }) - if err != nil || seq == 0 || seq > math.MaxInt32 || len(targets) == 0 { - return 0, nil, true, errors.New("player: invalid EatFoodAuto request") - } - s.mu.Lock() - defer s.mu.Unlock() - if s.session == "" || s.currentPack == nil || s.battleActive == nil { - return 0, nil, true, errors.New("player: EatFoodAuto context unavailable") - } - keyDigest := sha256.Sum256([]byte(s.session + ":EatFoodAuto:" + strconv.FormatUint(seq, 10))) - key := hex.EncodeToString(keyDigest[:]) - digest := sha256.Sum256(request) - digestString := hex.EncodeToString(digest[:]) - prior, found, err := s.store.LoadEntry("characters", "food_requests", key) - if err != nil { - return 0, nil, true, err - } - if found { - var reply foodReply - if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString { - return 0, nil, true, errors.New("player: EatFoodAuto sequence reused with different request") - } - return 27, append([]byte(nil), reply.Body...), true, nil - } - pack, err := s.currentPack() - if err != nil || pack <= 0 || s.battleActive() { - return 0, nil, true, errors.New("player: EatFoodAuto unavailable during battle or outside pack") - } - seenCharacters := map[uint64]bool{} - var items []Item - var body []byte - var changes []stateio.EntryMutation - for _, target := range targets { - if seenCharacters[target.index] { - return 0, nil, true, errors.New("player: duplicate EatFoodAuto character") - } - seenCharacters[target.index] = true - character, err := s.recoverCharacter(target.index, target.items) - if err != nil { - return 0, nil, true, err - } - items = append(items, target.items...) - raw, _ := json.Marshal(character.HP) - changes = append(changes, stateio.EntryMutation{Bucket: "current_hp", Key: strconv.FormatUint(target.index, 10), Payload: raw}) - info := wire.AppendVarint(nil, 1, target.index) - info = wire.AppendVarint(info, 2, character.HP) - body = wire.AppendBytes(body, 1, info) - } - // CanConsume accounts for a shared stack requested by several characters. - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, err - } - if err := s.inventory.Consume(items); err != nil { - return 0, nil, true, err - } - replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body}) - changes = append(changes, stateio.EntryMutation{Bucket: "food_requests", Key: key, Payload: replyRaw}) - if err := s.store.SaveWithEntries("characters", nil, changes); err != nil { - return 0, nil, true, err - } - return 27, body, true, nil -} - -func (s *FoodService) recoverCharacter(index uint64, items []Item) (Character, error) { - character, found := s.characters.Find(index) - if !found { - return Character{}, errors.New("player: EatFood character is not owned") - } - maximum, err := s.characters.MaxHealth(index) - if err != nil { - return Character{}, err - } - current, err := s.characters.CurrentHealth(index) - if err != nil { - return Character{}, err - } - var total uint64 - seen := map[uint64]bool{} - for _, item := range items { - if item.Type != 5 || item.InvenIndex == 0 || item.Count == 0 || item.Count > math.MaxInt32 || seen[item.InvenIndex] { - return Character{}, errors.New("player: invalid EatFood inventory stack") - } - seen[item.InvenIndex] = true - food, exists := s.design.Foods[item.ID] - if !exists { - return Character{}, errors.New("player: unknown EatFood dish") - } - value, err := food.Recovery(character.ID, maximum, item.Count) - if err != nil { - return Character{}, err - } - if value > math.MaxUint64-total { - return Character{}, errors.New("player: EatFood recovery overflow") - } - total += value - } - if current > maximum { - current = maximum - } - if total >= maximum-current { - character.HP = maximum - } else { - character.HP = current + total - } - return character, nil -} diff --git a/go/internal/server/player/eat_food_test.go b/go/internal/server/player/eat_food_test.go deleted file mode 100644 index 78ec7a9..0000000 --- a/go/internal/server/player/eat_food_test.go +++ /dev/null @@ -1,517 +0,0 @@ -package player - -import ( - "bytes" - "errors" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func foodTestService(t *testing.T, store stateio.Store) (*FoodService, *Inventory, *CharacterStore) { - t.Helper() - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(store, starter) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 7}, {InvenIndex: 78, ID: 360, Level: 1, HP: 7}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err = characters.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err = characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - design := &gamedata.FoodDesign{Foods: map[uint64]gamedata.Food{101: {ID: 101, Point: 15}, 102: {ID: 102, Type: 1, Point: 10, FavoritePoint: 25, FavoriteUniqueCharIDs: []uint64{35}}, 103: {ID: 103, Type: 2}, 104: {ID: 104, Point: 100, FoodBuffID: 101}, 105: {ID: 105, Point: 50, RecoveryType: 1}}} - s, err := OpenFoodService(store, design, inventory, characters) - if err != nil { - t.Fatal(err) - } - s.BeginSession("food-session") - if err = s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return false }); err != nil { - t.Fatal(err) - } - return s, inventory, characters -} - -func autoFoodRequest(seq uint64, indices []uint64, items []Item) []byte { - out := wire.AppendVarint(nil, 1, seq) - for i, index := range indices { - target := wire.AppendVarint(nil, 1, index) - target = wire.AppendBytes(target, 2, ItemWire(items[i])) - out = wire.AppendBytes(out, 2, target) - } - return out -} - -func TestEatFoodAutoSharesStacksValidatesWholeRequestAndReplaysSQLite(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s, inventory, characters := foodTestService(t, repo) - stacks, err := inventory.GrantOnce("auto-food", []gamedata.BattleReward{{Type: 5, ID: 102, Count: 3}}) - if err != nil { - t.Fatal(err) - } - for _, index := range []uint64{77, 78} { - if err = characters.SetCurrentHealth(index, 0); err != nil { - t.Fatal(err) - } - } - stack := stacks[0] - stack.Count = 2 - overspend := autoFoodRequest(1, []uint64{77, 78}, []Item{stack, stack}) - if _, _, _, err = s.Handle("/EatFoodAuto", overspend); err == nil { - t.Fatal("accepted aggregate stack overspend") - } - if hp, _ := characters.CurrentHealth(77); hp != 0 { - t.Fatal("partially healed rejected request") - } - if inventory.All()[0].Count != 3 { - t.Fatal("partially consumed rejected request") - } - stack.Count = 1 - request := autoFoodRequest(2, []uint64{77, 78}, []Item{stack, stack}) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - code, body, _, err := s.Handle("/EatFoodAuto", request) - if err != nil || code != 27 { - t.Fatalf("auto code=%d err=%v", code, err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 25 { - t.Fatalf("favorite hp=%d", hp) - } - if hp, _ := characters.CurrentHealth(78); hp != 10 { - t.Fatalf("ordinary hp=%d", hp) - } - var resultIndices []uint64 - if err = wire.Walk(body, func(field wire.Field) error { - index, _, err := wire.Varint(field.Value, 1) - resultIndices = append(resultIndices, index) - return err - }); err != nil || len(resultIndices) != 2 || resultIndices[0] != 77 || resultIndices[1] != 78 { - t.Fatalf("auto response=%v err=%v", resultIndices, err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - s, inventory, _ = foodTestService(t, repo) - code, replay, _, err := s.Handle("/EatFoodAuto", request) - if err != nil || code != 27 || !bytes.Equal(replay, body) || inventory.All()[0].Count != 1 { - t.Fatalf("auto replay code=%d err=%v", code, err) - } - // Equal sequences on the two endpoints refer to separate requests. - if _, _, _, err = s.Handle("/EatFood", foodRequest(2, 77, 0, stack)); err != nil { - t.Fatalf("normal/auto sequence collision: %v", err) - } -} - -func TestEatFoodAutoRejectsEmptyTargetWithoutConsumingValidTarget(t *testing.T) { - s, inventory, characters := foodTestService(t, stateio.NewMemory()) - stacks, err := inventory.GrantOnce("auto-empty", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}}) - if err != nil { - t.Fatal(err) - } - if err = characters.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - request := autoFoodRequest(1, []uint64{77}, stacks) - request = wire.AppendBytes(request, 2, wire.AppendVarint(nil, 1, 78)) - if _, _, _, err = s.Handle("/EatFoodAuto", request); err == nil { - t.Fatal("accepted empty recovery target") - } - if inventory.All()[0].Count != 1 { - t.Fatal("empty target consumed valid target food") - } - if hp, _ := characters.CurrentHealth(77); hp != 0 { - t.Fatal("empty target partially healed valid target") - } -} - -func foodRequest(seq, index, pack uint64, items ...Item) []byte { - out := wire.AppendVarint(nil, 1, seq) - if pack != 0 { - out = wire.AppendVarint(out, 2, pack) - } - out = wire.AppendVarint(out, 3, index) - for _, item := range items { - out = wire.AppendBytes(out, 4, ItemWire(item)) - } - return out -} - -func TestEatFoodSQLitePersistsRecoveryAndSequenceReplay(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s, inventory, characters := foodTestService(t, repo) - stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 4}, {Type: 5, ID: 102, Count: 2}}) - if err != nil { - t.Fatal(err) - } - if err = characters.SetCurrentHealth(77, 20); err != nil { - t.Fatal(err) - } - stacks[0].Count = 2 - stacks[1].Count = 1 - request := foodRequest(10, 77, 0, stacks...) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - code, body, handled, err := s.Handle("/EatFood", request) - if err != nil || code != 22 || !handled { - t.Fatalf("code=%d handled=%v err=%v", code, handled, err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if hp, err := characters.CurrentHealth(77); err != nil || hp != 75 { - t.Fatalf("hp=%d err=%v", hp, err) - } - if c, _ := characters.Find(77); c.HP != 75 || characters.All()[0].HP != 75 { - t.Fatal("current HP overwritten by maximum") - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - s, inventory, characters = foodTestService(t, repo) - code, replayed, _, err := s.Handle("/EatFood", request) - if err != nil || code != 22 || !bytes.Equal(body, replayed) { - t.Fatalf("replay err=%v", err) - } - if items := inventory.All(); len(items) != 2 || items[0].Count != 2 || items[1].Count != 1 { - t.Fatalf("retry consumed items %+v", items) - } - if hp, _ := characters.CurrentHealth(77); hp != 75 { - t.Fatalf("reopened hp=%d", hp) - } - request = foodRequest(10, 77, 0, stacks[0]) - if _, _, _, err = s.Handle("/EatFood", request); err == nil { - t.Fatal("accepted changed request with reused sequence") - } -} - -func TestEatFoodRejectsInvalidStacksAndContextWithoutMutation(t *testing.T) { - s, inventory, characters := foodTestService(t, stateio.NewMemory()) - stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 2}, {Type: 5, ID: 103, Count: 2}, {Type: 5, ID: 104, Count: 2}, {Type: 5, ID: 999, Count: 2}}) - if err != nil { - t.Fatal(err) - } - if err = characters.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - wrongID := stacks[0] - wrongID.ID = 102 - wrongType := stacks[0] - wrongType.Type = 8 - over := stacks[0] - over.Count = 3 - missing := stacks[0] - missing.InvenIndex++ - requests := [][]byte{foodRequest(1, 999, 0, stacks[0]), foodRequest(2, 77, 22, stacks[0]), foodRequest(3, 77, 0, wrongID), foodRequest(4, 77, 0, wrongType), foodRequest(5, 77, 0, over), foodRequest(6, 77, 0, missing), foodRequest(7, 77, 0, stacks[1]), foodRequest(8, 77, 0, stacks[2]), foodRequest(9, 77, 0, stacks[3]), foodRequest(10, 77, 0, stacks[0], stacks[0]), foodRequest(11, 77, 0)} - for i, request := range requests { - if _, _, _, err := s.Handle("/EatFood", request); err == nil { - t.Fatalf("invalid request %d accepted", i) - } - } - if err := s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return true }); err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/EatFood", foodRequest(12, 77, 0, stacks[0])); err == nil { - t.Fatal("accepted food during battle") - } - if hp, _ := characters.CurrentHealth(77); hp != 0 { - t.Fatalf("rejected request changed HP=%d", hp) - } - if got := inventory.All(); len(got) != 4 || got[0].Count != 2 { - t.Fatalf("rejected request consumed inventory %+v", got) - } -} - -func TestEatFoodPercentageRecoversDeadCharacterAndClampsToMaximum(t *testing.T) { - s, inventory, characters := foodTestService(t, stateio.NewMemory()) - stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 105, Count: 3}}) - if err != nil { - t.Fatal(err) - } - if err = characters.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - stack := stacks[0] - stack.Count = 1 - if _, _, _, err = s.Handle("/EatFood", foodRequest(1, 77, 21, stack)); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 50 { - t.Fatalf("percentage hp=%d", hp) - } - stack.Count = 2 - if _, _, _, err = s.Handle("/EatFood", foodRequest(2, 77, 21, stack)); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 100 { - t.Fatalf("clamped hp=%d", hp) - } - if len(inventory.All()) != 0 { - t.Fatal("empty food stack remains") - } - if err = characters.SetCurrentHealth(77, 101); err == nil { - t.Fatal("accepted health above maximum") - } -} - -func TestCurrentHealthGrowthAndImmortalClearPersistedInjury(t *testing.T) { - _, inventory, characters := foodTestService(t, stateio.NewMemory()) - stacks, err := inventory.GrantOnce("growth-food-health", []gamedata.BattleReward{{Type: 8, ID: 1, Count: 1}}) - if err != nil { - t.Fatal(err) - } - characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { - return 2, 0, nil, nil - } - if err = characters.SetCurrentHealth(77, 3); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77) - request = wire.AppendBytes(request, 3, ItemWire(stacks[0])) - if _, _, _, err = characters.Handle("/CharGrowth", request); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 100 { - t.Fatalf("grown current HP=%d", hp) - } - if err = characters.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77) - target, _, _ := wire.Varint(request, 2) - candidate, _ := characters.Find(target) - if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}}); err != nil { - t.Fatal(err) - } - if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 100 { - t.Fatalf("revived current HP=%d", hp) - } -} - -func TestCurrentHealthRetainsSavedCharacterHPWithoutSeparateEntryAcrossReopen(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - seed := []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 17}, {InvenIndex: 78, ID: 360, Level: 1, HP: 0}} - open := func(store stateio.Store) *CharacterStore { - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, seed, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err = characters.AttachMaxHealth(func(Character) (uint64, error) { return 500, nil }); err != nil { - t.Fatal(err) - } - if err = characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - return characters - } - check := func(characters *CharacterStore) { - for _, saved := range seed { - hp, err := characters.CurrentHealth(saved.InvenIndex) - if err != nil || hp != saved.HP { - t.Fatalf("current %d=%d want=%d err=%v", saved.InvenIndex, hp, saved.HP, err) - } - character, found := characters.Find(saved.InvenIndex) - if !found || character.HP != saved.HP { - t.Fatalf("Find=%+v found=%v", character, found) - } - } - all := characters.All() - if len(all) != 2 || all[0].HP != 17 || all[1].HP != 0 { - t.Fatalf("All=%+v", all) - } - maximum, err := characters.MaxHealth(77) - if err != nil || maximum != 500 { - t.Fatalf("maximum=%d err=%v", maximum, err) - } - } - characters := open(repo) - check(characters) - rows, err := repo.ListEntries("characters", "current_hp") - if err != nil || len(rows) != 0 { - t.Fatalf("reads created entries=%v err=%v", rows, err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - characters = open(repo) - check(characters) - if err = characters.SetCurrentHealth(77, 300); err != nil { - t.Fatal(err) - } - if err = characters.SetCurrentHealth(78, 0); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 300 { - t.Fatalf("explicit health entry lost=%d", hp) - } - // Revival must write the restored value, since deleting the entry alone - // would expose the zero HP in the owned character record again. - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 78) - target, _, _ := wire.Varint(request, 2) - candidate, _ := characters.Find(target) - if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}}); err != nil { - t.Fatal(err) - } - if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(78); hp != 500 { - t.Fatalf("revived health=%d", hp) - } -} - -func TestEatFoodRecoversSavedHPWithoutSeparateCurrentHealthEntry(t *testing.T) { - food, inventory, characters := foodTestService(t, stateio.NewMemory()) - // The seed has current HP 7 and maximum 100, with no current_hp entry. - stacks, err := inventory.GrantOnce("saved-health-food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}}) - if err != nil { - t.Fatal(err) - } - if _, _, _, err = food.Handle("/EatFood", foodRequest(1, 77, 21, stacks[0])); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 22 { - t.Fatalf("recovered saved health=%d want22", hp) - } - if len(inventory.All()) != 0 { - t.Fatal("food was not consumed") - } -} - -type failFoodStore struct { - stateio.AtomicEntryStore - fail bool -} - -func (s *failFoodStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { - if s.fail && domain == "characters" { - return errors.New("injected food save failure") - } - return s.AtomicEntryStore.SaveWithEntries(domain, core, changes) -} - -func TestEatFoodSQLiteRollbackRestoresInventoryHealthAndReplayLedger(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store := &failFoodStore{AtomicEntryStore: repo} - s, inventory, characters := foodTestService(t, store) - stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}}) - if err != nil { - t.Fatal(err) - } - if err = characters.SetCurrentHealth(77, 1); err != nil { - t.Fatal(err) - } - store.fail = true - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - request := foodRequest(1, 77, 21, stacks[0]) - if _, _, _, err = s.Handle("/EatFood", request); err == nil { - t.Fatal("injected failure was ignored") - } - if err = op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatalf("expected recovery fencing, got %v", err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - s, inventory, characters = foodTestService(t, repo) - if inventory.All()[0].Count != 1 { - t.Fatal("rollback lost inventory") - } - if hp, _ := characters.CurrentHealth(77); hp != 1 { - t.Fatalf("rollback HP=%d", hp) - } - ledger, err := repo.ListEntries("characters", "food_requests") - if err != nil || len(ledger) != 0 { - t.Fatalf("rollback ledger=%v err=%v", ledger, err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/EatFood", request); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if hp, _ := characters.CurrentHealth(77); hp != 16 { - t.Fatalf("recovery after rollback HP=%d", hp) - } -} diff --git a/go/internal/server/player/equipment.go b/go/internal/server/player/equipment.go deleted file mode 100644 index f2eab81..0000000 --- a/go/internal/server/player/equipment.go +++ /dev/null @@ -1,2059 +0,0 @@ -package player - -import ( - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "maps" - "math" - "reflect" - "sort" - "strconv" - "strings" - "sync" - "unicode/utf8" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -// Equipment is one server-owned equipment instance. The immutable definition -// (name, icon, slot and base stats) remains in the client's local GameData. -type Equipment struct { - InvenIndex uint64 `json:"inven_index"` - ID uint64 `json:"id"` - Level uint64 `json:"level"` - UseChar uint64 `json:"use_char,omitempty"` - KeepFlag uint64 `json:"keep_flag,omitempty"` - LockFlag uint64 `json:"lock_flag,omitempty"` - SortID uint64 `json:"sort_id,omitempty"` - Mark string `json:"mark,omitempty"` - MainOption []EquipmentOption `json:"main_option,omitempty"` - SubOption []EquipmentOption `json:"sub_option,omitempty"` - PrivateOption *EquipmentOption `json:"private_option,omitempty"` - Rank []uint64 `json:"rank,omitempty"` - UpgradeAttempts uint64 `json:"upgrade_attempts"` -} - -// EquipmentOption is the exact EquipmentOptionTable composite key used by an -// equipment instance. The client resolves the displayed stat curve locally. -type EquipmentOption struct { - GroupID uint64 `json:"group_id"` - ID uint64 `json:"id"` -} - -type equipmentSnapshot struct { - Version string `json:"version"` - NextIndex uint64 `json:"next_index"` - Equipment []Equipment `json:"-"` - Granted map[string]uint64 `json:"-"` -} - -// EquipmentInventory persists non-stackable equipment independently from -// ItemDBInfo inventory because the wire protocols are different types. -type EquipmentInventory struct { - mu sync.Mutex - store stateio.AtomicEntryStore - owned equipmentSnapshot - persisted equipmentSnapshot - corePresent bool - characters *CharacterStore - slots map[uint64]uint64 - upgrade *gamedata.EquipmentUpgradeDesign - craft *gamedata.EquipmentCraftDesign - smelting *gamedata.EquipmentSmeltingDesign - optionReroll *gamedata.EquipmentOptionRerollDesign - statDesign *gamedata.EquipmentStatDesign - wallet *Wallet - inventory *Inventory - sessionID string - smeltCache map[string]smeltingReply - pendingReroll *equipmentOptionRerollPending - presets map[equipmentPresetKey]equipmentPreset -} - -type smeltingReply struct { - code int - body []byte -} - -type equipmentOptionRerollPending struct { - Equipment Equipment `json:"equipment"` -} - -func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesign, wallet *Wallet, inventory *Inventory) error { - if design == nil || wallet == nil || inventory == nil { - return errors.New("player: incomplete equipment upgrade configuration") - } - s.mu.Lock() - defer s.mu.Unlock() - s.upgrade, s.wallet, s.inventory = design, wallet, inventory - return nil -} - -func (s *EquipmentInventory) AttachCraft(design *gamedata.EquipmentCraftDesign) error { - if design == nil { - return errors.New("player: incomplete equipment crafting configuration") - } - s.mu.Lock() - defer s.mu.Unlock() - s.craft = design - return nil -} - -func (s *EquipmentInventory) AttachSmelting(design *gamedata.EquipmentSmeltingDesign, wallet *Wallet, inventory *Inventory) error { - if design == nil || wallet == nil || inventory == nil { - return errors.New("player: incomplete equipment smelting configuration") - } - s.mu.Lock() - defer s.mu.Unlock() - s.smelting, s.wallet, s.inventory = design, wallet, inventory - return nil -} - -func (s *EquipmentInventory) AttachOptionReroll(design *gamedata.EquipmentOptionRerollDesign, wallet *Wallet, inventory *Inventory) error { - if design == nil || wallet == nil || inventory == nil { - return errors.New("player: incomplete equipment option reroll configuration") - } - s.mu.Lock() - defer s.mu.Unlock() - s.optionReroll, s.wallet, s.inventory = design, wallet, inventory - if s.pendingReroll != nil { - if err := s.validatePendingRerollLocked(s.pendingReroll); err != nil { - return err - } - } - return nil -} - -// BeginSession scopes the in-memory request replay cache. A repeated protobuf -// sequence in one login must return the first refinement result without a -// second roll or charge. -func (s *EquipmentInventory) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - if id == "" || id == s.sessionID { - return - } - s.sessionID = id - s.smeltCache = make(map[string]smeltingReply) -} - -func (s *EquipmentInventory) AttachSlots(slots map[uint64]uint64) error { - if len(slots) == 0 { - return errors.New("player: empty equipment slot design") - } - s.mu.Lock() - defer s.mu.Unlock() - s.slots = make(map[uint64]uint64, len(slots)) - for id, slot := range slots { - if id == 0 || slot > 4 { - return errors.New("player: invalid equipment slot design") - } - s.slots[id] = slot - } - return nil -} - -func (s *EquipmentInventory) AttachCharacters(characters *CharacterStore) error { - if characters == nil { - return errors.New("player: nil character store") - } - known := make(map[uint64]bool) - for _, character := range characters.RawAll() { - known[character.InvenIndex] = true - } - s.mu.Lock() - defer s.mu.Unlock() - for key := range s.presets { - if !known[key.CharacterIndex] { - return fmt.Errorf("player: equipment preset references unknown character %d", key.CharacterIndex) - } - } - s.characters = characters - return nil -} - -func OpenEquipmentInventory(store stateio.Store) (*EquipmentInventory, error) { - entries, ok := store.(stateio.AtomicEntryStore) - if !ok { - return nil, errors.New("player: nil equipment store") - } - s := &EquipmentInventory{store: entries, smeltCache: make(map[string]smeltingReply), presets: make(map[equipmentPresetKey]equipmentPreset), owned: equipmentSnapshot{ - Version: versionconfig.State(), NextIndex: 910000001, Granted: map[string]uint64{}, - }} - data, err := store.Load("equipment") - if err != nil { - return nil, err - } - if data != nil { - s.corePresent = true - if err := stateio.RequireExactJSONObject(data, "version", "next_index"); err != nil { - return nil, fmt.Errorf("player: incompatible equipment layout: %w", err) - } - var shape map[string]json.RawMessage - if err := json.Unmarshal(data, &shape); err != nil { - return nil, fmt.Errorf("player: decode equipment shape: %w", err) - } - for _, name := range []string{"equipment", "granted"} { - if _, exists := shape[name]; exists { - return nil, fmt.Errorf("player: equipment %s must use entries", name) - } - } - if err := json.Unmarshal(data, &s.owned); err != nil { - return nil, fmt.Errorf("player: decode equipment: %w", err) - } - } else if err := stateio.RequireNoEntries(entries, "equipment", "equipment", "granted", "reroll_pending", "presets"); err != nil { - return nil, fmt.Errorf("player: invalid equipment storage: %w", err) - } - if s.owned.Version != versionconfig.State() || s.owned.NextIndex < 910000001 { - return nil, errors.New("player: invalid saved equipment") - } - s.owned.Granted, err = loadUintEntries(entries, "equipment", "granted") - if err != nil { - return nil, err - } - rawEquipment, err := entries.ListEntries("equipment", "equipment") - if err != nil { - return nil, err - } - for key, value := range rawEquipment { - var entry Equipment - index, parseErr := strconv.ParseUint(key, 10, 64) - var shape map[string]json.RawMessage - if parseErr != nil || json.Unmarshal(value, &shape) != nil || json.Unmarshal(value, &entry) != nil || entry.InvenIndex != index { - return nil, fmt.Errorf("player: invalid equipment entry %q", key) - } - if _, present := shape["upgrade_attempts"]; !present { - return nil, errors.New("player: equipment save requires upgrade_attempts; migrate the development save") - } - s.owned.Equipment = append(s.owned.Equipment, entry) - } - sort.Slice(s.owned.Equipment, func(i, j int) bool { return s.owned.Equipment[i].InvenIndex < s.owned.Equipment[j].InvenIndex }) - for _, entry := range s.owned.Equipment { - if len(entry.Rank) != 3 { - return nil, fmt.Errorf("player: equipment %d requires exactly three rank slots, found %d; repair the development save before starting", entry.InvenIndex, len(entry.Rank)) - } - } - pendingEntries, err := entries.ListEntries("equipment", "reroll_pending") - if err != nil { - return nil, err - } - if len(pendingEntries) > 1 { - return nil, errors.New("player: multiple equipment option reroll candidates") - } - if raw, ok := pendingEntries["current"]; ok { - if err := stateio.RequireExactJSONObject(raw, "equipment"); err != nil { - return nil, fmt.Errorf("player: incompatible equipment option reroll candidate: %w", err) - } - var pending equipmentOptionRerollPending - if err := json.Unmarshal(raw, &pending); err != nil { - return nil, fmt.Errorf("player: decode equipment option reroll candidate: %w", err) - } - s.pendingReroll = &pending - if err := s.validatePendingRerollLocked(s.pendingReroll); err != nil { - return nil, err - } - } else if len(pendingEntries) != 0 { - return nil, errors.New("player: invalid equipment option reroll candidate key") - } - if err := s.loadEquipmentPresets(entries); err != nil { - return nil, err - } - s.persisted = cloneEquipmentSnapshot(s.owned) - return s, nil -} - -func (s *EquipmentInventory) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - data, err := s.store.Load("equipment") - if err != nil { - return err - } - if data != nil { - return nil - } - return s.commitLocked(cloneEquipmentSnapshot(s.owned), "initial account generation") -} - -// GrantOnce returns the same instance on a retry, allowing QuestClear response -// retries without duplicating ownership. -func (s *EquipmentInventory) GrantOnce(identity string, equipmentID uint64) (Equipment, error) { - if identity == "" || equipmentID == 0 { - return Equipment{}, errors.New("player: invalid equipment grant") - } - s.mu.Lock() - defer s.mu.Unlock() - if index := s.owned.Granted[identity]; index != 0 { - for _, current := range s.owned.Equipment { - if current.InvenIndex == index { - return current, nil - } - } - return Equipment{}, errors.New("player: equipment grant index is missing") - } - return s.grantLocked(identity, Equipment{ID: equipmentID}) -} - -// GrantGeneratedOnce saves an independently generated gacha instance. Retry -// calls return the original rolls rather than creating a second copy. -func (s *EquipmentInventory) GrantGeneratedOnce(identity string, entry Equipment) (Equipment, error) { - if identity == "" || entry.ID == 0 || len(entry.Rank) != 3 { - return Equipment{}, errors.New("player: invalid generated equipment") - } - s.mu.Lock() - defer s.mu.Unlock() - return s.grantLocked(identity, entry) -} - -func (s *EquipmentInventory) grantLocked(identity string, entry Equipment) (Equipment, error) { - if index := s.owned.Granted[identity]; index != 0 { - for _, current := range s.owned.Equipment { - if current.InvenIndex == index { - return current, nil - } - } - return Equipment{}, errors.New("player: equipment grant index is missing") - } - if len(entry.Rank) == 0 { - // Rank is a fixed three-slot client field (unlock thresholds +3/+6/+9). - // Even an unenhanced item must have three explicit zero values. - entry.Rank = []uint64{0, 0, 0} - } - if len(entry.Rank) != 3 { - return Equipment{}, errors.New("player: equipment requires exactly three rank slots") - } - entry.InvenIndex = s.owned.NextIndex - next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex + 1, - Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted)+1)} - next.Equipment = append(next.Equipment, entry) - maps.Copy(next.Granted, s.owned.Granted) - next.Granted[identity] = entry.InvenIndex - if err := s.commitLocked(next, "grant"); err != nil { - return Equipment{}, err - } - return entry, nil -} - -func EquipmentWire(entry Equipment) []byte { - base := wire.AppendVarint(nil, 1, entry.ID) - if entry.Level != 0 { - base = wire.AppendVarint(base, 2, entry.Level) - } - for _, option := range entry.MainOption { - base = wire.AppendBytes(base, 3, equipmentOptionWire(option)) - } - for _, option := range entry.SubOption { - base = wire.AppendBytes(base, 4, equipmentOptionWire(option)) - } - // The native ResolvedPrivateOption getter dereferences PrivateOption before - // checking IsValid. A present empty message selects its GameData fallback; - // an omitted field leaves the protobuf object null and crashes the UI. - var private []byte - if entry.PrivateOption != nil { - private = equipmentOptionWire(*entry.PrivateOption) - } - base = wire.AppendBytes(base, 5, private) - for _, rank := range entry.Rank { - base = wire.AppendVarint(base, 6, rank) - } - var out []byte - out = wire.AppendVarint(out, 1, entry.InvenIndex) - if entry.UseChar != 0 { - out = wire.AppendVarint(out, 2, entry.UseChar) - } - if entry.KeepFlag != 0 { - out = wire.AppendVarint(out, 3, entry.KeepFlag) - } - if entry.LockFlag != 0 { - out = wire.AppendVarint(out, 4, entry.LockFlag) - } - out = wire.AppendBytes(out, 5, base) - if entry.SortID != 0 { - out = wire.AppendVarint(out, 7, entry.SortID) - } - if entry.Mark != "" { - out = wire.AppendBytes(out, 8, []byte(entry.Mark)) - } - return out -} - -func equipmentOptionWire(option EquipmentOption) []byte { - out := wire.AppendVarint(nil, 1, option.GroupID) - return wire.AppendVarint(out, 2, option.ID) -} - -func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipBatchUse" && path != "/EquipPresetInfo" && path != "/EquipPresetSave" && path != "/EquipPresetNameChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipSmelting" && path != "/EquipSequenceSmelting" && path != "/EquipOptionReRoll" && path != "/EquipOptionReRollConfirm" && path != "/EquipMainOptChange" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" && path != "/EquipMaking" && path != "/EquipBreak" && path != "/EquipMakingToBreakAuto" && path != "/EquipUpgradeToBreakAuto" { - return 0, nil, false, nil - } - if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 { - return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path) - } - if path == "/EquipUse" { - return s.use(request) - } - if path == "/EquipClear" { - return s.clear(request) - } - if path == "/EquipBatchUse" { - return s.batchUse(request) - } - if path == "/EquipPresetInfo" { - return s.presetInfo() - } - if path == "/EquipPresetSave" { - return s.presetSave(request) - } - if path == "/EquipPresetNameChange" { - return s.presetNameChange(request) - } - if path == "/EquipUpgrade" { - return s.upgradeOnce(request) - } - if path == "/EquipSequenceUpgrade" { - return s.upgradeSequence(request) - } - if path == "/EquipMaking" { - return s.makeEquipment(request) - } - if path == "/EquipBreak" { - return s.breakEquipment(request) - } - if path == "/EquipMakingToBreakAuto" { - return s.makeToBreakAuto(request) - } - if path == "/EquipUpgradeToBreakAuto" { - return s.upgradeToBreakAuto(request) - } - if path == "/EquipSmelting" { - return s.smeltOnce(request) - } - if path == "/EquipSequenceSmelting" { - return s.smeltSequence(request) - } - if path == "/EquipOptionReRoll" { - return s.optionRerollRequest(request) - } - if path == "/EquipOptionReRollConfirm" { - return s.optionRerollConfirm(request) - } - if path == "/EquipMainOptChange" { - return s.mainOptionChange(request) - } - if path == "/EquipChange" { - return s.change(request) - } - if path == "/EquipMarkSet" || path == "/EquipMarkDelete" { - return s.mark(path, request) - } - if path == "/EquipLock" { - return s.lock(request) - } - s.mu.Lock() - defer s.mu.Unlock() - var response []byte - for _, entry := range s.owned.Equipment { - response = wire.AppendBytes(response, 1, EquipmentWire(entry)) - } - if s.pendingReroll != nil { - response = wire.AppendBytes(response, 2, EquipmentWire(s.pendingReroll.Equipment)) - } - return 34, response, true, nil -} - -func (s *EquipmentInventory) optionRerollRequest(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: EquipOptionReRoll missing equipment") - } - mainLocks, err := repeatedBoolField(request, 3, "EquipOptionReRoll main lock") - if err != nil { - return 0, nil, true, err - } - subLocks, err := repeatedBoolField(request, 4, "EquipOptionReRoll sub lock") - if err != nil { - return 0, nil, true, err - } - materials, err := equipmentRequestItems(request, 5, "EquipOptionReRoll") - if err != nil { - return 0, nil, true, err - } - rerollType, _, err := wire.Varint(request, 6) - if err != nil || rerollType > 1 { - return 0, nil, true, errors.New("player: EquipOptionReRoll unsupported reroll type") - } - - s.mu.Lock() - defer s.mu.Unlock() - cacheKey := s.smeltingCacheKey("option-reroll", seq) - if reply, ok := s.smeltCache[cacheKey]; ok { - return reply.code, append([]byte(nil), reply.body...), true, nil - } - if s.optionReroll == nil || s.wallet == nil || s.inventory == nil { - return 0, nil, true, errors.New("player: equipment option reroll unavailable") - } - position := s.equipmentPositionLocked(index) - if position < 0 { - return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll unknown equipment %d", index) - } - current := s.owned.Equipment[position] - base := current - if s.pendingReroll != nil { - if s.pendingReroll.Equipment.InvenIndex != index { - return 0, nil, true, errors.New("player: another equipment option reroll is awaiting confirmation") - } - // A retry from the result screen carries only lock masks. The values - // being locked are therefore the last candidate, not the still-official - // equipment options. Keep chaining candidates until confirm/keep clears - // the pending result. - base = s.pendingReroll.Equipment - } - definition, ok := s.optionReroll.Lookup(current.ID) - if !ok { - return 0, nil, true, fmt.Errorf("player: equipment %d has no option reroll design", current.ID) - } - if len(mainLocks) != len(base.MainOption) || len(mainLocks) != len(definition.MainGroups) || - len(subLocks) != len(base.SubOption) || len(subLocks) != len(definition.SubGroups) { - return 0, nil, true, errors.New("player: EquipOptionReRoll lock arrays do not match equipment options") - } - effectiveMainLocks := append([]bool(nil), mainLocks...) - if rerollType == 1 && len(effectiveMainLocks) != 0 { - // The client's has-another-option mode asks the server to leave the - // first main option alone. It is not a paid lock and remains false in - // the request mask. - effectiveMainLocks[0] = true - } - lockedCount, unlockedRerollable := uint64(0), uint64(0) - for i, locked := range mainLocks { - if base.MainOption[i].GroupID != definition.MainGroups[i] || base.MainOption[i].ID == 0 { - return 0, nil, true, fmt.Errorf("player: equipment %d main option %d does not match GameData", index, i) - } - canReroll := len(s.optionReroll.Groups[definition.MainGroups[i]].Choices) >= 2 - if locked && (i == 0 || !canReroll) { - return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll main option %d cannot be locked", i) - } - if locked { - lockedCount++ - } else if canReroll && !(rerollType == 1 && i == 0) { //nolint:staticcheck // QF1001 - unlockedRerollable++ - } - } - for i, locked := range subLocks { - if base.SubOption[i].GroupID != definition.SubGroups[i] || base.SubOption[i].ID == 0 { - return 0, nil, true, fmt.Errorf("player: equipment %d sub option %d does not match GameData", index, i) - } - canReroll := len(s.optionReroll.Groups[definition.SubGroups[i]].Choices) >= 2 - if locked && !canReroll { - return 0, nil, true, fmt.Errorf("player: EquipOptionReRoll sub option %d cannot be locked", i) - } - if locked { - lockedCount++ - } else if canReroll { - unlockedRerollable++ - } - } - if unlockedRerollable == 0 { - return 0, nil, true, errors.New("player: EquipOptionReRoll must leave a rerollable option unlocked") - } - costs, err := s.optionReroll.Cost(current.ID, lockedCount) - if err != nil { - return 0, nil, true, err - } - gold, consumed, err := validateOptionRerollMaterials(costs, materials, s.optionReroll.Conversion) - if err != nil { - return 0, nil, true, err - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return 0, nil, true, errors.New("player: insufficient gold for equipment option reroll") - } - if len(consumed) != 0 { - if err := s.inventory.CanConsume(consumed); err != nil { - return 0, nil, true, err - } - } - privateLocks := make([]bool, len(definition.PrivateGroups)) - for i := range privateLocks { - privateLocks[i] = true - } - rolled, err := s.optionReroll.RollUnlocked(current.ID, gamedata.EquipmentOptionRerollLocks{Main: effectiveMainLocks, Sub: subLocks, Private: privateLocks}) - if err != nil { - return 0, nil, true, err - } - candidate := cloneEquipment(base) - for i := range candidate.MainOption { - if !effectiveMainLocks[i] { - candidate.MainOption[i] = EquipmentOption{GroupID: rolled.Main[i].GroupID, ID: rolled.Main[i].ID} - } - } - for i := range candidate.SubOption { - if !subLocks[i] { - candidate.SubOption[i] = EquipmentOption{GroupID: rolled.Sub[i].GroupID, ID: rolled.Sub[i].ID} - } - } - pending := &equipmentOptionRerollPending{Equipment: candidate} - if err := s.commitOptionRerollLocked(pending, consumed, gold, "equip-option-reroll:"+cacheKey); err != nil { - return 0, nil, true, err - } - var response []byte - for _, option := range candidate.MainOption { - response = wire.AppendBytes(response, 1, equipmentOptionWire(option)) - } - for _, option := range candidate.SubOption { - response = wire.AppendBytes(response, 2, equipmentOptionWire(option)) - } - s.smeltCache[cacheKey] = smeltingReply{code: 192, body: append([]byte(nil), response...)} - return 192, response, true, nil -} - -func (s *EquipmentInventory) optionRerollConfirm(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: EquipOptionReRollConfirm missing equipment") - } - confirm, err := optionalBoolField(request, 3, "EquipOptionReRollConfirm confirm") - if err != nil { - return 0, nil, true, err - } - s.mu.Lock() - cacheKey := s.smeltingCacheKey("option-reroll-confirm", seq) - if reply, ok := s.smeltCache[cacheKey]; ok { - s.mu.Unlock() - return reply.code, append([]byte(nil), reply.body...), true, nil - } - if s.pendingReroll == nil || s.pendingReroll.Equipment.InvenIndex != index { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: equipment %d has no option reroll awaiting confirmation", index) - } - position := s.equipmentPositionLocked(index) - if position < 0 { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipOptionReRollConfirm unknown equipment %d", index) - } - next := cloneEquipmentSnapshot(s.owned) - if confirm { - next.Equipment[position].MainOption = append([]EquipmentOption(nil), s.pendingReroll.Equipment.MainOption...) - next.Equipment[position].SubOption = append([]EquipmentOption(nil), s.pendingReroll.Equipment.SubOption...) - } - if err := s.persistEquipmentState(next, nil, true, "option reroll confirm"); err != nil { - s.mu.Unlock() - return 0, nil, true, err - } - s.owned = next - s.persisted = cloneEquipmentSnapshot(next) - s.pendingReroll = nil - entry := cloneEquipment(next.Equipment[position]) - s.mu.Unlock() - response := wire.AppendBytes(nil, 1, EquipmentWire(entry)) - if character, ok := s.equippedCharacter(entry); ok { - response = wire.AppendBytes(response, 2, CharacterWire(character)) - } - s.mu.Lock() - s.smeltCache[cacheKey] = smeltingReply{code: 193, body: append([]byte(nil), response...)} - s.mu.Unlock() - return 193, response, true, nil -} - -func (s *EquipmentInventory) mainOptionChange(request []byte) (int, []byte, bool, error) { - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: EquipMainOptChange missing equipment") - } - groupID, groupFound, err := wire.Varint(request, 3) - if err != nil || !groupFound || groupID == 0 { - return 0, nil, true, errors.New("player: EquipMainOptChange missing option group") - } - optionID, optionFound, err := wire.Varint(request, 4) - if err != nil || !optionFound || optionID == 0 { - return 0, nil, true, errors.New("player: EquipMainOptChange missing option") - } - - s.mu.Lock() - locked := true - defer func() { - if locked { - s.mu.Unlock() - } - }() - if s.optionReroll == nil { - return 0, nil, true, errors.New("player: equipment option design unavailable") - } - position := s.equipmentPositionLocked(index) - if position < 0 { - return 0, nil, true, fmt.Errorf("player: EquipMainOptChange unknown equipment %d", index) - } - current := s.owned.Equipment[position] - definition, ok := s.optionReroll.Lookup(current.ID) - if !ok || definition.PrivateUniqueCharID == 0 || len(definition.MainGroups) == 0 || len(current.MainOption) == 0 { - return 0, nil, true, fmt.Errorf("player: equipment %d has no changeable main option", current.ID) - } - group, ok := s.optionReroll.Groups[groupID] - if !ok || groupID != definition.MainGroups[0] || current.MainOption[0].GroupID != groupID || len(group.Choices) < 2 || !optionChoiceExists(group, optionID) { - return 0, nil, true, fmt.Errorf("player: equipment %d main option %d/%d is not allowed", current.ID, groupID, optionID) - } - - next := cloneEquipmentSnapshot(s.owned) - next.Equipment[position].MainOption[0] = EquipmentOption{GroupID: groupID, ID: optionID} - pending := s.pendingReroll - pendingDirty := false - if pending != nil && pending.Equipment.InvenIndex == index { - copy := &equipmentOptionRerollPending{Equipment: cloneEquipment(pending.Equipment)} - if len(copy.Equipment.MainOption) == 0 || copy.Equipment.MainOption[0].GroupID != groupID { - return 0, nil, true, errors.New("player: option reroll candidate has no matching main option") - } - copy.Equipment.MainOption[0] = EquipmentOption{GroupID: groupID, ID: optionID} - pending = copy - pendingDirty = true - } - if err := s.persistEquipmentState(next, pending, pendingDirty, "main option change"); err != nil { - return 0, nil, true, err - } - s.owned = next - s.persisted = cloneEquipmentSnapshot(next) - if pendingDirty { - s.pendingReroll = pending - } - // Character health reads account equipment through pictorial ownership. - // Release the equipment lock before resolving the equipped character. - s.mu.Unlock() - locked = false - var response []byte - if character, ok := s.equippedCharacter(current); ok { - response = wire.AppendBytes(response, 1, CharacterWire(character)) - } - return 537, response, true, nil -} - -func repeatedBoolField(data []byte, number int, name string) ([]bool, error) { - var result []bool - err := wire.Walk(data, func(field wire.Field) error { - if field.Number != number { - return nil - } - switch field.Type { - case 0: - value, count := binary.Uvarint(field.Value) - if count <= 0 || value > 1 { - return fmt.Errorf("player: %s is invalid", name) - } - result = append(result, value == 1) - case 2: - for offset := 0; offset < len(field.Value); { - value, count := binary.Uvarint(field.Value[offset:]) - if count <= 0 || value > 1 { - return fmt.Errorf("player: %s is invalid", name) - } - result = append(result, value == 1) - offset += count - } - default: - return fmt.Errorf("player: %s is invalid", name) - } - return nil - }) - return result, err -} - -func optionalBoolField(data []byte, number int, name string) (bool, error) { - value := false - seen := false - err := wire.Walk(data, func(field wire.Field) error { - if field.Number != number { - return nil - } - if seen || field.Type != 0 { - return fmt.Errorf("player: %s is invalid", name) - } - raw, count := binary.Uvarint(field.Value) - if count <= 0 || raw > 1 { - return fmt.Errorf("player: %s is invalid", name) - } - seen = true - value = raw == 1 - return nil - }) - return value, err -} - -const ( - equipUpgradeSuccess = iota - equipUpgradeFail - equipUpgradeStopMaxLevel - equipUpgradeStopSuccess - equipUpgradeStopNotEnough - equipUpgradeStopGoldLimit - equipUpgradeStopTargetLevel - equipUpgradeStopMaxTryCount -) - -func (s *EquipmentInventory) upgradeOnce(request []byte) (int, []byte, bool, error) { - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: EquipUpgrade missing equipment") - } - var materials []Item - err = wire.Walk(request, func(field wire.Field) error { - if field.Number != 3 { - return nil - } - if field.Type != 2 { - return errors.New("player: EquipUpgrade invalid material") - } - var item Item - if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil { - return err - } - if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) { - return errors.New("player: EquipUpgrade invalid material") - } - materials = append(materials, item) - return nil - }) - if err != nil { - return 0, nil, true, err - } - if len(materials) == 0 { - return 0, nil, true, errors.New("player: EquipUpgrade has no material") - } - s.mu.Lock() - entry, success, _, _, err := s.attemptUpgradeLocked(index, materials) - s.mu.Unlock() - if err != nil { - return 0, nil, true, err - } - result := uint64(equipUpgradeFail) - if success { - result = equipUpgradeSuccess - } - response := wire.AppendBytes(nil, 1, EquipmentWire(entry)) - if result != 0 { - response = wire.AppendVarint(response, 2, result) - } - if character, ok := s.equippedCharacter(entry); ok { - response = wire.AppendBytes(response, 3, CharacterWire(character)) - } - return 37, response, true, nil -} - -func (s *EquipmentInventory) upgradeSequence(request []byte) (int, []byte, bool, error) { - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: EquipSequenceUpgrade missing equipment") - } - count, found, err := wire.Varint(request, 3) - if err != nil || !found || count == 0 || count > 100000 { - return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid attempt count") - } - goldLimit, _, err := wire.Varint(request, 5) - if err != nil { - return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid gold limit") - } - target, _, err := wire.Varint(request, 6) - if err != nil { - return 0, nil, true, errors.New("player: EquipSequenceUpgrade invalid target") - } - s.mu.Lock() - if s.upgrade == nil || s.wallet == nil { - s.mu.Unlock() - return 0, nil, true, errors.New("player: equipment upgrade unavailable") - } - position := s.equipmentPositionLocked(index) - if position < 0 { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipSequenceUpgrade unknown equipment %d", index) - } - maximum := s.upgrade.MaxLevel[s.owned.Equipment[position].ID] - if target == 0 || target > maximum { - target = maximum - } - var attempts, usedGold uint64 - result := uint64(equipUpgradeStopMaxTryCount) - var consumed []Item - var lack []Item - for attempts < count { - entry := s.owned.Equipment[position] - if entry.Level >= maximum { - result = equipUpgradeStopMaxLevel - break - } - if entry.Level >= target { - result = equipUpgradeStopTargetLevel - break - } - level, _, designErr := s.upgrade.Level(entry.ID, entry.Level) - if designErr != nil { - s.mu.Unlock() - return 0, nil, true, designErr - } - materials, gold, costErr := s.selectUpgradeCosts(level.Costs) - if costErr != nil { - result = equipUpgradeStopNotEnough - lack = upgradeLackItems(level.Costs) - break - } - if goldLimit != 0 && usedGold+gold > goldLimit { - result = equipUpgradeStopGoldLimit - break - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - result = equipUpgradeStopNotEnough - lack = upgradeLackItems(level.Costs) - break - } - updated, success, spent, actual, attemptErr := s.attemptUpgradeLocked(index, materials) - if attemptErr != nil { - s.mu.Unlock() - return 0, nil, true, attemptErr - } - attempts++ - usedGold += spent - consumed = append(consumed, actual...) - position = s.equipmentPositionLocked(index) - if success && updated.Level >= target { - result = equipUpgradeStopTargetLevel - if updated.Level >= maximum { - result = equipUpgradeStopMaxLevel - } - break - } - } - entry := s.owned.Equipment[position] - s.mu.Unlock() - response := wire.AppendBytes(nil, 1, EquipmentWire(entry)) - if character, ok := s.equippedCharacter(entry); ok { - response = wire.AppendBytes(response, 2, CharacterWire(character)) - } - response = wire.AppendVarint(response, 3, result) - response = wire.AppendVarint(response, 4, attempts) - for _, item := range consumed { - response = wire.AppendBytes(response, 5, ItemWire(item)) - } - for _, item := range lack { - response = wire.AppendBytes(response, 6, ItemWire(item)) - } - if usedGold != 0 { - response = wire.AppendVarint(response, 7, usedGold) - } - return 176, response, true, nil -} - -func (s *EquipmentInventory) smeltOnce(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: EquipSmelting missing equipment") - } - materials, err := equipmentRequestItems(request, 3, "EquipSmelting") - if err != nil { - return 0, nil, true, err - } - s.mu.Lock() - locked := true - defer func() { - if locked { - s.mu.Unlock() - } - }() - cacheKey := s.smeltingCacheKey("single", seq) - if reply, ok := s.smeltCache[cacheKey]; ok { - return reply.code, append([]byte(nil), reply.body...), true, nil - } - position, current, err := s.smeltingEquipmentLocked(index) - if err != nil { - return 0, nil, true, err - } - costs, err := s.smelting.Cost(current.ID) - if err != nil { - return 0, nil, true, err - } - gold, _, err := validateSmeltingMaterials(costs, materials) - if err != nil { - return 0, nil, true, err - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return 0, nil, true, errors.New("player: insufficient gold for equipment smelting") - } - var itemMaterials []Item - var consumedMileageMaterial uint64 - for _, item := range materials { - if item.Type != 4 { - itemMaterials = append(itemMaterials, item) - } - if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID { - consumedMileageMaterial += item.Count - } - } - if len(itemMaterials) != 0 { - if err := s.inventory.CanConsume(itemMaterials); err != nil { - return 0, nil, true, err - } - } - candidate, err := s.smelting.RollCandidate(current.ID) - if err != nil { - return 0, nil, true, err - } - currentScore, err := s.smelting.Score(current.ID, current.Rank) - if err != nil { - return 0, nil, true, err - } - candidateScore, err := s.smelting.Score(current.ID, candidate) - if err != nil { - return 0, nil, true, err - } - success := candidateScore > currentScore - next := cloneEquipmentSnapshot(s.owned) - if success { - next.Equipment[position].Rank = append([]uint64(nil), candidate...) - } - currency, earned, err := s.commitSmeltingLocked( - next, itemMaterials, gold, consumedMileageMaterial, - "equip-smelting:"+cacheKey, "smelting") - if err != nil { - return 0, nil, true, err - } - current = next.Equipment[position] - response := wire.AppendBytes(nil, 1, EquipmentWire(current)) - s.mu.Unlock() - locked = false - if character, ok := s.equippedCharacter(current); ok { - response = wire.AppendBytes(response, 2, CharacterWire(character)) - } - if !success { - response = wire.AppendVarint(response, 3, equipUpgradeFail) - for _, rank := range candidate { - response = wire.AppendVarint(response, 4, rank) - } - } - response = appendSmeltingMileage(response, 5, 6, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned) - s.mu.Lock() - s.smeltCache[cacheKey] = smeltingReply{code: 105, body: append([]byte(nil), response...)} - s.mu.Unlock() - return 105, response, true, nil -} - -func (s *EquipmentInventory) smeltSequence(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: EquipSequenceSmelting missing equipment") - } - count, found, err := wire.Varint(request, 3) - if err != nil || !found || count == 0 { - return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid attempt count") - } - target, _, err := wire.Varint(request, 4) - if err != nil { - return 0, nil, true, errors.New("player: EquipSequenceSmelting invalid target score") - } - s.mu.Lock() - locked := true - defer func() { - if locked { - s.mu.Unlock() - } - }() - cacheKey := s.smeltingCacheKey("sequence", seq) - if reply, ok := s.smeltCache[cacheKey]; ok { - return reply.code, append([]byte(nil), reply.body...), true, nil - } - position, current, err := s.smeltingEquipmentLocked(index) - if err != nil { - return 0, nil, true, err - } - if count > s.smelting.MaxStreak { - return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting attempt count %d exceeds %d", count, s.smelting.MaxStreak) - } - maximumRanks, err := s.smelting.MaximumRanks(current.ID) - if err != nil { - return 0, nil, true, err - } - maximumScore, err := s.smelting.Score(current.ID, maximumRanks) - if err != nil { - return 0, nil, true, err - } - if target > maximumScore { - return 0, nil, true, fmt.Errorf("player: EquipSequenceSmelting target %d exceeds %d", target, maximumScore) - } - costs, err := s.smelting.Cost(current.ID) - if err != nil { - return 0, nil, true, err - } - currentScore, err := s.smelting.Score(current.ID, current.Rank) - if err != nil { - return 0, nil, true, err - } - // The 2.35.10 client splits a requested sequence into packets of at most - // 1000 attempts. Exhausting this packet is not the user's global max-try - // stop: UPGRADE_SUCCESS tells the client to send the next chunk. Terminal - // stop values are reserved for target/max score and insufficient resources. - result := uint64(equipUpgradeSuccess) - var attempts, successes uint64 - if currentScore >= maximumScore { - result = equipUpgradeStopMaxLevel - } else if target != 0 && currentScore >= target { - result = equipUpgradeStopTargetLevel - } - for attempts < count && result == equipUpgradeSuccess { - if _, _, selectErr := s.selectSmeltingCosts(costs, attempts+1); selectErr != nil { - result = equipUpgradeStopNotEnough - break - } - candidate, rollErr := s.smelting.RollCandidate(current.ID) - if rollErr != nil { - return 0, nil, true, rollErr - } - candidateScore, scoreErr := s.smelting.Score(current.ID, candidate) - if scoreErr != nil { - return 0, nil, true, scoreErr - } - attempts++ - if candidateScore > currentScore { - current.Rank = append([]uint64(nil), candidate...) - currentScore = candidateScore - successes++ - } - if currentScore >= maximumScore { - result = equipUpgradeStopMaxLevel - } else if target != 0 && currentScore >= target { - result = equipUpgradeStopTargetLevel - } - } - var consumed, lack []Item - var gold, mileageMaterial, earned uint64 - currency := s.wallet.Snapshot() - if attempts != 0 { - consumed, gold, err = s.selectSmeltingCosts(costs, attempts) - if err != nil { - return 0, nil, true, err - } - for _, item := range consumed { - if item.Type == s.smelting.Mileage.UseType && item.ID == s.smelting.Mileage.UseID { - mileageMaterial += item.Count - } - } - var itemMaterials []Item - for _, item := range consumed { - if item.Type != 4 { - itemMaterials = append(itemMaterials, item) - } - } - next := cloneEquipmentSnapshot(s.owned) - next.Equipment[position].Rank = append([]uint64(nil), current.Rank...) - currency, earned, err = s.commitSmeltingLocked( - next, itemMaterials, gold, mileageMaterial, - "equip-sequence-smelting:"+cacheKey, "sequence smelting") - if err != nil { - return 0, nil, true, err - } - current = next.Equipment[position] - } - // NotEnough is used only when the next requested attempt could not be - // funded. Exhausting this packet retains Success so the client can continue - // a sequence whose total requested count exceeds the 1000-attempt chunk. - if result == equipUpgradeStopNotEnough { - lack = upgradeLackItems(costs) - } - response := wire.AppendBytes(nil, 1, EquipmentWire(current)) - s.mu.Unlock() - locked = false - if character, ok := s.equippedCharacter(current); ok { - response = wire.AppendBytes(response, 2, CharacterWire(character)) - } - response = wire.AppendVarint(response, 3, result) - response = wire.AppendVarint(response, 4, attempts) - for _, item := range consumed { - response = wire.AppendBytes(response, 5, ItemWire(item)) - } - for _, item := range lack { - response = wire.AppendBytes(response, 6, ItemWire(item)) - } - response = appendSmeltingMileage(response, 7, 8, currency.EquipMileageExchangeGage, s.smelting.Mileage, earned) - if successes != 0 { - response = wire.AppendVarint(response, 9, successes) - } - s.mu.Lock() - s.smeltCache[cacheKey] = smeltingReply{code: 177, body: append([]byte(nil), response...)} - s.mu.Unlock() - return 177, response, true, nil -} - -func (s *EquipmentInventory) smeltingCacheKey(kind string, seq uint64) string { - return kind + ":" + s.sessionID + ":seq:" + strconv.FormatUint(seq, 10) -} - -func (s *EquipmentInventory) smeltingEquipmentLocked(index uint64) (int, Equipment, error) { - if s.smelting == nil || s.wallet == nil || s.inventory == nil { - return -1, Equipment{}, errors.New("player: equipment smelting unavailable") - } - position := s.equipmentPositionLocked(index) - if position < 0 { - return -1, Equipment{}, fmt.Errorf("player: unknown equipment %d", index) - } - entry := s.owned.Equipment[position] - design, ok := s.smelting.Equipment[entry.ID] - if !ok || entry.Level != design.MaxLevel || len(entry.Rank) != 3 { - return -1, Equipment{}, fmt.Errorf("player: equipment %d is not ready for smelting", index) - } - if _, err := s.smelting.Score(entry.ID, entry.Rank); err != nil { - return -1, Equipment{}, fmt.Errorf("player: equipment %d has invalid smelting rank: %w", index, err) - } - return position, entry, nil -} - -func equipmentRequestItems(request []byte, number int, operation string) ([]Item, error) { - var result []Item - err := wire.Walk(request, func(field wire.Field) error { - if field.Number != number { - return nil - } - if field.Type != 2 { - return fmt.Errorf("player: %s invalid material", operation) - } - var item Item - if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil { - return err - } - if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) { - return fmt.Errorf("player: %s invalid material", operation) - } - result = append(result, item) - return nil - }) - if err != nil { - return nil, err - } - if len(result) == 0 { - return nil, fmt.Errorf("player: %s has no material", operation) - } - return result, nil -} - -func validateSmeltingMaterials(costs []gamedata.PromotionCost, materials []Item) (gold, mileageMaterial uint64, err error) { - want := make(map[[2]uint64]uint64, len(costs)) - for _, cost := range costs { - want[[2]uint64{cost.Type, cost.ID}] += cost.Count - } - got := make(map[[2]uint64]uint64, len(materials)) - for _, item := range materials { - got[[2]uint64{item.Type, item.ID}] += item.Count - if item.Type == 4 { - gold += item.Count - } else { - mileageMaterial += item.Count - } - } - if len(got) != len(want) { - return 0, 0, errors.New("player: equipment smelting material kinds mismatch") - } - for key, count := range want { - if got[key] != count { - return 0, 0, fmt.Errorf("player: equipment smelting material %d/%d=%d want=%d", key[0], key[1], got[key], count) - } - } - return gold, mileageMaterial, nil -} - -func (s *EquipmentInventory) selectSmeltingCosts(costs []gamedata.PromotionCost, attempts uint64) ([]Item, uint64, error) { - var result []Item - var gold uint64 - for _, cost := range costs { - if attempts != 0 && cost.Count > ^uint64(0)/attempts { - return nil, 0, errors.New("player: equipment smelting cost overflow") - } - count := cost.Count * attempts - switch cost.Type { - case 4: - if cost.ID != 0 || gold != 0 || !s.wallet.CanSpendGold(count) { - return nil, 0, errors.New("player: insufficient equipment smelting gold") - } - gold = count - result = append(result, Item{Type: 4, Count: count}) - case 8: - items, err := s.inventory.SelectMutable(cost.Type, cost.ID, count) - if err != nil { - return nil, 0, err - } - result = append(result, items...) - default: - return nil, 0, fmt.Errorf("player: unsupported equipment smelting cost type %d", cost.Type) - } - } - return result, gold, nil -} - -func appendSmeltingMileage(response []byte, gaugeField, rewardField int, gauge uint64, mileage gamedata.EquipmentSmeltingMileage, earned uint64) []byte { - if gauge != 0 { - response = wire.AppendVarint(response, gaugeField, gauge) - } - if earned != 0 { - reward := ItemWire(Item{ID: mileage.RewardID, Type: mileage.RewardType, Count: earned}) - bundle := wire.AppendBytes(nil, 1, reward) - response = wire.AppendBytes(response, rewardField, bundle) - } - return response -} - -// commitSmeltingLocked keeps refinement's three typed snapshots synchronized -// in memory. Caller holds equipment.mu; this method takes the remaining locks -// in wallet -> inventory order, calculates every candidate before writing, -// then publishes all three only after their saves succeed. The surrounding -// request transaction supplies durable all-or-none recovery across the writes. -func (s *EquipmentInventory) commitSmeltingLocked( - nextEquipment equipmentSnapshot, - consumed []Item, - gold, mileageMaterial uint64, - identity, operation string, -) (Currency, uint64, error) { - if identity == "" || mileageMaterial == 0 || s.wallet == nil || s.inventory == nil { - return Currency{}, 0, errors.New("player: invalid transactional equipment smelting") - } - s.wallet.mu.Lock() - defer s.wallet.mu.Unlock() - s.inventory.mu.Lock() - defer s.inventory.mu.Unlock() - - nextWallet := cloneWallet(s.wallet.state) - if nextWallet.Spent[identity] { - return Currency{}, 0, errors.New("player: equipment smelting request was already committed") - } - if nextWallet.Gold < gold { - return Currency{}, 0, errors.New("player: insufficient gold for equipment smelting") - } - nextWallet.Gold -= gold - nextWallet.Spent[identity] = true - threshold, rewardCount := s.smelting.Mileage.UseCount, s.smelting.Mileage.RewardCount - if threshold == 0 || rewardCount == 0 || nextWallet.EquipMileageExchangeGage >= threshold || - math.MaxUint64-nextWallet.EquipMileageExchangeGage < mileageMaterial { - return Currency{}, 0, errors.New("player: invalid equipment smelting gauge") - } - total := nextWallet.EquipMileageExchangeGage + mileageMaterial - exchanges := total / threshold - earned := exchanges * rewardCount - if exchanges != 0 && earned/exchanges != rewardCount || math.MaxUint64-nextWallet.EquipMileage < earned { - return Currency{}, 0, errors.New("player: equipment mileage overflow") - } - nextWallet.EquipMileageExchangeGage = total % threshold - nextWallet.EquipMileage += earned - - nextItems := cloneOwnedSnapshot(s.inventory.owned) - for _, want := range consumed { - if err := consumeOwnedItem(&nextItems, want); err != nil { - return Currency{}, 0, err - } - } - - walletData, err := json.Marshal(nextWallet) - if err != nil { - return Currency{}, 0, err - } - if err := s.store.SaveWithEntries("wallet", walletData, entry("spent", identity, []byte("true"))); err != nil { - return Currency{}, 0, fmt.Errorf("player: persist equipment %s wallet: %w", operation, err) - } - if err := s.inventory.commitOwned(nextItems); err != nil { - return Currency{}, 0, fmt.Errorf("player: persist equipment %s items: %w", operation, err) - } - if err := s.persistEquipment(nextEquipment, operation); err != nil { - return Currency{}, 0, err - } - s.owned = nextEquipment - s.persisted = cloneEquipmentSnapshot(nextEquipment) - s.wallet.state = nextWallet - s.inventory.owned = nextItems - return nextWallet.Currency, earned, nil -} - -func consumeOwnedItem(next *ownedSnapshot, want Item) error { - if next == nil || want.InvenIndex == 0 || want.ID == 0 || want.Type == 0 || want.Count == 0 { - return errors.New("player: invalid item consumption") - } - for i, current := range next.Items { - if current.InvenIndex != want.InvenIndex { - continue - } - if current.ID != want.ID || current.Type != want.Type || current.Count < want.Count { - return fmt.Errorf("player: item %d consumption mismatch", want.InvenIndex) - } - current.Count -= want.Count - if current.Count == 0 { - next.Items = append(next.Items[:i], next.Items[i+1:]...) - } else { - next.Items[i] = current - } - return nil - } - return fmt.Errorf("player: item %d is not mutable-owned", want.InvenIndex) -} - -func validateOptionRerollMaterials(costs []gamedata.PromotionCost, materials []Item, conversion *gamedata.EquipmentOptionRerollConversion) (uint64, []Item, error) { - if len(costs) == 0 || len(materials) == 0 { - return 0, nil, errors.New("player: EquipOptionReRoll has no material") - } - expected := make(map[[2]uint64]uint64, len(costs)) - for _, cost := range costs { - key := [2]uint64{cost.Type, cost.ID} - if cost.Type == 0 || cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || math.MaxUint64-expected[key] < cost.Count { - return 0, nil, errors.New("player: invalid equipment option reroll cost") - } - expected[key] += cost.Count - } - actual := make(map[[2]uint64]uint64, len(materials)) - consumed := make([]Item, 0, len(materials)) - for _, material := range materials { - key := [2]uint64{material.Type, material.ID} - if math.MaxUint64-actual[key] < material.Count { - return 0, nil, errors.New("player: EquipOptionReRoll material count overflows") - } - actual[key] += material.Count - if material.Type != 4 { - consumed = append(consumed, material) - } - } - for key, want := range expected { - if conversion != nil && key == ([2]uint64{conversion.TargetType, conversion.TargetID}) && - expected[[2]uint64{conversion.SourceType, conversion.SourceID}] == 0 { - targetKey := key - sourceKey := [2]uint64{conversion.SourceType, conversion.SourceID} - targetCount := actual[targetKey] - if targetCount > want || conversion.Ratio == 0 || want-targetCount > math.MaxUint64/conversion.Ratio || actual[sourceKey] != (want-targetCount)*conversion.Ratio { - return 0, nil, fmt.Errorf("player: EquipOptionReRoll converted material %d/%d does not match cost", key[0], key[1]) - } - delete(actual, targetKey) - delete(actual, sourceKey) - continue - } - if actual[key] != want { - return 0, nil, fmt.Errorf("player: EquipOptionReRoll material %d/%d=%d want=%d", key[0], key[1], actual[key], want) - } - delete(actual, key) - } - if len(actual) != 0 { - return 0, nil, errors.New("player: EquipOptionReRoll material kinds mismatch") - } - return expected[[2]uint64{4, 0}], consumed, nil -} - -func (s *EquipmentInventory) commitOptionRerollLocked(pending *equipmentOptionRerollPending, consumed []Item, gold uint64, identity string) error { - if pending == nil || identity == "" || s.wallet == nil || s.inventory == nil { - return errors.New("player: invalid transactional equipment option reroll") - } - s.wallet.mu.Lock() - defer s.wallet.mu.Unlock() - s.inventory.mu.Lock() - defer s.inventory.mu.Unlock() - - nextWallet := cloneWallet(s.wallet.state) - if nextWallet.Spent[identity] { - return errors.New("player: equipment option reroll request was already committed") - } - if nextWallet.Gold < gold { - return errors.New("player: insufficient gold for equipment option reroll") - } - nextWallet.Gold -= gold - nextWallet.Spent[identity] = true - nextItems := cloneOwnedSnapshot(s.inventory.owned) - for _, want := range consumed { - if err := consumeOwnedItem(&nextItems, want); err != nil { - return err - } - } - walletData, err := json.Marshal(nextWallet) - if err != nil { - return err - } - if err := s.store.SaveWithEntries("wallet", walletData, entry("spent", identity, []byte("true"))); err != nil { - return fmt.Errorf("player: persist equipment option reroll wallet: %w", err) - } - if err := s.inventory.commitOwned(nextItems); err != nil { - return fmt.Errorf("player: persist equipment option reroll items: %w", err) - } - if err := s.persistEquipmentState(s.owned, pending, true, "option reroll"); err != nil { - return err - } - s.wallet.state = nextWallet - s.inventory.owned = nextItems - s.pendingReroll = &equipmentOptionRerollPending{Equipment: cloneEquipment(pending.Equipment)} - return nil -} - -func upgradeLackItems(costs []gamedata.PromotionCost) []Item { - items := make([]Item, 0, len(costs)) - for _, cost := range costs { - items = append(items, Item{ID: cost.ID, Type: cost.Type, Count: cost.Count}) - } - return items -} - -func (s *EquipmentInventory) equipmentPositionLocked(index uint64) int { - for i := range s.owned.Equipment { - if s.owned.Equipment[i].InvenIndex == index { - return i - } - } - return -1 -} - -func (s *EquipmentInventory) selectUpgradeCosts(costs []gamedata.PromotionCost) ([]Item, uint64, error) { - var selected []Item - var gold uint64 - for _, cost := range costs { - switch cost.Type { - case 4: - if cost.ID != 0 || cost.Count == 0 || gold != 0 { - return nil, 0, errors.New("player: invalid equipment upgrade gold cost") - } - gold = cost.Count - selected = append(selected, Item{Type: 4, Count: cost.Count}) - case 8: - items, err := s.inventory.SelectMutable(cost.Type, cost.ID, cost.Count) - if err != nil { - return nil, 0, err - } - selected = append(selected, items...) - default: - return nil, 0, fmt.Errorf("player: unsupported equipment upgrade cost type %d", cost.Type) - } - } - return selected, gold, nil -} - -func (s *EquipmentInventory) attemptUpgradeLocked(index uint64, materials []Item) (Equipment, bool, uint64, []Item, error) { - if s.upgrade == nil || s.wallet == nil { - return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade unavailable") - } - position := s.equipmentPositionLocked(index) - if position < 0 { - return Equipment{}, false, 0, nil, fmt.Errorf("player: unknown equipment %d", index) - } - current := s.owned.Equipment[position] - level, _, err := s.upgrade.Level(current.ID, current.Level) - if err != nil { - return Equipment{}, false, 0, nil, err - } - want := make(map[[2]uint64]uint64, len(level.Costs)) - for _, cost := range level.Costs { - want[[2]uint64{cost.Type, cost.ID}] += cost.Count - } - got := make(map[[2]uint64]uint64) - var gold uint64 - var items []Item - for _, material := range materials { - got[[2]uint64{material.Type, material.ID}] += material.Count - if material.Type == 4 { - if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { - return Equipment{}, false, 0, nil, errors.New("player: invalid equipment upgrade currency") - } - gold = material.Count - } else { - items = append(items, material) - } - } - if len(got) != len(want) { - return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material kinds mismatch") - } - for key, count := range want { - if got[key] != count { - return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment upgrade material %d/%d=%d want=%d", key[0], key[1], got[key], count) - } - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return Equipment{}, false, 0, nil, errors.New("player: insufficient gold for equipment upgrade") - } - if len(items) != 0 { - if s.inventory == nil { - return Equipment{}, false, 0, nil, errors.New("player: equipment upgrade inventory unavailable") - } - if err := s.inventory.CanConsume(items); err != nil { - return Equipment{}, false, 0, nil, err - } - } - // Draw before any cross-store write: RNG failure must never charge the - // player. Ordinary enhancement also unlocks an official grade at the - // +3/+6/+9 pivots; smelting can later change those grades, but is separate. - success, err := s.upgrade.Roll(level.SuccessRatio) - if err != nil { - return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment upgrade: %w", err) - } - var rankSlot, rankValue uint64 - if success { - nextLevel := current.Level + 1 - switch nextLevel { - case 3: - rankSlot = 1 - case 6: - rankSlot = 2 - case 9: - rankSlot = 3 - } - if rankSlot != 0 { - if len(current.Rank) != 3 || current.Rank[rankSlot-1] != 0 { - return Equipment{}, false, 0, nil, fmt.Errorf("player: equipment %d invalid rank state at +%d", index, nextLevel) - } - rankValue, err = s.upgrade.RollRank(current.ID, rankSlot) - if err != nil { - return Equipment{}, false, 0, nil, fmt.Errorf("player: roll equipment rank: %w", err) - } - } - } - attempt := current.UpgradeAttempts + 1 - identity := "equip-upgrade:" + strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(attempt, 10) - if gold != 0 { - if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { - return Equipment{}, false, 0, nil, fmt.Errorf("player: spend equipment upgrade gold: %w", err) - } - } - if len(items) != 0 { - if err := s.inventory.Consume(items); err != nil { - return Equipment{}, false, 0, nil, fmt.Errorf("player: consume equipment upgrade items: %w", err) - } - } - next := cloneEquipmentSnapshot(s.owned) - next.Equipment[position].UpgradeAttempts = attempt - if success { - next.Equipment[position].Level++ - if rankSlot != 0 { - next.Equipment[position].Rank[rankSlot-1] = rankValue - } - } - if err := s.commitLocked(next, "upgrade"); err != nil { - return Equipment{}, false, 0, nil, err - } - return next.Equipment[position], success, gold, materials, nil -} - -func (s *EquipmentInventory) equippedCharacter(entry Equipment) (Character, bool) { - if entry.UseChar == 0 || s.characters == nil { - return Character{}, false - } - return s.characters.Find(entry.UseChar) -} - -func (s *EquipmentInventory) clear(request []byte) (int, []byte, bool, error) { - equipmentIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || equipmentIndex == 0 { - return 0, nil, true, errors.New("player: EquipClear missing equipment") - } - characterIndex, found, err := wire.Varint(request, 3) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: EquipClear missing character") - } - if s.characters == nil { - return 0, nil, true, errors.New("player: EquipClear character store unavailable") - } - if _, found := s.characters.Find(characterIndex); !found { - return 0, nil, true, fmt.Errorf("player: EquipClear unknown character %d", characterIndex) - } - s.mu.Lock() - next := cloneEquipmentSnapshot(s.owned) - position := -1 - for i := range next.Equipment { - if next.Equipment[i].InvenIndex == equipmentIndex { - position = i - break - } - } - if position < 0 { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipClear unknown equipment %d", equipmentIndex) - } - if next.Equipment[position].UseChar != characterIndex { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipClear equipment %d is not used by character %d", equipmentIndex, characterIndex) - } - next.Equipment[position].UseChar = 0 - if err := s.commitLocked(next, "clear"); err != nil { - s.mu.Unlock() - return 0, nil, true, err - } - s.mu.Unlock() - // Re-query after the equipment mutation so the shared stat calculator - // returns HP with the cleared item excluded. - character, found := s.characters.Find(characterIndex) - if !found { - return 0, nil, true, fmt.Errorf("player: EquipClear character %d disappeared", characterIndex) - } - return 36, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil -} - -func (s *EquipmentInventory) lock(request []byte) (int, []byte, bool, error) { - equipmentIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || equipmentIndex == 0 { - return 0, nil, true, errors.New("player: EquipLock missing equipment") - } - // LockFlag is int32, but proto3 omits its zero value. A missing field 3 is - // therefore the normal unlock request; present values are restricted to 1. - lockFlag, present, err := wire.Varint(request, 3) - if err != nil || (present && lockFlag != 1) { - return 0, nil, true, errors.New("player: EquipLock invalid lock flag") - } - if !present { - lockFlag = 0 - } - s.mu.Lock() - defer s.mu.Unlock() - next := cloneEquipmentSnapshot(s.owned) - position := -1 - for i := range next.Equipment { - if next.Equipment[i].InvenIndex == equipmentIndex { - position = i - break - } - } - if position < 0 { - return 0, nil, true, fmt.Errorf("player: EquipLock unknown equipment %d", equipmentIndex) - } - next.Equipment[position].LockFlag = lockFlag - if err := s.commitLocked(next, "lock"); err != nil { - return 0, nil, true, err - } - return 38, nil, true, nil -} - -func (s *EquipmentInventory) mark(path string, request []byte) (int, []byte, bool, error) { - equipmentIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || equipmentIndex == 0 { - return 0, nil, true, fmt.Errorf("player: %s missing equipment", strings.TrimPrefix(path, "/")) - } - mark := "" - packetCode := 397 - if path == "/EquipMarkSet" { - raw, present, fieldErr := wire.Bytes(request, 3) - if fieldErr != nil || !present || !validEquipmentMark(raw) { - return 0, nil, true, errors.New("player: EquipMarkSet invalid mark") - } - mark = string(raw) - packetCode = 396 - } - s.mu.Lock() - defer s.mu.Unlock() - next := cloneEquipmentSnapshot(s.owned) - position := -1 - for i := range next.Equipment { - if next.Equipment[i].InvenIndex == equipmentIndex { - position = i - break - } - } - if position < 0 { - return 0, nil, true, fmt.Errorf("player: %s unknown equipment %d", strings.TrimPrefix(path, "/"), equipmentIndex) - } - next.Equipment[position].Mark = mark - if err := s.commitLocked(next, strings.TrimPrefix(path, "/")); err != nil { - return 0, nil, true, err - } - return packetCode, nil, true, nil -} - -// EquipmentInfo.MakeStringCustomMarkData emits either "~|" -// (icon 1..15) or a text mark followed by "|". Text input is capped -// by the 2-byte MAXIMUM_CUSTOMMARK_TEXT_SIZE in CustomSettingPopupUI; a -// single character is prefixed with '_' so it cannot be confused with an ID. -func validEquipmentMark(raw []byte) bool { - if len(raw) == 0 || len(raw) > 8 || !utf8.Valid(raw) { - return false - } - parts := strings.Split(string(raw), "|") - if len(parts) != 2 { - return false - } - color, err := strconv.Atoi(parts[1]) - if err != nil || color < 0 || color > 5 { - return false - } - left := parts[0] - if suffix, found := strings.CutPrefix(left, "~"); found { - icon, err := strconv.Atoi(suffix) - return err == nil && icon >= 1 && icon <= 15 - } - if text, found := strings.CutPrefix(left, "_"); found { - return utf8.RuneCountInString(text) == 1 && len([]byte(text)) <= 2 - } - return len(left) >= 1 && len([]byte(left)) <= 2 -} - -func (s *EquipmentInventory) change(request []byte) (int, []byte, bool, error) { - equipmentIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || equipmentIndex == 0 { - return 0, nil, true, errors.New("player: EquipChange missing equipment") - } - characterIndex, found, err := wire.Varint(request, 3) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: EquipChange missing character") - } - if s.characters == nil { - return 0, nil, true, errors.New("player: EquipChange character store unavailable") - } - if _, found := s.characters.Find(characterIndex); !found { - return 0, nil, true, fmt.Errorf("player: EquipChange unknown character %d", characterIndex) - } - - s.mu.Lock() - if len(s.slots) == 0 { - s.mu.Unlock() - return 0, nil, true, errors.New("player: EquipChange slot design unavailable") - } - next := cloneEquipmentSnapshot(s.owned) - position := -1 - for i := range next.Equipment { - if next.Equipment[i].InvenIndex == equipmentIndex { - position = i - break - } - } - if position < 0 { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipChange unknown equipment %d", equipmentIndex) - } - selected := next.Equipment[position] - slot, exists := s.slots[selected.ID] - if !exists { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipChange equipment design %d not found", selected.ID) - } - if selected.UseChar != 0 && selected.UseChar != characterIndex { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipChange equipment %d belongs to another character", equipmentIndex) - } - replaced := false - for i := range next.Equipment { - current := &next.Equipment[i] - if current.InvenIndex == equipmentIndex || current.UseChar != characterIndex { - continue - } - currentSlot, known := s.slots[current.ID] - if !known { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipChange equipped design %d not found", current.ID) - } - if currentSlot == slot { - current.UseChar = 0 - replaced = true - } - } - if !replaced { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipChange character %d has no equipment in slot %d", characterIndex, slot) - } - next.Equipment[position].UseChar = characterIndex - if err := s.commitLocked(next, "change"); err != nil { - s.mu.Unlock() - return 0, nil, true, err - } - s.mu.Unlock() - - // Max HP depends on the now-current equipment set, so build CharInfo only - // after the atomic equipment save is visible to the shared stat calculator. - character, found := s.characters.Find(characterIndex) - if !found { - return 0, nil, true, fmt.Errorf("player: EquipChange character %d disappeared", characterIndex) - } - // PacketCodeTypeProto orders EquipChange at 45 (EquipInfo is 34). - return 45, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil -} - -func cloneEquipmentSnapshot(current equipmentSnapshot) equipmentSnapshot { - next := equipmentSnapshot{Version: current.Version, NextIndex: current.NextIndex, - Equipment: append([]Equipment(nil), current.Equipment...), Granted: make(map[string]uint64, len(current.Granted))} - for i := range next.Equipment { - next.Equipment[i] = cloneEquipment(next.Equipment[i]) - } - maps.Copy(next.Granted, current.Granted) - return next -} - -func cloneEquipment(current Equipment) Equipment { - next := current - next.MainOption = append([]EquipmentOption(nil), current.MainOption...) - next.SubOption = append([]EquipmentOption(nil), current.SubOption...) - next.Rank = append([]uint64(nil), current.Rank...) - if current.PrivateOption != nil { - option := *current.PrivateOption - next.PrivateOption = &option - } - return next -} - -func (s *EquipmentInventory) validatePendingRerollLocked(pending *equipmentOptionRerollPending) error { - if pending == nil || pending.Equipment.InvenIndex == 0 || pending.Equipment.ID == 0 { - return errors.New("player: invalid equipment option reroll candidate") - } - position := s.equipmentPositionLocked(pending.Equipment.InvenIndex) - if position < 0 { - return fmt.Errorf("player: option reroll candidate references unknown equipment %d", pending.Equipment.InvenIndex) - } - current := s.owned.Equipment[position] - if len(pending.Equipment.MainOption) != len(current.MainOption) || len(pending.Equipment.SubOption) != len(current.SubOption) { - return errors.New("player: option reroll candidate option counts do not match equipment") - } - for i, option := range pending.Equipment.MainOption { - if option.GroupID != current.MainOption[i].GroupID || option.ID == 0 { - return fmt.Errorf("player: option reroll candidate main option %d is invalid", i) - } - } - for i, option := range pending.Equipment.SubOption { - if option.GroupID != current.SubOption[i].GroupID || option.ID == 0 { - return fmt.Errorf("player: option reroll candidate sub option %d is invalid", i) - } - } - if s.optionReroll != nil { - definition, ok := s.optionReroll.Lookup(current.ID) - if !ok || len(definition.MainGroups) != len(pending.Equipment.MainOption) || len(definition.SubGroups) != len(pending.Equipment.SubOption) { - return errors.New("player: option reroll candidate has no matching GameData design") - } - for i, option := range pending.Equipment.MainOption { - if option.GroupID != definition.MainGroups[i] || !optionChoiceExists(s.optionReroll.Groups[option.GroupID], option.ID) { - return fmt.Errorf("player: option reroll candidate main option %d is not in GameData", i) - } - } - for i, option := range pending.Equipment.SubOption { - if option.GroupID != definition.SubGroups[i] || !optionChoiceExists(s.optionReroll.Groups[option.GroupID], option.ID) { - return fmt.Errorf("player: option reroll candidate sub option %d is not in GameData", i) - } - } - } - invariant := cloneEquipment(pending.Equipment) - invariant.MainOption = append([]EquipmentOption(nil), current.MainOption...) - invariant.SubOption = append([]EquipmentOption(nil), current.SubOption...) - if !reflect.DeepEqual(invariant, current) { - return errors.New("player: option reroll candidate modifies immutable equipment state") - } - return nil -} - -func optionChoiceExists(group gamedata.OptionGroup, id uint64) bool { - for _, choice := range group.Choices { - if choice.ID == id { - return true - } - } - return false -} - -func (s *EquipmentInventory) commitLocked(next equipmentSnapshot, operation string) error { - if err := s.persistEquipment(next, operation); err != nil { - return err - } - s.owned = next - s.persisted = cloneEquipmentSnapshot(next) - return nil -} - -func (s *EquipmentInventory) persistEquipment(next equipmentSnapshot, operation string) error { - return s.persistEquipmentState(next, nil, false, operation) -} - -func (s *EquipmentInventory) persistEquipmentState(next equipmentSnapshot, pending *equipmentOptionRerollPending, pendingDirty bool, operation string) error { - data, err := json.Marshal(next) - if err != nil { - return err - } - changes := make([]stateio.EntryMutation, 0) - for key, value := range next.Granted { - if value != 0 && s.persisted.Granted[key] != value { - changes = append(changes, stateio.EntryMutation{Bucket: "granted", Key: key, Payload: []byte(strconv.FormatUint(value, 10))}) - } - } - before := make(map[uint64]Equipment, len(s.persisted.Equipment)) - for _, item := range s.persisted.Equipment { - before[item.InvenIndex] = item - } - for _, item := range next.Equipment { - old, exists := before[item.InvenIndex] - if !exists || !reflect.DeepEqual(old, item) { - payload, err := json.Marshal(item) - if err != nil { - return err - } - changes = append(changes, stateio.EntryMutation{Bucket: "equipment", Key: strconv.FormatUint(item.InvenIndex, 10), Payload: payload}) - } - delete(before, item.InvenIndex) - } - for index := range before { - changes = append(changes, stateio.EntryMutation{Bucket: "equipment", Key: strconv.FormatUint(index, 10), Delete: true}) - } - if pendingDirty { - change := stateio.EntryMutation{Bucket: "reroll_pending", Key: "current", Delete: pending == nil} - if pending != nil { - change.Payload, err = json.Marshal(pending) - if err != nil { - return err - } - } - changes = append(changes, change) - } - if s.corePresent && next.Version == s.persisted.Version && next.NextIndex == s.persisted.NextIndex { - data = nil - } - if err := s.store.SaveWithEntries("equipment", data, changes); err != nil { - return fmt.Errorf("player: persist equipment %s: %w", operation, err) - } - s.corePresent = true - return nil -} - -func (s *EquipmentInventory) All() []Equipment { - s.mu.Lock() - defer s.mu.Unlock() - return append([]Equipment(nil), s.owned.Equipment...) -} - -// Granted returns a prior idempotent grant without creating an item. -func (s *EquipmentInventory) Granted(identity string) (Equipment, bool) { - s.mu.Lock() - defer s.mu.Unlock() - index := s.owned.Granted[identity] - if index == 0 { - return Equipment{}, false - } - for _, entry := range s.owned.Equipment { - if entry.InvenIndex == index { - return entry, true - } - } - return Equipment{}, false -} - -func (s *EquipmentInventory) use(request []byte) (int, []byte, bool, error) { - equipmentIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || equipmentIndex == 0 { - return 0, nil, true, errors.New("player: EquipUse missing equipment") - } - characterIndex, found, err := wire.Varint(request, 3) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: EquipUse missing character") - } - if s.characters == nil { - return 0, nil, true, errors.New("player: EquipUse character store unavailable") - } - // Find computes account buffs and may query EquipmentInventory.All(). Do - // not hold the equipment mutex while resolving the character's stats. - character, found := s.characters.Find(characterIndex) - if !found { - return 0, nil, true, fmt.Errorf("player: EquipUse unknown character %d", characterIndex) - } - s.mu.Lock() - defer s.mu.Unlock() - next := equipmentSnapshot{Version: s.owned.Version, NextIndex: s.owned.NextIndex, - Equipment: append([]Equipment(nil), s.owned.Equipment...), Granted: make(map[string]uint64, len(s.owned.Granted))} - maps.Copy(next.Granted, s.owned.Granted) - position := -1 - for i, current := range next.Equipment { - if current.InvenIndex == equipmentIndex { - position = i - break - } - } - if position < 0 { - return 0, nil, true, fmt.Errorf("player: EquipUse unknown equipment %d", equipmentIndex) - } - next.Equipment[position].UseChar = characterIndex - if err := s.commitLocked(next, "use"); err != nil { - return 0, nil, true, fmt.Errorf("player: persist equipment use: %w", err) - } - return 35, wire.AppendBytes(nil, 1, CharacterWire(character)), true, nil -} diff --git a/go/internal/server/player/equipment_batch_preset.go b/go/internal/server/player/equipment_batch_preset.go deleted file mode 100644 index 9c8a001..0000000 --- a/go/internal/server/player/equipment_batch_preset.go +++ /dev/null @@ -1,454 +0,0 @@ -package player - -import ( - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strconv" - "strings" - "unicode/utf8" - - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -const equipmentSlotCount = 5 - -type equipmentBatchUse struct { - CharacterIndex uint64 - Equipment []uint64 -} - -type equipmentPresetKey struct { - CharacterIndex uint64 - Slot uint64 -} - -type equipmentPresetItem struct { - Type uint64 `json:"type"` - EquipmentIndex uint64 `json:"equipment_index"` -} - -type equipmentPreset struct { - CharacterIndex uint64 `json:"character_index"` - Slot uint64 `json:"slot"` - Name string `json:"name"` - ResourceID uint64 `json:"resource_id"` - ResourceColor uint64 `json:"resource_color"` - Items []equipmentPresetItem `json:"items"` -} - -func (s *EquipmentInventory) batchUse(request []byte) (int, []byte, bool, error) { - entries, err := decodeEquipmentBatchUse(request) - if err != nil { - return 0, nil, true, err - } - if s.characters == nil { - return 0, nil, true, errors.New("player: EquipBatchUse character store unavailable") - } - requestedCharacters := make(map[uint64]bool, len(entries)) - for _, entry := range entries { - if requestedCharacters[entry.CharacterIndex] { - return 0, nil, true, fmt.Errorf("player: EquipBatchUse repeats character %d", entry.CharacterIndex) - } - requestedCharacters[entry.CharacterIndex] = true - if _, found := s.characters.Find(entry.CharacterIndex); !found { - return 0, nil, true, fmt.Errorf("player: EquipBatchUse unknown character %d", entry.CharacterIndex) - } - } - - s.mu.Lock() - if len(s.slots) == 0 { - s.mu.Unlock() - return 0, nil, true, errors.New("player: EquipBatchUse slot design unavailable") - } - desired := make(map[uint64]uint64) - for _, entry := range entries { - for slot, index := range entry.Equipment { - if index == 0 { - continue - } - if desired[index] != 0 { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipBatchUse repeats equipment %d", index) - } - position := s.equipmentPositionLocked(index) - if position < 0 { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipBatchUse unknown equipment %d", index) - } - item := s.owned.Equipment[position] - if designedSlot, ok := s.slots[item.ID]; !ok || designedSlot != uint64(slot) { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipBatchUse equipment %d does not belong in slot %d", index, slot) - } - if item.UseChar != 0 && item.UseChar != entry.CharacterIndex && !requestedCharacters[item.UseChar] { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipBatchUse equipment %d is still used by character %d", index, item.UseChar) - } - desired[index] = entry.CharacterIndex - } - } - - next := cloneEquipmentSnapshot(s.owned) - affected := make(map[uint64]bool, len(entries)) - for character := range requestedCharacters { - affected[character] = true - } - for i := range next.Equipment { - item := &next.Equipment[i] - if requestedCharacters[item.UseChar] { - item.UseChar = 0 - } - } - for i := range next.Equipment { - item := &next.Equipment[i] - character := desired[item.InvenIndex] - if character == 0 { - continue - } - if item.UseChar != 0 && item.UseChar != character { - affected[item.UseChar] = true - } - item.UseChar = character - } - if err := s.commitLocked(next, "batch use"); err != nil { - s.mu.Unlock() - return 0, nil, true, err - } - s.mu.Unlock() - - characterIndices := make([]uint64, 0, len(affected)) - for index := range affected { - characterIndices = append(characterIndices, index) - } - slices.Sort(characterIndices) - var response []byte - for _, index := range characterIndices { - if character, found := s.characters.Find(index); found { - response = wire.AppendBytes(response, 1, CharacterWire(character)) - } - } - return 276, response, true, nil -} - -func decodeEquipmentBatchUse(request []byte) ([]equipmentBatchUse, error) { - var result []equipmentBatchUse - err := wire.Walk(request, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - if field.Type != 2 { - return errors.New("player: EquipBatchUse invalid batch entry") - } - entry := equipmentBatchUse{} - var characterSeen bool - if err := wire.Walk(field.Value, func(nested wire.Field) error { - switch nested.Number { - case 1: - if characterSeen || nested.Type != 0 { - return errors.New("player: EquipBatchUse invalid character") - } - entry.CharacterIndex, _ = binary.Uvarint(nested.Value) - characterSeen = true - case 2: - values, err := decodeRepeatedUint64(nested) - if err != nil { - return errors.New("player: EquipBatchUse invalid equipment list") - } - entry.Equipment = append(entry.Equipment, values...) - } - return nil - }); err != nil { - return err - } - if !characterSeen || entry.CharacterIndex == 0 || len(entry.Equipment) != equipmentSlotCount { - return errors.New("player: EquipBatchUse requires one character and five equipment slots") - } - result = append(result, entry) - return nil - }) - if err != nil { - return nil, err - } - if len(result) == 0 { - return nil, errors.New("player: EquipBatchUse has no batch entries") - } - return result, nil -} - -func decodeRepeatedUint64(field wire.Field) ([]uint64, error) { - switch field.Type { - case 0: - value, count := binary.Uvarint(field.Value) - if count <= 0 { - return nil, wire.ErrMalformed - } - return []uint64{value}, nil - case 2: - var result []uint64 - for offset := 0; offset < len(field.Value); { - value, count := binary.Uvarint(field.Value[offset:]) - if count <= 0 { - return nil, wire.ErrMalformed - } - result = append(result, value) - offset += count - } - return result, nil - default: - return nil, wire.ErrMalformed - } -} - -func (s *EquipmentInventory) loadEquipmentPresets(entries stateio.EntryStore) error { - raw, err := entries.ListEntries("equipment", "presets") - if err != nil { - return err - } - for key, payload := range raw { - parsed, err := parseEquipmentPresetKey(key) - if err != nil { - return err - } - if err := stateio.RequireExactJSONObject(payload, "character_index", "slot", "name", "resource_id", "resource_color", "items"); err != nil { - return fmt.Errorf("player: incompatible equipment preset %q: %w", key, err) - } - var preset equipmentPreset - if err := json.Unmarshal(payload, &preset); err != nil || preset.CharacterIndex != parsed.CharacterIndex || preset.Slot != parsed.Slot { - return fmt.Errorf("player: invalid equipment preset %q", key) - } - if err := validateEquipmentPresetShape(preset); err != nil { - return fmt.Errorf("player: invalid equipment preset %q: %w", key, err) - } - s.presets[parsed] = preset - } - return nil -} - -func parseEquipmentPresetKey(value string) (equipmentPresetKey, error) { - left, right, found := strings.Cut(value, ":") - if !found { - return equipmentPresetKey{}, fmt.Errorf("player: invalid equipment preset key %q", value) - } - character, characterErr := strconv.ParseUint(left, 10, 64) - slot, slotErr := strconv.ParseUint(right, 10, 64) - if characterErr != nil || slotErr != nil || character == 0 || slot < 1 || slot > 5 || value != equipmentPresetStorageKey(equipmentPresetKey{CharacterIndex: character, Slot: slot}) { - return equipmentPresetKey{}, fmt.Errorf("player: invalid equipment preset key %q", value) - } - return equipmentPresetKey{CharacterIndex: character, Slot: slot}, nil -} - -func equipmentPresetStorageKey(key equipmentPresetKey) string { - return strconv.FormatUint(key.CharacterIndex, 10) + ":" + strconv.FormatUint(key.Slot, 10) -} - -func validateEquipmentPresetShape(preset equipmentPreset) error { - if preset.CharacterIndex == 0 || preset.Slot < 1 || preset.Slot > 5 || !utf8.ValidString(preset.Name) || - utf8.RuneCountInString(preset.Name) < 1 || utf8.RuneCountInString(preset.Name) > 16 || - preset.ResourceID < 1 || preset.ResourceID > 21 || preset.ResourceColor > 5 || len(preset.Items) != equipmentSlotCount { - return errors.New("invalid fields") - } - seen := make(map[uint64]bool, equipmentSlotCount) - for _, item := range preset.Items { - if item.Type >= equipmentSlotCount || seen[item.Type] { - return errors.New("invalid equipment slots") - } - seen[item.Type] = true - } - return nil -} - -func (s *EquipmentInventory) presetInfo() (int, []byte, bool, error) { - s.mu.Lock() - presets := make([]equipmentPreset, 0, len(s.presets)) - for _, preset := range s.presets { - presets = append(presets, cloneEquipmentPreset(preset)) - } - s.mu.Unlock() - sort.Slice(presets, func(i, j int) bool { - if presets[i].CharacterIndex != presets[j].CharacterIndex { - return presets[i].CharacterIndex < presets[j].CharacterIndex - } - return presets[i].Slot < presets[j].Slot - }) - var response []byte - for i := 0; i < len(presets); { - character := presets[i].CharacterIndex - var encoded []byte - encoded = wire.AppendVarint(encoded, 1, character) - for i < len(presets) && presets[i].CharacterIndex == character { - encoded = wire.AppendBytes(encoded, 2, equipmentPresetWire(presets[i])) - i++ - } - response = wire.AppendBytes(response, 1, encoded) - } - return 253, response, true, nil -} - -func equipmentPresetWire(preset equipmentPreset) []byte { - var out []byte - if preset.Name != "" { - out = wire.AppendString(out, 1, preset.Name) - } - out = wire.AppendVarint(out, 2, preset.Slot) - if preset.ResourceID != 0 { - out = wire.AppendVarint(out, 3, preset.ResourceID) - } - if preset.ResourceColor != 0 { - out = wire.AppendVarint(out, 4, preset.ResourceColor) - } - for _, item := range preset.Items { - var encoded []byte - if item.Type != 0 { - encoded = wire.AppendVarint(encoded, 1, item.Type) - } - if item.EquipmentIndex != 0 { - encoded = wire.AppendVarint(encoded, 2, item.EquipmentIndex) - } - out = wire.AppendBytes(out, 5, encoded) - } - return out -} - -func (s *EquipmentInventory) presetSave(request []byte) (int, []byte, bool, error) { - preset, err := decodeEquipmentPresetRequest(request, true) - if err != nil { - return 0, nil, true, err - } - if s.characters == nil { - return 0, nil, true, errors.New("player: EquipPresetSave character store unavailable") - } - if _, found := s.characters.Find(preset.CharacterIndex); !found { - return 0, nil, true, fmt.Errorf("player: EquipPresetSave unknown character %d", preset.CharacterIndex) - } - s.mu.Lock() - defer s.mu.Unlock() - if len(s.slots) == 0 { - return 0, nil, true, errors.New("player: EquipPresetSave slot design unavailable") - } - for _, item := range preset.Items { - if item.EquipmentIndex == 0 { - continue - } - position := s.equipmentPositionLocked(item.EquipmentIndex) - if position < 0 { - return 0, nil, true, fmt.Errorf("player: EquipPresetSave unknown equipment %d", item.EquipmentIndex) - } - equipment := s.owned.Equipment[position] - if s.slots[equipment.ID] != item.Type { - return 0, nil, true, fmt.Errorf("player: EquipPresetSave equipment %d does not match character slot", item.EquipmentIndex) - } - } - if err := s.persistEquipmentPreset(preset, "save"); err != nil { - return 0, nil, true, err - } - s.presets[equipmentPresetKey{CharacterIndex: preset.CharacterIndex, Slot: preset.Slot}] = cloneEquipmentPreset(preset) - return 254, nil, true, nil -} - -func (s *EquipmentInventory) presetNameChange(request []byte) (int, []byte, bool, error) { - metadata, err := decodeEquipmentPresetRequest(request, false) - if err != nil { - return 0, nil, true, err - } - key := equipmentPresetKey{CharacterIndex: metadata.CharacterIndex, Slot: metadata.Slot} - if s.characters == nil { - return 0, nil, true, errors.New("player: EquipPresetNameChange character store unavailable") - } - if _, found := s.characters.Find(metadata.CharacterIndex); !found { - return 0, nil, true, fmt.Errorf("player: EquipPresetNameChange unknown character %d", metadata.CharacterIndex) - } - s.mu.Lock() - defer s.mu.Unlock() - preset, found := s.presets[key] - if !found { - preset = metadata - } - preset.Name = metadata.Name - preset.ResourceID = metadata.ResourceID - preset.ResourceColor = metadata.ResourceColor - if err := s.persistEquipmentPreset(preset, "name change"); err != nil { - return 0, nil, true, err - } - s.presets[key] = cloneEquipmentPreset(preset) - return 259, nil, true, nil -} - -func decodeEquipmentPresetRequest(request []byte, withItems bool) (equipmentPreset, error) { - character, found, err := wire.Varint(request, 2) - if err != nil || !found || character == 0 { - return equipmentPreset{}, errors.New("player: equipment preset request missing character") - } - slot, found, err := wire.Varint(request, 3) - if err != nil || !found || slot < 1 || slot > 5 { - return equipmentPreset{}, errors.New("player: equipment preset request invalid slot") - } - nameBytes, _, err := wire.Bytes(request, 4) - if err != nil || !utf8.Valid(nameBytes) { - return equipmentPreset{}, errors.New("player: equipment preset request invalid name") - } - resourceID, _, err := wire.Varint(request, 5) - if err != nil || resourceID < 1 || resourceID > 21 { - return equipmentPreset{}, errors.New("player: equipment preset request invalid resource") - } - color, _, err := wire.Varint(request, 6) - if err != nil || color > 5 { - return equipmentPreset{}, errors.New("player: equipment preset request invalid color") - } - preset := equipmentPreset{CharacterIndex: character, Slot: slot, Name: string(nameBytes), ResourceID: resourceID, ResourceColor: color} - if withItems { - err = wire.Walk(request, func(field wire.Field) error { - if field.Number != 7 { - return nil - } - if field.Type != 2 { - return errors.New("player: EquipPresetSave invalid equipment entry") - } - typeID, _, err := wire.Varint(field.Value, 1) - if err != nil { - return err - } - index, _, err := wire.Varint(field.Value, 2) - if err != nil { - return err - } - preset.Items = append(preset.Items, equipmentPresetItem{Type: typeID, EquipmentIndex: index}) - return nil - }) - if err != nil { - return equipmentPreset{}, err - } - } else { - preset.Items = make([]equipmentPresetItem, equipmentSlotCount) - for i := range preset.Items { - preset.Items[i].Type = uint64(i) - } - } - if err := validateEquipmentPresetShape(preset); err != nil { - return equipmentPreset{}, fmt.Errorf("player: equipment preset request: %w", err) - } - sort.Slice(preset.Items, func(i, j int) bool { return preset.Items[i].Type < preset.Items[j].Type }) - return preset, nil -} - -func (s *EquipmentInventory) persistEquipmentPreset(preset equipmentPreset, operation string) error { - payload, err := json.Marshal(preset) - if err != nil { - return err - } - key := equipmentPresetKey{CharacterIndex: preset.CharacterIndex, Slot: preset.Slot} - change := stateio.EntryMutation{Bucket: "presets", Key: equipmentPresetStorageKey(key), Payload: payload} - if err := s.store.SaveWithEntries("equipment", nil, []stateio.EntryMutation{change}); err != nil { - return fmt.Errorf("player: persist equipment preset %s: %w", operation, err) - } - return nil -} - -func cloneEquipmentPreset(preset equipmentPreset) equipmentPreset { - preset.Items = append([]equipmentPresetItem(nil), preset.Items...) - return preset -} diff --git a/go/internal/server/player/equipment_batch_use_test.go b/go/internal/server/player/equipment_batch_use_test.go deleted file mode 100644 index bd60a76..0000000 --- a/go/internal/server/player/equipment_batch_use_test.go +++ /dev/null @@ -1,263 +0,0 @@ -package player - -import ( - "path/filepath" - "reflect" - "slices" - "testing" - - "bd2server/internal/server/wire" -) - -func TestEquipmentBatchUseOneClickClearPreservesUnfilteredSlots(t *testing.T) { - fixture := newEquipmentBatchUseFixture(t) - weapon := fixture.grant(t, "clear-weapon", 10, 100) - armor := fixture.grant(t, "clear-armor", 11, 100) - - code, response, handled, err := fixture.equipment.Handle("/EquipBatchUse", equipmentBatchUseRequest( - equipmentBatchUseEntry(100, 0, armor.InvenIndex, 0, 0, 0), - )) - if err != nil || !handled || code != 276 { - t.Fatalf("batch clear code=%d handled=%v err=%v", code, handled, err) - } - if got := responseCharacterIndices(t, response); !reflect.DeepEqual(got, []uint64{100}) { - t.Fatalf("response characters=%v", got) - } - assertEquipmentOwners(t, fixture.equipment, map[uint64]uint64{ - weapon.InvenIndex: 0, - armor.InvenIndex: 100, - }) -} - -func TestEquipmentBatchUseAutoMountReplacesOnlyRequestedSlot(t *testing.T) { - fixture := newEquipmentBatchUseFixture(t) - oldWeapon := fixture.grant(t, "auto-old-weapon", 10, 100) - armor := fixture.grant(t, "auto-armor", 11, 100) - newWeapon := fixture.grant(t, "auto-new-weapon", 12, 0) - - code, response, handled, err := fixture.equipment.Handle("/EquipBatchUse", equipmentBatchUseRequest( - equipmentBatchUseEntry(100, newWeapon.InvenIndex, armor.InvenIndex, 0, 0, 0), - )) - if err != nil || !handled || code != 276 { - t.Fatalf("auto mount code=%d handled=%v err=%v", code, handled, err) - } - if got := responseCharacterIndices(t, response); !reflect.DeepEqual(got, []uint64{100}) { - t.Fatalf("response characters=%v", got) - } - assertEquipmentOwners(t, fixture.equipment, map[uint64]uint64{ - oldWeapon.InvenIndex: 0, - armor.InvenIndex: 100, - newWeapon.InvenIndex: 100, - }) -} - -func TestEquipmentBatchUseTransfersAcrossCharactersReturnsBothAndPersists(t *testing.T) { - fixture := newEquipmentBatchUseFixture(t) - transferred := fixture.grant(t, "cross-character", 10, 200) - - code, response, handled, err := fixture.equipment.Handle("/EquipBatchUse", equipmentBatchUseRequest( - equipmentBatchUseEntry(200, 0, 0, 0, 0, 0), - equipmentBatchUseEntry(100, transferred.InvenIndex, 0, 0, 0, 0), - )) - if err != nil || !handled || code != 276 { - t.Fatalf("cross-character batch code=%d handled=%v err=%v", code, handled, err) - } - // The target changes and the previous owner loses equipment. Both - // characters must be returned so the client can refresh their derived HP. - if got := responseCharacterIndices(t, response); !reflect.DeepEqual(got, []uint64{100, 200}) { - t.Fatalf("response characters=%v", got) - } - assertEquipmentOwners(t, fixture.equipment, map[uint64]uint64{transferred.InvenIndex: 100}) - - restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath)) - if err != nil { - t.Fatal(err) - } - if err := restarted.AttachSlots(fixture.slots); err != nil { - t.Fatal(err) - } - if err := restarted.AttachCharacters(fixture.characters); err != nil { - t.Fatal(err) - } - assertEquipmentOwners(t, restarted, map[uint64]uint64{transferred.InvenIndex: 100}) -} - -func TestEquipmentBatchUseRejectsInvalidSnapshotsAtomically(t *testing.T) { - fixture := newEquipmentBatchUseFixture(t) - weapon := fixture.grant(t, "invalid-weapon", 10, 100) - armor := fixture.grant(t, "invalid-armor", 11, 100) - want := fixture.equipment.All() - - tests := []struct { - name string - request []byte - }{ - { - name: "duplicate equipment", - request: equipmentBatchUseRequest( - equipmentBatchUseEntry(100, weapon.InvenIndex, weapon.InvenIndex, 0, 0, 0), - ), - }, - { - name: "equipment in wrong slot", - request: equipmentBatchUseRequest( - equipmentBatchUseEntry(100, 0, weapon.InvenIndex, 0, 0, 0), - ), - }, - { - name: "unknown equipment", - request: equipmentBatchUseRequest( - equipmentBatchUseEntry(100, 999999999, armor.InvenIndex, 0, 0, 0), - ), - }, - { - name: "unknown character", - request: equipmentBatchUseRequest( - equipmentBatchUseEntry(999, weapon.InvenIndex, armor.InvenIndex, 0, 0, 0), - ), - }, - { - name: "transfer without previous owner snapshot", - request: equipmentBatchUseRequest( - equipmentBatchUseEntry(200, weapon.InvenIndex, 0, 0, 0, 0), - ), - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if _, _, handled, err := fixture.equipment.Handle("/EquipBatchUse", test.request); err == nil || !handled { - t.Fatalf("invalid batch handled=%v err=%v", handled, err) - } - if got := fixture.equipment.All(); !reflect.DeepEqual(got, want) { - t.Fatalf("invalid batch mutated state:\n got=%+v\nwant=%+v", got, want) - } - }) - } - - // Reopening the store proves rejected requests also left no partial disk - // mutation behind. - restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath)) - if err != nil { - t.Fatal(err) - } - if got := restarted.All(); !reflect.DeepEqual(got, want) { - t.Fatalf("invalid batches mutated persisted state:\n got=%+v\nwant=%+v", got, want) - } -} - -type equipmentBatchUseFixture struct { - equipmentPath string - equipment *EquipmentInventory - characters *CharacterStore - slots map[uint64]uint64 -} - -func newEquipmentBatchUseFixture(t *testing.T) *equipmentBatchUseFixture { - t.Helper() - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{ - {InvenIndex: 100, ID: 350, Level: 20}, - {InvenIndex: 200, ID: 351, Level: 20}, - }, inventory, "", "") - if err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "equipment.json") - equipment, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - slots := map[uint64]uint64{10: 0, 11: 1, 12: 0, 13: 2, 14: 3, 15: 4} - if err := equipment.AttachSlots(slots); err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - return &equipmentBatchUseFixture{ - equipmentPath: path, - equipment: equipment, - characters: characters, - slots: slots, - } -} - -func (f *equipmentBatchUseFixture) grant(t *testing.T, identity string, designID, character uint64) Equipment { - t.Helper() - entry, err := f.equipment.GrantOnce(identity, designID) - if err != nil { - t.Fatal(err) - } - if character == 0 { - return entry - } - f.equipment.mu.Lock() - next := cloneEquipmentSnapshot(f.equipment.owned) - for i := range next.Equipment { - if next.Equipment[i].InvenIndex == entry.InvenIndex { - next.Equipment[i].UseChar = character - entry = next.Equipment[i] - break - } - } - if err := f.equipment.commitLocked(next, "batch-use test setup"); err != nil { - f.equipment.mu.Unlock() - t.Fatal(err) - } - f.equipment.mu.Unlock() - return entry -} - -func equipmentBatchUseRequest(entries ...[]byte) []byte { - request := wire.AppendVarint(nil, 1, 1) - for _, entry := range entries { - request = wire.AppendBytes(request, 2, entry) - } - return request -} - -func equipmentBatchUseEntry(character uint64, equipment ...uint64) []byte { - entry := wire.AppendVarint(nil, 1, character) - for _, index := range equipment { - entry = wire.AppendVarint(entry, 2, index) - } - return entry -} - -func responseCharacterIndices(t *testing.T, response []byte) []uint64 { - t.Helper() - var result []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number != 1 { - return nil - } - index, found, err := wire.Varint(field.Value, 1) - if err != nil { - return err - } - if !found || index == 0 { - t.Fatalf("response contains invalid CharDBInfo") - } - result = append(result, index) - return nil - }); err != nil { - t.Fatal(err) - } - slices.Sort(result) - return result -} - -func assertEquipmentOwners(t *testing.T, inventory *EquipmentInventory, want map[uint64]uint64) { - t.Helper() - got := make(map[uint64]uint64) - for _, equipment := range inventory.All() { - got[equipment.InvenIndex] = equipment.UseChar - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("equipment owners=%v want=%v", got, want) - } -} diff --git a/go/internal/server/player/equipment_craft.go b/go/internal/server/player/equipment_craft.go deleted file mode 100644 index 21860bd..0000000 --- a/go/internal/server/player/equipment_craft.go +++ /dev/null @@ -1,583 +0,0 @@ -package player - -import ( - "errors" - "fmt" - "math" - "sort" - "strconv" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -const equipmentBatchLimit = 100 - -type equipmentAutoBreakResult struct { - Equipment []Equipment - Result uint64 - Attempts uint64 - Consumed []Item - Lack []Item - Gold uint64 - Granted []Item -} - -func (s *EquipmentInventory) makeEquipment(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - characterIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: EquipMaking missing character") - } - recipeID, found, err := wire.Varint(request, 3) - if err != nil || !found || recipeID == 0 { - return 0, nil, true, errors.New("player: EquipMaking missing recipe") - } - count, found, err := wire.Varint(request, 4) - if err != nil || !found || count == 0 || count > 1000 { - return 0, nil, true, errors.New("player: EquipMaking invalid count") - } - materials, err := equipmentRequestItems(request, 5, "EquipMaking") - if err != nil { - return 0, nil, true, err - } - cacheKey := s.smeltingCacheKey("making", seq) - if response, ok := s.cachedEquipmentReply(cacheKey); ok { - return response.code, response.body, true, nil - } - generated, gain, catalyst, maximum, err := s.prepareEquipmentMaking(characterIndex, recipeID, count, materials) - if err != nil { - return 0, nil, true, err - } - if err := s.inventory.Consume(materials); err != nil { - return 0, nil, true, fmt.Errorf("player: consume equipment making materials: %w", err) - } - if catalyst != 0 { - identity := "equip-making-catalyst:" + s.sessionID + ":" + strconv.FormatUint(seq, 10) - if _, err := s.wallet.SpendCatalystOnce(identity, catalyst); err != nil { - return 0, nil, true, fmt.Errorf("player: spend equipment making catalyst: %w", err) - } - } - created, err := s.grantGeneratedBatch("equip-making:"+s.sessionID+":"+strconv.FormatUint(seq, 10), generated, true) - if err != nil { - return 0, nil, true, fmt.Errorf("player: persist made equipment: %w", err) - } - if gain > 0 { - if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil { - return 0, nil, true, err - } - } - var response []byte - for _, entry := range created { - response = wire.AppendBytes(response, 1, EquipmentWire(entry)) - } - if gain != 0 { - response = wire.AppendVarint(response, 2, gain) - } - s.cacheEquipmentReply(cacheKey, 50, response) - return 50, response, true, nil -} - -func (s *EquipmentInventory) prepareEquipmentMaking(characterIndex, recipeID, count uint64, materials []Item) ([]Equipment, uint64, uint64, uint64, error) { - if s.craft == nil || s.inventory == nil || s.characters == nil || s.wallet == nil { - return nil, 0, 0, 0, errors.New("player: equipment making unavailable") - } - recipe, ok := s.craft.Recipe(recipeID) - if !ok { - return nil, 0, 0, 0, fmt.Errorf("player: unknown equipment making recipe %d", recipeID) - } - character, ok := s.characters.Find(characterIndex) - if !ok { - return nil, 0, 0, 0, fmt.Errorf("player: unknown equipment making character %d", characterIndex) - } - gain, catalyst, maximum, err := s.craft.Talent(character.ID, character.TalentLevel, recipe.TalentLevel, count, character.TalentExp) - if err != nil { - return nil, 0, 0, 0, err - } - if catalyst != 0 && !s.wallet.CanSpendCatalyst(catalyst) { - return nil, 0, 0, 0, errors.New("player: insufficient catalyst for equipment making") - } - if err := validateMakingMaterials(recipe.Costs, count, materials); err != nil { - return nil, 0, 0, 0, err - } - if err := s.inventory.CanConsume(materials); err != nil { - return nil, 0, 0, 0, err - } - if recipe.ResultCount != 0 && count > math.MaxUint64/recipe.ResultCount { - return nil, 0, 0, 0, errors.New("player: equipment making result count overflow") - } - resultCount := count * recipe.ResultCount - generated := make([]Equipment, 0, resultCount) - for range resultCount { - rolled, err := s.craft.Generate(recipeID) - if err != nil { - return nil, 0, 0, 0, fmt.Errorf("player: roll equipment making result: %w", err) - } - entry := Equipment{ID: rolled.Design.ID, Rank: []uint64{0, 0, 0}} - for _, option := range rolled.Main { - entry.MainOption = append(entry.MainOption, EquipmentOption{GroupID: option.GroupID, ID: option.ID}) - } - for _, option := range rolled.Sub { - entry.SubOption = append(entry.SubOption, EquipmentOption{GroupID: option.GroupID, ID: option.ID}) - } - if rolled.Private != nil { - entry.PrivateOption = &EquipmentOption{GroupID: rolled.Private.GroupID, ID: rolled.Private.ID} - } - generated = append(generated, entry) - } - return generated, gain, catalyst, maximum, nil -} - -func validateMakingMaterials(costs []gamedata.PromotionCost, count uint64, materials []Item) error { - want := make(map[[2]uint64]uint64, len(costs)) - for _, cost := range costs { - if cost.Count == 0 || count > math.MaxUint64/cost.Count { - return errors.New("player: equipment making material cost overflow") - } - key := [2]uint64{cost.Type, cost.ID} - value := cost.Count * count - if math.MaxUint64-want[key] < value { - return errors.New("player: equipment making material cost overflow") - } - want[key] += value - } - got := make(map[[2]uint64]uint64, len(materials)) - for _, item := range materials { - key := [2]uint64{item.Type, item.ID} - if math.MaxUint64-got[key] < item.Count { - return errors.New("player: equipment making material count overflow") - } - got[key] += item.Count - } - if len(got) != len(want) { - return errors.New("player: equipment making material kinds mismatch") - } - for key, count := range want { - if got[key] != count { - return fmt.Errorf("player: equipment making material %d/%d=%d want=%d", key[0], key[1], got[key], count) - } - } - return nil -} - -func (s *EquipmentInventory) breakEquipment(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - indices, err := repeatedEquipmentIndices(request, 2, equipmentBatchLimit) - if err != nil { - return 0, nil, true, err - } - cacheKey := s.smeltingCacheKey("break", seq) - if response, ok := s.cachedEquipmentReply(cacheKey); ok { - return response.code, response.body, true, nil - } - s.mu.Lock() - if s.upgrade == nil || s.inventory == nil { - s.mu.Unlock() - return 0, nil, true, errors.New("player: equipment break unavailable") - } - rewards := make(map[[2]uint64]uint64) - positions := make([]int, 0, len(indices)) - for _, index := range indices { - position := s.equipmentPositionLocked(index) - if position < 0 { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: EquipBreak unknown equipment %d", index) - } - entry := s.owned.Equipment[position] - if entry.UseChar != 0 || entry.LockFlag != 0 || !s.upgrade.CanBreak(entry.ID) { - s.mu.Unlock() - return 0, nil, true, fmt.Errorf("player: equipment %d cannot be broken", index) - } - result, err := s.upgrade.BreakRewards(entry.ID, entry.Level) - if err != nil { - s.mu.Unlock() - return 0, nil, true, err - } - if err := addBattleRewards(rewards, result); err != nil { - s.mu.Unlock() - return 0, nil, true, err - } - positions = append(positions, position) - } - sort.Sort(sort.Reverse(sort.IntSlice(positions))) - next := cloneEquipmentSnapshot(s.owned) - for _, position := range positions { - next.Equipment = append(next.Equipment[:position], next.Equipment[position+1:]...) - } - if err := s.commitLocked(next, "break"); err != nil { - s.mu.Unlock() - return 0, nil, true, err - } - s.mu.Unlock() - identity := "equip-break:" + s.sessionID + ":" + strconv.FormatUint(seq, 10) - granted, err := s.inventory.GrantOnce(identity, aggregateBattleRewards(rewards)) - if err != nil { - return 0, nil, true, fmt.Errorf("player: grant equipment break rewards: %w", err) - } - if granted == nil { - granted = s.inventory.GrantedItems(identity) - } - response := wire.AppendBytes(nil, 1, rewardItemBundle(granted)) - s.cacheEquipmentReply(cacheKey, 56, response) - return 56, response, true, nil -} - -func (s *EquipmentInventory) upgradeToBreakAuto(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - indices, err := repeatedEquipmentIndices(request, 2, equipmentBatchLimit) - if err != nil { - return 0, nil, true, err - } - target, _, err := wire.Varint(request, 3) - if err != nil { - return 0, nil, true, errors.New("player: EquipUpgradeToBreakAuto invalid target") - } - cacheKey := s.smeltingCacheKey("upgrade-break-auto", seq) - if response, ok := s.cachedEquipmentReply(cacheKey); ok { - return response.code, response.body, true, nil - } - result, err := s.runUpgradeToBreak(indices, target, "equip-upgrade-break:"+s.sessionID+":"+strconv.FormatUint(seq, 10)) - if err != nil { - return 0, nil, true, err - } - response := encodeUpgradeBreakResponse(result, 1, 2, 3, 4, 5, 6, 7) - s.cacheEquipmentReply(cacheKey, 516, response) - return 516, response, true, nil -} - -func (s *EquipmentInventory) makeToBreakAuto(request []byte) (int, []byte, bool, error) { - seq, _, _ := wire.Varint(request, 1) - characterIndex, found, err := wire.Varint(request, 2) - if err != nil || !found || characterIndex == 0 { - return 0, nil, true, errors.New("player: EquipMakingToBreakAuto missing character") - } - recipeID, found, err := wire.Varint(request, 3) - if err != nil || !found || recipeID == 0 { - return 0, nil, true, errors.New("player: EquipMakingToBreakAuto missing recipe") - } - count, found, err := wire.Varint(request, 4) - if err != nil || !found || count == 0 || count > equipmentBatchLimit { - return 0, nil, true, errors.New("player: EquipMakingToBreakAuto invalid count") - } - materials, err := equipmentRequestItems(request, 5, "EquipMakingToBreakAuto") - if err != nil { - return 0, nil, true, err - } - target, _, err := wire.Varint(request, 6) - if err != nil { - return 0, nil, true, errors.New("player: EquipMakingToBreakAuto invalid target") - } - cacheKey := s.smeltingCacheKey("making-break-auto", seq) - if response, ok := s.cachedEquipmentReply(cacheKey); ok { - return response.code, response.body, true, nil - } - generated, gain, catalyst, maximum, err := s.prepareEquipmentMaking(characterIndex, recipeID, count, materials) - if err != nil { - return 0, nil, true, err - } - if err := s.inventory.Consume(materials); err != nil { - return 0, nil, true, fmt.Errorf("player: consume equipment making materials: %w", err) - } - if catalyst != 0 { - identity := "equip-making-break-catalyst:" + s.sessionID + ":" + strconv.FormatUint(seq, 10) - if _, err := s.wallet.SpendCatalystOnce(identity, catalyst); err != nil { - return 0, nil, true, fmt.Errorf("player: spend automatic equipment making catalyst: %w", err) - } - } - created, err := s.grantGeneratedBatch("", generated, false) - if err != nil { - return 0, nil, true, fmt.Errorf("player: persist auto-break made equipment: %w", err) - } - indices := make([]uint64, 0, len(created)) - for _, entry := range created { - indices = append(indices, entry.InvenIndex) - } - if gain > 0 { - if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil { - return 0, nil, true, err - } - } - result, err := s.runUpgradeToBreak(indices, target, "equip-making-break-reward:"+s.sessionID+":"+strconv.FormatUint(seq, 10)) - if err != nil { - return 0, nil, true, err - } - var response []byte - if gain != 0 { - response = wire.AppendVarint(response, 1, gain) - } - response = append(response, encodeUpgradeBreakResponse(result, 2, 3, 4, 5, 6, 7, 8)...) - s.cacheEquipmentReply(cacheKey, 515, response) - return 515, response, true, nil -} - -func (s *EquipmentInventory) grantGeneratedBatch(identityPrefix string, entries []Equipment, trackReplay bool) ([]Equipment, error) { - if len(entries) == 0 || (trackReplay && identityPrefix == "") { - return nil, errors.New("player: invalid generated equipment batch") - } - s.mu.Lock() - defer s.mu.Unlock() - if trackReplay { - existing := make([]Equipment, 0, len(entries)) - found := 0 - for i := range entries { - index := s.owned.Granted[identityPrefix+":"+strconv.Itoa(i)] - if index == 0 { - continue - } - found++ - position := s.equipmentPositionLocked(index) - if position < 0 { - return nil, errors.New("player: generated equipment replay index is missing") - } - existing = append(existing, cloneEquipment(s.owned.Equipment[position])) - } - if found != 0 { - if found != len(entries) { - return nil, errors.New("player: partial generated equipment replay batch") - } - return existing, nil - } - } - next := cloneEquipmentSnapshot(s.owned) - created := make([]Equipment, 0, len(entries)) - for i, source := range entries { - entry := cloneEquipment(source) - if entry.ID == 0 || len(entry.Rank) != 3 { - return nil, errors.New("player: invalid generated equipment in batch") - } - entry.InvenIndex = next.NextIndex - next.NextIndex++ - next.Equipment = append(next.Equipment, entry) - created = append(created, cloneEquipment(entry)) - if trackReplay { - next.Granted[identityPrefix+":"+strconv.Itoa(i)] = entry.InvenIndex - } - } - if err := s.commitLocked(next, "generated equipment batch"); err != nil { - return nil, err - } - return created, nil -} - -func (s *EquipmentInventory) runUpgradeToBreak(indices []uint64, target uint64, rewardIdentity string) (equipmentAutoBreakResult, error) { - s.mu.Lock() - if s.upgrade == nil || s.wallet == nil || s.inventory == nil { - s.mu.Unlock() - return equipmentAutoBreakResult{}, errors.New("player: automatic equipment upgrade and break unavailable") - } - for _, index := range indices { - position := s.equipmentPositionLocked(index) - if position < 0 { - s.mu.Unlock() - return equipmentAutoBreakResult{}, fmt.Errorf("player: unknown equipment %d", index) - } - entry := s.owned.Equipment[position] - if entry.UseChar != 0 || entry.LockFlag != 0 || !s.upgrade.CanBreak(entry.ID) { - s.mu.Unlock() - return equipmentAutoBreakResult{}, fmt.Errorf("player: equipment %d cannot be automatically broken", index) - } - } - result := equipmentAutoBreakResult{Result: equipUpgradeStopTargetLevel} - stopUpgrades := false - for _, index := range indices { - position := s.equipmentPositionLocked(index) - entry := s.owned.Equipment[position] - maximum := s.upgrade.MaxLevel[entry.ID] - goal := min(target, maximum) - for !stopUpgrades && entry.Level < goal { - level, _, err := s.upgrade.Level(entry.ID, entry.Level) - if err != nil { - s.mu.Unlock() - return equipmentAutoBreakResult{}, err - } - materials, gold, err := s.selectUpgradeCosts(level.Costs) - if err != nil || (gold != 0 && !s.wallet.CanSpendGold(gold)) { - result.Result = equipUpgradeStopNotEnough - result.Lack = upgradeLackItems(level.Costs) - stopUpgrades = true - break - } - updated, _, spent, consumed, err := s.attemptUpgradeLocked(index, materials) - if err != nil { - s.mu.Unlock() - return equipmentAutoBreakResult{}, err - } - result.Attempts++ - result.Gold += spent - for _, item := range consumed { - if item.Type != 4 { - result.Consumed = append(result.Consumed, item) - } - } - entry = updated - if result.Attempts > 300000 { - s.mu.Unlock() - return equipmentAutoBreakResult{}, errors.New("player: automatic equipment upgrade exceeded safety limit") - } - } - position = s.equipmentPositionLocked(index) - result.Equipment = append(result.Equipment, cloneEquipment(s.owned.Equipment[position])) - } - rewards := make(map[[2]uint64]uint64) - positions := make([]int, 0, len(indices)) - for _, entry := range result.Equipment { - breakRewards, err := s.upgrade.BreakRewards(entry.ID, entry.Level) - if err != nil { - s.mu.Unlock() - return equipmentAutoBreakResult{}, err - } - if err := addBattleRewards(rewards, breakRewards); err != nil { - s.mu.Unlock() - return equipmentAutoBreakResult{}, err - } - positions = append(positions, s.equipmentPositionLocked(entry.InvenIndex)) - } - sort.Sort(sort.Reverse(sort.IntSlice(positions))) - next := cloneEquipmentSnapshot(s.owned) - for _, position := range positions { - next.Equipment = append(next.Equipment[:position], next.Equipment[position+1:]...) - } - if err := s.commitLocked(next, "automatic upgrade and break"); err != nil { - s.mu.Unlock() - return equipmentAutoBreakResult{}, err - } - s.mu.Unlock() - granted, err := s.inventory.GrantOnce(rewardIdentity, aggregateBattleRewards(rewards)) - if err != nil { - return equipmentAutoBreakResult{}, fmt.Errorf("player: grant automatic equipment break rewards: %w", err) - } - if granted == nil { - granted = s.inventory.GrantedItems(rewardIdentity) - } - result.Granted = granted - result.Consumed = aggregateItemCounts(result.Consumed) - result.Lack = aggregateItemCounts(result.Lack) - return result, nil -} - -func encodeUpgradeBreakResponse(result equipmentAutoBreakResult, equipmentField, resultField, attemptsField, consumedField, lackField, goldField, rewardsField int) []byte { - var response []byte - for _, entry := range result.Equipment { - response = wire.AppendBytes(response, equipmentField, EquipmentWire(entry)) - } - if result.Result != 0 { - response = wire.AppendVarint(response, resultField, result.Result) - } - if result.Attempts != 0 { - response = wire.AppendVarint(response, attemptsField, result.Attempts) - } - for _, item := range result.Consumed { - response = wire.AppendBytes(response, consumedField, ItemWire(item)) - } - for _, item := range result.Lack { - response = wire.AppendBytes(response, lackField, ItemWire(item)) - } - if result.Gold != 0 { - response = wire.AppendVarint(response, goldField, result.Gold) - } - response = wire.AppendBytes(response, rewardsField, rewardItemBundle(result.Granted)) - return response -} - -func repeatedEquipmentIndices(request []byte, number, maximum int) ([]uint64, error) { - var result []uint64 - err := wire.Walk(request, func(field wire.Field) error { - if field.Number != number { - return nil - } - values, err := decodeRepeatedUint64(field) - if err != nil { - return err - } - result = append(result, values...) - return nil - }) - if err != nil || len(result) == 0 || len(result) > maximum { - return nil, errors.New("player: invalid equipment index batch") - } - seen := make(map[uint64]bool, len(result)) - for _, index := range result { - if index == 0 || seen[index] { - return nil, errors.New("player: invalid or duplicate equipment index") - } - seen[index] = true - } - return result, nil -} - -func addBattleRewards(total map[[2]uint64]uint64, rewards []gamedata.BattleReward) error { - for _, reward := range rewards { - key := [2]uint64{reward.Type, reward.ID} - if reward.Count == 0 || math.MaxUint64-total[key] < reward.Count { - return errors.New("player: equipment break reward overflow") - } - total[key] += reward.Count - } - return nil -} - -func aggregateBattleRewards(values map[[2]uint64]uint64) []gamedata.BattleReward { - keys := make([][2]uint64, 0, len(values)) - for key, count := range values { - if count != 0 { - keys = append(keys, key) - } - } - sort.Slice(keys, func(i, j int) bool { - if keys[i][0] != keys[j][0] { - return keys[i][0] < keys[j][0] - } - return keys[i][1] < keys[j][1] - }) - out := make([]gamedata.BattleReward, 0, len(keys)) - for _, key := range keys { - out = append(out, gamedata.BattleReward{Type: key[0], ID: key[1], Count: values[key]}) - } - return out -} - -func aggregateItemCounts(items []Item) []Item { - values := make(map[[2]uint64]uint64) - for _, item := range items { - values[[2]uint64{item.Type, item.ID}] += item.Count - } - keys := make([][2]uint64, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Slice(keys, func(i, j int) bool { - if keys[i][0] != keys[j][0] { - return keys[i][0] < keys[j][0] - } - return keys[i][1] < keys[j][1] - }) - out := make([]Item, 0, len(keys)) - for _, key := range keys { - out = append(out, Item{Type: key[0], ID: key[1], Count: values[key]}) - } - return out -} - -func rewardItemBundle(items []Item) []byte { - var bundle []byte - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) - } - return bundle -} - -func (s *EquipmentInventory) cachedEquipmentReply(key string) (smeltingReply, bool) { - s.mu.Lock() - defer s.mu.Unlock() - reply, ok := s.smeltCache[key] - if ok { - reply.body = append([]byte(nil), reply.body...) - } - return reply, ok -} - -func (s *EquipmentInventory) cacheEquipmentReply(key string, code int, body []byte) { - s.mu.Lock() - defer s.mu.Unlock() - s.smeltCache[key] = smeltingReply{code: code, body: append([]byte(nil), body...)} -} diff --git a/go/internal/server/player/equipment_craft_test.go b/go/internal/server/player/equipment_craft_test.go deleted file mode 100644 index 7a2ac26..0000000 --- a/go/internal/server/player/equipment_craft_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package player - -import ( - "encoding/binary" - "os" - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) { - testEquipMakingInstalled(t, 140, 14) -} - -// Char10140 has Talent901: maximum level1, no experience in Skill901/1, -// and cumulative Growth901/1 threshold0 in current GameData. -func TestEquipMakingNonGrowingTalentAgainstInstalledCurrentVersion(t *testing.T) { - testEquipMakingInstalled(t, 10140, 0) -} - -func testEquipMakingInstalled(t *testing.T, characterID, expectedExperience uint64) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test") - } - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - materials, err := inventory.GrantOnce("making-materials", []gamedata.BattleReward{{Type: 8, ID: 204, Count: 3}, {Type: 8, ID: 201, Count: 3}}) - if err != nil || len(materials) != 2 { - t.Fatalf("materials=%+v err=%v", materials, err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Catalyst: 10}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: characterID, Level: 1, TalentLevel: 1}}, inventory, root, "20260923193640") - if err != nil { - t.Fatal(err) - } - equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - craft, err := gamedata.LoadEquipmentCraftDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - upgrade, err := gamedata.LoadEquipmentUpgradeDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - if err := equipment.AttachUpgrade(upgrade, wallet, inventory); err != nil { - t.Fatal(err) - } - if err := equipment.AttachCraft(craft); err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - equipment.BeginSession("making-test") - request := wire.AppendVarint(nil, 1, 5) - request = wire.AppendVarint(request, 2, 77) - request = wire.AppendVarint(request, 3, 1) - request = wire.AppendVarint(request, 4, 1) - for _, item := range materials { - request = wire.AppendBytes(request, 5, ItemWire(item)) - } - code, response, handled, err := equipment.Handle("/EquipMaking", request) - if err != nil || !handled || code != 50 { - t.Fatalf("making code=%d handled=%t response=%x err=%v", code, handled, response, err) - } - if len(equipment.All()) != 1 || len(inventory.All()) != 0 { - t.Fatalf("making equipment=%+v inventory=%+v", equipment.All(), inventory.All()) - } - if wallet.Snapshot().Catalyst != 7 { - t.Fatalf("making catalyst=%d", wallet.Snapshot().Catalyst) - } - if character, ok := characters.Find(77); !ok || character.TalentExp != expectedExperience { - t.Fatalf("making character=%+v found=%t", character, ok) - } - replayCode, replay, _, err := equipment.Handle("/EquipMaking", request) - if err != nil || replayCode != code || string(replay) != string(response) || len(equipment.All()) != 1 || wallet.Snapshot().Catalyst != 7 { - t.Fatalf("making replay code=%d response=%x equipment=%+v catalyst=%d err=%v", replayCode, replay, equipment.All(), wallet.Snapshot().Catalyst, err) - } - autoMaterials, err := inventory.GrantOnce("making-auto-materials", []gamedata.BattleReward{{Type: 8, ID: 204, Count: 3}, {Type: 8, ID: 201, Count: 3}}) - if err != nil || len(autoMaterials) != 2 { - t.Fatalf("auto materials=%+v err=%v", autoMaterials, err) - } - autoRequest := wire.AppendVarint(nil, 1, 6) - autoRequest = wire.AppendVarint(autoRequest, 2, 77) - autoRequest = wire.AppendVarint(autoRequest, 3, 1) - autoRequest = wire.AppendVarint(autoRequest, 4, 1) - for _, item := range autoMaterials { - autoRequest = wire.AppendBytes(autoRequest, 5, ItemWire(item)) - } - autoCode, autoResponse, autoHandled, err := equipment.Handle("/EquipMakingToBreakAuto", autoRequest) - if err != nil || !autoHandled || autoCode != 515 { - t.Fatalf("making auto code=%d handled=%t response=%x err=%v", autoCode, autoHandled, autoResponse, err) - } - if _, found, _ := wire.Bytes(autoResponse, 2); !found { - t.Fatalf("making auto omitted source equipment: %x", autoResponse) - } - if _, found, _ := wire.Bytes(autoResponse, 8); !found || len(equipment.All()) != 1 || len(inventory.All()) == 0 || wallet.Snapshot().Catalyst != 4 { - t.Fatalf("making auto response=%x equipment=%+v inventory=%+v catalyst=%d", autoResponse, equipment.All(), inventory.All(), wallet.Snapshot().Catalyst) - } -} - -func TestEquipBreakUsesLevelRewardAndReplays(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - design := equipmentBreakTestDesign() - if err := equipment.AttachUpgrade(design, wallet, inventory); err != nil { - t.Fatal(err) - } - equipment.BeginSession("break-test") - entry, err := equipment.GrantGeneratedOnce("break-source", Equipment{ID: 10, Level: 1, Rank: []uint64{0, 0, 0}}) - if err != nil { - t.Fatal(err) - } - var packed [10]byte - n := binary.PutUvarint(packed[:], entry.InvenIndex) - request := wire.AppendBytes(wire.AppendVarint(nil, 1, 7), 2, packed[:n]) - code, response, handled, err := equipment.Handle("/EquipBreak", request) - if err != nil || !handled || code != 56 || len(response) == 0 { - t.Fatalf("break code=%d handled=%t response=%x err=%v", code, handled, response, err) - } - if len(equipment.All()) != 0 { - t.Fatalf("broken equipment remained: %+v", equipment.All()) - } - if got := inventory.All(); len(got) != 1 || got[0].ID != 201 || got[0].Type != 8 || got[0].Count != 2 { - t.Fatalf("break inventory=%+v", got) - } - replayCode, replay, _, err := equipment.Handle("/EquipBreak", request) - if err != nil || replayCode != code || string(replay) != string(response) || len(inventory.All()) != 1 { - t.Fatalf("break replay code=%d response=%x inventory=%+v err=%v", replayCode, replay, inventory.All(), err) - } -} - -func TestEquipUpgradeToBreakAutoUpgradesThenRemoves(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - design := equipmentBreakTestDesign() - if err := equipment.AttachUpgrade(design, wallet, inventory); err != nil { - t.Fatal(err) - } - equipment.BeginSession("upgrade-break-test") - entry, err := equipment.GrantOnce("upgrade-break-source", 10) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 9) - request = wire.AppendVarint(request, 2, entry.InvenIndex) - request = wire.AppendVarint(request, 3, 2) - code, response, handled, err := equipment.Handle("/EquipUpgradeToBreakAuto", request) - if err != nil || !handled || code != 516 { - t.Fatalf("auto code=%d handled=%t response=%x err=%v", code, handled, response, err) - } - if attempts, _, _ := wire.Varint(response, 3); attempts != 2 { - t.Fatalf("auto attempts=%d response=%x", attempts, response) - } - if used, _, _ := wire.Varint(response, 6); used != 30 || wallet.Snapshot().Gold != 70 { - t.Fatalf("auto used=%d wallet=%+v", used, wallet.Snapshot()) - } - if len(equipment.All()) != 0 { - t.Fatalf("auto-broken equipment remained: %+v", equipment.All()) - } - if got := inventory.All(); len(got) != 1 || got[0].ID != 202 || got[0].Count != 3 { - t.Fatalf("auto break inventory=%+v", got) - } - replayCode, replay, _, err := equipment.Handle("/EquipUpgradeToBreakAuto", request) - if err != nil || replayCode != code || string(replay) != string(response) || wallet.Snapshot().Gold != 70 || len(inventory.All()) != 1 { - t.Fatalf("auto replay code=%d response=%x wallet=%+v inventory=%+v err=%v", replayCode, replay, wallet.Snapshot(), inventory.All(), err) - } -} - -func equipmentBreakTestDesign() *gamedata.EquipmentUpgradeDesign { - return &gamedata.EquipmentUpgradeDesign{ - MaxLevel: map[uint64]uint64{10: 2}, Group: map[uint64]uint64{10: 20}, RankGroup: map[uint64]uint64{10: 30}, - Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{ - {20, 0}: {Level: 0, Costs: []gamedata.PromotionCost{{Type: 4, Count: 10}}, SuccessRatio: 1}, - {20, 1}: {Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 20}}, SuccessRatio: 1}, - }, - Break: map[[2]uint64][]gamedata.BattleReward{ - {20, 0}: {{Type: 8, ID: 200, Count: 1}}, - {20, 1}: {{Type: 8, ID: 201, Count: 2}}, - {20, 2}: {{Type: 8, ID: 202, Count: 3}}, - }, - NotTrash: map[uint64]bool{10: true}, - } -} diff --git a/go/internal/server/player/equipment_option_reroll_test.go b/go/internal/server/player/equipment_option_reroll_test.go deleted file mode 100644 index c266a99..0000000 --- a/go/internal/server/player/equipment_option_reroll_test.go +++ /dev/null @@ -1,624 +0,0 @@ -package player - -import ( - "fmt" - "path/filepath" - "reflect" - "testing" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func TestEquipmentOptionRerollLocksChargesReplaysAndRecoversPending(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - fixture.equipment.BeginSession("option-reroll-session") - - request := fixture.rerollRequest(41, []bool{false, true}, []bool{false, true, false}) - if rerollType, found, err := wire.Varint(request, 6); err != nil || !found || rerollType != 1 { - t.Fatalf("malformed test reroll type=%d found=%v err=%v request=%x", rerollType, found, err, request) - } - code, response, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request) - if err != nil || !handled || code != 192 { - t.Fatalf("reroll code=%d handled=%v err=%v", code, handled, err) - } - wantMain := []EquipmentOption{{GroupID: 100, ID: 1}, {GroupID: 101, ID: 2}} - wantSub := []EquipmentOption{{GroupID: 200, ID: 11}, {GroupID: 201, ID: 20}, {GroupID: 202, ID: 31}} - main, sub := optionRerollResponseOptions(t, response) - if !reflect.DeepEqual(main, wantMain) || !reflect.DeepEqual(sub, wantSub) { - t.Fatalf("candidate main=%+v sub=%+v", main, sub) - } - assertOptionRerollBalances(t, fixture, 800, 13) - - // The candidate is not official equipment until the second-phase confirm. - current := fixture.equipment.All()[0] - if !reflect.DeepEqual(current.MainOption, fixture.original.MainOption) || !reflect.DeepEqual(current.SubOption, fixture.original.SubOption) { - t.Fatalf("reroll changed official equipment before confirm: %+v", current) - } - - // Same session and sequence must replay exactly, without charging or rolling again. - replayCode, replay, replayHandled, replayErr := fixture.equipment.Handle("/EquipOptionReRoll", request) - if replayErr != nil || !replayHandled || replayCode != code || string(replay) != string(response) { - t.Fatalf("reroll replay code=%d handled=%v same=%v err=%v", replayCode, replayHandled, string(replay) == string(response), replayErr) - } - assertOptionRerollBalances(t, fixture, 800, 13) - - _, info, infoHandled, infoErr := fixture.equipment.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 42)) - if infoErr != nil || !infoHandled { - t.Fatalf("EquipInfo handled=%v err=%v", infoHandled, infoErr) - } - pending, found, err := wire.Bytes(info, 2) - if err != nil || !found { - t.Fatalf("EquipInfo missing pending option reroll: found=%v err=%v", found, err) - } - index, pendingMain, pendingSub := equipmentWireOptions(t, pending) - if index != fixture.original.InvenIndex || !reflect.DeepEqual(pendingMain, wantMain) || !reflect.DeepEqual(pendingSub, wantSub) { - t.Fatalf("EquipInfo pending index=%d main=%+v sub=%+v", index, pendingMain, pendingSub) - } - - // Pending candidates are server state, not a transient UI cache. Reopening the - // equipment domain must preserve field 2 until the player resolves it. - restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath)) - if err != nil { - t.Fatal(err) - } - if err := restarted.AttachOptionReroll(fixture.design, fixture.wallet, fixture.inventory); err != nil { - t.Fatal(err) - } - restarted.BeginSession("option-reroll-after-restart") - _, restartedInfo, _, err := restarted.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 43)) - if err != nil { - t.Fatal(err) - } - restoredPending, found, err := wire.Bytes(restartedInfo, 2) - if err != nil || !found || string(restoredPending) != string(pending) { - t.Fatalf("pending did not survive restart: found=%v same=%v err=%v", found, string(restoredPending) == string(pending), err) - } - - confirm := optionRerollConfirmRequest(44, fixture.original.InvenIndex, true) - confirmCode, confirmResponse, confirmHandled, confirmErr := restarted.Handle("/EquipOptionReRollConfirm", confirm) - if confirmErr != nil || !confirmHandled || confirmCode != 193 { - t.Fatalf("confirm code=%d handled=%v err=%v", confirmCode, confirmHandled, confirmErr) - } - confirmedWire, found, err := wire.Bytes(confirmResponse, 1) - if err != nil || !found { - t.Fatalf("confirm response missing equipment: found=%v err=%v", found, err) - } - _, confirmedMain, confirmedSub := equipmentWireOptions(t, confirmedWire) - if !reflect.DeepEqual(confirmedMain, wantMain) || !reflect.DeepEqual(confirmedSub, wantSub) { - t.Fatalf("confirmed main=%+v sub=%+v", confirmedMain, confirmedSub) - } - confirmed := restarted.All()[0] - if !reflect.DeepEqual(confirmed.MainOption, wantMain) || !reflect.DeepEqual(confirmed.SubOption, wantSub) { - t.Fatalf("confirmed equipment=%+v", confirmed) - } - _, resolvedInfo, _, err := restarted.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 45)) - if err != nil { - t.Fatal(err) - } - if _, found, _ := wire.Bytes(resolvedInfo, 2); found { - t.Fatal("confirmed candidate remained in EquipInfo field 2") - } -} - -func TestEquipmentOptionRerollKeepPreservesOfficialOptions(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - fixture.equipment.BeginSession("option-reroll-keep") - request := fixture.rerollRequest(51, []bool{false, true}, []bool{false, true, false}) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err != nil || !handled || code != 192 { - t.Fatalf("reroll code=%d handled=%v err=%v", code, handled, err) - } - assertOptionRerollBalances(t, fixture, 800, 13) - - keep := optionRerollConfirmRequest(52, fixture.original.InvenIndex, false) - code, response, handled, err := fixture.equipment.Handle("/EquipOptionReRollConfirm", keep) - if err != nil || !handled || code != 193 { - t.Fatalf("keep code=%d handled=%v err=%v", code, handled, err) - } - keptWire, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("keep response missing equipment: found=%v err=%v", found, err) - } - _, keptMain, keptSub := equipmentWireOptions(t, keptWire) - if !reflect.DeepEqual(keptMain, fixture.original.MainOption) || !reflect.DeepEqual(keptSub, fixture.original.SubOption) { - t.Fatalf("keep response main=%+v sub=%+v", keptMain, keptSub) - } - current := fixture.equipment.All()[0] - if !reflect.DeepEqual(current.MainOption, fixture.original.MainOption) || !reflect.DeepEqual(current.SubOption, fixture.original.SubOption) { - t.Fatalf("keep changed official equipment: %+v", current) - } - _, info, _, err := fixture.equipment.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 53)) - if err != nil { - t.Fatal(err) - } - if _, found, _ := wire.Bytes(info, 2); found { - t.Fatal("discarded candidate remained in EquipInfo field 2") - } -} - -func TestEquipmentOptionRerollRetryLocksPreviousCandidate(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - fixture.equipment.BeginSession("option-reroll-retry") - - first := fixture.rerollRequest(54, []bool{false, false}, []bool{false, false, false}) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", first); err != nil || !handled || code != 192 { - t.Fatalf("first reroll code=%d handled=%v err=%v", code, handled, err) - } - firstCandidate := fixture.equipment.pendingReroll.Equipment - if firstCandidate.MainOption[1].ID == fixture.original.MainOption[1].ID || firstCandidate.SubOption[1].ID == fixture.original.SubOption[1].ID { - t.Fatalf("fixture did not change lock targets: first=%+v original=%+v", firstCandidate, fixture.original) - } - - // The result screen sends only masks on a retry. Locked values must come - // from the first candidate even though it has not been confirmed yet. - second := fixture.rerollRequest(55, []bool{false, true}, []bool{false, true, false}) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", second); err != nil || !handled || code != 192 { - t.Fatalf("second reroll code=%d handled=%v err=%v", code, handled, err) - } - secondCandidate := fixture.equipment.pendingReroll.Equipment - if secondCandidate.MainOption[1] != firstCandidate.MainOption[1] || secondCandidate.SubOption[1] != firstCandidate.SubOption[1] { - t.Fatalf("retry lost locked candidate values: first=%+v second=%+v", firstCandidate, secondCandidate) - } - if secondCandidate.MainOption[1] == fixture.original.MainOption[1] || secondCandidate.SubOption[1] == fixture.original.SubOption[1] { - t.Fatalf("retry restored official values instead of candidate: original=%+v second=%+v", fixture.original, secondCandidate) - } -} - -func TestEquipmentMainOptionChangeUpdatesPendingCandidate(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - fixture.equipment.BeginSession("main-option-change") - - first := fixture.rerollRequest(56, []bool{false, false}, []bool{false, false, false}) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", first); err != nil || !handled || code != 192 { - t.Fatalf("reroll code=%d handled=%v err=%v", code, handled, err) - } - if got := fixture.equipment.pendingReroll.Equipment.MainOption[0].ID; got != 1 { - t.Fatalf("pending first main option=%d want=1 before change", got) - } - - change := wire.AppendVarint(nil, 1, 57) - change = wire.AppendVarint(change, 2, fixture.original.InvenIndex) - change = wire.AppendVarint(change, 3, 100) - change = wire.AppendVarint(change, 4, 9) - if code, response, handled, err := fixture.equipment.Handle("/EquipMainOptChange", change); err != nil || !handled || code != 537 || len(response) != 0 { - t.Fatalf("main option change code=%d response=%x handled=%v err=%v", code, response, handled, err) - } - if got := fixture.equipment.All()[0].MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) { - t.Fatalf("official first main option=%+v", got) - } - if got := fixture.equipment.pendingReroll.Equipment.MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) { - t.Fatalf("pending first main option=%+v", got) - } - - restarted, err := OpenEquipmentInventory(testStore(fixture.equipmentPath)) - if err != nil { - t.Fatal(err) - } - if err := restarted.AttachOptionReroll(fixture.design, fixture.wallet, fixture.inventory); err != nil { - t.Fatal(err) - } - if got := restarted.All()[0].MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) { - t.Fatalf("restarted official first main option=%+v", got) - } - if restarted.pendingReroll == nil || restarted.pendingReroll.Equipment.MainOption[0] != (EquipmentOption{GroupID: 100, ID: 9}) { - t.Fatalf("restarted pending candidate=%+v", restarted.pendingReroll) - } - - confirm := optionRerollConfirmRequest(58, fixture.original.InvenIndex, true) - if code, _, handled, err := restarted.Handle("/EquipOptionReRollConfirm", confirm); err != nil || !handled || code != 193 { - t.Fatalf("confirm code=%d handled=%v err=%v", code, handled, err) - } - if got := restarted.All()[0].MainOption[0]; got != (EquipmentOption{GroupID: 100, ID: 9}) { - t.Fatalf("confirm reverted changed first main option: %+v", got) - } -} - -func TestEquipmentMainOptionChangeRejectsForgedChoice(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - before := fixture.equipment.All()[0] - request := wire.AppendVarint(nil, 1, 59) - request = wire.AppendVarint(request, 2, fixture.original.InvenIndex) - request = wire.AppendVarint(request, 3, 100) - request = wire.AppendVarint(request, 4, 999) - if code, _, handled, err := fixture.equipment.Handle("/EquipMainOptChange", request); err == nil || !handled || code != 0 { - t.Fatalf("forged main option code=%d handled=%v err=%v", code, handled, err) - } - if after := fixture.equipment.All()[0]; !reflect.DeepEqual(after, before) { - t.Fatalf("forged main option changed equipment: before=%+v after=%+v", before, after) - } -} - -func TestEquipmentMainOptionChangeEquippedCharacterHealthReadsEquipment(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - const characterIndex = 920000001 - characters := &CharacterStore{characters: []Character{{InvenIndex: characterIndex, ID: 50, Level: 1}}} - if err := characters.AttachMaxHealth(func(Character) (uint64, error) { - // The production max-health provider obtains pictorial equipment via All. - if got := fixture.equipment.All()[0].MainOption[0].ID; got != 9 { - return 0, fmt.Errorf("health provider observed stale main option %d", got) - } - return 123, nil - }); err != nil { - t.Fatal(err) - } - fixture.equipment.characters = characters - fixture.equipment.owned.Equipment[0].UseChar = characterIndex - request := wire.AppendVarint(nil, 1, 60) - request = wire.AppendVarint(request, 2, fixture.original.InvenIndex) - request = wire.AppendVarint(request, 3, 100) - request = wire.AppendVarint(request, 4, 9) - type result struct { - code int - body []byte - handled bool - err error - } - done := make(chan result, 1) - go func() { - code, body, handled, err := fixture.equipment.Handle("/EquipMainOptChange", request) - done <- result{code, body, handled, err} - }() - select { - case got := <-done: - if got.err != nil || !got.handled || got.code != 537 { - t.Fatalf("change result=%+v", got) - } - character, found, err := wire.Bytes(got.body, 1) - if err != nil || !found || len(character) == 0 { - t.Fatalf("equipped character response missing: found=%v err=%v", found, err) - } - hp, found, err := wire.Varint(character, 3) - if err != nil || found || hp != 0 { - t.Fatalf("updated health=%d found=%v err=%v", hp, found, err) - } - maximum, err := characters.MaxHealth(characterIndex) - if err != nil || maximum != 123 { - t.Fatalf("updated maximum=%d err=%v", maximum, err) - } - case <-time.After(2 * time.Second): - t.Fatal("equipped main option change deadlocked while resolving character health") - } -} - -func TestEquipmentOptionRerollRejectsForgedMaterialsWithoutCharge(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - fixture.equipment.BeginSession("option-reroll-forged-material") - - request := optionRerollRequest(61, fixture.original.InvenIndex, - []bool{false, true}, []bool{false, true, false}, 1, - []Item{{Type: 4, Count: 1}, {InvenIndex: fixture.material.InvenIndex, Type: 8, ID: 17, Count: 1}}) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err == nil || !handled || code != 0 { - t.Fatalf("forged materials code=%d handled=%v err=%v", code, handled, err) - } - assertOptionRerollBalances(t, fixture, 1000, 20) - _, info, _, err := fixture.equipment.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 62)) - if err != nil { - t.Fatal(err) - } - if _, found, _ := wire.Bytes(info, 2); found { - t.Fatal("rejected forged request created a pending candidate") - } -} - -func TestEquipmentOptionRerollRejectsInvalidLockMasksWithoutCharge(t *testing.T) { - tests := []struct { - name string - main []bool - sub []bool - }{ - {name: "wrong array length", main: []bool{false}, sub: []bool{false, false, false}}, - {name: "first main slot locked", main: []bool{true, false}, sub: []bool{false, false, false}}, - {name: "all rerollable slots locked", main: []bool{false, true}, sub: []bool{true, true, true}}, - } - for i, test := range tests { - t.Run(test.name, func(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - fixture.equipment.BeginSession("option-reroll-invalid-lock") - locked := countLockedOptions(test.main) + countLockedOptions(test.sub) - request := optionRerollRequest(uint64(70+i), fixture.original.InvenIndex, test.main, test.sub, 1, - fixture.consumeItems(uint64(locked))) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err == nil || !handled || code != 0 { - t.Fatalf("invalid locks code=%d handled=%v err=%v", code, handled, err) - } - assertOptionRerollBalances(t, fixture, 1000, 20) - }) - } -} - -func TestEquipmentOptionRerollConversionConsumesMixedConcreteStacks(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - fixture.equipment.BeginSession("option-reroll-conversion") - fixture.design.Conversion = &gamedata.EquipmentOptionRerollConversion{ - Ratio: 10, SourceType: 8, SourceID: 16, TargetType: 8, TargetID: 17, - } - sources, err := fixture.inventory.GrantOnce("option-reroll-source-material", []gamedata.BattleReward{{Type: 8, ID: 16, Count: 100}}) - if err != nil || len(sources) != 1 { - t.Fatalf("grant conversion source=%+v err=%v", sources, err) - } - - // Two locked slots require 7 target units. The client consumes two actual - // target units plus 50 source units (10 source == one target). - target := fixture.material - target.Count = 2 - source := sources[0] - source.Count = 50 - request := optionRerollRequest(81, fixture.original.InvenIndex, - []bool{false, true}, []bool{false, true, false}, 1, - []Item{{Type: 4, Count: 200}, target, source}) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err != nil || !handled || code != 192 { - t.Fatalf("conversion reroll code=%d handled=%v err=%v", code, handled, err) - } - if got := fixture.wallet.Snapshot().Gold; got != 800 { - t.Fatalf("conversion gold=%d want=800", got) - } - if got := optionRerollItemCount(fixture.inventory, 8, 17); got != 18 { - t.Fatalf("target material=%d want=18", got) - } - if got := optionRerollItemCount(fixture.inventory, 8, 16); got != 50 { - t.Fatalf("source material=%d want=50", got) - } -} - -func TestEquipmentOptionRerollConfirmEquippedReturnsCharacter(t *testing.T) { - fixture := newEquipmentOptionRerollFixture(t) - character := Character{InvenIndex: 535607162, ID: 350, Level: 20} - characters, err := OpenCharacterStore(testStore(filepath.Join(filepath.Dir(fixture.equipmentPath), "characters.json")), - []Character{character}, fixture.inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := fixture.equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - fixture.equipment.mu.Lock() - next := cloneEquipmentSnapshot(fixture.equipment.owned) - next.Equipment[0].UseChar = character.InvenIndex - if err := fixture.equipment.commitLocked(next, "option reroll equipped test setup"); err != nil { - fixture.equipment.mu.Unlock() - t.Fatal(err) - } - fixture.equipment.mu.Unlock() - fixture.original = fixture.equipment.All()[0] - fixture.equipment.BeginSession("option-reroll-equipped") - - request := fixture.rerollRequest(91, []bool{false, true}, []bool{false, true, false}) - if code, _, handled, err := fixture.equipment.Handle("/EquipOptionReRoll", request); err != nil || !handled || code != 192 { - t.Fatalf("equipped reroll code=%d handled=%v err=%v", code, handled, err) - } - confirm := optionRerollConfirmRequest(92, fixture.original.InvenIndex, true) - code, response, handled, err := fixture.equipment.Handle("/EquipOptionReRollConfirm", confirm) - if err != nil || !handled || code != 193 { - t.Fatalf("equipped confirm code=%d handled=%v err=%v", code, handled, err) - } - charWire, found, err := wire.Bytes(response, 2) - if err != nil || !found { - t.Fatalf("confirm response missing CharDBInfo field 2: found=%v err=%v", found, err) - } - charIndex, found, err := wire.Varint(charWire, 1) - if err != nil || !found || charIndex != character.InvenIndex { - t.Fatalf("CharDBInfo index=%d want=%d found=%v err=%v", charIndex, character.InvenIndex, found, err) - } -} - -type equipmentOptionRerollFixture struct { - design *gamedata.EquipmentOptionRerollDesign - equipment *EquipmentInventory - wallet *Wallet - inventory *Inventory - original Equipment - material Item - equipmentPath string -} - -func newEquipmentOptionRerollFixture(t *testing.T) *equipmentOptionRerollFixture { - t.Helper() - dir := t.TempDir() - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - materials, err := inventory.GrantOnce("option-reroll-material", []gamedata.BattleReward{{Type: 8, ID: 17, Count: 20}}) - if err != nil || len(materials) != 1 { - t.Fatalf("grant option material=%+v err=%v", materials, err) - } - equipmentPath := filepath.Join(dir, "equipment.json") - equipment, err := OpenEquipmentInventory(testStore(equipmentPath)) - if err != nil { - t.Fatal(err) - } - design := optionRerollDomainTestDesign() - if err := equipment.AttachOptionReroll(design, wallet, inventory); err != nil { - t.Fatal(err) - } - entry, err := equipment.GrantOnce("option-reroll-equipment", 77) - if err != nil { - t.Fatal(err) - } - equipment.mu.Lock() - next := cloneEquipmentSnapshot(equipment.owned) - next.Equipment[0].Level = 9 - next.Equipment[0].MainOption = []EquipmentOption{{GroupID: 100, ID: 1}, {GroupID: 101, ID: 2}} - next.Equipment[0].SubOption = []EquipmentOption{{GroupID: 200, ID: 10}, {GroupID: 201, ID: 20}, {GroupID: 202, ID: 30}} - next.Equipment[0].PrivateOption = &EquipmentOption{GroupID: 300, ID: 40} - next.Equipment[0].Rank = []uint64{4, 4, 4} - if err := equipment.commitLocked(next, "option reroll test setup"); err != nil { - equipment.mu.Unlock() - t.Fatal(err) - } - equipment.mu.Unlock() - original := equipment.All()[0] - if original.InvenIndex != entry.InvenIndex { - t.Fatalf("equipment index=%d want=%d", original.InvenIndex, entry.InvenIndex) - } - return &equipmentOptionRerollFixture{ - design: design, equipment: equipment, wallet: wallet, inventory: inventory, - original: original, material: materials[0], equipmentPath: equipmentPath, - } -} - -func optionRerollDomainTestDesign() *gamedata.EquipmentOptionRerollDesign { - choices := func(oldID, nextID uint64) []gamedata.WeightedOption { - // A zero-weight old value and one positive new value make the domain test - // deterministic while retaining two legal choices in every rerollable group. - return []gamedata.WeightedOption{{ID: oldID, Weight: 0}, {ID: nextID, Weight: 1}} - } - return &gamedata.EquipmentOptionRerollDesign{ - Equipment: map[uint64]gamedata.EquipmentOptionRerollItem{77: { - ID: 77, OptionRerollID: 9, PrivateUniqueCharID: 350, - MainGroups: []uint64{100, 101}, SubGroups: []uint64{200, 201, 202}, PrivateGroups: []uint64{300}, - }}, - Costs: map[uint64]gamedata.EquipmentOptionRerollCost{9: {Resources: []gamedata.EquipmentOptionRerollResource{ - {Type: 4, BaseCount: 100, LockCount: 50}, - {Type: 8, ID: 17, BaseCount: 3, LockCount: 2}, - }}}, - Groups: map[uint64]gamedata.OptionGroup{ - 100: {ID: 100, Choices: choices(1, 9)}, - 101: {ID: 101, Choices: choices(2, 3)}, - 200: {ID: 200, Choices: choices(10, 11)}, - 201: {ID: 201, Choices: choices(20, 21)}, - 202: {ID: 202, Choices: choices(30, 31)}, - 300: {ID: 300, Choices: choices(40, 41)}, - }, - } -} - -func (f *equipmentOptionRerollFixture) consumeItems(locked uint64) []Item { - material := f.material - material.Count = 3 + 2*locked - return []Item{{Type: 4, Count: 100 + 50*locked}, material} -} - -func (f *equipmentOptionRerollFixture) rerollRequest(seq uint64, main, sub []bool) []byte { - locked := countLockedOptions(main) + countLockedOptions(sub) - return optionRerollRequest(seq, f.original.InvenIndex, main, sub, 1, f.consumeItems(uint64(locked))) -} - -func optionRerollRequest(seq, equipmentIndex uint64, main, sub []bool, rerollType uint64, consume []Item) []byte { - request := wire.AppendVarint(nil, 1, seq) - request = wire.AppendVarint(request, 2, equipmentIndex) - request = appendPackedOptionLocks(request, 3, main) - request = appendPackedOptionLocks(request, 4, sub) - for _, item := range consume { - request = wire.AppendBytes(request, 5, ItemWire(item)) - } - if rerollType != 0 { - request = wire.AppendVarint(request, 6, rerollType) - } - return request -} - -func optionRerollConfirmRequest(seq, equipmentIndex uint64, confirm bool) []byte { - request := wire.AppendVarint(nil, 1, seq) - request = wire.AppendVarint(request, 2, equipmentIndex) - if confirm { - request = wire.AppendVarint(request, 3, 1) - } - return request -} - -func appendPackedOptionLocks(dst []byte, field int, locks []bool) []byte { - packed := make([]byte, len(locks)) - for i, locked := range locks { - if locked { - packed[i] = 1 - } - } - return wire.AppendBytes(dst, field, packed) -} - -func countLockedOptions(locks []bool) int { - count := 0 - for _, locked := range locks { - if locked { - count++ - } - } - return count -} - -func optionRerollResponseOptions(t *testing.T, response []byte) ([]EquipmentOption, []EquipmentOption) { - t.Helper() - var main, sub []EquipmentOption - if err := wire.Walk(response, func(field wire.Field) error { - if field.Type != 2 || (field.Number != 1 && field.Number != 2) { - return nil - } - option := decodeOptionRerollOption(t, field.Value) - if field.Number == 1 { - main = append(main, option) - } else { - sub = append(sub, option) - } - return nil - }); err != nil { - t.Fatal(err) - } - return main, sub -} - -func equipmentWireOptions(t *testing.T, equipment []byte) (uint64, []EquipmentOption, []EquipmentOption) { - t.Helper() - index, found, err := wire.Varint(equipment, 1) - if err != nil || !found { - t.Fatalf("equipment wire missing index: found=%v err=%v", found, err) - } - base, found, err := wire.Bytes(equipment, 5) - if err != nil || !found { - t.Fatalf("equipment wire missing base info: found=%v err=%v", found, err) - } - var main, sub []EquipmentOption - if err := wire.Walk(base, func(field wire.Field) error { - if field.Type != 2 || (field.Number != 3 && field.Number != 4) { - return nil - } - option := decodeOptionRerollOption(t, field.Value) - if field.Number == 3 { - main = append(main, option) - } else { - sub = append(sub, option) - } - return nil - }); err != nil { - t.Fatal(err) - } - return index, main, sub -} - -func decodeOptionRerollOption(t *testing.T, data []byte) EquipmentOption { - t.Helper() - group, groupFound, err := wire.Varint(data, 1) - if err != nil || !groupFound { - t.Fatalf("option missing group: found=%v err=%v", groupFound, err) - } - id, idFound, err := wire.Varint(data, 2) - if err != nil || !idFound { - t.Fatalf("option missing id: found=%v err=%v", idFound, err) - } - return EquipmentOption{GroupID: group, ID: id} -} - -func assertOptionRerollBalances(t *testing.T, fixture *equipmentOptionRerollFixture, gold, material uint64) { - t.Helper() - if got := fixture.wallet.Snapshot().Gold; got != gold { - t.Fatalf("gold=%d want=%d", got, gold) - } - got := optionRerollItemCount(fixture.inventory, 8, 17) - if got != material { - t.Fatalf("option material=%d want=%d", got, material) - } -} - -func optionRerollItemCount(inventory *Inventory, itemType, id uint64) uint64 { - var count uint64 - for _, item := range inventory.All() { - if item.Type == itemType && item.ID == id { - count += item.Count - } - } - return count -} diff --git a/go/internal/server/player/equipment_preset_test.go b/go/internal/server/player/equipment_preset_test.go deleted file mode 100644 index 3fb3d76..0000000 --- a/go/internal/server/player/equipment_preset_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package player - -import ( - "path/filepath" - "reflect" - "testing" - - "bd2server/internal/server/wire" -) - -func TestEquipmentPresetSaveInfoRenameAndRestart(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - character := Character{InvenIndex: 100, ID: 350, Level: 20} - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{character}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - if err := equipment.AttachSlots(map[uint64]uint64{10: 0, 11: 1}); err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - first, _ := equipment.GrantOnce("preset-first", 10) - second, _ := equipment.GrantOnce("preset-second", 11) - equipment.mu.Lock() - next := cloneEquipmentSnapshot(equipment.owned) - next.Equipment[0].UseChar = character.InvenIndex - next.Equipment[1].UseChar = character.InvenIndex - if err := equipment.commitLocked(next, "preset test setup"); err != nil { - equipment.mu.Unlock() - t.Fatal(err) - } - equipment.mu.Unlock() - - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, character.InvenIndex) - request = wire.AppendVarint(request, 3, 2) - request = wire.AppendString(request, 4, "Boss") - request = wire.AppendVarint(request, 5, 7) - request = wire.AppendVarint(request, 6, 3) - for slot, index := range []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0} { - item := wire.AppendVarint(nil, 1, uint64(slot)) - if index != 0 { - item = wire.AppendVarint(item, 2, index) - } - request = wire.AppendBytes(request, 7, item) - } - if code, _, handled, err := equipment.Handle("/EquipPresetSave", request); err != nil || !handled || code != 254 { - t.Fatalf("preset save code=%d handled=%v err=%v", code, handled, err) - } - assertEquipmentPresetInfo(t, equipment, character.InvenIndex, 2, "Boss", 7, 3, []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0}) - - rename := wire.AppendVarint(nil, 1, 2) - rename = wire.AppendVarint(rename, 2, character.InvenIndex) - rename = wire.AppendVarint(rename, 3, 2) - rename = wire.AppendString(rename, 4, "Raid") - rename = wire.AppendVarint(rename, 5, 9) - rename = wire.AppendVarint(rename, 6, 4) - if code, _, handled, err := equipment.Handle("/EquipPresetNameChange", rename); err != nil || !handled || code != 259 { - t.Fatalf("preset rename code=%d handled=%v err=%v", code, handled, err) - } - assertEquipmentPresetInfo(t, equipment, character.InvenIndex, 2, "Raid", 9, 4, []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0}) - - restarted, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - if err := restarted.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - assertEquipmentPresetInfo(t, restarted, character.InvenIndex, 2, "Raid", 9, 4, []uint64{first.InvenIndex, second.InvenIndex, 0, 0, 0}) -} - -func TestEquipmentPresetRejectsForgedEquipment(t *testing.T) { - dir := t.TempDir() - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - characters, _ := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 20}, {InvenIndex: 200, ID: 351, Level: 20}}, inventory, "", "") - equipment, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - _ = equipment.AttachSlots(map[uint64]uint64{10: 0}) - _ = equipment.AttachCharacters(characters) - item, _ := equipment.GrantOnce("preset-forged", 10) - equipment.mu.Lock() - next := cloneEquipmentSnapshot(equipment.owned) - next.Equipment[0].UseChar = 200 - _ = equipment.commitLocked(next, "preset forged setup") - equipment.mu.Unlock() - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 100), 3, 1) - request = wire.AppendString(request, 4, "Forged") - request = wire.AppendVarint(request, 5, 1) - for slot := range equipmentSlotCount { - entry := wire.AppendVarint(nil, 1, uint64(slot)) - if slot == 1 { - entry = wire.AppendVarint(entry, 2, item.InvenIndex) - } - request = wire.AppendBytes(request, 7, entry) - } - if _, _, handled, err := equipment.Handle("/EquipPresetSave", request); err == nil || !handled { - t.Fatalf("forged preset handled=%v err=%v", handled, err) - } - if len(equipment.presets) != 0 { - t.Fatal("forged preset was persisted") - } -} - -func TestEquipmentPresetNameChangeCreatesEmptySlot(t *testing.T) { - dir := t.TempDir() - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - characters, _ := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 20}}, inventory, "", "") - equipment, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - _ = equipment.AttachCharacters(characters) - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 100), 3, 5) - request = wire.AppendString(request, 4, "Empty") - request = wire.AppendVarint(request, 5, 1) - if code, _, handled, err := equipment.Handle("/EquipPresetNameChange", request); err != nil || !handled || code != 259 { - t.Fatalf("empty preset rename code=%d handled=%v err=%v", code, handled, err) - } - assertEquipmentPresetInfo(t, equipment, 100, 5, "Empty", 1, 0, []uint64{0, 0, 0, 0, 0}) -} - -func assertEquipmentPresetInfo(t *testing.T, inventory *EquipmentInventory, character, slot uint64, name string, resource, color uint64, wantItems []uint64) { - t.Helper() - code, response, handled, err := inventory.Handle("/EquipPresetInfo", wire.AppendVarint(nil, 1, 99)) - if err != nil || !handled || code != 253 { - t.Fatalf("preset info code=%d handled=%v err=%v", code, handled, err) - } - charInfo, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("preset char info found=%v err=%v", found, err) - } - if got, _, _ := wire.Varint(charInfo, 1); got != character { - t.Fatalf("preset character=%d want=%d", got, character) - } - preset, found, err := wire.Bytes(charInfo, 2) - if err != nil || !found { - t.Fatalf("preset found=%v err=%v", found, err) - } - if got, _, _ := wire.Varint(preset, 2); got != slot { - t.Fatalf("preset slot=%d want=%d", got, slot) - } - if got, _, _ := wire.Bytes(preset, 1); string(got) != name { - t.Fatalf("preset name=%q want=%q", got, name) - } - if got, _, _ := wire.Varint(preset, 3); got != resource { - t.Fatalf("preset resource=%d want=%d", got, resource) - } - if got, _, _ := wire.Varint(preset, 4); got != color { - t.Fatalf("preset color=%d want=%d", got, color) - } - var gotItems []uint64 - if err := wire.Walk(preset, func(field wire.Field) error { - if field.Number == 5 && field.Type == 2 { - index, _, err := wire.Varint(field.Value, 2) - if err != nil { - return err - } - gotItems = append(gotItems, index) - } - return nil - }); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(gotItems, wantItems) { - t.Fatalf("preset items=%v want=%v", gotItems, wantItems) - } -} diff --git a/go/internal/server/player/equipment_reconnect_test.go b/go/internal/server/player/equipment_reconnect_test.go deleted file mode 100644 index a0c63e4..0000000 --- a/go/internal/server/player/equipment_reconnect_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package player - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/wire" - "path/filepath" - "reflect" - "testing" -) - -// EquipInfo is the client's complete reconstruction source after it clears -// both equipment dictionaries during relogin. Exercise real SQLite writes and -// reopen rather than only the item serializer or an in-memory snapshot. -func TestEquipmentReconnectRestoresEveryCharacterAndClearedReplacement(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - open := func(repo *accountstate.Repository) *EquipmentInventory { - items, e := OpenInventory(repo, &Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - chars, e := OpenCharacterStore(repo, []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100}, {InvenIndex: 78, ID: 360, Level: 1, HP: 100}}, items, "", "") - if e != nil { - t.Fatal(e) - } - if err := chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if e = chars.EnsurePersisted(); e != nil { - t.Fatal(e) - } - eq, e := OpenEquipmentInventory(repo) - if e != nil { - t.Fatal(e) - } - if e = eq.AttachCharacters(chars); e != nil { - t.Fatal(e) - } - if e = eq.AttachSlots(map[uint64]uint64{100: 0, 101: 0, 102: 1, 103: 0}); e != nil { - t.Fatal(e) - } - return eq - } - eq := open(repo) - eq.BeginSession("before-restart") - var entries []Equipment - for i, id := range []uint64{100, 101, 102, 103} { - entry, e := eq.GrantOnce("grant"+string(rune('a'+i)), id) - if e != nil { - t.Fatal(e) - } - entries = append(entries, entry) - } - request := func(seq, equip, char uint64) []byte { - return wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, equip), 3, char) - } - for _, r := range []struct { - path string - seq, index, char uint64 - }{{"/EquipUse", 1, entries[0].InvenIndex, 77}, {"/EquipUse", 2, entries[2].InvenIndex, 77}, {"/EquipUse", 3, entries[3].InvenIndex, 78}, {"/EquipChange", 4, entries[1].InvenIndex, 77}} { - op, e := repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - if _, _, _, e = eq.Handle(r.path, request(r.seq, r.index, r.char)); e != nil { - t.Fatal(e) - } - if e = op.Commit(); e != nil { - t.Fatal(e) - } - } - want := map[uint64]uint64{entries[0].InvenIndex: 0, entries[1].InvenIndex: 77, entries[2].InvenIndex: 77, entries[3].InvenIndex: 78} - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - eq = open(repo) - eq.BeginSession("after-restart") - code, response, handled, err := eq.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || !handled || code != 34 { - t.Fatal(err) - } - got := map[uint64]uint64{} - err = wire.Walk(response, func(f wire.Field) error { - if f.Number != 1 { - return nil - } - index, _, e := wire.Varint(f.Value, 1) - if e != nil { - return e - } - owner, _, e := wire.Varint(f.Value, 2) - got[index] = owner - return e - }) - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("reconnect ownership got=%v want=%v error=%v", got, want, err) - } - for _, entry := range eq.All() { - if entry.UseChar != want[entry.InvenIndex] { - t.Fatal("serialized ownership differs from saved equipment") - } - } -} diff --git a/go/internal/server/player/equipment_stats.go b/go/internal/server/player/equipment_stats.go deleted file mode 100644 index 2b0e929..0000000 --- a/go/internal/server/player/equipment_stats.go +++ /dev/null @@ -1,81 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "errors" - "fmt" - "math" -) - -func (s *EquipmentInventory) AttachStatDesign(design *gamedata.EquipmentStatDesign) error { - if design == nil { - return errors.New("player: nil equipment stat design") - } - s.mu.Lock() - defer s.mu.Unlock() - s.statDesign = design - return nil -} - -// StatContributions resolves only maximum-health equipment effects. Snapshot -// options under the equipment lock and release it before calculating; callers -// may themselves be the CharacterStore maximum-health callback. -func (s *EquipmentInventory) StatContributions(character Character) ([]gamedata.StatContribution, error) { - if character.InvenIndex == 0 { - return nil, errors.New("player: invalid equipment stat character") - } - type query struct { - option gamedata.EquipmentOption - sub bool - } - var queries []query - s.mu.Lock() - design := s.statDesign - for _, equipment := range s.owned.Equipment { - if equipment.UseChar != character.InvenIndex { - continue - } - if equipment.Level > math.MaxInt32 { - s.mu.Unlock() - return nil, fmt.Errorf("player: invalid equipment %d level", equipment.InvenIndex) - } - var rank [3]int - for i, value := range equipment.Rank { - if i >= 3 || value > 4 { - s.mu.Unlock() - return nil, fmt.Errorf("player: invalid equipment %d ranks", equipment.InvenIndex) - } - rank[i] = int(value) - } - add := func(option EquipmentOption, sub bool) { - if option.ID == 1 || option.ID == 2 { - queries = append(queries, query{gamedata.EquipmentOption{GroupID: option.GroupID, ID: option.ID, Level: int(equipment.Level), Rank: rank}, sub}) - } - } - for _, option := range equipment.MainOption { - add(option, false) - } - for _, option := range equipment.SubOption { - add(option, true) - } - if equipment.PrivateOption != nil { - add(*equipment.PrivateOption, false) - } - } - s.mu.Unlock() - if len(queries) == 0 { - return nil, nil - } - if design == nil { - return nil, errors.New("player: equipment stat design unavailable") - } - result := make([]gamedata.StatContribution, 0, len(queries)) - for _, query := range queries { - contribution, err := design.HealthContribution(query.option, query.sub) - if err != nil { - return nil, err - } - result = append(result, contribution) - } - return result, nil -} diff --git a/go/internal/server/player/equipment_stats_test.go b/go/internal/server/player/equipment_stats_test.go deleted file mode 100644 index 71258f1..0000000 --- a/go/internal/server/player/equipment_stats_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "testing" - "time" -) - -func TestEquipmentStatContributionsUseOnlyEquippedOwnerAndAllHealthOptions(t *testing.T) { - s := &EquipmentInventory{owned: equipmentSnapshot{Equipment: []Equipment{ - {InvenIndex: 1, UseChar: 77, Level: 1, Rank: []uint64{1, 0, 0}, MainOption: []EquipmentOption{{GroupID: 10, ID: 1}, {GroupID: 999, ID: 3}}, SubOption: []EquipmentOption{{GroupID: 20, ID: 2}}, PrivateOption: &EquipmentOption{GroupID: 10, ID: 1}}, - {InvenIndex: 2, UseChar: 78, MainOption: []EquipmentOption{{GroupID: 999, ID: 1}}}, - {InvenIndex: 3, MainOption: []EquipmentOption{{GroupID: 999, ID: 1}}}, - }}} - d := &gamedata.EquipmentStatDesign{Options: map[[2]uint64]gamedata.EquipmentStatRule{ - {10, 1}: {Default: 7.9, Growth: 2, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, nil, nil}}, - {20, 2}: {Default: 0.01359, Growth: 100}, - }} - if err := s.AttachStatDesign(d); err != nil { - t.Fatal(err) - } - got, err := s.StatContributions(Character{InvenIndex: 77}) - if err != nil || len(got) != 3 { - t.Fatalf("contributions=%+v err=%v", got, err) - } - if hp := gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, got).Health; hp != 121 { - t.Fatalf("equipped health=%v", hp) - } - // Maximum health must safely enter equipment snapshot resolution without - // changing the persisted current HP returned by Find. - _, _, characters := foodTestService(t, stateio.NewMemory()) - if err := characters.AttachMaxHealth(func(c Character) (uint64, error) { - contributions, err := s.StatContributions(c) - return uint64(gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions).Health), err - }); err != nil { - t.Fatal(err) - } - done := make(chan Character, 1) - go func() { - maximum, err := characters.MaxHealth(77) - if err != nil || maximum != 121 { - t.Errorf("maximum=%d err=%v", maximum, err) - } - c, _ := characters.Find(77) - done <- c - }() - select { - case c := <-done: - if c.HP != 7 { - t.Fatalf("Find health=%d", c.HP) - } - case <-time.After(time.Second): - t.Fatal("Find deadlocked calculating equipped health") - } -} - -func TestEatFoodWithRankedEquipmentAndEmptyHealthOptionRankCurves(t *testing.T) { - food, inventory, characters := foodTestService(t, stateio.NewMemory()) - equipment := &EquipmentInventory{owned: equipmentSnapshot{Equipment: []Equipment{{ - InvenIndex: 1, UseChar: 77, Level: 1, Rank: []uint64{2, 2, 2}, - MainOption: []EquipmentOption{{GroupID: 10, ID: 1}}, - }}}} - if err := equipment.AttachStatDesign(&gamedata.EquipmentStatDesign{Options: map[[2]uint64]gamedata.EquipmentStatRule{ - {10, 1}: {Default: 10, Growth: 2, Levels: []float64{0, 1}}, - }}); err != nil { - t.Fatal(err) - } - if err := characters.AttachMaxHealth(func(c Character) (uint64, error) { - contributions, err := equipment.StatContributions(c) - if err != nil { - return 0, err - } - return uint64(gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions).Health), nil - }); err != nil { - t.Fatal(err) - } - if err := characters.SetCurrentHealth(77, 20); err != nil { - t.Fatal(err) - } - stacks, err := inventory.GrantOnce("ranked-equipment-food", []gamedata.BattleReward{{Type: 5, ID: 105, Count: 1}}) - if err != nil { - t.Fatal(err) - } - if _, _, _, err := food.Handle("/EatFood", foodRequest(1, 77, 21, stacks[0])); err != nil { - t.Fatal(err) - } - // Base 100 + equipment 12 = 112 maximum; the 50% dish heals 56. - if hp, err := characters.CurrentHealth(77); err != nil || hp != 76 { - t.Fatalf("healed health=%d err=%v", hp, err) - } - if len(inventory.All()) != 0 { - t.Fatal("successful recovery did not consume dish") - } -} diff --git a/go/internal/server/player/equipment_test.go b/go/internal/server/player/equipment_test.go deleted file mode 100644 index c059041..0000000 --- a/go/internal/server/player/equipment_test.go +++ /dev/null @@ -1,751 +0,0 @@ -package player - -import ( - "encoding/binary" - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func TestEquipmentUpgradeAndSequenceUseGameDataCosts(t *testing.T) { - dir := t.TempDir() - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - design := &gamedata.EquipmentUpgradeDesign{ - MaxLevel: map[uint64]uint64{943035: 3}, Group: map[uint64]uint64{943035: 954}, - RankGroup: map[uint64]uint64{943035: 904}, RankRatio: map[[2]uint64][]float64{{904, 1}: {1, 0, 0, 0}}, - Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{ - {954, 0}: {Level: 0, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}}, SuccessRatio: 1}, - {954, 1}: {Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}}, SuccessRatio: 1}, - {954, 2}: {Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 300}}, SuccessRatio: 1}, - }, - } - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if err := store.AttachUpgrade(design, wallet, items); err != nil { - t.Fatal(err) - } - entry, err := store.GrantOnce("upgrade", 943035) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex) - request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 100})) - code, response, handled, err := store.Handle("/EquipUpgrade", request) - if err != nil || !handled || code != 37 { - t.Fatalf("single upgrade code=%d handled=%v err=%v", code, handled, err) - } - encoded, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("single upgrade equipment: %v", err) - } - base, _, _ := wire.Bytes(encoded, 5) - if level, _, _ := wire.Varint(base, 2); level != 1 { - t.Fatalf("single upgraded level=%d", level) - } - sequence := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex) - sequence = wire.AppendVarint(sequence, 3, 9) - sequence = wire.AppendVarint(sequence, 6, 3) - code, response, handled, err = store.Handle("/EquipSequenceUpgrade", sequence) - if err != nil || !handled || code != 176 { - t.Fatalf("sequence upgrade code=%d handled=%v err=%v", code, handled, err) - } - if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopMaxLevel { - t.Fatalf("sequence result=%d", result) - } - if attempts, _, _ := wire.Varint(response, 4); attempts != 2 { - t.Fatalf("sequence attempts=%d", attempts) - } - if used, _, _ := wire.Varint(response, 7); used != 500 { - t.Fatalf("sequence used gold=%d", used) - } - if wallet.Snapshot().Gold != 400 { - t.Fatalf("wallet gold=%d", wallet.Snapshot().Gold) - } - restored, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - if got := restored.All(); len(got) != 1 || got[0].Level != 3 || got[0].UpgradeAttempts != 3 { - t.Fatalf("persisted upgrade=%+v", got) - } - if rank := restored.All()[0].Rank; len(rank) != 3 || rank[0] != 1 || rank[1] != 0 || rank[2] != 0 { - t.Fatalf("+3 should roll only the first rank: %v", rank) - } -} - -func TestEquipmentSmeltingImprovesByTotalScoreAndReplaysWithoutSecondCharge(t *testing.T) { - dir := t.TempDir() - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000, EquipMileageExchangeGage: 990}) - if err != nil { - t.Fatal(err) - } - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - materials, err := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 100}}) - if err != nil { - t.Fatal(err) - } - store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - design := smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}}) - if err := store.AttachSmelting(design, wallet, inventory); err != nil { - t.Fatal(err) - } - store.BeginSession("smelting-test") - entry, err := store.GrantOnce("refinable", 943035) - if err != nil { - t.Fatal(err) - } - store.owned.Equipment[0].Level = 9 - store.owned.Equipment[0].Rank = []uint64{4, 1, 1} // 6 -> candidate 1+4+4=9. - if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, entry.InvenIndex) - request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80})) - material := materials[0] - material.Count = 30 - request = wire.AppendBytes(request, 3, ItemWire(material)) - code, response, handled, err := store.Handle("/EquipSmelting", request) - if err != nil || !handled || code != 105 { - t.Fatalf("smelting code=%d handled=%v err=%v", code, handled, err) - } - if result, found, _ := wire.Varint(response, 3); found || result != 0 { - t.Fatalf("successful smelting result=%d found=%v", result, found) - } - if got := store.All()[0].Rank; len(got) != 3 || got[0] != 1 || got[1] != 4 || got[2] != 4 { - t.Fatalf("smelting rank=%v", got) - } - if gauge, _, _ := wire.Varint(response, 5); gauge != 20 { - t.Fatalf("smelting gauge=%d", gauge) - } - bundle, found, err := wire.Bytes(response, 6) - if err != nil || !found { - t.Fatalf("smelting mileage bundle: %v", err) - } - reward, found, err := wire.Bytes(bundle, 1) - if err != nil || !found { - t.Fatalf("smelting mileage item: %v", err) - } - if typ, _, _ := wire.Varint(reward, 3); typ != 68 { - t.Fatalf("smelting mileage type=%d", typ) - } - if count, _, _ := wire.Varint(reward, 4); count != 1 { - t.Fatalf("smelting mileage count=%d", count) - } - if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 { - t.Fatalf("smelting wallet=%+v", currency) - } - if _, replay, _, err := store.Handle("/EquipSmelting", request); err != nil || string(replay) != string(response) { - t.Fatalf("smelting replay differs err=%v", err) - } - if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileage != 1 || currency.EquipMileageExchangeGage != 20 { - t.Fatalf("smelting replay charged again: %+v", currency) - } -} - -func TestEquipmentSmeltingFailureConsumesAndReturnsCandidateGrades(t *testing.T) { - dir := t.TempDir() - wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - materials, _ := inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 30}}) - store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil { - t.Fatal(err) - } - entry, _ := store.GrantOnce("refinable", 943035) - store.owned.Equipment[0].Level = 9 - store.owned.Equipment[0].Rank = []uint64{4, 4, 4} - if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex) - request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 80})) - request = wire.AppendBytes(request, 3, ItemWire(materials[0])) - _, response, _, err := store.Handle("/EquipSmelting", request) - if err != nil { - t.Fatal(err) - } - if result, _, _ := wire.Varint(response, 3); result != equipUpgradeFail { - t.Fatalf("smelting failure result=%d", result) - } - var grades []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 4 { - value, _ := binary.Uvarint(field.Value) - grades = append(grades, value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(grades) != 3 || grades[0] != 1 || grades[1] != 1 || grades[2] != 1 { - t.Fatalf("failure candidate grades=%v", grades) - } - if got := store.All()[0].Rank; got[0] != 4 || got[1] != 4 || got[2] != 4 { - t.Fatalf("failure overwrote ranks=%v", got) - } - if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 { - t.Fatalf("failure did not consume: %+v", currency) - } -} - -func TestEquipmentSequenceSmeltingRepeatsAndStopsAtTargetScore(t *testing.T) { - dir := t.TempDir() - wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - _, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}}) - store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {0, 0, 0, 1}, {0, 0, 0, 1}}), wallet, inventory); err != nil { - t.Fatal(err) - } - entry, _ := store.GrantOnce("refinable", 943035) - store.owned.Equipment[0].Level = 9 - store.owned.Equipment[0].Rank = []uint64{4, 1, 1} - if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex) - request = wire.AppendVarint(request, 3, 100) - request = wire.AppendVarint(request, 4, 9) - code, response, handled, err := store.Handle("/EquipSequenceSmelting", request) - if err != nil || !handled || code != 177 { - t.Fatalf("sequence smelting code=%d handled=%v err=%v", code, handled, err) - } - if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopTargetLevel { - t.Fatalf("sequence smelting result=%d", result) - } - if attempts, _, _ := wire.Varint(response, 4); attempts != 1 { - t.Fatalf("sequence smelting attempts=%d", attempts) - } - if successes, _, _ := wire.Varint(response, 9); successes != 1 { - t.Fatalf("sequence smelting successes=%d", successes) - } - if currency := wallet.Snapshot(); currency.Gold != 920 || currency.EquipMileageExchangeGage != 30 { - t.Fatalf("sequence smelting wallet=%+v", currency) - } -} - -func TestEquipmentSequenceSmeltingPacketLimitIsNotTerminal(t *testing.T) { - dir := t.TempDir() - wallet, _ := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1000}) - inventory, _ := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - _, _ = inventory.GrantOnce("refine-material", []gamedata.BattleReward{{Type: 8, ID: 10, Count: 300}}) - store, _ := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err := store.AttachSmelting(smeltingTestDesign([][]float64{{1, 0, 0, 0}, {1, 0, 0, 0}, {1, 0, 0, 0}}), wallet, inventory); err != nil { - t.Fatal(err) - } - entry, _ := store.GrantOnce("refinable", 943035) - store.owned.Equipment[0].Level = 9 - store.owned.Equipment[0].Rank = []uint64{1, 1, 1} - if err := store.commitLocked(cloneEquipmentSnapshot(store.owned), "test setup"); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex) - request = wire.AppendVarint(request, 3, 2) - request = wire.AppendVarint(request, 4, 12) - _, response, _, err := store.Handle("/EquipSequenceSmelting", request) - if err != nil { - t.Fatal(err) - } - if result, _, _ := wire.Varint(response, 3); result != equipUpgradeSuccess { - t.Fatalf("packet exhaustion result=%d want non-terminal success", result) - } - if attempts, _, _ := wire.Varint(response, 4); attempts != 2 { - t.Fatalf("sequence smelting attempts=%d", attempts) - } -} - -func smeltingTestDesign(ratios [][]float64) *gamedata.EquipmentSmeltingDesign { - design := &gamedata.EquipmentSmeltingDesign{ - Equipment: map[uint64]gamedata.EquipmentSmeltingItem{943035: {Grade: 4, RankGroup: 904, MaxLevel: 9}}, - Ranks: map[[2]uint64]gamedata.EquipmentSmeltingRank{}, - Grades: map[uint64][]gamedata.PromotionCost{4: {{Type: 4, Count: 80}, {Type: 8, ID: 10, Count: 30}}}, - Mileage: gamedata.EquipmentSmeltingMileage{UseType: 8, UseID: 10, UseCount: 1000, RewardType: 68, RewardCount: 1}, - MaxStreak: 5000, - } - for i := range 3 { - design.Ranks[[2]uint64{904, uint64(i + 1)}] = gamedata.EquipmentSmeltingRank{Values: []uint64{1, 2, 3, 4}, GrowthPoint: []uint64{1, 2, 3, 4}, Ratio: ratios[i]} - } - return design -} - -func TestEquipmentUpgradeFailureConsumesGoldWithoutLevel(t *testing.T) { - dir := t.TempDir() - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 200}) - if err != nil { - t.Fatal(err) - } - store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - design := &gamedata.EquipmentUpgradeDesign{MaxLevel: map[uint64]uint64{1: 1}, Group: map[uint64]uint64{1: 2}, Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{{2, 0}: {Costs: []gamedata.PromotionCost{{Type: 4, Count: 50}}, SuccessRatio: 0}}} - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if err := store.AttachUpgrade(design, wallet, items); err != nil { - t.Fatal(err) - } - entry, err := store.GrantOnce("failure", 1) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex) - request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 50})) - _, response, _, err := store.Handle("/EquipUpgrade", request) - if err != nil { - t.Fatal(err) - } - if result, _, _ := wire.Varint(response, 2); result != equipUpgradeFail { - t.Fatalf("failure result=%d", result) - } - if got := store.All()[0]; got.Level != 0 || got.UpgradeAttempts != 1 { - t.Fatalf("failed upgrade state=%+v", got) - } - if wallet.Snapshot().Gold != 150 { - t.Fatalf("failure gold=%d", wallet.Snapshot().Gold) - } -} - -func TestEquipmentUpgradeConsumesResourceStacksAndSequenceStopsWhenEmpty(t *testing.T) { - dir := t.TempDir() - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1}) - if err != nil { - t.Fatal(err) - } - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - resources, err := inventory.GrantOnce("upgrade-items", []gamedata.BattleReward{{Type: 8, ID: 3001, Count: 2}}) - if err != nil { - t.Fatal(err) - } - store, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - design := &gamedata.EquipmentUpgradeDesign{ - MaxLevel: map[uint64]uint64{10: 3}, Group: map[uint64]uint64{10: 20}, - RankGroup: map[uint64]uint64{10: 30}, RankRatio: map[[2]uint64][]float64{{30, 1}: {1, 0, 0, 0}}, - Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{ - {20, 0}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1}, - {20, 1}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1}, - {20, 2}: {Costs: []gamedata.PromotionCost{{Type: 8, ID: 3001, Count: 1}}, SuccessRatio: 1}, - }, - } - if err := store.AttachUpgrade(design, wallet, inventory); err != nil { - t.Fatal(err) - } - equipment, err := store.GrantOnce("resource-upgrade", 10) - if err != nil { - t.Fatal(err) - } - first := resources[0] - first.Count = 1 - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, equipment.InvenIndex) - request = wire.AppendBytes(request, 3, ItemWire(first)) - if _, _, _, err := store.Handle("/EquipUpgrade", request); err != nil { - t.Fatal(err) - } - sequence := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, equipment.InvenIndex) - sequence = wire.AppendVarint(sequence, 3, 10) - sequence = wire.AppendVarint(sequence, 6, 3) - _, response, _, err := store.Handle("/EquipSequenceUpgrade", sequence) - if err != nil { - t.Fatal(err) - } - if result, _, _ := wire.Varint(response, 3); result != equipUpgradeStopNotEnough { - t.Fatalf("resource sequence result=%d", result) - } - var lack []byte - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 6 { - lack = field.Value - } - return nil - }); err != nil { - t.Fatal(err) - } - if itemType, _, _ := wire.Varint(lack, 3); itemType != 8 { - t.Fatalf("missing resource type=%d", itemType) - } - if attempts, _, _ := wire.Varint(response, 4); attempts != 1 { - t.Fatalf("resource sequence attempts=%d", attempts) - } - if got := store.All()[0]; got.Level != 2 { - t.Fatalf("resource upgraded level=%d", got.Level) - } - if err := inventory.CanConsume([]Item{first}); err == nil { - t.Fatal("resource stack was not exhausted") - } -} - -func TestEquipmentCustomMarkSetDeletePersistsAndReturnsInInfo(t *testing.T) { - path := filepath.Join(t.TempDir(), "equipment.json") - store, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - entry, err := store.GrantOnce("marked", 943035) - if err != nil { - t.Fatal(err) - } - set := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex) - set = wire.AppendBytes(set, 3, []byte("~15|5")) - code, response, handled, err := store.Handle("/EquipMarkSet", set) - if err != nil || !handled || code != 396 || len(response) != 0 { - t.Fatalf("mark set code=%d handled=%v response=%x err=%v", code, handled, response, err) - } - restored, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - infoRequest := wire.AppendVarint(nil, 1, 2) - _, info, _, err := restored.Handle("/EquipInfo", infoRequest) - if err != nil { - t.Fatal(err) - } - equipment, found, err := wire.Bytes(info, 1) - if err != nil || !found { - t.Fatalf("marked equipment missing: %v", err) - } - mark, found, err := wire.Bytes(equipment, 8) - if err != nil || !found || string(mark) != "~15|5" { - t.Fatalf("mark=%q found=%v err=%v", mark, found, err) - } - deleteRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex) - code, response, handled, err = restored.Handle("/EquipMarkDelete", deleteRequest) - if err != nil || !handled || code != 397 || len(response) != 0 { - t.Fatalf("mark delete code=%d handled=%v response=%x err=%v", code, handled, response, err) - } - cleared, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - if got := cleared.All(); len(got) != 1 || got[0].Mark != "" { - t.Fatalf("cleared equipment=%+v", got) - } - for _, invalid := range []string{"", "~0|0", "~16|0", "~1|6", "abc|0", "x", "_|0"} { - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, entry.InvenIndex) - if invalid != "" { - request = wire.AppendBytes(request, 3, []byte(invalid)) - } - if _, _, _, err := cleared.Handle("/EquipMarkSet", request); err == nil { - t.Fatalf("invalid mark %q accepted", invalid) - } - } -} - -func TestEquipmentLockAndProtoDefaultUnlockPersist(t *testing.T) { - path := filepath.Join(t.TempDir(), "equipment.json") - store, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - entry, err := store.GrantOnce("lockable", 943035) - if err != nil { - t.Fatal(err) - } - lock := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, entry.InvenIndex), 3, 1) - code, response, handled, err := store.Handle("/EquipLock", lock) - if err != nil || !handled || code != 38 || len(response) != 0 { - t.Fatalf("lock code=%d handled=%v response=%x err=%v", code, handled, response, err) - } - locked, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - if got := locked.All(); len(got) != 1 || got[0].LockFlag != 1 { - t.Fatalf("locked equipment=%+v", got) - } - unlock := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, entry.InvenIndex) - code, response, handled, err = locked.Handle("/EquipLock", unlock) - if err != nil || !handled || code != 38 || len(response) != 0 { - t.Fatalf("unlock code=%d handled=%v response=%x err=%v", code, handled, response, err) - } - unlocked, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - if got := unlocked.All(); len(got) != 1 || got[0].LockFlag != 0 { - t.Fatalf("unlocked equipment=%+v", got) - } - invalid := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, entry.InvenIndex), 3, 2) - if _, _, _, err := unlocked.Handle("/EquipLock", invalid); err == nil { - t.Fatal("invalid lock flag accepted") - } -} - -func TestEquipmentGrantPersistsAndIsIdempotent(t *testing.T) { - path := filepath.Join(t.TempDir(), "equipment.json") - store, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - first, err := store.GrantOnce("pack21:quest28:equip10010", 10010) - if err != nil { - t.Fatal(err) - } - again, err := store.GrantOnce("pack21:quest28:equip10010", 10010) - if err != nil { - t.Fatal(err) - } - if first.InvenIndex != again.InvenIndex || first.ID != again.ID || first.InvenIndex == 0 || first.ID != 10010 { - t.Fatalf("grants: %+v %+v", first, again) - } - restored, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - code, response, ok, err := restored.Handle("/EquipInfo", request) - if err != nil || !ok || code != 34 { - t.Fatalf("handle: %d %v %v", code, ok, err) - } - encoded, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("equipment missing: %v", err) - } - index, _, _ := wire.Varint(encoded, 1) - base, _, _ := wire.Bytes(encoded, 5) - id, _, _ := wire.Varint(base, 1) - if index != first.InvenIndex || id != 10010 { - t.Fatalf("wire index=%d id=%d", index, id) - } - var ranks []uint64 - if err := wire.Walk(base, func(field wire.Field) error { - if field.Number == 6 { - value, _ := binary.Uvarint(field.Value) - ranks = append(ranks, value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(ranks) != 3 || ranks[0] != 0 || ranks[1] != 0 || ranks[2] != 0 { - t.Fatalf("initial rank slots=%v, want three explicit zeroes", ranks) - } -} - -func TestEquipmentUsePersistsCharacterBinding(t *testing.T) { - dir := t.TempDir() - starter := &Starter{Version: "2.35.10"} - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), - []Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "") - if err != nil { - t.Fatal(err) - } - equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - entry, err := equipment.GrantOnce("quest28", 10010) - if err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, entry.InvenIndex) - request = wire.AppendVarint(request, 3, 535607162) - code, response, ok, err := equipment.Handle("/EquipUse", request) - if err != nil || !ok || code != 35 { - t.Fatalf("use: code=%d ok=%v err=%v", code, ok, err) - } - character, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("response character: %v", err) - } - index, _, _ := wire.Varint(character, 1) - if index != 535607162 { - t.Fatalf("character index=%d", index) - } - restored, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - infoRequest := wire.AppendVarint(nil, 1, 2) - _, info, _, err := restored.Handle("/EquipInfo", infoRequest) - if err != nil { - t.Fatal(err) - } - encoded, found, err := wire.Bytes(info, 1) - if err != nil || !found { - t.Fatalf("restored equipment: %v", err) - } - useChar, found, err := wire.Varint(encoded, 2) - if err != nil || !found || useChar != 535607162 { - t.Fatalf("useChar=%d found=%v err=%v", useChar, found, err) - } -} - -func TestEquipmentClearPersistsUnboundEquipmentAndReturnsCharacter(t *testing.T) { - dir := t.TempDir() - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), - []Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "") - if err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "equipment.json") - equipment, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - entry, err := equipment.GrantOnce("equipped", 943035) - if err != nil { - t.Fatal(err) - } - equipment.owned.Equipment[0].UseChar = 535607162 - equipment.owned.Equipment[0].LockFlag = 1 // Lock prevents disposal, not unequipping. - if err := equipment.commitLocked(cloneEquipmentSnapshot(equipment.owned), "test setup"); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 9), 2, entry.InvenIndex) - request = wire.AppendVarint(request, 3, 535607162) - code, response, handled, err := equipment.Handle("/EquipClear", request) - if err != nil || !handled || code != 36 { - t.Fatalf("clear code=%d handled=%v err=%v", code, handled, err) - } - character, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("missing clear character: %v", err) - } - if index, _, _ := wire.Varint(character, 1); index != 535607162 { - t.Fatalf("clear character index=%d", index) - } - restored, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - if got := restored.All(); len(got) != 1 || got[0].UseChar != 0 || got[0].LockFlag != 1 { - t.Fatalf("cleared equipment=%+v", got) - } - if _, _, _, err := equipment.Handle("/EquipClear", request); err == nil { - t.Fatal("already-cleared equipment accepted") - } - wrongCharacter := wire.AppendVarint(wire.AppendVarint(nil, 1, 10), 2, entry.InvenIndex) - wrongCharacter = wire.AppendVarint(wrongCharacter, 3, 999) - if _, _, _, err := equipment.Handle("/EquipClear", wrongCharacter); err == nil { - t.Fatal("unknown character accepted") - } -} - -func TestEquipmentChangeReplacesOnlyMatchingGameDataSlot(t *testing.T) { - dir := t.TempDir() - items, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), - []Character{{InvenIndex: 535607162, ID: 350, Level: 20}}, items, "", "") - if err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "equipment.json") - equipment, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - if err := equipment.AttachSlots(map[uint64]uint64{10010: 0, 943009: 0, 943619: 4}); err != nil { - t.Fatal(err) - } - old, err := equipment.GrantOnce("old", 10010) - if err != nil { - t.Fatal(err) - } - replacement, err := equipment.GrantOnce("replacement", 943009) - if err != nil { - t.Fatal(err) - } - otherSlot, err := equipment.GrantOnce("other-slot", 943619) - if err != nil { - t.Fatal(err) - } - old.UseChar = 535607162 - otherSlot.UseChar = 535607162 - equipment.owned.Equipment[0] = old - equipment.owned.Equipment[2] = otherSlot - if err := equipment.commitLocked(cloneEquipmentSnapshot(equipment.owned), "test setup"); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, replacement.InvenIndex) - request = wire.AppendVarint(request, 3, 535607162) - code, response, handled, err := equipment.Handle("/EquipChange", request) - if err != nil || !handled || code != 45 { - t.Fatalf("change code=%d handled=%v err=%v", code, handled, err) - } - character, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("missing response character: %v", err) - } - if index, _, _ := wire.Varint(character, 1); index != 535607162 { - t.Fatalf("response character=%d", index) - } - restored, err := OpenEquipmentInventory(testStore(path)) - if err != nil { - t.Fatal(err) - } - got := restored.All() - if got[0].UseChar != 0 || got[1].UseChar != 535607162 || got[2].UseChar != 535607162 { - t.Fatalf("changed equipment=%+v", got) - } -} - -func TestEquipmentWirePrivateObjectPresenceAndExplicitOption(t *testing.T) { - for _, private := range []*EquipmentOption{nil, {GroupID: 301, ID: 2}} { - entry := Equipment{ID: 943035, InvenIndex: 1, Rank: []uint64{1, 2, 3}, PrivateOption: private} - // EquipInfo and equipment reward bundles share this serializer with all - // enhancement/refinement responses. - encoded := EquipmentWire(entry) - base, _, _ := wire.Bytes(encoded, 5) - option, present, err := wire.Bytes(base, 5) - if err != nil || !present { - t.Fatal("PrivateOption was omitted", err) - } - group, _, _ := wire.Varint(option, 1) - id, _, _ := wire.Varint(option, 2) - if private == nil { - if len(option) != 0 || group != 0 || id != 0 { - t.Fatal("empty fallback object malformed") - } - } else if group != private.GroupID || id != private.ID { - t.Fatal("explicit private option altered") - } - if entry.PrivateOption != private { - t.Fatal("wire generation mutated saved ownership") - } - } -} diff --git a/go/internal/server/player/friendship.go b/go/internal/server/player/friendship.go deleted file mode 100644 index b797fd4..0000000 --- a/go/internal/server/player/friendship.go +++ /dev/null @@ -1,415 +0,0 @@ -package player - -import ( - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "math" - "slices" - "strconv" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -// FriendshipService applies inventory gifts and counseling inside the account -// request transaction. Static IDs, thresholds and rewards come from GameData. -type FriendshipService struct { - mu sync.Mutex - design *gamedata.FriendshipDesign - awake *gamedata.CharAwakeDesign - potential *gamedata.CostumePotentialDesign - collection *CollectionStore - inventory *Inventory - wallet *Wallet - sessionID string - now func() time.Time -} - -func NewFriendshipService(design *gamedata.FriendshipDesign, awake *gamedata.CharAwakeDesign, potential *gamedata.CostumePotentialDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*FriendshipService, error) { - if design == nil || awake == nil || potential == nil || collection == nil || inventory == nil || wallet == nil { - return nil, errors.New("player: incomplete friendship service") - } - s := &FriendshipService{design: design, awake: awake, potential: potential, collection: collection, inventory: inventory, wallet: wallet, now: time.Now} - for _, entry := range collection.FriendshipEntries() { - if entry.Daily != nil && entry.Daily.Used > design.Default.MaxCounselingAP { - return nil, errors.New("player: saved daily friendship AP exceeds maximum") - } - if entry.State == nil { - continue - } - state := entry.State - if _, owned := s.owned(state.CostumeID); !owned { - return nil, errors.New("player: saved friendship costume is not owned") - } - level, ok := design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}] - if !ok || (level.NextEXP != 0 && state.EXP >= level.NextEXP) || (state.Level >= design.Default.MaxLevels[2] && state.EXP != 0) { - return nil, errors.New("player: saved friendship level is not in GameData") - } - if state.CounselingCount > design.Default.MaxCounselingAPByCostume && state.Level < design.Default.MaxLevels[2] { - return nil, errors.New("player: invalid saved costume counseling count") - } - for _, id := range state.Sessions { - if _, ok := design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: id}]; !ok { - return nil, errors.New("player: saved counseling session is not in GameData") - } - } - } - return s, nil -} - -func (s *FriendshipService) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - s.sessionID = id -} - -func (s *FriendshipService) FriendshipAP() (uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - daily := s.daily(s.collection.FriendshipEntries(), s.now().UTC().Format("2006-01-02")) - if daily.Used >= s.design.Default.MaxCounselingAP { - return 0, nil - } - return s.design.Default.MaxCounselingAP - daily.Used, nil -} - -func (s *FriendshipService) daily(entries map[string]FriendshipEntry, day string) FriendshipDaily { - if entry := entries["daily"]; entry.Daily != nil && entry.Daily.Day == day { - return *entry.Daily - } - return FriendshipDaily{Day: day} -} - -func (s *FriendshipService) owned(id uint64) (Costume, bool) { - costumeID, ok := s.design.Costumes[id] - if !ok { - return Costume{}, false - } - return s.collection.CostumeByID(costumeID) -} - -func (s *FriendshipService) maximum(costume Costume) uint64 { - max := s.design.Default.MaxLevels[0] - unique := s.potential.CostumeUnique[costume.ID] - awake, exists := s.collection.CharAwakeState(unique) - rule, hasRule := s.awake.Characters[unique] - if !exists || !hasRule || !awake.IsAwake { - return max - } - for i, level := range awake.ImprintLevels { - if len(rule.ImprintGrowth[i]) == 0 || level != uint64(len(rule.ImprintGrowth[i])) { - return max - } - } - max = s.design.Default.MaxLevels[1] - nodes := s.potential.Nodes[costume.ID] - if len(nodes) == 0 || len(costume.PotentialIDs) != len(nodes) { - return max - } - for _, id := range costume.PotentialIDs { - if _, ok := nodes[id]; !ok { - return max - } - } - return s.design.Default.MaxLevels[2] -} - -func friendshipWire(state FriendshipState) []byte { - b := wire.AppendVarint(nil, 1, state.CostumeID) - b = wire.AppendVarint(b, 2, state.Level) - if state.EXP != 0 { - b = wire.AppendVarint(b, 3, state.EXP) - } - if state.LastCounselingDate != 0 { - b = wire.AppendVarint(b, 4, state.LastCounselingDate) - } - return b -} - -func (s *FriendshipService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/FriendshipInfo" && path != "/FriendshipGift" && path != "/FriendshipCounseling" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > math.MaxInt32 { - return 0, nil, true, errors.New("player: friendship invalid sequence") - } - entries := s.collection.FriendshipEntries() - if path == "/FriendshipInfo" { - return 612, s.info(entries), true, nil - } - session := s.sessionID - if session == "" { - return 0, nil, true, errors.New("player: friendship mutation requires an authenticated session") - } - keyHash := sha256.Sum256([]byte(session + ":" + strconv.FormatUint(seq, 10))) - key := "reply:" + hex.EncodeToString(keyHash[:]) - digestHash := sha256.Sum256(request) - digest := hex.EncodeToString(digestHash[:]) - code := 614 - if path == "/FriendshipCounseling" { - code = 613 - } - if entry := entries[key]; entry.Reply != nil { - if entry.Reply.Digest != digest || entry.Reply.Code != code { - return 0, nil, true, errors.New("player: friendship sequence reused with different request") - } - return code, append([]byte(nil), entry.Reply.Body...), true, nil - } - id, found, err := wire.Varint(request, 2) - if err != nil || !found || id == 0 || id > math.MaxInt32 { - return 0, nil, true, errors.New("player: friendship invalid costume") - } - costume, owned := s.owned(id) - if !owned { - return 0, nil, true, fmt.Errorf("player: friendship costume %d is not owned", id) - } - state := FriendshipState{CostumeID: id, Level: 1} - if entry := entries[friendshipStateKey(id)]; entry.State != nil { - state = *entry.State - state.Sessions = append([]uint64(nil), state.Sessions...) - } - max := s.maximum(costume) - var body []byte - if code == 614 { - body, err = s.gift(request, state, max, key, digest) - } else { - body, err = s.counsel(request, state, max, entries, key, digest) - } - return code, body, true, err -} - -func (s *FriendshipService) info(entries map[string]FriendshipEntry) []byte { - ids := make([]uint64, 0, len(s.design.Costumes)) - for id := range s.design.Costumes { - if _, ok := s.owned(id); ok { - ids = append(ids, id) - } - } - slices.Sort(ids) - var body []byte - for _, id := range ids { - state := FriendshipState{CostumeID: id, Level: 1} - if entry := entries[friendshipStateKey(id)]; entry.State != nil { - state = *entry.State - } - body = wire.AppendBytes(body, 1, friendshipWire(state)) - if len(state.Sessions) != 0 { - counsel := wire.AppendVarint(nil, 1, id) - for _, session := range state.Sessions { - counsel = wire.AppendVarint(counsel, 2, session) - } - body = wire.AppendBytes(body, 2, counsel) - } - } - return body -} - -func (s *FriendshipService) gift(request []byte, state FriendshipState, max uint64, key, digest string) ([]byte, error) { - if state.Level >= max { - return nil, errors.New("player: friendship level is at its unlocked maximum") - } - items, err := equipmentRequestItems(request, 3, "FriendshipGift") - if err != nil { - return nil, err - } - var exp uint64 - seen := map[uint64]bool{} - for _, item := range items { - if seen[item.InvenIndex] || item.InvenIndex > math.MaxInt64 || item.ID > math.MaxInt32 || item.Type > math.MaxInt32 || item.Count > math.MaxInt32 { - return nil, errors.New("player: invalid friendship gift item") - } - seen[item.InvenIndex] = true - rule, ok := s.design.Gifts[[2]uint64{item.Type, item.ID}] - if !ok { - return nil, errors.New("player: item is not a friendship gift") - } - unit := rule.Experience(state.CostumeID) - if unit == 0 || item.Count > math.MaxInt32/unit || exp > math.MaxInt32-unit*item.Count { - return nil, errors.New("player: invalid friendship gift experience") - } - exp += unit * item.Count - } - if err := s.inventory.CanConsume(items); err != nil { - return nil, err - } - next, rewards, err := s.advance(state, max, exp) - if err != nil { - return nil, err - } - if err := s.inventory.Consume(items); err != nil { - return nil, err - } - bundle, err := s.grant(key, rewards) - if err != nil { - return nil, err - } - body := wire.AppendBytes(nil, 1, bundle) - body = wire.AppendBytes(body, 2, friendshipWire(next)) - body = wire.AppendVarint(body, 3, exp) - if err := s.collection.ApplyFriendship(next, nil, key, FriendshipReply{Digest: digest, Code: 614, Body: body}); err != nil { - return nil, err - } - return body, nil -} - -func (s *FriendshipService) counsel(request []byte, state FriendshipState, max uint64, entries map[string]FriendshipEntry, key, digest string) ([]byte, error) { - session, found, err := wire.Varint(request, 3) - if err != nil || !found || session == 0 || session > math.MaxInt32 { - return nil, errors.New("player: invalid counseling session") - } - rule, exists := s.design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: session}] - if !exists { - return nil, errors.New("player: counseling session does not belong to costume") - } - choice, _, err := wire.Varint(request, 4) - if err != nil || choice >= rule.ChoiceCount { - return nil, errors.New("player: invalid counseling choice") - } - quick, _, err := wire.Varint(request, 5) - if err != nil || quick > 1 { - return nil, errors.New("player: invalid counseling quick flag") - } - if quick == 1 && (uint64(len(state.Sessions)) < s.design.Default.QuickCounselingUnlockCount || state.Level >= max || choice != 1) { - return nil, errors.New("player: quick counseling is not available") - } - now := s.now() - day := now.UTC().Format("2006-01-02") - daily := s.daily(entries, day) - free := state.Level >= s.design.Default.MaxLevels[2] - if !free { - if daily.Used >= s.design.Default.MaxCounselingAP { - return nil, errors.New("player: no daily friendship AP remaining") - } - if state.CounselingDay == day && state.CounselingCount >= s.design.Default.MaxCounselingAPByCostume { - return nil, errors.New("player: costume daily counseling limit reached") - } - } - correct := quick == 1 || choice == s.design.Default.CorrectSelectDialogIndex - exp := s.design.Default.IncorrectEXP - if correct { - exp = s.design.Default.CorrectEXP - } - if state.Level >= max { - exp = 0 - } - next, rewards, err := s.advance(state, max, exp) - if err != nil { - return nil, err - } - completed := slices.Contains(state.Sessions, session) - if !free { - rewards = append(rewards, s.design.Default.CounselingRewards...) - daily.Used++ - } else if !completed && quick == 0 { - // At the final cap the client allows unrestricted story playback. A - // previously unseen story earns its default reward once; repeated - // playback does not create a source of unlimited account currency. - rewards = append(rewards, s.design.Default.CounselingRewards...) - } - if next.CounselingDay != day { - next.CounselingDay = day - next.CounselingCount = 0 - } - next.CounselingCount++ - if now.UnixMilli() <= 0 { - return nil, errors.New("player: invalid counseling time") - } - next.LastCounselingDate = uint64(now.UnixMilli()) - if quick == 0 { - if !completed { - next.Sessions = append(next.Sessions, session) - slices.Sort(next.Sessions) - } - } - bundle, err := s.grant(key, rewards) - if err != nil { - return nil, err - } - body := wire.AppendBytes(nil, 1, bundle) - body = wire.AppendBytes(body, 2, friendshipWire(next)) - if correct { - body = wire.AppendVarint(body, 3, 1) - } - if exp != 0 { - body = wire.AppendVarint(body, 4, exp) - } - if err := s.collection.ApplyFriendship(next, &daily, key, FriendshipReply{Digest: digest, Code: 613, Body: body}); err != nil { - return nil, err - } - return body, nil -} - -func (s *FriendshipService) advance(state FriendshipState, max, exp uint64) (FriendshipState, []gamedata.Reward, error) { - if state.EXP > math.MaxInt32-exp { - return state, nil, errors.New("player: friendship experience overflow") - } - state.EXP += exp - var rewards []gamedata.Reward - for state.Level < max { - rule, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}] - if !ok || rule.NextEXP == 0 { - return state, nil, errors.New("player: missing friendship level threshold") - } - if state.EXP < rule.NextEXP { - break - } - state.EXP -= rule.NextEXP - state.Level++ - next, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}] - if !ok { - return state, nil, errors.New("player: missing friendship level reward") - } - rewards = append(rewards, next.Rewards...) - } - if state.Level >= max { - state.EXP = 0 - } - return state, rewards, nil -} - -func (s *FriendshipService) grant(identity string, rewards []gamedata.Reward) ([]byte, error) { - if len(rewards) == 0 { - return nil, nil - } - var items []gamedata.BattleReward - for _, reward := range rewards { - if reward.Type == 0 || reward.Count == 0 || reward.Count > math.MaxInt32 { - return nil, errors.New("player: invalid friendship reward") - } - switch reward.Type { - case 2, 3, 4, 12, 20: - default: - if reward.ID == 0 || reward.ID > math.MaxInt32 { - return nil, errors.New("player: invalid friendship item reward") - } - items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 - } - } - if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { - return nil, err - } - granted, err := s.inventory.GrantOnce(identity+":items", items) - if err != nil { - return nil, err - } - var bundle []byte - for _, item := range granted { - bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) - } - for _, reward := range rewards { - view := Item{ID: reward.ID, Type: reward.Type, Count: reward.Count} - bundle = wire.AppendBytes(bundle, 6, ItemWire(view)) - switch reward.Type { - case 2, 3, 4, 12, 20: - bundle = wire.AppendBytes(bundle, 1, ItemWire(view)) - } - } - return bundle, nil -} diff --git a/go/internal/server/player/friendship_sqlite_test.go b/go/internal/server/player/friendship_sqlite_test.go deleted file mode 100644 index ecb8180..0000000 --- a/go/internal/server/player/friendship_sqlite_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package player - -import ( - "bytes" - "errors" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" -) - -type friendshipFailStore struct { - stateio.AtomicEntryStore - fail bool -} - -func (s *friendshipFailStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { - if s.fail && domain == "collection" { - return errors.New("injected collection write failure") - } - return s.AtomicEntryStore.SaveWithEntries(domain, core, changes) -} - -func sqliteFriendshipService(t *testing.T, store stateio.Store, template *FriendshipService) (*FriendshipService, *Inventory, *Wallet) { - t.Helper() - collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}}) - if err != nil { - t.Fatal(err) - } - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{}) - if err != nil { - t.Fatal(err) - } - s, err := NewFriendshipService(template.design, template.awake, template.potential, collection, inventory, wallet) - if err != nil { - t.Fatal(err) - } - s.BeginSession("durable-session") - return s, inventory, wallet -} - -func TestFriendshipSQLiteAtomicFailureRestartAndExactRetry(t *testing.T) { - for _, rollback := range []bool{false, true} { - t.Run(map[bool]string{false: "commit-replay", true: "rollback-retry"}[rollback], func(t *testing.T) { - template := newFriendshipHarness(t).service - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store := &friendshipFailStore{AtomicEntryStore: repo} - var wallet *Wallet - s, inventory, _ := sqliteFriendshipService(t, store, template) - items, err := inventory.GrantOnce("seed", []gamedata.BattleReward{{Type: 8, ID: 7, Count: 3}}) - if err != nil { - t.Fatal(err) - } - item := items[0] - item.Count = 1 - request := giftRequest(1, 10, item) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - store.fail = rollback - _, body, _, handleErr := s.Handle("/FriendshipGift", request) - if rollback { - if handleErr == nil { - t.Fatal("injected failure was ignored") - } - if err := op.Rollback(); err == nil { - t.Fatal("dirty rollback must fence writer until restart") - } - } else { - if handleErr != nil { - t.Fatal(handleErr) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - s, inventory, wallet = sqliteFriendshipService(t, repo, template) - if rollback { - if wallet.Snapshot().FreeJewelry != 0 || len(s.collection.FriendshipEntries()) != 0 { - t.Fatal("failed operation left committed rewards or progress") - } - if err := inventory.CanConsume(items); err != nil { - t.Fatal("failed gift consumed committed inventory") - } - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, replay, _, err := s.Handle("/FriendshipGift", request) - if err != nil { - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - if !rollback && !bytes.Equal(body, replay) { - t.Fatal("restart response changed") - } - if wallet.Snapshot().FreeJewelry != 7 { - t.Fatal("reward was not applied exactly once") - } - if err := inventory.CanConsume([]Item{{InvenIndex: item.InvenIndex, ID: 7, Type: 8, Count: 2}}); err != nil { - t.Fatal(err) - } - if err := inventory.CanConsume(items); err == nil { - t.Fatal("request was not charged exactly once") - } - }) - } -} diff --git a/go/internal/server/player/friendship_state.go b/go/internal/server/player/friendship_state.go deleted file mode 100644 index 551e4f9..0000000 --- a/go/internal/server/player/friendship_state.go +++ /dev/null @@ -1,192 +0,0 @@ -package player - -import ( - "encoding/hex" - "errors" - "fmt" - "math" - "strconv" - "strings" - "time" - - "bd2server/internal/server/wire" -) - -type FriendshipState struct { - CostumeID uint64 `json:"costume_id"` - Level uint64 `json:"level"` - EXP uint64 `json:"exp"` - LastCounselingDate uint64 `json:"last_counseling_date"` - CounselingDay string `json:"counseling_day,omitempty"` - CounselingCount uint64 `json:"counseling_count,omitempty"` - Sessions []uint64 `json:"sessions,omitempty"` -} - -type FriendshipDaily struct { - Day string `json:"day"` - Used uint64 `json:"used"` -} - -type FriendshipReply struct { - Digest string `json:"digest"` - Code int `json:"code"` - Body []byte `json:"body"` -} - -// FriendshipEntry keeps progress and successful request replies in the existing -// collection domain. Each record has exactly one kind, so collection commits -// can update one costume, the daily counter and a replay together. -type FriendshipEntry struct { - State *FriendshipState `json:"state,omitempty"` - Daily *FriendshipDaily `json:"daily,omitempty"` - Reply *FriendshipReply `json:"reply,omitempty"` -} - -func friendshipStateKey(id uint64) string { return "costume:" + strconv.FormatUint(id, 10) } - -func cloneFriendshipEntries(in map[string]FriendshipEntry) map[string]FriendshipEntry { - out := make(map[string]FriendshipEntry, len(in)) - for key, entry := range in { - if entry.State != nil { - state := *entry.State - state.Sessions = append([]uint64(nil), state.Sessions...) - entry.State = &state - } - if entry.Daily != nil { - daily := *entry.Daily - entry.Daily = &daily - } - if entry.Reply != nil { - reply := *entry.Reply - reply.Body = append([]byte(nil), reply.Body...) - entry.Reply = &reply - } - out[key] = entry - } - return out -} - -func validateFriendshipEntries(entries map[string]FriendshipEntry) error { - for key, entry := range entries { - kinds := 0 - if entry.State != nil { - kinds++ - } - if entry.Daily != nil { - kinds++ - } - if entry.Reply != nil { - kinds++ - } - if kinds != 1 { - return fmt.Errorf("player: invalid friendship entry %q", key) - } - if state := entry.State; state != nil { - if key != friendshipStateKey(state.CostumeID) || state.CostumeID == 0 || state.CostumeID > math.MaxInt32 || state.Level == 0 || state.Level > math.MaxInt32 || state.EXP > math.MaxInt32 || state.LastCounselingDate > math.MaxInt64 { - return fmt.Errorf("player: invalid friendship state %q", key) - } - if state.LastCounselingDate == 0 { - if state.CounselingDay != "" || state.CounselingCount != 0 || len(state.Sessions) != 0 { - return errors.New("player: friendship counseling state has no date") - } - } else if state.CounselingCount == 0 || state.CounselingDay != time.UnixMilli(int64(state.LastCounselingDate)).UTC().Format("2006-01-02") { - return errors.New("player: inconsistent friendship counseling date") - } - seen := map[uint64]bool{} - for _, id := range state.Sessions { - if id == 0 || id > math.MaxInt32 || seen[id] { - return errors.New("player: invalid friendship counseling sessions") - } - seen[id] = true - } - } - if entry.Daily != nil && (key != "daily" || entry.Daily.Day == "") { - return errors.New("player: invalid friendship daily entry") - } - if entry.Daily != nil { - if parsed, err := time.Parse("2006-01-02", entry.Daily.Day); err != nil || parsed.Format("2006-01-02") != entry.Daily.Day { - return errors.New("player: invalid friendship daily date") - } - } - if reply := entry.Reply; reply != nil { - if !strings.HasPrefix(key, "reply:") || len(key) != len("reply:")+64 || (reply.Code != 613 && reply.Code != 614) || len(reply.Digest) != 64 || len(reply.Body) == 0 { - return errors.New("player: invalid friendship replay") - } - if _, err := hex.DecodeString(reply.Digest); err != nil { - return errors.New("player: invalid friendship request digest") - } - if _, err := hex.DecodeString(strings.TrimPrefix(key, "reply:")); err != nil { - return errors.New("player: invalid friendship replay key") - } - if err := validateFriendshipReply(*reply); err != nil { - return err - } - } - } - return nil -} - -func validateFriendshipReply(reply FriendshipReply) error { - infoCount := 0 - err := wire.Walk(reply.Body, func(field wire.Field) error { - switch field.Number { - case 1: - if field.Type != 2 { - return errors.New("invalid friendship reward bundle") - } - return wire.Walk(field.Value, func(wire.Field) error { return nil }) - case 2: - if field.Type != 2 { - return errors.New("invalid friendship response info") - } - infoCount++ - id, found, err := wire.Varint(field.Value, 1) - if err != nil || !found || id == 0 || id > math.MaxInt32 { - return errors.New("invalid friendship response costume") - } - level, found, err := wire.Varint(field.Value, 2) - if err != nil || !found || level == 0 || level > math.MaxInt32 { - return errors.New("invalid friendship response level") - } - case 3, 4: - if field.Type != 0 || (reply.Code == 614 && field.Number == 4) { - return errors.New("invalid friendship response scalar") - } - value, _, err := wire.Varint(reply.Body, field.Number) - if err != nil || value > math.MaxInt32 || (reply.Code == 613 && field.Number == 3 && value > 1) { - return errors.New("invalid friendship response value") - } - default: - return errors.New("unexpected friendship response field") - } - return nil - }) - if err != nil || infoCount != 1 { - return errors.New("player: invalid friendship replay response") - } - return nil -} - -func (s *CollectionStore) FriendshipEntries() map[string]FriendshipEntry { - s.mu.Lock() - defer s.mu.Unlock() - return cloneFriendshipEntries(s.data.Friendships) -} - -func (s *CollectionStore) ApplyFriendship(state FriendshipState, daily *FriendshipDaily, replayKey string, reply FriendshipReply) error { - s.mu.Lock() - defer s.mu.Unlock() - next := cloneCollection(s.data) - next.Friendships[friendshipStateKey(state.CostumeID)] = FriendshipEntry{State: &state} - if daily != nil { - next.Friendships["daily"] = FriendshipEntry{Daily: daily} - } - if _, exists := next.Friendships[replayKey]; exists { - return errors.New("player: friendship request already applied") - } - next.Friendships[replayKey] = FriendshipEntry{Reply: &reply} - if err := validateFriendshipEntries(next.Friendships); err != nil { - return err - } - return s.commit(next) -} diff --git a/go/internal/server/player/friendship_test.go b/go/internal/server/player/friendship_test.go deleted file mode 100644 index bf3a9c6..0000000 --- a/go/internal/server/player/friendship_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package player - -import ( - "bytes" - "math" - "testing" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestFriendshipInfoValidatesSequence(t *testing.T) { - h := newFriendshipHarness(t) - for _, request := range [][]byte{nil, wire.AppendVarint(nil, 1, 0), wire.AppendVarint(nil, 1, math.MaxInt32+1), {0x08, 0x80}} { - if _, _, handled, err := h.service.Handle("/FriendshipInfo", request); !handled || err == nil { - t.Fatal("invalid information request sequence was accepted") - } - } - if code, body, handled, err := h.service.Handle("/FriendshipInfo", wire.AppendVarint(nil, 1, 1)); err != nil || !handled || code != 612 || len(body) == 0 { - t.Fatalf("valid information request: code=%d handled=%v err=%v", code, handled, err) - } -} - -func TestFriendshipMutationsRequireSession(t *testing.T) { - h := newFriendshipHarness(t) - h.service.BeginSession("") - item := h.item - item.Count = 1 - for path, request := range map[string][]byte{"/FriendshipGift": giftRequest(1, 10, item), "/FriendshipCounseling": counselingRequest(1, 10, 1, 0, false)} { - if _, _, handled, err := h.service.Handle(path, request); !handled || err == nil { - t.Fatalf("%s accepted a mutation without a session", path) - } - } - if len(h.collection.FriendshipEntries()) != 0 || h.wallet.Snapshot().FreeJewelry != 0 { - t.Fatal("missing-session request mutated account state") - } - if err := h.inventory.CanConsume([]Item{h.item}); err != nil { - t.Fatal("missing-session request consumed inventory") - } - h.service.BeginSession("authenticated-session") - if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item)); err != nil { - t.Fatal(err) - } -} - -type friendshipHarness struct { - service *FriendshipService - store *stateio.Memory - collection *CollectionStore - inventory *Inventory - wallet *Wallet - item Item - clock time.Time -} - -func newFriendshipHarness(t *testing.T) *friendshipHarness { - t.Helper() - h := &friendshipHarness{store: stateio.NewMemory(), clock: time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC)} - var err error - h.collection, err = OpenCollectionStore(h.store, []Costume{{InvenIndex: 1, ID: 100, Level: 5}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}}) - if err != nil { - t.Fatal(err) - } - h.inventory, err = OpenInventory(h.store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - items, err := h.inventory.GrantOnce("gift-test", []gamedata.BattleReward{{Type: 8, ID: 7, Count: 50}}) - if err != nil { - t.Fatal(err) - } - h.item = items[0] - h.wallet, err = OpenWallet(h.store, Currency{}) - if err != nil { - t.Fatal(err) - } - d := &gamedata.FriendshipDesign{Default: gamedata.FriendshipDefaultDesign{CorrectEXP: 100, IncorrectEXP: 80, MaxCounselingAP: 3, MaxCounselingAPByCostume: 1, QuickCounselingUnlockCount: 2, MaxLevels: [3]uint64{3, 4, 5}, CounselingRewards: []gamedata.Reward{{Type: 3, Count: 13}}}, Costumes: map[uint64]uint64{10: 100, 20: 200, 30: 300, 40: 400, 50: 500}, Gifts: map[[2]uint64]gamedata.FriendshipGiftDesign{{8, 7}: {Type: 8, ItemID: 7, EXP: 60, FavoriteEXP: 150, FavoriteCostumeIDs: []uint64{10}}}, Levels: map[gamedata.FriendshipKey]gamedata.FriendshipLevelDesign{}, Sessions: map[gamedata.FriendshipKey]gamedata.FriendshipSessionDesign{}} - for id := range d.Costumes { - for level := uint64(1); level <= 5; level++ { - d.Levels[gamedata.FriendshipKey{GroupID: id, ID: level}] = gamedata.FriendshipLevelDesign{NextEXP: 100} - } - for session := uint64(1); session <= 3; session++ { - d.Sessions[gamedata.FriendshipKey{GroupID: id, ID: session}] = gamedata.FriendshipSessionDesign{ChoiceCount: 2} - } - } - d.Levels[gamedata.FriendshipKey{GroupID: 10, ID: 2}] = gamedata.FriendshipLevelDesign{NextEXP: 100, Rewards: []gamedata.Reward{{Type: 8, ID: 88, Count: 2}, {Type: 3, Count: 7}}} - awake := &gamedata.CharAwakeDesign{Characters: map[uint64]gamedata.CharAwakeCharacter{1: {ImprintGrowth: [3][]gamedata.CharAwakeGrowth{{{}}, {{}}, {{}}}}}} - potential := &gamedata.CostumePotentialDesign{CostumeUnique: map[uint64]uint64{100: 1}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{100: {71: {ID: 71}, 72: {ID: 72}}}} - h.service, err = NewFriendshipService(d, awake, potential, h.collection, h.inventory, h.wallet) - if err != nil { - t.Fatal(err) - } - h.service.now = func() time.Time { return h.clock } - h.service.BeginSession("test-session") - return h -} - -func giftRequest(seq, id uint64, item Item) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 2, id) - return wire.AppendBytes(b, 3, ItemWire(item)) -} -func counselingRequest(seq, id, session, choice uint64, quick bool) []byte { - b := wire.AppendVarint(nil, 1, seq) - b = wire.AppendVarint(b, 2, id) - b = wire.AppendVarint(b, 3, session) - if choice != 0 { - b = wire.AppendVarint(b, 4, choice) - } - if quick { - b = wire.AppendVarint(b, 5, 1) - } - return b -} - -func TestFriendshipGiftConsumesFavoriteAndRestoresExactReplay(t *testing.T) { - h := newFriendshipHarness(t) - item := h.item - item.Count = 1 - request := giftRequest(1, 10, item) - code, body, handled, err := h.service.Handle("/FriendshipGift", request) - if err != nil || !handled || code != 614 { - t.Fatalf("gift %d %v %v", code, handled, err) - } - exp, _, _ := wire.Varint(body, 3) - if exp != 150 { - t.Fatalf("favorite exp=%d", exp) - } - state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State - if state.Level != 2 || state.EXP != 50 { - t.Fatalf("state=%+v", state) - } - if h.wallet.Snapshot().FreeJewelry != 7 { - t.Fatal("level currency was not granted") - } - if err := h.inventory.CanConsume([]Item{{InvenIndex: item.InvenIndex, ID: 7, Type: 8, Count: 50}}); err == nil { - t.Fatal("gift stack was not consumed") - } - if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 20, item)); err == nil { - t.Fatal("changed request reused sequence") - } - var openErr error - h.collection, openErr = OpenCollectionStore(h.store, []Costume{{InvenIndex: 1, ID: 100, Level: 5}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}}) - if openErr != nil { - t.Fatal(openErr) - } - h.inventory, openErr = OpenInventory(h.store, &Starter{Version: "2.35.10"}) - if openErr != nil { - t.Fatal(openErr) - } - h.wallet, openErr = OpenWallet(h.store, Currency{}) - if openErr != nil { - t.Fatal(openErr) - } - reopened, openErr := NewFriendshipService(h.service.design, h.service.awake, h.service.potential, h.collection, h.inventory, h.wallet) - if openErr != nil { - t.Fatal(openErr) - } - reopened.BeginSession("test-session") - _, replay, _, err := reopened.Handle("/FriendshipGift", request) - if err != nil || !bytes.Equal(body, replay) { - t.Fatalf("durable replay=%v", err) - } - if h.wallet.Snapshot().FreeJewelry != 7 { - t.Fatal("retry granted currency twice") - } - _, info, _, err := reopened.Handle("/FriendshipInfo", wire.AppendVarint(nil, 1, 2)) - if err != nil { - t.Fatal(err) - } - var level uint64 - if err := wire.Walk(info, func(field wire.Field) error { - if field.Number == 1 { - id, _, _ := wire.Varint(field.Value, 1) - if id == 10 { - level, _, _ = wire.Varint(field.Value, 2) - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if level != 2 { - t.Fatalf("restored level=%d", level) - } -} - -func TestFriendshipRejectsCounterfeitGiftsAndUnownedCostumes(t *testing.T) { - for _, test := range []struct { - name string - id uint64 - change func(*Item) - }{ - {"unowned", 50, func(*Item) {}}, {"fake-index", 10, func(i *Item) { i.InvenIndex++ }}, {"fake-type", 10, func(i *Item) { i.Type = 7 }}, {"fake-id", 10, func(i *Item) { i.ID = 8 }}, {"too-many", 10, func(i *Item) { i.Count = 51 }}, - } { - t.Run(test.name, func(t *testing.T) { - h := newFriendshipHarness(t) - item := h.item - item.Count = 1 - test.change(&item) - _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, test.id, item)) - if err == nil { - t.Fatal("accepted counterfeit") - } - if len(h.collection.FriendshipEntries()) != 0 || h.wallet.Snapshot().FreeJewelry != 0 { - t.Fatal("rejected gift mutated state") - } - if err := h.inventory.CanConsume([]Item{h.item}); err != nil { - t.Fatal(err) - } - }) - } -} - -func TestFriendshipCounselingDailyLimitsChoicesAndUTCRollover(t *testing.T) { - h := newFriendshipHarness(t) - _, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 1, 0, false)) - if err != nil { - t.Fatal(err) - } - correct, _, _ := wire.Varint(body, 3) - exp, _, _ := wire.Varint(body, 4) - if correct != 1 || exp != 100 { - t.Fatalf("correct=%d exp=%d", correct, exp) - } - if h.wallet.Snapshot().FreeJewelry != 20 { - t.Fatal("level plus counseling currency incorrect") - } - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 10, 2, 0, false)); err == nil { - t.Fatal("same costume counseled twice") - } - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 99, 0, false)); err == nil { - t.Fatal("unknown session accepted") - } - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 1, 2, false)); err == nil { - t.Fatal("unknown choice accepted") - } - _, body, _, err = h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 1, 1, false)) - if err != nil { - t.Fatal(err) - } - exp, _, _ = wire.Varint(body, 4) - correct, _, _ = wire.Varint(body, 3) - if exp != 80 || correct != 0 { - t.Fatal("incorrect choice got correct exp") - } - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(3, 30, 1, 0, false)); err != nil { - t.Fatal(err) - } - ap, _ := h.service.FriendshipAP() - if ap != 0 { - t.Fatalf("ap=%d", ap) - } - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(4, 40, 1, 0, false)); err == nil { - t.Fatal("fourth account AP accepted") - } - h.clock = h.clock.Add(2 * time.Minute) - ap, _ = h.service.FriendshipAP() - if ap != 3 { - t.Fatalf("rollover ap=%d", ap) - } - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(4, 10, 2, 1, false)); err != nil { - t.Fatal(err) - } - state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State - if len(state.Sessions) != 2 || state.LastCounselingDate != uint64(h.clock.UnixMilli()) { - t.Fatalf("counsel state=%+v", state) - } -} - -func TestFriendshipQuickUnlockAndAwakeningPotentialGates(t *testing.T) { - h := newFriendshipHarness(t) - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 1, 1, true)); err == nil { - t.Fatal("locked quick accepted") - } - state := FriendshipState{CostumeID: 10, Level: 1, Sessions: []uint64{1, 2}, LastCounselingDate: uint64(h.clock.Add(-24 * time.Hour).UnixMilli()), CounselingDay: h.clock.Add(-24 * time.Hour).Format("2006-01-02"), CounselingCount: 1} - if err := h.collection.ApplyFriendship(state, nil, "reply:"+string(bytes.Repeat([]byte("a"), 64)), FriendshipReply{Digest: string(bytes.Repeat([]byte("b"), 64)), Code: 613, Body: wire.AppendBytes(nil, 2, friendshipWire(state))}); err != nil { - t.Fatal(err) - } - _, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 3, 1, true)) - if err != nil { - t.Fatal(err) - } - correct, _, _ := wire.Varint(body, 3) - if correct != 1 { - t.Fatal("quick compared fixed choice1 with normal correct choice0") - } - state = *h.collection.FriendshipEntries()[friendshipStateKey(10)].State - if len(state.Sessions) != 2 { - t.Fatal("quick added a new completed story") - } - costume, _ := h.collection.CostumeByID(100) - if max := h.service.maximum(costume); max != 3 { - t.Fatalf("+5 incorrectly unlocked max=%d", max) - } - if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil { - t.Fatal(err) - } - if max := h.service.maximum(costume); max != 4 { - t.Fatalf("awake max=%d", max) - } - if err := h.collection.ActivateCostumePotential(costume.InvenIndex, []uint64{71, 72}); err != nil { - t.Fatal(err) - } - costume, _ = h.collection.CostumeByID(100) - if max := h.service.maximum(costume); max != 5 { - t.Fatalf("potential max=%d", max) - } -} - -func TestFriendshipGiftCapDiscardsOverflowAndPaysEachReachedLevel(t *testing.T) { - h := newFriendshipHarness(t) - h.service.design.Levels[gamedata.FriendshipKey{GroupID: 10, ID: 3}] = gamedata.FriendshipLevelDesign{NextEXP: 100, Rewards: []gamedata.Reward{{Type: 47, ID: 900, Count: 1}}} - item := h.item - item.Count = 5 - _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item)) - if err != nil { - t.Fatal(err) - } - state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State - if state.Level != 3 || state.EXP != 0 { - t.Fatalf("gate did not discard overflow %+v", state) - } - var rewards []Item - for _, entry := range h.inventory.owned.Items { - if entry.Type == 47 { - rewards = append(rewards, entry) - } - } - if len(rewards) != 1 || rewards[0].ID != 900 || rewards[0].Count != 1 { - t.Fatalf("cap level ID card reward %+v", rewards) - } - item.Count = 1 - if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(2, 10, item)); err == nil { - t.Fatal("gift accepted at locked gate") - } - if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil { - t.Fatal(err) - } - if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(2, 10, item)); err != nil { - t.Fatal(err) - } - state = h.collection.FriendshipEntries()[friendshipStateKey(10)].State - if state.Level != 4 || state.EXP != 0 { - t.Fatalf("awake gate state %+v", state) - } -} - -func TestFriendshipFinalCapStoryPlaybackDoesNotConsumeAPOrRepeatReward(t *testing.T) { - h := newFriendshipHarness(t) - if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil { - t.Fatal(err) - } - if err := h.collection.ActivateCostumePotential(1, []uint64{71, 72}); err != nil { - t.Fatal(err) - } - item := h.item - item.Count = 5 - if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item)); err != nil { - t.Fatal(err) - } - _, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 10, 1, 0, false)) - if err != nil { - t.Fatal(err) - } - exp, _, _ := wire.Varint(body, 4) - if exp != 0 { - t.Fatal("final cap counseling gained exp") - } - before := h.wallet.Snapshot().FreeJewelry - if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(3, 10, 1, 0, false)); err != nil { - t.Fatal(err) - } - if before != 20 || h.wallet.Snapshot().FreeJewelry != before { - t.Fatal("final cap story reward repeated") - } - ap, _ := h.service.FriendshipAP() - if ap != 3 { - t.Fatalf("final cap playback consumed ap=%d", ap) - } -} - -func TestFriendshipRejectsCorruptSavedProgress(t *testing.T) { - for _, test := range []struct { - name string - mutate func(*FriendshipState) - }{ - {"date", func(s *FriendshipState) { - s.LastCounselingDate = 1 - s.CounselingDay = "2026-10-01" - s.CounselingCount = 1 - }}, - {"experience", func(s *FriendshipState) { s.EXP = 100 }}, - {"session", func(s *FriendshipState) { s.Sessions = []uint64{999} }}, - } { - t.Run(test.name, func(t *testing.T) { - h := newFriendshipHarness(t) - state := FriendshipState{CostumeID: 10, Level: 1} - test.mutate(&state) - h.collection.data.Friendships[friendshipStateKey(10)] = FriendshipEntry{State: &state} - if err := validateFriendshipEntries(h.collection.data.Friendships); err != nil { - return - } - if _, err := NewFriendshipService(h.service.design, h.service.awake, h.service.potential, h.collection, h.inventory, h.wallet); err == nil { - t.Fatal("accepted corrupt progress") - } - }) - } -} diff --git a/go/internal/server/player/growth.go b/go/internal/server/player/growth.go deleted file mode 100644 index 58ced3d..0000000 --- a/go/internal/server/player/growth.go +++ /dev/null @@ -1,602 +0,0 @@ -package player - -import ( - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "strconv" - "strings" - "sync" - "time" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type characterSnapshot struct { - Version string `json:"version"` - CharacterOrder []uint64 `json:"character_order"` -} - -// CharacterStore owns mutable character growth separately from immutable -// character design data. The seed supplies the initially owned instances. -type CharacterStore struct { - mu sync.Mutex - store stateio.AtomicEntryStore - characters []Character - persisted map[uint64]bool - persistedOrder []uint64 - persistedCore bool - inventory *Inventory - gameDataRoot string - gameDataVersion string - grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) - collection *CollectionStore - wallet *Wallet - maxHealth func(Character) (uint64, error) - promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) - talentGrowth *gamedata.TalentGrowthDesign - immortal *gamedata.ImmortalDesign - immortalReplies map[string]talentUpgradeReply - sessionID string - talentReplies map[string]map[string]talentUpgradeReply - talentApplied map[string]talentUpgradeReply -} - -type talentUpgradeReply struct { - Digest string `json:"digest"` - Code int `json:"code"` - Body []byte `json:"body,omitempty"` -} - -func (s *CharacterStore) AttachWallet(wallet *Wallet) error { - if wallet == nil { - return errors.New("player: nil growth wallet") - } - s.mu.Lock() - defer s.mu.Unlock() - s.wallet = wallet - return nil -} - -func (s *CharacterStore) AttachImmortalDesign(design *gamedata.ImmortalDesign) error { - if design == nil { - return errors.New("player: nil immortal design") - } - s.immortal = design - return nil -} - -func (s *CharacterStore) AttachTalentGrowth(design *gamedata.TalentGrowthDesign) error { - if design == nil { - return errors.New("player: nil talent growth design") - } - s.mu.Lock() - defer s.mu.Unlock() - s.talentGrowth = design - return nil -} - -// BeginSession scopes protobuf sequence replay. Network retries reuse the -// exact TalentSkillUpgrade request and must receive success without a second -// level increase or charge. -func (s *CharacterStore) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - if id == "" || id == s.sessionID { - return - } - s.sessionID = id - if s.talentReplies == nil { - s.talentReplies = make(map[string]map[string]talentUpgradeReply) - } - if s.talentReplies[id] == nil { - if len(s.talentReplies) >= 1024 { - for oldID := range s.talentReplies { - if oldID != id { - delete(s.talentReplies, oldID) - break - } - } - } - s.talentReplies[id] = make(map[string]talentUpgradeReply) - } -} - -// AttachMaxHealth makes growth and post-battle revival consume the same -// GameData/collection calculator as AllCharRefresh and BattleEnter. -func (s *CharacterStore) AttachMaxHealth(maxHealth func(Character) (uint64, error)) error { - if maxHealth == nil { - return errors.New("player: nil maximum health calculator") - } - s.mu.Lock() - defer s.mu.Unlock() - s.maxHealth = maxHealth - return nil -} - -func (s *CharacterStore) AttachCollection(collection *CollectionStore) error { - if collection == nil { - return errors.New("player: nil collection store") - } - s.mu.Lock() - defer s.mu.Unlock() - s.collection = collection - return nil -} - -func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Inventory, gameDataRoot, gameDataVersion string) (*CharacterStore, error) { - if store == nil || inventory == nil { - return nil, errors.New("player: invalid character store configuration") - } - entries, ok := store.(stateio.AtomicEntryStore) - if !ok { - return nil, errors.New("player: character store requires atomic entries") - } - s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, talentReplies: make(map[string]map[string]talentUpgradeReply), talentApplied: make(map[string]talentUpgradeReply)} - s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { - return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials) - } - s.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) { - return gamedata.CharacterGrowthPromotions(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, submitted) - } - data, err := entries.Load("characters") - if err != nil { - return nil, err - } - if data == nil { - orphaned, err := entries.ListEntries("characters", "characters") - if err != nil { - return nil, fmt.Errorf("player: list character entries: %w", err) - } - if len(orphaned) != 0 { - return nil, errors.New("player: character entries exist without core") - } - applied, err := entries.ListEntries("characters", "talent_upgrades") - if err != nil { - return nil, err - } - if len(applied) != 0 { - return nil, errors.New("player: talent upgrade entries exist without character core") - } - return s, validateCharacters(s.characters) - } - saved, loaded, err := loadCharacterEntries(entries, data) - if err != nil { - return nil, err - } - s.characters = loaded - s.persistedOrder = append([]uint64{}, saved.CharacterOrder...) - s.persistedCore = true - seen := make(map[uint64]bool, len(s.characters)) - for _, character := range s.characters { - seen[character.InvenIndex] = true - s.persisted[character.InvenIndex] = true - } - for _, character := range seed { - if !seen[character.InvenIndex] { - return nil, fmt.Errorf("player: current character state omits seeded inventory index %d", character.InvenIndex) - } - } - if err := s.loadTalentUpgradeLedger(entries); err != nil { - return nil, err - } - if err := validateCharacters(s.characters); err != nil { - return nil, err - } - return s, nil -} - -func (s *CharacterStore) loadTalentUpgradeLedger(entries stateio.AtomicEntryStore) error { - rows, err := entries.ListEntries("characters", "talent_upgrades") - if err != nil { - return err - } - for key, payload := range rows { - parts := strings.Split(key, ":") - if len(parts) != 2 { - return fmt.Errorf("player: invalid talent upgrade ledger key %q", key) - } - index, indexErr := strconv.ParseUint(parts[0], 10, 64) - level, levelErr := strconv.ParseUint(parts[1], 10, 64) - var reply talentUpgradeReply - if indexErr != nil || levelErr != nil || index == 0 || level < 2 || json.Unmarshal(payload, &reply) != nil || - reply.Code != talentSkillUpgradePacketCode || len(reply.Digest) != 64 { - return fmt.Errorf("player: invalid talent upgrade ledger entry %q", key) - } - if _, err := hex.DecodeString(reply.Digest); err != nil { - return fmt.Errorf("player: invalid talent upgrade digest %q", key) - } - s.talentApplied[key] = reply - } - return nil -} - -func (s *CharacterStore) EnsurePersisted() error { - s.mu.Lock() - defer s.mu.Unlock() - return s.persist(append([]Character(nil), s.characters...)) -} - -func validateCharacters(characters []Character) error { - seen := make(map[uint64]bool, len(characters)) - for _, character := range characters { - if character.InvenIndex == 0 || character.ID == 0 || character.Level == 0 || seen[character.InvenIndex] { - return errors.New("player: invalid saved character") - } - seen[character.InvenIndex] = true - } - return nil -} - -func (s *CharacterStore) All() []Character { - characters := s.RawAll() - active := characters[:0] - for _, c := range characters { - if !CharacterExpired(c, time.Now()) { - active = append(active, c) - } - } - characters = active - for i := range characters { - if hp, err := s.savedCurrentHealth(characters[i]); err == nil { - characters[i].HP = hp - } - } - return characters -} - -// RawAll is for account-ownership queries used by the stat calculator itself. -// Calling All() from that calculator would recursively calculate its inputs. -func (s *CharacterStore) RawAll() []Character { - s.mu.Lock() - characters := append([]Character(nil), s.characters...) - collection := s.collection - s.mu.Unlock() - if collection != nil { - characters = append(characters, collection.Characters()...) - } - return characters -} - -func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) { - for _, character := range s.RawAll() { - if character.InvenIndex == inventoryIndex { - if CharacterExpired(character, time.Now()) { - return Character{}, false - } - if hp, err := s.savedCurrentHealth(character); err == nil { - character.HP = hp - } - return character, true - } - } - return Character{}, false -} - -func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool, error) { - if path == "/CharImmortal" { - return s.charImmortal(request) - } - if path == "/TalentSkillUpgrade" { - return s.talentSkillUpgrade(request) - } - if path != "/CharGrowth" { - return 0, nil, false, nil - } - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 { - return 0, nil, true, errors.New("player: CharGrowth missing character") - } - var materials []Item - if err := wire.Walk(request, func(field wire.Field) error { - if field.Number != 3 { - return nil - } - if field.Type != 2 { - return errors.New("player: invalid growth material") - } - var item Item - if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil { - return err - } - if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) { - return errors.New("player: incomplete growth material") - } - materials = append(materials, item) - return nil - }); err != nil { - return 0, nil, true, err - } - if len(materials) == 0 { - return 0, nil, true, errors.New("player: CharGrowth has no materials") - } - - s.mu.Lock() - defer s.mu.Unlock() - position := -1 - for i, current := range s.characters { - if current.InvenIndex == index { - position = i - break - } - } - var current Character - fromCollection := false - if position >= 0 { - current = s.characters[position] - } else if s.collection != nil { - current, fromCollection = s.collection.FindCharacter(index) - } - if position < 0 && !fromCollection { - return 0, nil, true, fmt.Errorf("player: unknown character inventory index %d", index) - } - isPromotion := false - for _, material := range materials { - if material.Type == 4 { - isPromotion = true - break - } - } - if isPromotion { - return s.promoteCharacter(current, position, fromCollection, materials) - } - growthMaterials := make([]gamedata.GrowthMaterial, len(materials)) - for i, material := range materials { - if material.Type != 8 { - return 0, nil, true, fmt.Errorf("player: unsupported growth material type %d", material.Type) - } - growthMaterials[i] = gamedata.GrowthMaterial{ID: material.ID, Count: material.Count} - } - newLevel, newExp, refunds, err := s.grow(current, growthMaterials) - if err != nil { - return 0, nil, true, fmt.Errorf("player: calculate character growth: %w", err) - } - current.Level = newLevel - current.Exp = newExp - if s.maxHealth != nil { - maxHealth := s.maxHealth - s.mu.Unlock() - hp, healthErr := maxHealth(current) - s.mu.Lock() - current.HP, err = hp, healthErr - if err != nil { - return 0, nil, true, fmt.Errorf("player: calculate grown character maximum health: %w", err) - } - } - returned, err := s.inventory.ConsumeAndRefund(materials, refunds) - if err != nil { - return 0, nil, true, fmt.Errorf("player: consume growth material: %w", err) - } - if fromCollection { - if err := s.collection.UpdateCharacter(current.ID, current); err != nil { - return 0, nil, true, fmt.Errorf("player: persist collection character growth: %w", err) - } - } else { - next := append([]Character(nil), s.characters...) - next[position] = current - if err := s.persist(next); err != nil { - return 0, nil, true, fmt.Errorf("player: persist character growth: %w", err) - } - s.characters = next - } - if err := s.resetCurrentHealth(current.InvenIndex); err != nil { - return 0, nil, true, err - } - response := wire.AppendBytes(nil, 1, CharacterWire(current)) - var bundle []byte - for _, item := range returned { - bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) - } - response = wire.AppendBytes(response, 2, bundle) - return 433, response, true, nil -} - -func (s *CharacterStore) promoteCharacter(current Character, position int, fromCollection bool, materials []Item) (int, []byte, bool, error) { - var items []Item - requested := make(map[[2]uint64]uint64) - var gold uint64 - for _, material := range materials { - if material.Type == 4 { //nolint:staticcheck // QF1003 - if material.InvenIndex != 0 || material.ID != 0 || gold != 0 { - return 0, nil, true, errors.New("player: invalid promotion currency") - } - gold = material.Count - } else if material.Type == 8 { - items = append(items, material) - key := [2]uint64{8, material.ID} - if material.Count > ^uint64(0)-requested[key] { - return 0, nil, true, errors.New("player: promotion material overflow") - } - requested[key] += material.Count - } else { - return 0, nil, true, fmt.Errorf("player: unsupported promotion item type %d", material.Type) - } - } - if gold == 0 { - return 0, nil, true, errors.New("player: promotion has no gold cost") - } - submitted := make([]gamedata.PromotionCost, 0, len(requested)+1) - for key, count := range requested { - submitted = append(submitted, gamedata.PromotionCost{Type: key[0], ID: key[1], Count: count}) - } - submitted = append(submitted, gamedata.PromotionCost{Type: 4, Count: gold}) - result, err := s.promoteGrowth(current, submitted) - if err != nil { - return 0, nil, true, fmt.Errorf("player: calculate combined character promotion: %w; request=%+v", err, materials) - } - if gold != 0 && (s.wallet == nil || !s.wallet.CanSpendGold(gold)) { - return 0, nil, true, errors.New("player: insufficient gold for promotion") - } - if len(items) == 0 { - return 0, nil, true, errors.New("player: promotion has no item material") - } - previousID := current.ID - current.ID = result.CharacterID - current.Level = result.Level - current.Exp = result.Exp - if fromCollection { - if err := s.collection.CanUpdateCharacter(previousID, current); err != nil { - return 0, nil, true, fmt.Errorf("player: validate promoted collection character: %w", err) - } - } - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: validate promotion items: %w", err) - } - if s.maxHealth != nil { - maxHealth := s.maxHealth - s.mu.Unlock() - hp, healthErr := maxHealth(current) - s.mu.Lock() - if healthErr != nil { - return 0, nil, true, fmt.Errorf("player: calculate promoted character health: %w", healthErr) - } - current.HP = hp - } - if gold != 0 { - identity := "char-promote:" + strconv.FormatUint(current.InvenIndex, 10) + ":" + strconv.FormatUint(current.ID, 10) - if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { - return 0, nil, true, fmt.Errorf("player: consume promotion gold: %w", err) - } - } - returned, err := s.inventory.ConsumeAndRefund(items, result.Refunds) - if err != nil { - return 0, nil, true, fmt.Errorf("player: consume promotion items: %w", err) - } - if fromCollection { - if err := s.collection.UpdateCharacter(previousID, current); err != nil { - return 0, nil, true, fmt.Errorf("player: persist promoted collection character: %w", err) - } - } else { - next := append([]Character(nil), s.characters...) - next[position] = current - if err := s.persist(next); err != nil { - return 0, nil, true, fmt.Errorf("player: persist promoted character: %w", err) - } - s.characters = next - } - if err := s.resetCurrentHealth(current.InvenIndex); err != nil { - return 0, nil, true, err - } - response := wire.AppendBytes(nil, 1, CharacterWire(current)) - var bundle []byte - for _, item := range returned { - bundle = wire.AppendBytes(bundle, 1, ItemWire(item)) - } - if len(bundle) != 0 { - response = wire.AppendBytes(response, 2, bundle) - } - return 433, response, true, nil -} - -// charImmortal completes the automatic post-battle revival for characters -// whose TalentSkillTable.ClassType is 14. The story character 6010 has -// ValueList[0]=10000 at every talent level (100%). The authoritative maximum -// Maximum HP is calculated separately from the persisted current HP and the -// restored value is saved explicitly for subsequent character snapshots. -func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error) { - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 { - return 0, nil, true, errors.New("player: CharImmortal missing sequence") - } - digest := fmt.Sprintf("%x", sha256.Sum256(request)) - s.mu.Lock() - key := fmt.Sprintf("%s:%d", s.sessionID, seq) - prior, already := s.immortalReplies[key] - s.mu.Unlock() - if already { - if prior.Digest != digest { - return 0, nil, true, errors.New("player: immortal replay payload changed") - } - return prior.Code, append([]byte(nil), prior.Body...), true, nil - } - var indices []uint64 - err = wire.Walk(request, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - if field.Type != 0 && field.Type != 2 { - return errors.New("player: CharImmortal invalid inventory index field") - } - for data := field.Value; len(data) != 0; { - index, count := binary.Uvarint(data) - if count <= 0 || index == 0 { - return errors.New("player: CharImmortal invalid inventory index") - } - indices = append(indices, index) - data = data[count:] - } - return nil - }) - if err != nil { - return 0, nil, true, err - } - if len(indices) == 0 || len(indices) > 5 { - return 0, nil, true, fmt.Errorf("player: CharImmortal invalid character count %d", len(indices)) - } - seen := make(map[uint64]bool, len(indices)) - var response []byte - for _, index := range indices { - if seen[index] { - return 0, nil, true, fmt.Errorf("player: CharImmortal duplicate character %d", index) - } - seen[index] = true - character, found := s.Find(index) - if !found { - return 0, nil, true, fmt.Errorf("player: CharImmortal unknown character %d", index) - } - if character.HP != 0 { - return 0, nil, true, errors.New("player: immortal requires a defeated character") - } - if !s.immortal.CanRestore(character.ID, character.TalentLevel) { - return 0, nil, true, errors.New("player: character has no supported immortal talent at current level") - } - maximum, err := s.MaxHealth(index) - if err != nil { - return 0, nil, true, err - } - character.HP = maximum - response = wire.AppendBytes(response, 1, CharacterWire(character)) - } - for _, index := range indices { - maximum, err := s.MaxHealth(index) - if err != nil { - return 0, nil, true, err - } - if err := s.SetCurrentHealth(index, maximum); err != nil { - return 0, nil, true, err - } - } - s.mu.Lock() - if s.immortalReplies == nil { - s.immortalReplies = map[string]talentUpgradeReply{} - } - if len(s.immortalReplies) >= 1024 { - s.immortalReplies = map[string]talentUpgradeReply{} - } - s.immortalReplies[key] = talentUpgradeReply{Digest: digest, Code: 96, Body: append([]byte(nil), response...)} - s.mu.Unlock() - return 96, response, true, nil -} - -func CharacterWire(c Character) []byte { - fields := []struct { - n int - v uint64 - }{{1, c.InvenIndex}, {2, c.ID}, {3, c.HP}, {4, c.Level}, {5, c.CostumeID}, {6, c.Exp}, {7, c.UseCostume}, {8, c.TalentLevel}, {9, c.TalentExp}, {10, c.SolidarityReward}, {11, c.ExpiryTime}, {13, c.ConnectPotentialCostume}} - var out []byte - for _, field := range fields { - if field.v != 0 { - out = wire.AppendVarint(out, field.n, field.v) - } - } - for _, p := range c.Pictorialbook { - book := wire.AppendVarint(wire.AppendVarint(nil, 1, p.ID), 2, p.GroupID) - out = wire.AppendBytes(out, 12, book) - } - return out -} diff --git a/go/internal/server/player/growth_entries_test.go b/go/internal/server/player/growth_entries_test.go deleted file mode 100644 index 3f32567..0000000 --- a/go/internal/server/player/growth_entries_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package player - -import ( - "bytes" - "encoding/json" - "reflect" - "testing" - - "bd2server/internal/server/stateio" -) - -func TestCharacterEntriesPreserveOrderAndUpdateOneEntity(t *testing.T) { - storage := &collectionWriteSpy{Memory: stateio.NewMemory()} - inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - seed := []Character{ - {InvenIndex: 3, ID: 30, Level: 1}, - {InvenIndex: 1, ID: 10, Level: 1}, - {InvenIndex: 2, ID: 20, Level: 1}, - } - characters, err := OpenCharacterStore(storage, seed, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - core, err := storage.Load("characters") - if err != nil { - t.Fatal(err) - } - if bytes.Contains(core, []byte(`"characters"`)) || !bytes.Contains(core, []byte(`"character_order":[3,1,2]`)) { - t.Fatalf("incorrect character core: %s", core) - } - if storage.coreWrites != 1 || len(storage.mutations) != 1 || len(storage.mutations[0]) != 3 { - t.Fatalf("initial writes: cores=%d mutations=%+v", storage.coreWrites, storage.mutations) - } - next := append([]Character(nil), characters.characters...) - next[1].Level = 20 - if err := characters.persist(next); err != nil { - t.Fatal(err) - } - characters.characters = next - if storage.coreWrites != 1 || len(storage.mutations) != 2 || len(storage.mutations[1]) != 1 || storage.mutations[1][0].Key != "1" { - t.Fatalf("growth rewrote unrelated characters: cores=%d mutations=%+v", storage.coreWrites, storage.mutations) - } - reloaded, err := OpenCharacterStore(storage, nil, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if got := reloaded.RawAll(); !reflect.DeepEqual(got, next) { - t.Fatalf("reloaded order or content differs: %+v", got) - } -} - -func TestCharacterEntriesRejectIncompleteOrLegacyState(t *testing.T) { - valid, err := json.Marshal(Character{InvenIndex: 1, ID: 10, Level: 1}) - if err != nil { - t.Fatal(err) - } - wrongIndex, err := json.Marshal(Character{InvenIndex: 2, ID: 10, Level: 1}) - if err != nil { - t.Fatal(err) - } - for _, test := range []struct { - name string - core string - entries map[string][]byte - }{ - {"legacy array", `{"version":"2.35.10","characters":[]}`, nil}, - {"duplicate order", `{"version":"2.35.10","character_order":[1,1]}`, map[string][]byte{"1": valid, "2": wrongIndex}}, - {"missing entry", `{"version":"2.35.10","character_order":[1]}`, nil}, - {"extra entry", `{"version":"2.35.10","character_order":[]}`, map[string][]byte{"1": valid}}, - {"mismatched index", `{"version":"2.35.10","character_order":[1]}`, map[string][]byte{"1": wrongIndex}}, - {"noncanonical key", `{"version":"2.35.10","character_order":[1]}`, map[string][]byte{"01": valid}}, - } { - t.Run(test.name, func(t *testing.T) { - storage := stateio.NewMemory() - if err := storage.Save("characters", []byte(test.core)); err != nil { - t.Fatal(err) - } - for key, payload := range test.entries { - if err := storage.PutEntry("characters", "characters", key, payload); err != nil { - t.Fatal(err) - } - } - inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if _, err := OpenCharacterStore(storage, nil, inventory, "", ""); err == nil { - t.Fatal("accepted invalid character state") - } - }) - } -} - -func TestCharacterEntriesRejectOrphansWithoutCore(t *testing.T) { - storage := stateio.NewMemory() - if err := storage.PutEntry("characters", "characters", "1", []byte(`{"inven_index":1,"id":10,"level":1}`)); err != nil { - t.Fatal(err) - } - inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if _, err := OpenCharacterStore(storage, nil, inventory, "", ""); err == nil { - t.Fatal("accepted orphaned character entry") - } -} diff --git a/go/internal/server/player/growth_test.go b/go/internal/server/player/growth_test.go deleted file mode 100644 index ab7170b..0000000 --- a/go/internal/server/player/growth_test.go +++ /dev/null @@ -1,443 +0,0 @@ -package player - -import ( - "bytes" - "encoding/binary" - "fmt" - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) { - dir := t.TempDir() - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) - if err != nil { - t.Fatal(err) - } - owned := []Character{ - {InvenIndex: 535604120, ID: 6010, HP: 7, Level: 1, TalentLevel: 1}, - {InvenIndex: 535607162, ID: 350, HP: 512, Level: 20, TalentLevel: 1}, - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), owned, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.SetCurrentHealth(owned[0].InvenIndex, 0); err != nil { - t.Fatal(err) - } - if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{6010: 42}, FullRestore: map[[2]uint64]bool{{42, 1}: true}}); err != nil { - t.Fatal(err) - } - packed := binary.AppendUvarint(nil, owned[0].InvenIndex) - request := wire.AppendBytes(wire.AppendVarint(nil, 1, 23), 2, packed) - code, response, ok, err := characters.Handle("/CharImmortal", request) - if err != nil || !ok || code != 96 { - t.Fatalf("immortal: code=%d handled=%v err=%v", code, ok, err) - } - encoded, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("missing revived character: %v", err) - } - index, _, _ := wire.Varint(encoded, 1) - hp, _, _ := wire.Varint(encoded, 3) - if index != owned[0].InvenIndex || hp != 7 { - t.Fatalf("revived character index=%d hp=%d", index, hp) - } - for _, invalid := range [][]byte{ - wire.AppendVarint(wire.AppendVarint(nil, 1, 24), 2, 999), - wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 25), 2, owned[0].InvenIndex), 2, owned[0].InvenIndex), - wire.AppendVarint(nil, 1, 26), - } { - if _, _, handled, err := characters.Handle("/CharImmortal", invalid); err == nil || !handled { - t.Fatalf("invalid immortal request accepted: handled=%v err=%v", handled, err) - } - } -} - -func TestCharacterGrowthConsumesMaterialAndPersists(t *testing.T) { - dir := t.TempDir() - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) - if err != nil { - t.Fatal(err) - } - items, err := inventory.GrantOnce("battle", []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, Level: 1}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { - return 20, 0, []gamedata.GrowthMaterial{{ID: 7, Count: 6}}, nil - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 77) - material := ItemWire(items[0]) - request = wire.AppendBytes(request, 3, material) - code, response, ok, err := characters.Handle("/CharGrowth", request) - if err != nil || !ok || code != 433 { - t.Fatalf("growth: code=%d ok=%v err=%v", code, ok, err) - } - encoded, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("character response: %v", err) - } - level, found, err := wire.Varint(encoded, 4) - if err != nil || !found || level != 20 { - t.Fatalf("level=%d found=%v err=%v", level, found, err) - } - restored, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), nil, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if got := restored.All(); len(got) != 1 || got[0].Level != 20 { - t.Fatalf("restored=%+v", got) - } - _, itemResponse, _, err := inventory.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 2)) - if err != nil { - t.Fatal(err) - } - owned, found, err := wire.Bytes(itemResponse, 1) - if err != nil || !found { - t.Fatalf("refund absent: found=%v err=%v", found, err) - } - id, _, _ := wire.Varint(owned, 2) - count, _, _ := wire.Varint(owned, 4) - if id != 7 || count != 6 { - t.Fatalf("wrong refund id=%d count=%d", id, count) - } - bundle, found, err := wire.Bytes(response, 2) - if err != nil || !found { - t.Fatalf("missing growth reward bundle: %v", err) - } - reward, found, err := wire.Bytes(bundle, 1) - if err != nil || !found { - t.Fatalf("missing reward item: %v", err) - } - rewardID, _, _ := wire.Varint(reward, 2) - if rewardID != 7 { - t.Fatalf("reward id=%d", rewardID) - } - if err := inventory.Consume(items); err == nil { - t.Fatal("already spent growth material accepted again") - } -} - -func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - items, err := inventory.GrantOnce("growth", []gamedata.BattleReward{{Type: 8, ID: 8, Count: 2}}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, HP: 122, Level: 1}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) { - return 20, 0, nil, nil - } - if err := characters.AttachMaxHealth(func(character Character) (uint64, error) { - // The callback must be safe to query account ownership while growth - // changes the same CharacterStore. - if len(characters.RawAll()) != 1 { - t.Fatal("owned character disappeared during health calculation") - } - if character.Level == 20 { - return 513, nil - } - return 122, nil - }); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 11), 2, 77) - request = wire.AppendBytes(request, 3, ItemWire(items[0])) - _, response, _, err := characters.Handle("/CharGrowth", request) - if err != nil { - t.Fatal(err) - } - grown, _, err := wire.Bytes(response, 1) - if err != nil { - t.Fatal(err) - } - if hp, _, _ := wire.Varint(grown, 3); hp != 513 { - t.Fatalf("grown HP=%d want 513", hp) - } - if err := characters.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - if err := characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{350: 42}, FullRestore: map[[2]uint64]bool{{42, 0}: true}}); err != nil { - t.Fatal(err) - } - immortal := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77) - _, response, _, err = characters.Handle("/CharImmortal", immortal) - if err != nil { - t.Fatal(err) - } - revived, _, err := wire.Bytes(response, 1) - if err != nil { - t.Fatal(err) - } - if hp, _, _ := wire.Varint(revived, 3); hp != 513 { - t.Fatalf("revived HP=%d want 513", hp) - } - data, found, err := testStore(filepath.Join(dir, "characters.json")).(stateio.EntryStore).LoadEntry("characters", "characters", "77") - if err != nil || !found || !bytes.Contains(data, []byte(`"hp":513`)) { - t.Fatalf("growth HP was not persisted: %s err=%v", data, err) - } -} - -func TestCharacterPromotionUsesExactGameDataCosts(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - items, err := inventory.GrantOnce("promotion-material", []gamedata.BattleReward{{Type: 8, ID: 11, Count: 2}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 1500}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, Level: 20, HP: 513}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) { - if character.ID != 350 || character.Level != 20 { - return gamedata.PromotionGrowthResult{}, fmt.Errorf("not promotable: %+v", character) - } - if !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 11, Count: 1}, {Type: 4, Count: 1000}}) { - return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected submitted costs: %+v", submitted) - } - return gamedata.PromotionGrowthResult{CharacterID: 351, Level: 20, Costs: submitted}, nil - } - item := items[0] - item.Count = 1 - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77) - request = wire.AppendBytes(request, 3, ItemWire(item)) - request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 1000})) - code, response, handled, err := characters.Handle("/CharGrowth", request) - if err != nil || !handled || code != 433 { - t.Fatalf("promote code=%d handled=%v err=%v", code, handled, err) - } - encoded, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("missing promoted character %v", err) - } - if id, _, _ := wire.Varint(encoded, 2); id != 351 { - t.Fatalf("promoted id=%d", id) - } - if snapshot := wallet.Snapshot(); snapshot.Gold != 500 { - t.Fatalf("gold after promotion=%d", snapshot.Gold) - } - loaded, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), nil, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if got := loaded.All(); len(got) != 1 || got[0].ID != 351 { - t.Fatalf("persisted promotion=%+v", got) - } - if err := inventory.Consume([]Item{item}); err != nil { - t.Fatalf("remaining material x1 should exist: %v", err) - } - if _, _, _, err := characters.Handle("/CharGrowth", request); err == nil { - t.Fatal("duplicate class-up accepted") - } -} - -func TestCharacterGrowthPromotesAndLevelsInOneRequestAcrossStacks(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - first, err := inventory.GrantOnce("slime-a", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 7}}) - if err != nil { - t.Fatal(err) - } - second, err := inventory.GrantOnce("slime-b", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 103}}) - if err != nil { - t.Fatal(err) - } - classUp, err := inventory.GrantOnce("class-up", []gamedata.BattleReward{{Type: 8, ID: 12, Count: 3}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 3000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 351, Level: 40}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) { - if character.ID != 351 || character.Level != 40 { - return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected stage: %+v", character) - } - if !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 9, Count: 110}, {Type: 8, ID: 12, Count: 2}, {Type: 4, Count: 2000}}) { - return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected submitted costs: %+v", submitted) - } - return gamedata.PromotionGrowthResult{CharacterID: 352, Level: 60, Costs: submitted, Refunds: []gamedata.GrowthMaterial{{ID: 7, Count: 1}}}, nil - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77) - for _, material := range []Item{first[0], second[0], {InvenIndex: classUp[0].InvenIndex, ID: 12, Type: 8, Count: 2}, {Type: 4, Count: 2000}} { - request = wire.AppendBytes(request, 3, ItemWire(material)) - } - code, response, handled, err := characters.Handle("/CharGrowth", request) - if err != nil || !handled || code != 433 { - t.Fatalf("combined growth code=%d handled=%v err=%v", code, handled, err) - } - encoded, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("missing character: %v", err) - } - if id, _, _ := wire.Varint(encoded, 2); id != 352 { - t.Fatalf("promoted character id=%d", id) - } - if level, _, _ := wire.Varint(encoded, 4); level != 60 { - t.Fatalf("grown level=%d", level) - } - if wallet.Snapshot().Gold != 1000 { - t.Fatalf("gold=%d", wallet.Snapshot().Gold) - } - if err := inventory.Consume([]Item{first[0]}); err == nil { - t.Fatal("first experience stack was not consumed") - } - if err := inventory.Consume([]Item{second[0]}); err == nil { - t.Fatal("second experience stack was not consumed") - } - if err := inventory.Consume([]Item{{InvenIndex: classUp[0].InvenIndex, ID: 12, Type: 8, Count: 1}}); err != nil { - t.Fatalf("one unspent class-up material must remain: %v", err) - } - if bundle, found, err := wire.Bytes(response, 2); err != nil || !found || len(bundle) == 0 { - t.Fatalf("missing refunded slime bundle: found=%v err=%v", found, err) - } -} - -func promotionCostsEqual(got, want []gamedata.PromotionCost) bool { - counts := make(map[[2]uint64]uint64, len(got)) - for _, cost := range got { - counts[[2]uint64{cost.Type, cost.ID}] += cost.Count - } - if len(counts) != len(want) { - return false - } - for _, cost := range want { - if counts[[2]uint64{cost.Type, cost.ID}] != cost.Count { - return false - } - } - return true -} - -func TestCollectionCharacterCombinedGrowthChangesIDWithoutChargingTwice(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - items, err := inventory.GrantOnce("growth-materials", []gamedata.BattleReward{{Type: 8, ID: 9, Count: 800}, {Type: 8, ID: 11, Count: 1}, {Type: 8, ID: 12, Count: 2}, {Type: 8, ID: 13, Count: 3}, {Type: 8, ID: 14, Count: 4}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 12000}) - if err != nil { - t.Fatal(err) - } - collectionPath := filepath.Join(dir, "collection.json") - collection, err := OpenCollectionStore(testStore(collectionPath), nil) - if err != nil { - t.Fatal(err) - } - nextCollection := cloneCollection(collection.data) - nextCollection.Characters = []Character{{InvenIndex: 920000054, ID: 6510, Level: 1}} - if err := collection.commit(nextCollection); err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 350, Level: 1}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - if err := characters.AttachCollection(collection); err != nil { - t.Fatal(err) - } - characters.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) { - if character.ID != 6510 || character.Level != 1 || !promotionCostsEqual(submitted, []gamedata.PromotionCost{{Type: 8, ID: 9, Count: 753}, {Type: 8, ID: 11, Count: 1}, {Type: 8, ID: 12, Count: 2}, {Type: 8, ID: 13, Count: 3}, {Type: 8, ID: 14, Count: 4}, {Type: 4, Count: 10000}}) { - return gamedata.PromotionGrowthResult{}, fmt.Errorf("unexpected combined growth: %+v %+v", character, submitted) - } - return gamedata.PromotionGrowthResult{CharacterID: 6514, Level: 100, Refunds: []gamedata.GrowthMaterial{{ID: 7, Count: 3}}}, nil - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 920000054) - for i, material := range items { - if i == 0 { - material.Count = 753 - } - request = wire.AppendBytes(request, 3, ItemWire(material)) - } - request = wire.AppendBytes(request, 3, ItemWire(Item{Type: 4, Count: 10000})) - code, _, handled, err := characters.Handle("/CharGrowth", request) - if err != nil || !handled || code != 433 { - t.Fatalf("collection promotion code=%d handled=%v err=%v", code, handled, err) - } - if got, found := collection.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 { - t.Fatalf("promoted collection character=%+v found=%v", got, found) - } - if wallet.Snapshot().Gold != 2000 { - t.Fatalf("gold=%d", wallet.Snapshot().Gold) - } - if _, _, _, err := characters.Handle("/CharGrowth", request); err == nil { - t.Fatal("replay of the old promotion was accepted") - } - if wallet.Snapshot().Gold != 2000 { - t.Fatal("replay charged gold again") - } - reloaded, err := OpenCollectionStore(testStore(collectionPath), nil) - if err != nil { - t.Fatal(err) - } - if got, found := reloaded.FindCharacter(920000054); !found || got.ID != 6514 || got.Level != 100 { - t.Fatalf("reloaded promoted collection character=%+v found=%v", got, found) - } -} - -func TestCharacterStoreRejectsSeedDrift(t *testing.T) { - dir := t.TempDir() - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), starter) - if err != nil { - t.Fatal(err) - } - first, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 1, ID: 10, Level: 1}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := first.persist(first.All()); err != nil { - t.Fatal(err) - } - if _, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 1, ID: 10, Level: 1}, {InvenIndex: 2, ID: 20, Level: 15}}, inventory, "", ""); err == nil { - t.Fatal("accepted seed characters absent from current persisted state") - } -} diff --git a/go/internal/server/player/immortal_design_test.go b/go/internal/server/player/immortal_design_test.go deleted file mode 100644 index a705124..0000000 --- a/go/internal/server/player/immortal_design_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "testing" -) - -func TestImmortalRequiresDesignedDeadTalentAndReplaysSameSequence(t *testing.T) { - store := stateio.NewMemory() - inv, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 999, HP: 0, Level: 1, TalentLevel: 3}}, inv, "", "") - if err != nil { - t.Fatal(err) - } - s.maxHealth = func(Character) (uint64, error) { return 700, nil } - if err := s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 3}: true}}); err != nil { - t.Fatal(err) - } - s.BeginSession("a") - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77) - _, body, _, err := s.Handle("/CharImmortal", req) - if err != nil { - t.Fatal(err) - } - _, again, _, err := s.Handle("/CharImmortal", req) - if err != nil || !bytes.Equal(body, again) { - t.Fatalf("replay err=%v", err) - } - if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 13), 2, 77)); err == nil { - t.Fatal("alive new request restored") - } - if err := s.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - if err := s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 2}: true}}); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 14), 2, 77)); err == nil { - t.Fatal("wrong talent level revived") - } -} diff --git a/go/internal/server/player/inventory_slot_currency_test.go b/go/internal/server/player/inventory_slot_currency_test.go deleted file mode 100644 index 8e637e5..0000000 --- a/go/internal/server/player/inventory_slot_currency_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -func TestInventorySlotChargesDesignedCurrencyAndReplays(t *testing.T) { - for _, typ := range []uint64{2, 3, 4} { - t.Run(string(rune('0'+typ)), func(t *testing.T) { - store := stateio.NewMemory() - wallet, err := OpenWallet(store, Currency{Jewelry: 1000, FreeJewelry: 1000, Gold: 1000}) - if err != nil { - t.Fatal(err) - } - rule := gamedata.InventorySlotRule{Default: 10, Maximum: 20, PriceType: typ, BasePrice: 30, MaxPrice: 100} - s, err := OpenInventorySlots(store, &gamedata.InventorySlotDesign{Items: rule, Storage: rule, Equipment: rule, EquipmentStorage: rule}, InventorySlotCounts{10, 10, 10, 10}, wallet) - if err != nil { - t.Fatal(err) - } - s.BeginSession("test") - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 2) - if _, _, _, err = s.Handle("/InvenAddSlot", req); err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/InvenAddSlot", req); err != nil { - t.Fatal(err) - } - got := wallet.Snapshot() - want := Currency{Jewelry: 1000, FreeJewelry: 1000, Gold: 1000} - switch typ { - case 2: - want.Jewelry -= 90 - case 3: - want.FreeJewelry -= 90 - case 4: - want.Gold -= 90 - } - if got != want { - t.Fatalf("currency=%+v want=%+v", got, want) - } - if s.state.Items != 12 { - t.Fatal("replay expanded slots twice") - } - }) - } -} diff --git a/go/internal/server/player/inventory_slots_test.go b/go/internal/server/player/inventory_slots_test.go deleted file mode 100644 index baa3c9e..0000000 --- a/go/internal/server/player/inventory_slots_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package player - -import ( - "os" - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func inventorySlotTestDesign() *gamedata.InventorySlotDesign { - return &gamedata.InventorySlotDesign{ - Items: gamedata.InventorySlotRule{Default: 100, Maximum: 500, PriceType: 4, BasePrice: 300, MaxPrice: 10000}, - Storage: gamedata.InventorySlotRule{Default: 100, Maximum: 400, PriceType: 4, BasePrice: 300, MaxPrice: 10000}, - Equipment: gamedata.InventorySlotRule{Default: 500, Maximum: 2000, PriceType: 4, BasePrice: 300, MaxPrice: 10000}, - EquipmentStorage: gamedata.InventorySlotRule{Default: 100, Maximum: 400, PriceType: 4, BasePrice: 300, MaxPrice: 10000}, - } -} - -func inventorySlotTestCounts() InventorySlotCounts { - return InventorySlotCounts{Items: 100, Storage: 100, Equipment: 500, EquipmentStorage: 100} -} - -func TestInventorySlotExpansionChargesPersistsAndReplays(t *testing.T) { - store := stateio.NewMemory() - wallet, err := OpenWallet(store, Currency{Gold: 100000}) - if err != nil { - t.Fatal(err) - } - slots, err := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet) - if err != nil { - t.Fatal(err) - } - slots.BeginSession("test") - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 2) - code, response, ok, err := slots.Handle("/InvenAddSlot", request) - if err != nil || !ok || code != 24 || len(response) != 0 { - t.Fatalf("code=%d response=%x ok=%t err=%v", code, response, ok, err) - } - if wallet.Snapshot().Gold != 99100 { // first two slots cost 400 + 500 - t.Fatalf("gold=%d", wallet.Snapshot().Gold) - } - if _, _, _, err := slots.Handle("/InvenAddSlot", request); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 99100 { - t.Fatalf("replay charged twice: %d", wallet.Snapshot().Gold) - } - reopened, err := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet) - if err != nil { - t.Fatal(err) - } - counts, err := reopened.InventorySlotCounts() - if err != nil || counts.Items != 102 { - t.Fatalf("counts=%+v err=%v", counts, err) - } -} - -func TestInventorySlotEndpointsUseTheirProtocolCodes(t *testing.T) { - for path, code := range map[string]int{ - "/InvenAddSlot": 24, "/StorageAddSlot": 25, "/EquipAddSlot": 39, "/EquipStorageAddSlot": 82, - } { - t.Run(path, func(t *testing.T) { - store := stateio.NewMemory() - wallet, _ := OpenWallet(store, Currency{Gold: 1000}) - slots, _ := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet) - slots.BeginSession("test") - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1) - got, body, ok, err := slots.Handle(path, request) - if err != nil || !ok || got != code || len(body) != 0 || wallet.Snapshot().Gold != 600 { - t.Fatalf("code=%d body=%x ok=%t gold=%d err=%v", got, body, ok, wallet.Snapshot().Gold, err) - } - }) - } -} - -func TestUnlimitedInventoryUsesGameDataMaximumAtLoginOnly(t *testing.T) { - store := stateio.NewMemory() - wallet, _ := OpenWallet(store, Currency{}) - slots, _ := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet) - path := filepath.Join(t.TempDir(), "dev-tools.json") - slots.AttachDevelopmentSettings(path) - if err := os.WriteFile(path, []byte(`{"version":1,"inventory":{"unlimited":true}}`), 0o600); err != nil { - t.Fatal(err) - } - counts, err := slots.InventorySlotCounts() - if err != nil { - t.Fatal(err) - } - if counts.Items != 500 || counts.Equipment != 2000 || counts.Storage != 100 || counts.EquipmentStorage != 100 { - t.Fatalf("counts=%+v", counts) - } - if err := os.WriteFile(path, []byte(`{"version":1,"inventory":{"unlimited":false}}`), 0o600); err != nil { - t.Fatal(err) - } - counts, err = slots.InventorySlotCounts() - if err != nil || counts != inventorySlotTestCounts() { - t.Fatalf("restored counts=%+v err=%v", counts, err) - } -} - -func TestUnlimitedInventoryRejectsMalformedDevelopmentSettings(t *testing.T) { - store := stateio.NewMemory() - wallet, _ := OpenWallet(store, Currency{}) - slots, _ := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet) - path := filepath.Join(t.TempDir(), "dev-tools.json") - slots.AttachDevelopmentSettings(path) - if err := os.WriteFile(path, []byte(`{"version":1,"inventory":{"unlimited":true},"extra":1}`), 0o600); err != nil { - t.Fatal(err) - } - if _, err := slots.InventorySlotCounts(); err == nil { - t.Fatal("malformed settings accepted") - } - if err := os.WriteFile(path, []byte(`{"version":1}`), 0o600); err != nil { - t.Fatal(err) - } - if _, err := slots.InventorySlotCounts(); err == nil { - t.Fatal("settings without inventory accepted") - } -} - -func TestInventorySlotPriceCapsEachAddedSlot(t *testing.T) { - rule := gamedata.InventorySlotRule{Default: 100, Maximum: 500, PriceType: 4, BasePrice: 300, MaxPrice: 10000} - price, err := inventorySlotPrice(rule, 196, 2) - if err != nil || price != 20000 { - t.Fatalf("price=%d err=%v", price, err) - } -} diff --git a/go/internal/server/player/item_craft.go b/go/internal/server/player/item_craft.go deleted file mode 100644 index fdfa0f6..0000000 --- a/go/internal/server/player/item_craft.go +++ /dev/null @@ -1,212 +0,0 @@ -package player - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type itemCraftReceipt struct { - Digest string - Body []byte -} -type ItemCraftService struct { - mu sync.Mutex - design *gamedata.ItemCraftDesign - talents *gamedata.TalentUseDesign - store stateio.Store - items *Inventory - characters *CharacterStore - wallet *Wallet - known func(uint64) bool - context func() (int, bool, error) - session string -} - -func NewItemCraftService(d *gamedata.ItemCraftDesign, talents *gamedata.TalentUseDesign, store stateio.Store, items *Inventory, characters *CharacterStore, wallet *Wallet, known func(uint64) bool) (*ItemCraftService, error) { - if d == nil || talents == nil || store == nil || items == nil || characters == nil || wallet == nil || known == nil { - return nil, fmt.Errorf("craft: missing dependencies") - } - return &ItemCraftService{design: d, talents: talents, store: store, items: items, characters: characters, wallet: wallet, known: known}, nil -} -func (s *ItemCraftService) AttachContext(context func() (int, bool, error)) { s.context = context } -func (s *ItemCraftService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } -func (s *ItemCraftService) Handle(path string, request []byte) (int, []byte, bool, error) { - var recipes map[uint64]gamedata.ItemCraftRecipe - var code int - switch path { - case "/Cooking": - recipes = s.design.Cooking - code = 48 - case "/Alchemy": - recipes = s.design.Alchemy - code = 49 - case "/AlchemyBatch": - recipes = s.design.Alchemy - code = 262 - default: - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - fail := func(err error) (int, []byte, bool, error) { return code, nil, true, err } - if s.session == "" || s.context == nil { - return fail(fmt.Errorf("craft: session/context unavailable")) - } - values := map[int]uint64{} - err := wire.Walk(request, func(f wire.Field) error { - if f.Number >= 1 && f.Number <= 4 { - if f.Type != 0 { - return fmt.Errorf("craft: invalid scalar") - } - if _, exists := values[f.Number]; exists { - return fmt.Errorf("craft: duplicate scalar") - } - v, _, err := wire.Varint(request, f.Number) - if err != nil { - return err - } - values[f.Number] = v - } - return nil - }) - if err != nil { - return fail(err) - } - seq, index, recipeID, count := values[1], values[2], values[3], values[4] - if seq == 0 || seq > math.MaxInt32 || index == 0 || index > math.MaxInt64 || recipeID == 0 || recipeID > math.MaxInt32 || count == 0 || count > math.MaxInt32 { - return fail(fmt.Errorf("craft: invalid request")) - } - keyHash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", s.session, path, seq))) - key := hex.EncodeToString(keyHash[:]) - digestHash := sha256.Sum256(request) - digest := hex.EncodeToString(digestHash[:]) - var receipts map[string]itemCraftReceipt - raw, err := s.store.Load("itemcraft") - if err != nil { - return fail(err) - } - if raw != nil && json.Unmarshal(raw, &receipts) != nil { - return fail(fmt.Errorf("craft: invalid receipt state")) - } - if receipts == nil { - receipts = map[string]itemCraftReceipt{} - } - if r, exists := receipts[key]; exists { - if r.Digest != digest { - return fail(fmt.Errorf("craft: changed request replay")) - } - return code, r.Body, true, nil - } - pack, battle, err := s.context() - if err != nil { - return fail(err) - } - if battle || pack <= 0 { - return fail(fmt.Errorf("craft: unavailable in current scene")) - } - recipe, exists := recipes[recipeID] - if !exists { - return fail(fmt.Errorf("craft: unknown recipe")) - } - if recipe.Class == 7 && !s.known(recipeID) { - return fail(fmt.Errorf("craft: recipe not learned")) - } - character, owned := s.characters.Find(index) - if !owned || IsStoryCharacter(character) { - return fail(fmt.Errorf("craft: unavailable producer")) - } - talent, exists := s.talents.Characters[character.ID] - if !exists || talent.BannedPacks[pack] { - return fail(fmt.Errorf("craft: producer talent blocked in pack")) - } - current := s.talents.Rules[[2]uint64{talent.Group, character.TalentLevel}] - limitIndex := 0 - if recipe.Class == 7 { - limitIndex = 1 - } - // Batch count is the missing intermediate quantity requested by equipment - // making (EquipmentMakingUI.OnClickUI), not the AlchemyUI craft slider. - // Its full material graph and int32 quantities are validated below. - if path != "/AlchemyBatch" && (len(current.Values) <= limitIndex || current.Values[limitIndex] < 1 || math.IsNaN(current.Values[limitIndex]) || math.IsInf(current.Values[limitIndex], 0) || count > uint64(current.Values[limitIndex])) { - return fail(fmt.Errorf("craft: count exceeds talent limit")) - } - gain, catalyst, maximum, err := s.talents.CraftTalent(character.ID, character.TalentLevel, recipe.Class, recipe.TalentLevel, count, character.TalentExp) - if err != nil { - return fail(err) - } - materials, err := equipmentRequestItems(request, 5, path) - if err != nil { - return fail(err) - } - if path == "/AlchemyBatch" { - gain, catalyst, maximum, err = s.prepareAlchemyBatch(character, recipe, count, materials) - } else { - err = validateMakingMaterials(recipe.Costs, count, materials) - // Conversion recipes charge per produced resource, as AlchemyUI.GetNeededCurrency does. - if err == nil && recipe.Class == 8 && recipe.Category == 2 { - if recipe.Result.Count == 0 || catalyst > math.MaxInt32/recipe.Result.Count { - err = fmt.Errorf("craft: catalyst overflow") - } else { - catalyst *= recipe.Result.Count - } - } - } - if err != nil { - return fail(err) - } - if err = s.items.CanConsume(materials); err != nil { - return fail(err) - } - if catalyst > 0 && !s.wallet.CanSpendCatalyst(catalyst) { - return fail(fmt.Errorf("craft: insufficient catalyst")) - } - if recipe.Result.Count > math.MaxInt32/count { - return fail(fmt.Errorf("craft: result quantity overflow")) - } - result := recipe.Result - result.Count *= count - if path == "/AlchemyBatch" { - result.Count = count - } - if err = s.items.Consume(materials); err != nil { - return fail(err) - } - if catalyst > 0 { - if _, err = s.wallet.SpendCatalystOnce("itemcraft:"+key, catalyst); err != nil { - return fail(err) - } - } - granted, err := s.items.GrantOnce("itemcraft:"+key, []gamedata.BattleReward{result}) - if err != nil { - return fail(err) - } - if gain > 0 { - if _, err = s.characters.AddTalentExperience(index, gain, maximum); err != nil { - return fail(err) - } - } - var response []byte - for _, item := range granted { - response = wire.AppendBytes(response, 1, ItemWire(item)) - } - if gain > 0 { - response = wire.AppendVarint(response, 2, gain) - } - receipts[key] = itemCraftReceipt{Digest: digest, Body: response} - raw, err = json.Marshal(receipts) - if err != nil { - return fail(err) - } - if err = s.store.Save("itemcraft", raw); err != nil { - return fail(err) - } - return code, response, true, nil -} diff --git a/go/internal/server/player/item_craft_test.go b/go/internal/server/player/item_craft_test.go deleted file mode 100644 index c8f5841..0000000 --- a/go/internal/server/player/item_craft_test.go +++ /dev/null @@ -1,326 +0,0 @@ -package player - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "errors" - "os" - "path/filepath" - "slices" - "testing" -) - -func craftFixture(t *testing.T, store stateio.Store, class uint64) (*ItemCraftService, *Inventory, *CharacterStore, *Wallet) { - t.Helper() - items, e := OpenInventory(store, &Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - _, e = items.GrantOnce("materials", []gamedata.BattleReward{{Type: 8, ID: 1, Count: 200}, {Type: 5, ID: 1, Count: 20}, {Type: 8, ID: 2, Count: 2}}) - if e != nil { - t.Fatal(e) - } - chars, e := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, TalentLevel: 2}}, items, "", "") - if e != nil { - t.Fatal(e) - } - if e = chars.EnsurePersisted(); e != nil { - t.Fatal(e) - } - wallet, e := OpenWallet(store, Currency{Catalyst: 100}) - if e != nil { - t.Fatal(e) - } - if e = wallet.EnsurePersisted(); e != nil { - t.Fatal(e) - } - talents := &gamedata.TalentUseDesign{Characters: map[uint64]gamedata.TalentUseCharacter{350: {Group: 42, MaxLevel: 2}}, Rules: map[[2]uint64]gamedata.TalentUseRule{{42, 1}: {Class: class, Catalyst: 2, Experience: 3, Values: []float64{100, 100}}, {42, 2}: {Class: class, Catalyst: 4, Experience: 6, Values: []float64{100, 100}}}, Growth: &gamedata.TalentGrowthDesign{Characters: map[uint64]gamedata.CharacterTalent{350: {GrowthGroup: 6, MaxLevel: 2}}, Levels: map[[2]uint64]gamedata.TalentGrowthLevel{{6, 1}: {NeedExp: 10}, {6, 2}: {NeedExp: 10}}}} - d := &gamedata.ItemCraftDesign{Cooking: map[uint64]gamedata.ItemCraftRecipe{101: {ID: 101, Class: 7, TalentLevel: 1, Result: gamedata.BattleReward{Type: 5, ID: 2, Count: 1}, Costs: []gamedata.PromotionCost{{Type: 5, ID: 1, Count: 2}}}}, Alchemy: map[uint64]gamedata.ItemCraftRecipe{101: {ID: 101, Class: 8, Category: 1, TalentLevel: 1, Result: gamedata.BattleReward{Type: 8, ID: 2, Count: 1}, Costs: []gamedata.PromotionCost{{Type: 8, ID: 1, Count: 5}}}, 102: {ID: 102, Class: 8, Category: 1, TalentLevel: 2, Result: gamedata.BattleReward{Type: 8, ID: 3, Count: 1}, Costs: []gamedata.PromotionCost{{Type: 8, ID: 2, Count: 3}}}, 103: {ID: 103, Class: 8, Category: 2, TalentLevel: 1, Result: gamedata.BattleReward{Type: 8, ID: 4, Count: 10}, Costs: []gamedata.PromotionCost{{Type: 8, ID: 1, Count: 10}}}}} - s, e := NewItemCraftService(d, talents, store, items, chars, wallet, func(id uint64) bool { return id == 101 }) - if e != nil { - t.Fatal(e) - } - s.BeginSession("craft") - s.AttachContext(func() (int, bool, error) { return 21, false, nil }) - return s, items, chars, wallet -} - -func TestItemCraftAgainstInstalledGameData(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for current GameData integration") - } - d, e := gamedata.LoadItemCraftDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - talents, e := gamedata.LoadTalentUseDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - for _, class := range []uint64{7, 8} { - t.Run(map[uint64]string{7: "Cooking", 8: "Alchemy"}[class], func(t *testing.T) { - var producers []uint64 - for id, c := range talents.Characters { - r, ok := talents.Rules[[2]uint64{c.Group, 1}] - if ok && r.Class == class { - producers = append(producers, id) - } - } - slices.Sort(producers) - if len(producers) == 0 { - t.Fatal("missing actual producers") - } - for _, id := range producers { - store := stateio.NewMemory() - items, e := OpenInventory(store, &Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - chars, e := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: id, Level: 1, TalentLevel: 1}}, items, root, "20260923193640") - if e != nil { - t.Fatal(e) - } - wallet, e := OpenWallet(store, Currency{Catalyst: 1000}) - if e != nil { - t.Fatal(e) - } - recipes := d.Cooking - path := "/Cooking" - if class == 8 { - recipes = d.Alchemy - path = "/Alchemy" - } - var ids []uint64 - for rid, r := range recipes { - if r.TalentLevel == 1 { - ids = append(ids, rid) - } - } - slices.Sort(ids) - if len(ids) == 0 { - t.Fatal("missing level1 recipe") - } - r := recipes[ids[0]] - var materials []gamedata.BattleReward - for _, c := range r.Costs { - materials = append(materials, gamedata.BattleReward{Type: c.Type, ID: c.ID, Count: c.Count}) //nolint:staticcheck // S1016 - } - if _, e = items.GrantOnce("materials", materials); e != nil { - t.Fatal(e) - } - s, e := NewItemCraftService(d, talents, store, items, chars, wallet, func(uint64) bool { return true }) - if e != nil { - t.Fatal(e) - } - s.BeginSession("real") - s.AttachContext(func() (int, bool, error) { return 1, false, nil }) - _, _, _, e = s.Handle(path, craftRequest(items, r.ID, 1, materials...)) - if e != nil { - t.Fatalf("actual producer %d recipe %d: %v", id, r.ID, e) - } - if craftCount(items, r.Result.Type, r.Result.ID) != r.Result.Count { - t.Fatal("actual result missing") - } - } - }) - } -} -func craftRequest(items *Inventory, recipe, count uint64, costs ...gamedata.BattleReward) []byte { - b := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, recipe), 4, count) - for _, cost := range costs { - for _, item := range items.All() { - if item.Type == cost.Type && item.ID == cost.ID { - item.Count = cost.Count - b = wire.AppendBytes(b, 5, ItemWire(item)) - break - } - } - } - return b -} -func craftCount(items *Inventory, kind, id uint64) uint64 { - var n uint64 - for _, v := range items.All() { - if v.Type == kind && v.ID == id { - n += v.Count - } - } - return n -} - -func TestItemCraftChargesRecipeLevelClampsExperienceAndReplaysAfterRestart(t *testing.T) { - for _, class := range []uint64{7, 8} { - t.Run(map[uint64]string{7: "Cooking", 8: "Alchemy"}[class], func(t *testing.T) { - store := stateio.NewMemory() - s, items, chars, wallet := craftFixture(t, store, class) - kind := uint64(8) - path := "/Alchemy" - if class == 7 { - kind = 5 - path = "/Cooking" - } - input := uint64(10) - if class == 7 { - input = 4 - } - b := craftRequest(items, 101, 2, gamedata.BattleReward{Type: kind, ID: 1, Count: input}) - _, out, _, e := s.Handle(path, b) - if e != nil { - t.Fatal(e) - } - c, _ := chars.Find(77) - if wallet.Snapshot().Catalyst != 96 || c.TalentExp != 12 || craftCount(items, kind, 2) != map[uint64]uint64{7: 2, 8: 4}[class] { - t.Fatalf("bad craft settlement: wallet=%+v character=%+v items=%+v", wallet.Snapshot(), c, items.All()) - } - next, _, _, againWallet := craftFixture(t, store, class) - _, again, _, e := next.Handle(path, b) - if e != nil || !bytes.Equal(out, again) || againWallet.Snapshot().Catalyst != 96 { - t.Fatalf("craft replay changed: %v", e) - } - if _, _, _, e = next.Handle(path, wire.AppendVarint(b, 4, 3)); e == nil { - t.Fatal("changed/duplicate scalar accepted") - } - }) - } -} -func TestAlchemyBatchUsesIntermediateInventoryAndValidatesWholeGraph(t *testing.T) { - s, items, chars, wallet := craftFixture(t, stateio.NewMemory(), 8) - b := craftRequest(items, 102, 2, gamedata.BattleReward{Type: 8, ID: 2, Count: 2}, gamedata.BattleReward{Type: 8, ID: 1, Count: 20}) - _, out, _, e := s.Handle("/AlchemyBatch", b) - if e != nil { - t.Fatal(e) - } - c, _ := chars.Find(77) - if craftCount(items, 8, 3) != 2 || craftCount(items, 8, 2) != 0 || craftCount(items, 8, 1) != 180 || wallet.Snapshot().Catalyst != 84 || c.TalentExp != 20 { - t.Fatalf("batch wrong settlement: %+v %+v %+v", items.All(), wallet.Snapshot(), c) - } - _, again, _, e := s.Handle("/AlchemyBatch", b) - if e != nil || !bytes.Equal(out, again) || craftCount(items, 8, 3) != 2 { - t.Fatal("batch replay changed") - } - for _, path := range []string{"/Alchemy", "/AlchemyBatch"} { - s, items, _, wallet = craftFixture(t, stateio.NewMemory(), 8) - bad := craftRequest(items, 102, 2, gamedata.BattleReward{Type: 8, ID: 1, Count: 1}) - if _, _, _, e = s.Handle(path, bad); e == nil || wallet.Snapshot().Catalyst != 100 || craftCount(items, 8, 1) != 200 { - t.Fatalf("underpaid craft accepted by %s", path) - } - } -} -func TestAlchemyConversionChargesProducedQuantity(t *testing.T) { - s, items, _, wallet := craftFixture(t, stateio.NewMemory(), 8) - b := craftRequest(items, 103, 2, gamedata.BattleReward{Type: 8, ID: 1, Count: 20}) - if _, _, _, e := s.Handle("/Alchemy", b); e != nil { - t.Fatal(e) - } - if craftCount(items, 8, 4) != 20 || wallet.Snapshot().Catalyst != 60 { - t.Fatal("conversion output/catalyst mismatch") - } -} - -// Equipment making requests missing intermediate resources in one batch; the -// amount can exceed the ordinary alchemy slider limit. Check settlement and -// replay using the same SQLite transaction boundary as the request dispatcher. -func TestAlchemyBatchSQLiteCanExceedOrdinaryCraftLimit(t *testing.T) { - repo, err := accountstate.Open(filepath.Join(t.TempDir(), "state.db")) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - s, items, chars, wallet := craftFixture(t, repo, 8) - rule := s.talents.Rules[[2]uint64{42, 2}] - rule.Values[0] = 1 - s.talents.Rules[[2]uint64{42, 2}] = rule - request := craftRequest(items, 102, 2, gamedata.BattleReward{Type: 8, ID: 2, Count: 2}, gamedata.BattleReward{Type: 8, ID: 1, Count: 20}) - if _, _, _, err = s.Handle("/Alchemy", request); err == nil { - t.Fatal("ordinary alchemy exceeded its slider limit") - } - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, response, _, err := s.Handle("/AlchemyBatch", request) - if err != nil { - _ = op.Rollback() - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - c, _ := chars.Find(77) - if craftCount(items, 8, 3) != 2 || wallet.Snapshot().Catalyst != 84 || c.TalentExp != 20 { - t.Fatalf("batch settlement items=%+v currency=%+v producer=%+v", items.All(), wallet.Snapshot(), c) - } - next, reloadedItems, reloadedChars, reloadedWallet := craftFixture(t, repo, 8) - _, replay, _, err := next.Handle("/AlchemyBatch", request) - if err != nil || !bytes.Equal(response, replay) { - t.Fatalf("persisted replay response=%x err=%v", replay, err) - } - c, _ = reloadedChars.Find(77) - if craftCount(reloadedItems, 8, 3) != 2 || reloadedWallet.Snapshot().Catalyst != 84 || c.TalentExp != 20 { - t.Fatal("persisted replay changed settlement") - } -} - -type failCraftReceipt struct{ stateio.Store } - -func (s failCraftReceipt) Save(name string, b []byte) error { - if name == "itemcraft" { - return errors.New("receipt unavailable") - } - return s.Store.Save(name, b) -} -func TestItemCraftFinalReceiptFailureRollsBackEveryDomain(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, e := accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - s, items, _, _ := craftFixture(t, repo, 8) - s.store = failCraftReceipt{repo} - b := craftRequest(items, 101, 2, gamedata.BattleReward{Type: 8, ID: 1, Count: 10}) - op, e := repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - if _, _, _, e = s.Handle("/Alchemy", b); e == nil { - t.Fatal("failed receipt accepted") - } - if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) { - t.Fatal(e) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, e = accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - next, items, chars, wallet := craftFixture(t, repo, 8) - c, _ := chars.Find(77) - if c.TalentExp != 0 || wallet.Snapshot().Catalyst != 100 || craftCount(items, 8, 1) != 200 || craftCount(items, 8, 2) != 2 { - t.Fatal("partial craft survived rollback") - } - op, e = repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - if _, _, _, e = next.Handle("/Alchemy", b); e != nil { - t.Fatal(e) - } - if e = op.Commit(); e != nil { - t.Fatal(e) - } -} diff --git a/go/internal/server/player/normalized_entries_test.go b/go/internal/server/player/normalized_entries_test.go deleted file mode 100644 index 87a99d9..0000000 --- a/go/internal/server/player/normalized_entries_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package player - -import ( - "bytes" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" -) - -func TestWalletLedgerUsesEntries(t *testing.T) { - store := stateio.NewMemory() - wallet, err := OpenWallet(store, Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - if _, err := wallet.SpendGoldOnce("upgrade:1", 10); err != nil { - t.Fatal(err) - } - if _, err := wallet.GrantQuestOnce("quest:1", []gamedata.Reward{{Type: 4, Count: 20}}); err != nil { - t.Fatal(err) - } - core, err := store.Load("wallet") - if err != nil || bytes.Contains(core, []byte(`"granted"`)) || bytes.Contains(core, []byte(`"spent"`)) { - t.Fatalf("wallet core contains ledger: %s, %v", core, err) - } - for _, bucket := range []string{"granted", "spent"} { - entries, err := store.ListEntries("wallet", bucket) - if err != nil || len(entries) != 1 { - t.Fatalf("%s entries=%v: %v", bucket, entries, err) - } - } - reopened, err := OpenWallet(store, Currency{}) - if err != nil { - t.Fatal(err) - } - if !reopened.WasSpent("upgrade:1") || !reopened.WasGranted("quest:1") || reopened.Snapshot().Gold != 110 { - t.Fatalf("reopened wallet=%+v", reopened.Snapshot()) - } -} - -func TestInventoryEntitiesAndGrantLedgerUseEntries(t *testing.T) { - store := stateio.NewMemory() - starter := &Starter{Version: "2.35.10"} - inventory, err := OpenInventory(store, starter) - if err != nil { - t.Fatal(err) - } - items, err := inventory.GrantOnce("battle:1", []gamedata.BattleReward{{Type: 8, ID: 21, Count: 3}}) - if err != nil || len(items) != 1 { - t.Fatalf("grant=%v: %v", items, err) - } - core, err := store.Load("items") - if err != nil || bytes.Contains(core, []byte(`"items"`)) || bytes.Contains(core, []byte(`"granted"`)) || bytes.Contains(core, []byte(`"grant_items"`)) { - t.Fatalf("items core contains entries: %s, %v", core, err) - } - for _, bucket := range []string{"items", "granted", "grant_items"} { - entries, err := store.ListEntries("items", bucket) - if err != nil || len(entries) != 1 { - t.Fatalf("%s entries=%v: %v", bucket, entries, err) - } - } - reopened, err := OpenInventory(store, starter) - if err != nil { - t.Fatal(err) - } - if got := reopened.GrantedItems("battle:1"); len(got) != 1 || got[0].InvenIndex != items[0].InvenIndex { - t.Fatalf("reopened granted items=%+v", got) - } -} - -func TestEquipmentEntitiesAndGrantLedgerUseEntries(t *testing.T) { - store := stateio.NewMemory() - equipment, err := OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - first, err := equipment.GrantOnce("quest:1", 943035) - if err != nil { - t.Fatal(err) - } - core, err := store.Load("equipment") - if err != nil || bytes.Contains(core, []byte(`"equipment"`)) || bytes.Contains(core, []byte(`"granted"`)) { - t.Fatalf("equipment core contains entries: %s, %v", core, err) - } - for _, bucket := range []string{"equipment", "granted"} { - entries, err := store.ListEntries("equipment", bucket) - if err != nil || len(entries) != 1 { - t.Fatalf("%s entries=%v: %v", bucket, entries, err) - } - } - reopened, err := OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - if got, ok := reopened.Granted("quest:1"); !ok || got.InvenIndex != first.InvenIndex { - t.Fatalf("reopened grant=%+v, %t", got, ok) - } -} - -func TestEmptyInventoryAndEquipmentEnsureCore(t *testing.T) { - store := stateio.NewMemory() - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if err := inventory.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if core, err := store.Load("items"); err != nil || core == nil { - t.Fatalf("items core=%q: %v", core, err) - } - equipment, err := OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - if err := equipment.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if core, err := store.Load("equipment"); err != nil || core == nil { - t.Fatalf("equipment core=%q: %v", core, err) - } -} - -func TestInlineLedgersAndEntitiesRejected(t *testing.T) { - tests := []struct { - name string - core []byte - open func(*stateio.Memory) error - }{ - {"wallet", []byte(`{"version":"2.35.10","equip_mileage":0,"equip_mileage_exchange_gage":0,"granted":{}}`), func(s *stateio.Memory) error { _, err := OpenWallet(s, Currency{}); return err }}, - {"items", []byte(`{"version":"2.35.10","next_index":900000001,"items":[]}`), func(s *stateio.Memory) error { _, err := OpenInventory(s, &Starter{Version: "2.35.10"}); return err }}, - {"equipment", []byte(`{"version":"2.35.10","next_index":910000001,"equipment":[]}`), func(s *stateio.Memory) error { _, err := OpenEquipmentInventory(s); return err }}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store := stateio.NewMemory() - if err := store.Save(tt.name, tt.core); err != nil { - t.Fatal(err) - } - if err := tt.open(store); err == nil { - t.Fatal("accepted inline old-format state") - } - }) - } -} diff --git a/go/internal/server/player/owned_test.go b/go/internal/server/player/owned_test.go deleted file mode 100644 index 5cf63cf..0000000 --- a/go/internal/server/player/owned_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package player - -import ( - "os" - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func TestBattleRewardPersistsWithoutDuplicate(t *testing.T) { - path := filepath.Join(t.TempDir(), "owned.json") - starter := &Starter{Version: "2.35.10"} - inv, err := OpenInventory(testStore(path), starter) - if err != nil { - t.Fatal(err) - } - reward := []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}} - items, err := inv.GrantOnce("pack21:monster1", reward) - if err != nil || len(items) != 1 || items[0].Count != 3 { - t.Fatalf("items=%+v err=%v", items, err) - } - inv, err = OpenInventory(testStore(path), starter) - if err != nil { - t.Fatal(err) - } - items, err = inv.GrantOnce("pack21:monster1", reward) - if err != nil || len(items) != 0 { - t.Fatalf("duplicate grant=%+v err=%v", items, err) - } - code, response, ok, err := inv.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 77)) - if err != nil || !ok || code != 21 { - t.Fatalf("response=%d ok=%v err=%v", code, ok, err) - } - item, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatal("reward missing from inventory") - } - got, _, _ := wire.Varint(item, 2) - if got != 8 { - t.Fatalf("item id=%d", got) - } -} - -func TestSkinGrantSkipsOwnedAndBatchDuplicatesAcrossRestart(t *testing.T) { - path := filepath.Join(t.TempDir(), "owned.json") - starter := &Starter{Version: "2.35.10", Items: []Item{{InvenIndex: 1, ID: 4001, Type: 45, Count: 1}}} - inv, err := OpenInventory(testStore(path), starter) - if err != nil { - t.Fatal(err) - } - if items, err := inv.GrantOnce("first", []gamedata.BattleReward{{Type: 45, ID: 4091, Count: 1}}); err != nil || len(items) != 1 { - t.Fatalf("first grant=%+v err=%v", items, err) - } - rewards := []gamedata.BattleReward{{Type: 45, ID: 4001, Count: 1}, {Type: 45, ID: 4091, Count: 1}, {Type: 45, ID: 4092, Count: 1}, {Type: 45, ID: 4092, Count: 1}, {Type: 8, ID: 8, Count: 3}} - items, err := inv.GrantOnce("mixed", rewards) - if err != nil || len(items) != 2 || items[0].ID != 4092 || items[1].Type != 8 { - t.Fatalf("mixed grant=%+v err=%v", items, err) - } - inv, err = OpenInventory(testStore(path), starter) - if err != nil { - t.Fatal(err) - } - if items, err := inv.GrantOnce("mixed", rewards); err != nil || len(items) != 0 { - t.Fatalf("replay grant=%+v err=%v", items, err) - } - if prior := inv.GrantedItems("mixed"); len(prior) != 2 || prior[0].ID != 4092 { - t.Fatalf("replay items=%+v", prior) - } - if items, err := inv.GrantOnce("owned-only", rewards[:2]); err != nil || len(items) != 0 { - t.Fatalf("owned-only grant=%+v err=%v", items, err) - } - inv, err = OpenInventory(testStore(path), starter) - if err != nil { - t.Fatal(err) - } - if !inv.WasGranted("owned-only") || len(inv.GrantedItems("owned-only")) != 0 || len(inv.owned.Items) != 3 { - t.Fatalf("owned-only identity or inventory changed: %+v", inv.owned) - } -} - -func TestUseRandomBoxPersistsExactStackAndRewardFromInstalledGameData(t *testing.T) { - root := os.Getenv("BD2_TEST_GAMEDATA_ROOT") - if root == "" { - t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test") - } - design, err := gamedata.LoadRandomBoxDesign(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "items.json") - inv, err := OpenInventory(testStore(path), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if err := inv.AttachRandomBoxes(design); err != nil { - t.Fatal(err) - } - boxes, err := inv.GrantOnce("mail:box", []gamedata.BattleReward{{Type: 9, ID: 433302, Count: 100000}}) - if err != nil || len(boxes) != 1 { - t.Fatalf("grant box = %+v, %v", boxes, err) - } - request := wire.AppendVarint(nil, 1, 73) - request = wire.AppendVarint(request, 2, boxes[0].InvenIndex) - request = wire.AppendVarint(request, 3, 100000) - code, response, handled, err := inv.Handle("/UseRandomBox", request) - if err != nil || !handled || code != 143 { - t.Fatalf("UseRandomBox code=%d handled=%t err=%v", code, handled, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("UseRandomBox reward bundle missing: found=%t err=%v", found, err) - } - item, found, err := wire.Bytes(bundle, 1) - if err != nil || !found { - t.Fatalf("UseRandomBox ItemDBInfo missing: found=%t err=%v", found, err) - } - id, _, _ := wire.Varint(item, 2) - typ, _, _ := wire.Varint(item, 3) - count, _, _ := wire.Varint(item, 4) - if id != 704 || typ != 8 || count != 100000 { - t.Fatalf("UseRandomBox response item id=%d type=%d count=%d", id, typ, count) - } - all := inv.All() - if len(all) != 1 || all[0].ID != 704 || all[0].Type != 8 || all[0].Count != 100000 { - t.Fatalf("UseRandomBox persisted inventory=%+v", all) - } - restored, err := OpenInventory(testStore(path), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - all = restored.All() - if len(all) != 1 || all[0].ID != 704 || all[0].Count != 100000 { - t.Fatalf("UseRandomBox restored inventory=%+v", all) - } -} diff --git a/go/internal/server/player/preset_runtime.go b/go/internal/server/player/preset_runtime.go deleted file mode 100644 index 014854a..0000000 --- a/go/internal/server/player/preset_runtime.go +++ /dev/null @@ -1,243 +0,0 @@ -package player - -import ( - "errors" - "fmt" - "slices" - "sort" - - "bd2server/internal/server/wire" -) - -// PresetEquipmentBinding is one character's complete five-slot equipment -// target used by the ordinary party preset service. -type PresetEquipmentBinding struct { - CharacterIndex uint64 - Equipment []uint64 -} - -// ValidatePresetEquipment checks the complete fixed-width target without -// mutating ownership. It lets a cross-domain PresetUse fail before any -// character or deck state has been changed. -func (s *EquipmentInventory) ValidatePresetEquipment(bindings []PresetEquipmentBinding) error { - if len(bindings) == 0 { - return errors.New("player: empty preset equipment bindings") - } - seenCharacters := make(map[uint64]bool, len(bindings)) - for _, binding := range bindings { - if binding.CharacterIndex == 0 || seenCharacters[binding.CharacterIndex] || len(binding.Equipment) != equipmentSlotCount { - return errors.New("player: invalid preset equipment binding") - } - seenCharacters[binding.CharacterIndex] = true - if s.characters == nil { - return errors.New("player: preset character store unavailable") - } - if _, found := s.characters.Find(binding.CharacterIndex); !found { - return fmt.Errorf("player: preset references unknown character %d", binding.CharacterIndex) - } - } - // CharacterStore.Find may calculate maximum HP, and the production stat - // calculator reads equipped items. It must run before taking this mutex or - // preset validation deadlocks by trying to re-enter EquipmentInventory. - s.mu.Lock() - defer s.mu.Unlock() - if len(s.slots) == 0 { - return errors.New("player: preset equipment slot design unavailable") - } - seenEquipment := make(map[uint64]bool) - for _, binding := range bindings { - for slot, index := range binding.Equipment { - if index == 0 { - continue - } - if seenEquipment[index] { - return fmt.Errorf("player: preset repeats equipment %d", index) - } - seenEquipment[index] = true - position := s.equipmentPositionLocked(index) - if position < 0 { - return fmt.Errorf("player: preset references unknown equipment %d", index) - } - item := s.owned.Equipment[position] - if designedSlot, found := s.slots[item.ID]; !found || designedSlot != uint64(slot) { - return fmt.Errorf("player: preset equipment %d does not belong in slot %d", index, slot) - } - } - } - return nil -} - -// ApplyPresetEquipment applies the same authoritative state transition as -// /EquipBatchUse without fabricating a second wire protocol implementation. -func (s *EquipmentInventory) ApplyPresetEquipment(bindings []PresetEquipmentBinding) ([]Character, error) { - if err := s.ValidatePresetEquipment(bindings); err != nil { - return nil, err - } - // PresetUse is a server-side atomic operation. Unlike the client-orchestrated - // EquipPreset UI, it must also clear any previous owner of an equipment - // instance in the same batch. - s.mu.Lock() - requested := make(map[uint64]bool, len(bindings)) - desired := make(map[uint64]bool) - for _, binding := range bindings { - requested[binding.CharacterIndex] = true - for _, index := range binding.Equipment { - desired[index] = index != 0 - } - } - additional := make(map[uint64][]uint64) - for _, item := range s.owned.Equipment { - if item.UseChar == 0 || requested[item.UseChar] || !desired[item.InvenIndex] { - continue - } - if additional[item.UseChar] == nil { - additional[item.UseChar] = make([]uint64, equipmentSlotCount) - for _, equipped := range s.owned.Equipment { - if equipped.UseChar == item.UseChar { - if slot, ok := s.slots[equipped.ID]; ok && slot < equipmentSlotCount { - additional[item.UseChar][slot] = equipped.InvenIndex - } - } - } - } - if slot, ok := s.slots[item.ID]; ok && slot < equipmentSlotCount { - additional[item.UseChar][slot] = 0 - } - } - s.mu.Unlock() - for character, equipment := range additional { - bindings = append(bindings, PresetEquipmentBinding{CharacterIndex: character, Equipment: equipment}) - } - sort.Slice(bindings, func(i, j int) bool { return bindings[i].CharacterIndex < bindings[j].CharacterIndex }) - request := wire.AppendVarint(nil, 1, 1) - for _, binding := range bindings { - entry := wire.AppendVarint(nil, 1, binding.CharacterIndex) - for _, index := range binding.Equipment { - entry = wire.AppendVarint(entry, 2, index) - } - request = wire.AppendBytes(request, 2, entry) - } - _, response, handled, err := s.batchUse(request) - if err != nil { - return nil, err - } - if !handled { - return nil, errors.New("player: preset equipment batch was not handled") - } - var result []Character - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number != 1 || field.Type != 2 { - return nil - } - index, found, err := wire.Varint(field.Value, 1) - if err != nil || !found || index == 0 { - return errors.New("player: malformed preset equipment character response") - } - character, found := s.characters.Find(index) - if !found { - return fmt.Errorf("player: preset equipment character %d disappeared", index) - } - result = append(result, character) - return nil - }); err != nil { - return nil, err - } - return result, nil -} - -// ApplyPresetCostumes changes the currently selected costume for each listed -// character and persists both seeded and collection-owned character records. -func (s *CharacterStore) ApplyPresetCostumes(assignments map[uint64]uint64) ([]Character, error) { - if len(assignments) == 0 { - return nil, errors.New("player: empty preset costume assignments") - } - s.mu.Lock() - base := append([]Character(nil), s.characters...) - collection := s.collection - s.mu.Unlock() - if collection == nil { - return nil, errors.New("player: preset costumes require collection store") - } - collectionCharacters := collection.Characters() - seenCostumes := make(map[uint64]bool) - updated := make(map[uint64]Character, len(assignments)) - for characterIndex, costumeIndex := range assignments { - var current Character - found := false - for _, character := range base { - if character.InvenIndex == characterIndex { - current, found = character, true - break - } - } - if !found { - for _, character := range collectionCharacters { - if character.InvenIndex == characterIndex { - current, found = character, true - break - } - } - } - if !found { - return nil, fmt.Errorf("player: preset references unknown character %d", characterIndex) - } - if costumeIndex == 0 { - current.CostumeID = 0 - current.UseCostume = 0 - } else { - if seenCostumes[costumeIndex] { - return nil, fmt.Errorf("player: preset repeats costume %d", costumeIndex) - } - costume, found := collection.CostumeByIndex(costumeIndex) - if !found { - return nil, fmt.Errorf("player: preset references unknown costume %d", costumeIndex) - } - seenCostumes[costumeIndex] = true - current.CostumeID = costume.ID - current.UseCostume = costumeIndex - } - updated[characterIndex] = current - } - - nextBase := append([]Character(nil), base...) - baseChanged := false - for i := range nextBase { - if character, ok := updated[nextBase[i].InvenIndex]; ok { - nextBase[i] = character - delete(updated, character.InvenIndex) - baseChanged = true - } - } - if baseChanged { - s.mu.Lock() - if err := s.persist(nextBase); err != nil { - s.mu.Unlock() - return nil, err - } - s.characters = nextBase - s.mu.Unlock() - } - for index, character := range updated { - old, found := collection.FindCharacter(index) - if !found { - return nil, fmt.Errorf("player: preset collection character %d disappeared", index) - } - if err := collection.UpdateCharacter(old.ID, character); err != nil { - return nil, err - } - } - indices := make([]uint64, 0, len(assignments)) - for index := range assignments { - indices = append(indices, index) - } - slices.Sort(indices) - result := make([]Character, 0, len(indices)) - for _, index := range indices { - character, found := s.Find(index) - if !found { - return nil, fmt.Errorf("player: preset character %d disappeared after update", index) - } - result = append(result, character) - } - return result, nil -} diff --git a/go/internal/server/player/preset_runtime_test.go b/go/internal/server/player/preset_runtime_test.go deleted file mode 100644 index cf85b9a..0000000 --- a/go/internal/server/player/preset_runtime_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package player - -import ( - "errors" - "path/filepath" - "testing" - "time" -) - -func TestValidatePresetEquipmentDoesNotHoldEquipmentLockDuringCharacterStats(t *testing.T) { - dir := t.TempDir() - inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 100, ID: 350, Level: 1}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json"))) - if err != nil { - t.Fatal(err) - } - if err := equipment.AttachSlots(map[uint64]uint64{10: 0}); err != nil { - t.Fatal(err) - } - if err := equipment.AttachCharacters(characters); err != nil { - t.Fatal(err) - } - owned, err := equipment.GrantOnce("preset-deadlock", 10) - if err != nil { - t.Fatal(err) - } - if err := characters.AttachMaxHealth(func(Character) (uint64, error) { - // This mirrors the production stat calculator's dependency on the - // equipment inventory. Validation must not hold equipment.mu here. - if len(equipment.All()) == 0 { - return 0, errors.New("missing equipment") - } - return 100, nil - }); err != nil { - t.Fatal(err) - } - - done := make(chan error, 1) - go func() { - done <- equipment.ValidatePresetEquipment([]PresetEquipmentBinding{{ - CharacterIndex: 100, - Equipment: []uint64{owned.InvenIndex, 0, 0, 0, 0}, - }}) - }() - select { - case err := <-done: - if err != nil { - t.Fatal(err) - } - case <-time.After(time.Second): - t.Fatal("preset equipment validation deadlocked through character stat calculation") - } -} diff --git a/go/internal/server/player/profile.go b/go/internal/server/player/profile.go deleted file mode 100644 index d447233..0000000 --- a/go/internal/server/player/profile.go +++ /dev/null @@ -1,170 +0,0 @@ -package player - -import ( - "encoding/json" - "errors" - "math" - "time" - "unicode" - "unicode/utf8" - - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type masterTitle struct { - Name string `json:"name"` - Month uint64 `json:"month"` - Day uint64 `json:"day"` -} - -// MasterTitleService persists the identity whose empty name sends IntroUI -// back to the tutorial even when LoginUser advertises another saved pack. -// It uses one entry in the existing progress domain, leaving its core intact. -type MasterTitleService struct { - store stateio.AtomicEntryStore - defaultTitle masterTitle -} - -func OpenMasterTitleService(store stateio.Store, defaultName string) (*MasterTitleService, error) { - entries, ok := store.(stateio.AtomicEntryStore) - if !ok || !validMasterTitleName(defaultName, false) { - return nil, errors.New("player: invalid master title service") - } - s := &MasterTitleService{store: entries, defaultTitle: masterTitle{Name: defaultName}} - if _, err := s.load(); err != nil { - return nil, err - } - return s, nil -} - -func validMasterTitleName(name string, fromRequest bool) bool { - if !utf8.ValidString(name) || len(name) == 0 || len(name) > 24 || (fromRequest && len(name) < 4) { - return false - } - for _, r := range name { - if unicode.IsSpace(r) || unicode.IsControl(r) { - return false - } - if fromRequest { - lower := unicode.ToLower(r) - if !((lower >= '0' && lower <= '9') || (lower >= 'a' && lower <= 'z') || (r >= 0xAC00 && r <= 0xD7A3) || (r >= 0x4E00 && r <= 0x9FD5) || (r >= 0x3041 && r <= 0x30FE) || (r >= 0x0180 && r <= 0x024F)) { //nolint:staticcheck // QF1001 - return false - } - } - } - return true -} - -func validMasterBirthday(month, day uint64) bool { - if month == 0 && day == 0 { - return true - } - if month < 1 || month > 12 || day < 1 || day > 31 { - return false - } - // The client explicitly uses leap year 2024 for birthday selection. - return uint64(time.Date(2024, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()) >= day -} - -func (s *MasterTitleService) load() (masterTitle, error) { - raw, found, err := s.store.LoadEntry("progress", "master_title", "identity") - if err != nil { - return masterTitle{}, err - } - if !found { - return s.defaultTitle, nil - } - if err := stateio.RequireExactJSONObject(raw, "name", "month", "day"); err != nil { - return masterTitle{}, err - } - var title masterTitle - if json.Unmarshal(raw, &title) != nil || !validMasterTitleName(title.Name, false) || !validMasterBirthday(title.Month, title.Day) { - return masterTitle{}, errors.New("player: invalid saved master title") - } - return title, nil -} - -// EnsurePersisted records the existing account display name once, with an -// unknown birthday. Startup calls it after progress core initialization. -func (s *MasterTitleService) EnsurePersisted() error { - _, found, err := s.store.LoadEntry("progress", "master_title", "identity") - if err != nil { - return err - } - if found { - _, err = s.load() - return err - } - core, err := s.store.Load("progress") - if err != nil { - return err - } - if len(core) == 0 { - return errors.New("player: master title requires initialized progress") - } - payload, err := json.Marshal(s.defaultTitle) - if err != nil { - return err - } - return s.store.SaveWithEntries("progress", nil, []stateio.EntryMutation{{Bucket: "master_title", Key: "identity", Payload: payload}}) -} - -func (s *MasterTitleService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/MasterTitleInfo" && path != "/MasterTitleInfoUpdate" { - return 0, nil, false, nil - } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > math.MaxInt32 { - return 0, nil, true, errors.New("player: invalid master title sequence") - } - if path == "/MasterTitleInfo" { - title, err := s.load() - if err != nil { - return 0, nil, true, err - } - body := wire.AppendString(nil, 1, title.Name) - if title.Month != 0 { - body = wire.AppendVarint(body, 2, title.Month) - } - if title.Day != 0 { - body = wire.AppendVarint(body, 3, title.Day) - } - return 590, body, true, nil - } - rawName, found, err := wire.Bytes(request, 2) - if err != nil || !found || !validMasterTitleName(string(rawName), true) { - return 0, nil, true, errors.New("player: invalid master title name") - } - month, _, err := wire.Varint(request, 3) - if err != nil { - return 0, nil, true, err - } - day, _, err := wire.Varint(request, 4) - if err != nil || !validMasterBirthday(month, day) { - return 0, nil, true, errors.New("player: invalid master title birthday") - } - next := masterTitle{Name: string(rawName), Month: month, Day: day} - current, err := s.load() - if err != nil { - return 0, nil, true, err - } - if current == next { - return 592, nil, true, nil - } - core, err := s.store.Load("progress") - if err != nil { - return 0, nil, true, err - } - if len(core) == 0 { - return 0, nil, true, errors.New("player: master title requires initialized progress") - } - payload, err := json.Marshal(next) - if err != nil { - return 0, nil, true, err - } - if err := s.store.SaveWithEntries("progress", nil, []stateio.EntryMutation{{Bucket: "master_title", Key: "identity", Payload: payload}}); err != nil { - return 0, nil, true, err - } - return 592, nil, true, nil -} diff --git a/go/internal/server/player/profile_test.go b/go/internal/server/player/profile_test.go deleted file mode 100644 index 4652594..0000000 --- a/go/internal/server/player/profile_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package player - -import ( - "bytes" - "errors" - "testing" - - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func newMasterProfile(t *testing.T, store stateio.Store) (*MasterTitleService, []byte) { - t.Helper() - p, err := progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - if err := p.EnsurePersisted(); err != nil { - t.Fatal(err) - } - core, err := store.Load("progress") - if err != nil { - t.Fatal(err) - } - s, err := OpenMasterTitleService(store, "Guest_30738202") - if err != nil { - t.Fatal(err) - } - if err := s.EnsurePersisted(); err != nil { - t.Fatal(err) - } - return s, core -} - -func TestMasterTitleSurvivesRestartAndKeepsUnknownBirthday(t *testing.T) { - store := stateio.NewMemory() - s, core := newMasterProfile(t, store) - request := wire.AppendVarint(nil, 1, 1) - code, body, handled, err := s.Handle("/MasterTitleInfo", request) - if err != nil || !handled || code != 590 { - t.Fatalf("profile code=%d handled=%v err=%v", code, handled, err) - } - name, found, err := wire.Bytes(body, 1) - if err != nil || !found || string(name) != "Guest_30738202" { - t.Fatal("profile name is empty, so IntroUI would force tutorial") - } - for _, field := range []int{2, 3} { - if _, found, _ := wire.Varint(body, field); found { - t.Fatal("unknown birthday was invented") - } - } - update := wire.AppendString(wire.AppendVarint(nil, 1, 2), 2, "玩家名字") - update = wire.AppendVarint(wire.AppendVarint(update, 3, 2), 4, 29) - if code, body, handled, err := s.Handle("/MasterTitleInfoUpdate", update); err != nil || !handled || code != 592 || len(body) != 0 { - t.Fatalf("update %d %v %v", code, handled, err) - } - if _, _, _, err := s.Handle("/MasterTitleInfoUpdate", update); err != nil { - t.Fatal("idempotent set failed", err) - } - s, err = OpenMasterTitleService(store, "anotherDefault") - if err != nil { - t.Fatal(err) - } - _, body, _, err = s.Handle("/MasterTitleInfo", request) - if err != nil { - t.Fatal(err) - } - name, _, _ = wire.Bytes(body, 1) - month, _, _ := wire.Varint(body, 2) - day, _, _ := wire.Varint(body, 3) - if string(name) != "玩家名字" || month != 2 || day != 29 { - t.Fatalf("restart profile name=%s birthday=%d/%d", name, month, day) - } - after, _ := store.Load("progress") - if !bytes.Equal(core, after) { - t.Fatal("profile modified progress core") - } -} - -func TestMasterTitleRejectsInvalidRequestWithoutWriting(t *testing.T) { - store := stateio.NewMemory() - s, _ := newMasterProfile(t, store) - before, _, _ := store.LoadEntry("progress", "master_title", "identity") - for _, request := range [][]byte{nil, wire.AppendVarint(nil, 1, 0), wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, ""), wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "Bad Name"), wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "ab"), wire.AppendVarint(wire.AppendVarint(wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "Player"), 3, 2), 4, 30)} { - if _, _, handled, err := s.Handle("/MasterTitleInfoUpdate", request); !handled || err == nil { - t.Fatal("invalid profile accepted") - } - } - if _, _, _, err := s.Handle("/MasterTitleInfo", nil); err == nil { - t.Fatal("missing info sequence accepted") - } - after, _, _ := store.LoadEntry("progress", "master_title", "identity") - if !bytes.Equal(before, after) { - t.Fatal("invalid profile request wrote state") - } -} - -type masterWriteFailure struct { - *stateio.Memory - fail bool -} - -func (s *masterWriteFailure) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { - if s.fail { - return errors.New("profile write failure") - } - return s.Memory.SaveWithEntries(domain, core, changes) -} - -func TestMasterTitleWriteFailureLeavesPriorResponse(t *testing.T) { - store := &masterWriteFailure{Memory: stateio.NewMemory()} - s, _ := newMasterProfile(t, store) - store.fail = true - update := wire.AppendString(wire.AppendVarint(nil, 1, 1), 2, "ChangedName") - if _, _, _, err := s.Handle("/MasterTitleInfoUpdate", update); err == nil { - t.Fatal("write error ignored") - } - _, body, _, err := s.Handle("/MasterTitleInfo", wire.AppendVarint(nil, 1, 2)) - if err != nil { - t.Fatal(err) - } - name, _, _ := wire.Bytes(body, 1) - if string(name) != "Guest_30738202" { - t.Fatal("failed write changed profile") - } -} diff --git a/go/internal/server/player/quest_costume_restart_test.go b/go/internal/server/player/quest_costume_restart_test.go deleted file mode 100644 index 6bf2ce5..0000000 --- a/go/internal/server/player/quest_costume_restart_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package player - -import ( - "strings" - "testing" - - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestEarnedQuestCostumeMustBeRestoredBeforeBurstValidation(t *testing.T) { - store := stateio.NewMemory() - base := []Costume{{InvenIndex: 88, ID: 900}} - reward := Costume{InvenIndex: 99, ID: 4202, UseChar: 77} - collection, err := OpenCollectionStore(store, base) - if err != nil { - t.Fatal(err) - } - if err := collection.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := collection.AttachRewardCostume(reward); err != nil { - t.Fatal(err) - } - record := CostumeBurstUpgradeRecord{CostumeID: reward.ID, Level: 1, Digest: strings.Repeat("a", 64), Code: 578, Body: wire.AppendVarint(nil, 1, 1)} - if err := collection.ApplyCostumeBurst(reward.InvenIndex, 0, 1, record); err != nil { - t.Fatal(err) - } - if _, err := OpenCollectionStore(store, base); err == nil { - t.Fatal("accepted burst ledger with incomplete restored ownership") - } - reloaded, err := OpenCollectionStore(store, append(base, reward)) - if err != nil { - t.Fatal(err) - } - got, owned := reloaded.CostumeByID(reward.ID) - if !owned || got.InvenIndex != reward.InvenIndex || got.BurstLevel != 1 { - t.Fatalf("restored reward costume=%+v owned=%v", got, owned) - } - if got, found := reloaded.CostumeBurstReplay(reward.InvenIndex, 1); !found || got.Digest != record.Digest { - t.Fatal("restart lost burst upgrade replay") - } -} diff --git a/go/internal/server/player/quest_rewards.go b/go/internal/server/player/quest_rewards.go deleted file mode 100644 index 08e70e9..0000000 --- a/go/internal/server/player/quest_rewards.go +++ /dev/null @@ -1,8 +0,0 @@ -package player - -// CollectionRewardBundle encodes a persisted character/costume grant for -// any reward-bearing response. Its copy upgrades and post-max exchanges are -// identical to recruitment rewards and are replayed from the collection ledger. -func CollectionRewardBundle(c *CollectionStore, g CollectionGrant) []byte { - return recruitRewardBundle(c, g) -} diff --git a/go/internal/server/player/recruit.go b/go/internal/server/player/recruit.go deleted file mode 100644 index 2be420d..0000000 --- a/go/internal/server/player/recruit.go +++ /dev/null @@ -1,383 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "errors" - "fmt" - "math" - "math/big" - "slices" - "strconv" - "sync" - "time" -) - -// RecruitNPCResolver must authorize the current pack/map/quest NPC and return -// its Scout interaction value. A client NPC ID alone grants no authority. -type RecruitNPCResolver func(uint64) (uint64, error) - -// RecruitService participates in the enclosing account transaction, which -// atomically commits inventory costs, collection copies and completion/replay. -type RecruitService struct { - mu sync.Mutex - design *gamedata.RecruitDesign - catalog CostumeDesignSource - collection *CollectionStore - inventory *Inventory - wallet *Wallet - resolver RecruitNPCResolver - session string - now func() time.Time -} - -func NewRecruitService(design *gamedata.RecruitDesign, catalog CostumeDesignSource, collection *CollectionStore, inventory *Inventory, wallet *Wallet, resolver RecruitNPCResolver) (*RecruitService, error) { - if design == nil || catalog == nil || collection == nil || inventory == nil || wallet == nil || resolver == nil { - return nil, errors.New("player: incomplete recruitment service") - } - for _, r := range design.Rules { - if _, ok := catalog.Character(r.CostumeID); !ok { - return nil, fmt.Errorf("player: recruit costume %d has no design", r.CostumeID) - } - } - return &RecruitService{design: design, catalog: catalog, collection: collection, inventory: inventory, wallet: wallet, resolver: resolver, now: time.Now}, nil -} -func (s *RecruitService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } -func recruitIdentity(id uint64) string { return "recruit:" + strconv.FormatUint(id, 10) } -func (s *RecruitService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/MercenaryScout" && path != "/CharScoutInfo" && path != "/CharSpecialScoutBuy" && path != "/CharSpecialScoutReset" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > math.MaxInt32 { - return 0, nil, true, errors.New("player: invalid recruitment sequence") - } - if path == "/CharScoutInfo" { - state, e := s.specialState() - if e != nil { - return 0, nil, true, e - } - if e = s.persistSpecial(state); e != nil { - return 0, nil, true, e - } - return 148, s.scoutInfo(state), true, nil - } - if s.session == "" { - return 0, nil, true, errors.New("player: recruitment requires authenticated session") - } - digestBytes := sha256.Sum256(request) - digest := hex.EncodeToString(digestBytes[:]) - keyBytes := sha256.Sum256([]byte(s.session + ":" + path + ":" + strconv.FormatUint(seq, 10))) - replyKey := "recruit-reply:" + hex.EncodeToString(keyBytes[:]) - if b, ok, e := s.collection.GachaBatchResponse(replyKey, digest); ok || e != nil { - return recruitCode(path), b, true, e - } - var state specialRecruitState - if path != "/MercenaryScout" { - state, err = s.specialState() - if err != nil { - return 0, nil, true, err - } - } - if path == "/CharSpecialScoutReset" { - if state.Count >= s.design.ResetLimit { - return 0, nil, true, errors.New("player: scout reset limit reached") - } - if !s.wallet.CanSpendFreeJewelry(s.design.ResetCount) { - return 0, nil, true, errors.New("player: insufficient scout reset jewelry") - } - state, err = s.rollSpecial(state.Count + 1) - if err != nil { - return 0, nil, true, err - } - if _, err = s.wallet.SpendFreeJewelryOnce(replyKey, s.design.ResetCount); err != nil { - return 0, nil, true, err - } - if err = s.persistSpecial(state); err != nil { - return 0, nil, true, err - } - var body []byte - for _, id := range state.IDs { - body = wire.AppendVarint(body, 1, id) - } - body = wire.AppendVarint(body, 2, state.Next) - if err = s.collection.RecordGachaBatch(replyKey, digest, body); err != nil { - return 0, nil, true, err - } - return 150, body, true, nil - } - npc, found, err := wire.Varint(request, 2) - if err != nil || !found || npc == 0 || npc > math.MaxInt32 { - return 0, nil, true, errors.New("player: invalid recruit NPC") - } - id := npc - if path == "/MercenaryScout" { - id, err = s.resolver(npc) - if err != nil { - return 0, nil, true, err - } - } - r, ok := s.design.Rules[id] - if !ok { - return 0, nil, true, errors.New("player: missing recruit rule") - } - if path == "/MercenaryScout" && r.Type != 0 { - return 0, nil, true, errors.New("player: NPC is not ordinary recruit") - } - if path == "/CharSpecialScoutBuy" { - available := false - for _, v := range state.IDs { - if v == id { - available = true - } - } - if r.Type != 1 || !available { - return 0, nil, true, errors.New("player: special recruit is not appearing") - } - } - identity := recruitIdentity(id) - if _, ok := s.collection.Grant(identity); ok { - return 0, nil, true, errors.New("player: character already recruited") - } - materials, err := equipmentRequestItems(request, 3, "MercenaryScout") - if err != nil { - return 0, nil, true, err - } - items, gold, err := validateCostumeBurstMaterials(r.Costs, materials) - if err != nil || gold != 0 { - return 0, nil, true, fmt.Errorf("player: invalid recruit materials: %v", err) - } - if err = s.inventory.CanConsume(items); err != nil { - return 0, nil, true, err - } - if err = s.inventory.Consume(items); err != nil { - return 0, nil, true, err - } - grant, err := s.collection.GrantCostumes(identity, []uint64{r.CostumeID}, s.catalog) - if err != nil { - return 0, nil, true, err - } - var rewards []gamedata.Reward - for _, x := range grant.Exchanges { - rewards = append(rewards, gamedata.Reward{Type: x.ExchangeItemType, ID: x.ExchangeItemID, Count: x.ExchangeCount}) - } - if len(rewards) > 0 { - if _, err = s.wallet.GrantQuestOnce(identity+":exchange", rewards); err != nil { - return 0, nil, true, err - } - } - if path == "/CharSpecialScoutBuy" { - if err = s.persistSpecial(state); err != nil { - return 0, nil, true, err - } - } - body := wire.AppendBytes(nil, 1, recruitRewardBundle(s.collection, grant)) - if err = s.collection.RecordGachaBatch(replyKey, digest, body); err != nil { - return 0, nil, true, err - } - return recruitCode(path), body, true, nil -} - -func recruitCode(path string) int { - if path == "/CharSpecialScoutBuy" { - return 149 - } - if path == "/CharSpecialScoutReset" { - return 150 - } - return 13 -} - -type specialRecruitState struct { - IDs []uint64 - Count, Next uint64 - Day string -} - -func (s *CollectionStore) saveRecruitState(body []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - next := cloneCollection(s.data) - next.Grants["special-recruit-state"] = CollectionGrant{GachaResponse: append([]byte(nil), body...)} - return s.commit(next) -} -func (s *RecruitService) specialState() (specialRecruitState, error) { - g, ok := s.collection.Grant("special-recruit-state") - if !ok { - return s.rollSpecial(0) - } - ids, e := recruitIDs(g.GachaResponse, 1) - if e != nil { - return specialRecruitState{}, e - } - count, _, e := wire.Varint(g.GachaResponse, 2) - if e != nil { - return specialRecruitState{}, e - } - next, _, e := wire.Varint(g.GachaResponse, 3) - if e != nil { - return specialRecruitState{}, e - } - dayBytes, dayOK, e := wire.Bytes(g.GachaResponse, 4) - if e != nil || !dayOK { - return specialRecruitState{}, errors.New("player: missing scout rotation day") - } - day := string(dayBytes) - if _, e = time.Parse("2006-01-02", day); e != nil { - return specialRecruitState{}, errors.New("player: invalid scout rotation day") - } - state := specialRecruitState{IDs: ids, Count: count, Next: next, Day: day} - if e != nil { - return state, e - } - seen := map[uint64]bool{} - if next == 0 || next > math.MaxInt64 || count > s.design.ResetLimit || len(ids) > int(s.design.AppearCount) { - return state, errors.New("player: invalid saved scout rotation") - } - for _, id := range ids { - r, ok := s.design.Rules[id] - if !ok || r.Type != 1 || r.AppearProb == 0 || seen[id] { - return state, errors.New("player: invalid saved scout appearance") - } - seen[id] = true - } - if state.Day != s.now().UTC().Format("2006-01-02") { - state.Count = 0 - state.Day = s.now().UTC().Format("2006-01-02") - } - if next <= uint64(s.now().UnixMilli()) { - return s.rollSpecial(state.Count) - } - return state, nil -} - -// The local rotation policy uses GameData weights without replacement. Its -// distribution is a server policy; official initial/random state is unknown. -func (s *RecruitService) rollSpecial(count uint64) (specialRecruitState, error) { - state := specialRecruitState{Day: s.now().UTC().Format("2006-01-02"), Count: count, Next: uint64(s.now().Add(time.Duration(s.design.AutoResetMinute) * time.Minute).UnixMilli())} - var pool []uint64 - for id, r := range s.design.Rules { - if r.Type == 1 && r.AppearProb > 0 { - if _, done := s.collection.Grant(recruitIdentity(id)); !done { - pool = append(pool, id) - } - } - } - slices.Sort(pool) - for len(state.IDs) < int(s.design.AppearCount) && len(pool) > 0 { - var total uint64 - for _, id := range pool { - if total > math.MaxUint64-s.design.Rules[id].AppearProb { - return state, errors.New("player: scout appearance weight overflow") - } - total += s.design.Rules[id].AppearProb - } - draw, e := rand.Int(rand.Reader, new(big.Int).SetUint64(total)) - if e != nil { - return state, e - } - n := draw.Uint64() - for i, id := range pool { - w := s.design.Rules[id].AppearProb - if n < w { - state.IDs = append(state.IDs, id) - pool = append(pool[:i], pool[i+1:]...) - break - } - n -= w - } - } - return state, nil -} -func (s *RecruitService) persistSpecial(state specialRecruitState) error { - var b []byte - for _, id := range state.IDs { - b = wire.AppendVarint(b, 1, id) - } - b = wire.AppendVarint(b, 2, state.Count) - b = wire.AppendVarint(b, 3, state.Next) - b = wire.AppendString(b, 4, state.Day) - return s.collection.saveRecruitState(b) -} - -func (s *RecruitService) scoutInfo(state specialRecruitState) []byte { - var b []byte - for _, id := range state.IDs { - if _, done := s.collection.Grant(recruitIdentity(id)); !done { - b = wire.AppendVarint(b, 1, id) - } - } - b = wire.AppendVarint(b, 2, state.Count) - b = wire.AppendVarint(b, 3, state.Next) - var ids []uint64 - for id := range s.design.Rules { - if _, ok := s.collection.Grant(recruitIdentity(id)); ok { - ids = append(ids, id) - } - } - slices.Sort(ids) - for _, id := range ids { - b = wire.AppendVarint(b, 4, id) - } - return b -} - -func recruitRewardBundle(c *CollectionStore, g CollectionGrant) []byte { - var b []byte - for _, idx := range g.CharacterIndices { - if v, ok := c.CharacterByIndex(idx); ok { - b = wire.AppendBytes(b, 2, CharacterWire(v)) - b = wire.AppendBytes(b, 6, ItemWire(Item{ID: v.ID, Type: 6, Count: 1})) - } - } - for _, idx := range g.CostumeIndices { - if v, ok := c.CostumeByIndex(idx); ok { - b = wire.AppendBytes(b, 3, CostumeWire(v)) - } - } - for _, id := range g.ViewCostumeIDs { - b = wire.AppendBytes(b, 6, ItemWire(Item{ID: id, Type: 11, Count: 1})) - } - for _, u := range g.Upgrades { - v := wire.AppendVarint(nil, 1, u.InvenIndex) - v = wire.AppendVarint(v, 2, 11) - v = wire.AppendVarint(v, 3, u.CostumeID) - v = wire.AppendVarint(v, 4, u.Before) - v = wire.AppendVarint(v, 5, u.After) - b = wire.AppendBytes(b, 9, v) - } - for _, x := range g.Exchanges { - v := wire.AppendVarint(nil, 1, x.OriginalItemType) - v = wire.AppendVarint(v, 2, x.OriginalItemID) - v = wire.AppendVarint(v, 3, x.OriginalCount) - v = wire.AppendVarint(v, 4, x.ExchangeItemType) - v = wire.AppendVarint(v, 5, x.ExchangeItemID) - v = wire.AppendVarint(v, 6, x.ExchangeCount) - b = wire.AppendBytes(b, 8, v) - r := wire.AppendVarint(nil, 2, x.ExchangeItemType) - r = wire.AppendVarint(r, 3, x.ExchangeCount) - b = wire.AppendBytes(b, 10, r) - } - return b -} - -func recruitIDs(b []byte, n int) ([]uint64, error) { - var ids []uint64 - err := wire.Walk(b, func(f wire.Field) error { - if f.Number == n { - if f.Type != 0 { - return errors.New("player: invalid scout state IDs") - } - id, _ := binary.Uvarint(f.Value) - ids = append(ids, id) - } - return nil - }) - return ids, err -} diff --git a/go/internal/server/player/recruit_test.go b/go/internal/server/player/recruit_test.go deleted file mode 100644 index 870feed..0000000 --- a/go/internal/server/player/recruit_test.go +++ /dev/null @@ -1,318 +0,0 @@ -package player - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "errors" - "path/filepath" - "testing" - "time" -) - -type failingRecruitCatalog struct { - design CostumeDesignSource - fail bool -} - -func (c *failingRecruitCatalog) Character(id uint64) (gamedata.CharacterDesign, bool) { - if c.fail { - return gamedata.CharacterDesign{}, false - } - return c.design.Character(id) -} -func TestRecruitSQLiteFailureAfterConsumeRollsBackAllDomains(t *testing.T) { - fixture, _ := recruitFixture(t, filepath.Join(t.TempDir(), "design.json")) - path := filepath.Join(t.TempDir(), "state.db") - repo, e := accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - starter := &Starter{Version: "2.35.10"} - inventory, e := OpenInventory(repo, starter) - if e != nil { - t.Fatal(e) - } - items, e := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 71, Count: 2}}) - if e != nil { - t.Fatal(e) - } - wallet, e := OpenWallet(repo, Currency{FreeJewelry: 500}) - if e != nil { - t.Fatal(e) - } - collection, e := OpenCollectionStore(repo, nil) - if e != nil { - t.Fatal(e) - } - for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted} { - if e = ensure(); e != nil { - t.Fatal(e) - } - } - catalog := &failingRecruitCatalog{design: fixture.catalog} - s, e := NewRecruitService(fixture.design, catalog, collection, inventory, wallet, fixture.resolver) - if e != nil { - t.Fatal(e) - } - s.BeginSession("session") - s.now = fixture.now - op, e := repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - catalog.fail = true - ticket := items[0] - ticket.Count = 1 - if _, _, _, e = s.Handle("/MercenaryScout", costumeBurstRequest(1, 99, ticket)); e == nil { - t.Fatal("expected grant failure after material deduction") - } - if e = op.Rollback(); e == nil { - t.Fatal("dirty rollback must fence published in-memory state") - } - if e = repo.Close(); e != nil { - t.Fatal(e) - } - repo, e = accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - inventory, e = OpenInventory(repo, starter) - if e != nil { - t.Fatal(e) - } - wallet, e = OpenWallet(repo, Currency{}) - if e != nil { - t.Fatal(e) - } - collection, e = OpenCollectionStore(repo, nil) - if e != nil { - t.Fatal(e) - } - if e = inventory.CanConsume(items); e != nil { - t.Fatalf("rollback lost ticket: %v", e) - } - if wallet.Snapshot().FreeJewelry != 500 || len(collection.Costumes()) != 0 || len(collection.Characters()) != 0 { - t.Fatal("rollback committed partial rewards/currency") - } - if _, ok := collection.Grant(recruitIdentity(10)); ok { - t.Fatal("rollback committed completion") - } - if _, ok := collection.Grant("special-recruit-state"); ok { - t.Fatal("normal rollback wrote rotation") - } - catalog.fail = false - s, e = NewRecruitService(fixture.design, catalog, collection, inventory, wallet, fixture.resolver) - if e != nil { - t.Fatal(e) - } - s.BeginSession("session") - s.now = fixture.now - op, e = repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - if _, _, _, e = s.Handle("/MercenaryScout", costumeBurstRequest(1, 99, ticket)); e != nil { - t.Fatal(e) - } - if e = op.Commit(); e != nil { - t.Fatal(e) - } - if _, ok := collection.Grant(recruitIdentity(10)); !ok { - t.Fatal("successful retry not committed") - } -} - -func recruitFixture(t *testing.T, path string) (*RecruitService, Item) { - t.Helper() - store := testStore(path) - inventory, e := OpenInventory(store, &Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - items, e := inventory.GrantOnce("tickets", []gamedata.BattleReward{{Type: 8, ID: 71, Count: 10}}) - if e != nil { - t.Fatal(e) - } - wallet, e := OpenWallet(store, Currency{FreeJewelry: 500}) - if e != nil { - t.Fatal(e) - } - collection, e := OpenCollectionStore(store, nil) - if e != nil { - t.Fatal(e) - } - d := &gamedata.RecruitDesign{Rules: map[uint64]gamedata.RecruitRule{}, Characters: map[uint64]gamedata.CharacterDesign{}, AppearCount: 2, AutoResetMinute: 120, ResetCount: 50, ResetType: 3, ResetLimit: 2} - for _, id := range []uint64{10, 20, 30, 40} { - r := gamedata.RecruitRule{ID: id, CostumeID: id*10 + 1, Type: 1, AppearProb: id, Costs: []gamedata.PromotionCost{{Type: 8, ID: 71, Count: 1}}} - if id == 10 { - r.Type = 0 - } - d.Rules[id] = r - d.Characters[r.CostumeID] = gamedata.CharacterDesign{ID: id, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 100} - } - s, e := NewRecruitService(d, d, collection, inventory, wallet, func(n uint64) (uint64, error) { - if n != 99 { - return 0, errors.New("unauthorized NPC") - } - return 10, nil - }) - if e != nil { - t.Fatal(e) - } - s.BeginSession("session") - s.now = func() time.Time { return time.UnixMilli(2000000000000) } - item := items[0] - item.Count = 1 - return s, item -} -func TestRecruitSpecialPersistsAppearanceCompletesAndReplays(t *testing.T) { - path := filepath.Join(t.TempDir(), "account.json") - s, ticket := recruitFixture(t, path) - req := wire.AppendVarint(nil, 1, 1) - code, body, _, e := s.Handle("/CharScoutInfo", req) - if e != nil || code != 148 { - t.Fatalf("info %d %v", code, e) - } - ids, e := recruitIDs(body, 1) - if e != nil || len(ids) != 2 || ids[0] == ids[1] { - t.Fatalf("appearance %v %v", ids, e) - } - request := costumeBurstRequest(2, ids[0], ticket) - code, first, _, e := s.Handle("/CharSpecialScoutBuy", request) - if e != nil || code != 149 { - t.Fatalf("buy %d %v", code, e) - } - if _, ok := s.collection.Grant(recruitIdentity(ids[0])); !ok { - t.Fatal("completion missing") - } - if _, ok := s.collection.CostumeByID(ids[0]*10 + 1); !ok { - t.Fatal("costume missing") - } - _, second, _, e := s.Handle("/CharSpecialScoutBuy", request) - if e != nil || !bytes.Equal(first, second) { - t.Fatalf("replay %v", e) - } - if _, _, _, e = s.Handle("/CharSpecialScoutBuy", costumeBurstRequest(3, ids[0], ticket)); e == nil { - t.Fatal("repeat recruitment accepted") - } - store := testStore(path) - inventory, e := OpenInventory(store, &Starter{Version: "2.35.10"}) - if e != nil { - t.Fatal(e) - } - wallet, e := OpenWallet(store, Currency{}) - if e != nil { - t.Fatal(e) - } - collection, e := OpenCollectionStore(store, nil) - if e != nil { - t.Fatal(e) - } - restored, e := NewRecruitService(s.design, s.catalog, collection, inventory, wallet, s.resolver) - if e != nil { - t.Fatal(e) - } - restored.BeginSession("session") - restored.now = s.now - _, again, _, e := restored.Handle("/CharSpecialScoutBuy", request) - if e != nil || !bytes.Equal(again, first) { - t.Fatalf("durable replay %v", e) - } - _, info, _, e := restored.Handle("/CharScoutInfo", req) - completed, _ := recruitIDs(info, 4) - appearing, _ := recruitIDs(info, 1) - if e != nil || len(completed) != 1 || completed[0] != ids[0] || len(appearing) != 1 { - t.Fatalf("restored %x %v", info, e) - } - remaining := ticket - remaining.Count = 9 - if e = inventory.CanConsume([]Item{remaining}); e != nil { - t.Fatalf("double charged %v", e) - } -} -func TestRecruitNormalAuthorizesNPCAndRejectsForgedCosts(t *testing.T) { - s, ticket := recruitFixture(t, filepath.Join(t.TempDir(), "account.json")) - for _, req := range [][]byte{costumeBurstRequest(1, 98, ticket), costumeBurstRequest(2, 99, Item{InvenIndex: ticket.InvenIndex, ID: 71, Type: 8, Count: 2}), costumeBurstRequest(3, 20, ticket)} { - if _, _, _, e := s.Handle("/MercenaryScout", req); e == nil { - t.Fatal("invalid normal recruit accepted") - } - } - if _, exists := s.collection.Grant("special-recruit-state"); exists { - t.Fatal("rejected normal recruit wrote rotation") - } - code, body, _, e := s.Handle("/MercenaryScout", costumeBurstRequest(4, 99, ticket)) - if e != nil || code != 13 { - t.Fatalf("normal %d %v", code, e) - } - bundle, ok, e := wire.Bytes(body, 1) - if e != nil || !ok { - t.Fatal("missing reward bundle") - } - if _, ok, e = wire.Bytes(bundle, 2); e != nil || !ok { - t.Fatal("missing character") - } - if _, ok, e = wire.Bytes(bundle, 3); e != nil || !ok { - t.Fatal("missing costume") - } -} - -func TestRecruitInvalidSpecialRequestsDoNotPersistRotation(t *testing.T) { - s, ticket := recruitFixture(t, filepath.Join(t.TempDir(), "account.json")) - if _, _, _, e := s.Handle("/CharSpecialScoutBuy", costumeBurstRequest(1, 999, ticket)); e == nil { - t.Fatal("unknown recruit accepted") - } - if _, exists := s.collection.Grant("special-recruit-state"); exists { - t.Fatal("invalid buy wrote rotation") - } - s.wallet.state.Currency.FreeJewelry = 0 //nolint:staticcheck // QF1008 - if _, _, _, e := s.Handle("/CharSpecialScoutReset", wire.AppendVarint(nil, 1, 2)); e == nil { - t.Fatal("unaffordable reset accepted") - } - if _, exists := s.collection.Grant("special-recruit-state"); exists { - t.Fatal("invalid reset wrote rotation") - } -} -func TestRecruitResetUsesFreeJewelryLimitReplayAndAutomaticDeadline(t *testing.T) { - s, _ := recruitFixture(t, filepath.Join(t.TempDir(), "account.json")) - request := wire.AppendVarint(nil, 1, 1) - code, first, _, e := s.Handle("/CharSpecialScoutReset", request) - if e != nil || code != 150 { - t.Fatalf("reset %d %v", code, e) - } - if s.wallet.Snapshot().FreeJewelry != 450 { - t.Fatal("reset cost") - } - _, again, _, e := s.Handle("/CharSpecialScoutReset", request) - if e != nil || !bytes.Equal(first, again) || s.wallet.Snapshot().FreeJewelry != 450 { - t.Fatal("reset replay charged") - } - if _, _, _, e = s.Handle("/CharSpecialScoutReset", wire.AppendVarint(nil, 1, 2)); e != nil { - t.Fatal(e) - } - if _, _, _, e = s.Handle("/CharSpecialScoutReset", wire.AppendVarint(nil, 1, 3)); e == nil { - t.Fatal("reset limit ignored") - } - s.now = func() time.Time { return time.UnixMilli(2000000000000).Add(121 * time.Minute) } - _, body, _, e := s.Handle("/CharScoutInfo", wire.AppendVarint(nil, 1, 4)) - count, _, _ := wire.Varint(body, 2) - next, _, _ := wire.Varint(body, 3) - if e != nil || count != 2 || next <= uint64(s.now().UnixMilli()) { - t.Fatalf("automatic reset %x %v", body, e) - } - s.now = func() time.Time { return time.UnixMilli(2000000000000).Add(24 * time.Hour) } - _, body, _, e = s.Handle("/CharScoutInfo", wire.AppendVarint(nil, 1, 5)) - count, _, _ = wire.Varint(body, 2) - if e != nil || count != 0 { - t.Fatal("daily reset did not clear manual count") - } - if s.wallet.Snapshot().FreeJewelry != 400 { - t.Fatal("automatic reset charged") - } -} diff --git a/go/internal/server/player/starter.go b/go/internal/server/player/starter.go deleted file mode 100644 index 7f2d794..0000000 --- a/go/internal/server/player/starter.go +++ /dev/null @@ -1,211 +0,0 @@ -// Package player owns the initial, mutable inventory/character snapshot used -// by the local account. GameData defines what each ID means; this file holds -// only the new player's ownership and progress. -package player - -import ( - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "os" - - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -type Item struct { - InvenIndex uint64 `json:"inven_index"` - ID uint64 `json:"id"` - Type uint64 `json:"type"` - Count uint64 `json:"count"` - KeepFlag uint64 `json:"keep_flag,omitempty"` - TimeValue uint64 `json:"time_value,omitempty"` - ExpiryTime uint64 `json:"expiry_time,omitempty"` - Pictorialbook *Pictorial `json:"pictorialbook,omitempty"` - SortID uint64 `json:"sort_id,omitempty"` - UseCount uint64 `json:"use_count,omitempty"` -} - -type Costume struct { - InvenIndex uint64 `json:"inven_index"` - ID uint64 `json:"id"` - Level uint64 `json:"level,omitempty"` - UseChar uint64 `json:"use_char,omitempty"` - SortID uint64 `json:"sort_id,omitempty"` - PotentialIDs []uint64 `json:"-"` - DesignID uint64 `json:"design_id,omitempty"` - BurstLevel uint64 `json:"burst_level,omitempty"` - TimeValue uint64 `json:"time_value,omitempty"` - Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` -} - -type Pictorial struct { - ID uint64 `json:"id"` - GroupID uint64 `json:"group_id"` -} - -type Character struct { - InvenIndex uint64 `json:"inven_index"` - ID uint64 `json:"id"` - HP uint64 `json:"hp,omitempty"` - Level uint64 `json:"level,omitempty"` - CostumeID uint64 `json:"costume_id,omitempty"` - Exp uint64 `json:"exp,omitempty"` - UseCostume uint64 `json:"use_costume,omitempty"` - TalentLevel uint64 `json:"talent_level,omitempty"` - TalentExp uint64 `json:"talent_exp,omitempty"` - SolidarityReward uint64 `json:"solidarity_reward,omitempty"` - ExpiryTime uint64 `json:"expiry_time,omitempty"` - ConnectPotentialCostume uint64 `json:"connect_potential_costume,omitempty"` - Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` -} - -type Starter struct { - Version string `json:"version"` - CookingRecipes []uint64 `json:"cooking_recipes,omitempty"` - Items []Item `json:"items"` - Costumes []Costume `json:"costumes"` - Characters []Character `json:"characters"` - Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` - FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"` -} - -func Load(path string) (*Starter, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("player: read starter: %w", err) - } - var starter Starter - if err := json.Unmarshal(data, &starter); err != nil { - return nil, fmt.Errorf("player: decode starter: %w", err) - } - if err := starter.Validate(); err != nil { - return nil, err - } - return &starter, nil -} - -func (s *Starter) Validate() error { - if s == nil || s.Version != versionconfig.State() { - return errors.New("player: wrong starter version") - } - seenRecipes := map[uint64]bool{} - for _, id := range s.CookingRecipes { - if id == 0 || id > 0x7fffffff || seenRecipes[id] { - return errors.New("player: invalid initial cooking recipe") - } - seenRecipes[id] = true - } - for _, item := range s.Items { - if item.ID == 0 || item.Count == 0 { - return errors.New("player: invalid item") - } - } - for _, costume := range s.Costumes { - if costume.ID == 0 || costume.InvenIndex == 0 { - return errors.New("player: invalid costume") - } - } - for _, character := range s.Characters { - if character.ID == 0 || character.InvenIndex == 0 { - return errors.New("player: invalid character") - } - } - return nil -} - -func (s *Starter) Write(path string) error { - if err := s.Validate(); err != nil { - return err - } - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, append(data, '\n'), 0o600) -} - -func add(dst []byte, field int, value uint64) []byte { - if value != 0 { - return wire.AppendVarint(dst, field, value) - } - return dst -} - -func (s *Starter) Handle(path string, request []byte) (int, []byte, bool, error) { - var code int - var response []byte - switch path { - case "/ItemInfo": - code = 21 - for _, entry := range s.Items { - response = wire.AppendBytes(response, 1, ItemWire(entry)) - } - case "/CostumeInfo": - code = 40 - for _, entry := range s.Costumes { - response = wire.AppendBytes(response, 1, CostumeWire(entry)) - } - case "/CharInfo": - code = 9 - for _, entry := range s.Characters { - var character []byte - character = add(character, 1, entry.InvenIndex) - character = add(character, 2, entry.ID) - character = add(character, 3, entry.HP) - character = add(character, 4, entry.Level) - character = add(character, 5, entry.CostumeID) - character = add(character, 6, entry.Exp) - character = add(character, 7, entry.UseCostume) - character = add(character, 8, entry.TalentLevel) - character = add(character, 9, entry.TalentExp) - character = add(character, 10, entry.SolidarityReward) - character = add(character, 11, entry.ExpiryTime) - character = add(character, 13, entry.ConnectPotentialCostume) - response = wire.AppendBytes(response, 1, character) - } - response = add(response, 2, s.FieldCharControlDeckType) - default: - return 0, nil, false, nil - } - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 { - return 0, nil, true, fmt.Errorf("player: %s invalid sequence", path) - } - return code, response, true, nil -} - -func CostumeWire(entry Costume) []byte { - var costume []byte - costume = add(costume, 1, entry.InvenIndex) - costume = add(costume, 2, entry.ID) - costume = add(costume, 3, entry.Level) - costume = add(costume, 4, entry.UseChar) - for _, p := range entry.Pictorialbook { - book := add(add(nil, 1, p.ID), 2, p.GroupID) - costume = wire.AppendBytes(costume, 5, book) - } - costume = add(costume, 6, entry.SortID) - for _, id := range entry.PotentialIDs { - costume = wire.AppendVarint(costume, 8, id) - } - costume = add(costume, 9, entry.DesignID) - costume = add(costume, 10, entry.BurstLevel) - costume = add(costume, 12, entry.TimeValue) - return costume -} - -func decodeVarints(proto []byte, fields map[int]*uint64) error { - return wire.Walk(proto, func(field wire.Field) error { - value, known := fields[field.Number] - if !known { - return fmt.Errorf("player: unsupported starter field %d", field.Number) - } - if field.Type != 0 { - return fmt.Errorf("player: starter field %d wire type %d", field.Number, field.Type) - } - *value, _ = binary.Uvarint(field.Value) - return nil - }) -} diff --git a/go/internal/server/player/starter_test.go b/go/internal/server/player/starter_test.go deleted file mode 100644 index 1d57441..0000000 --- a/go/internal/server/player/starter_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package player - -import ( - "bytes" - "path/filepath" - "testing" - - "bd2server/internal/server/wire" -) - -func TestStarterCanAnswerWithoutCapture(t *testing.T) { - seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "starter_player.json")) - if err != nil { - t.Fatal(err) - } - for path, code := range map[string]int{"/ItemInfo": 21, "/CostumeInfo": 40, "/CharInfo": 9} { - actual, proto, ok, err := seed.Handle(path, wire.AppendVarint(nil, 1, 900001)) - if err != nil || !ok || actual != code || len(proto) == 0 { - t.Fatalf("%s response: code=%d len=%d ok=%v err=%v", path, actual, len(proto), ok, err) - } - } - if _, _, ok, err := seed.Handle("/NotImplemented", nil); ok || err != nil { - t.Fatal("accepted unknown endpoint") - } -} - -func TestStarterProtocolEncodingAndRoundTrip(t *testing.T) { - seed := &Starter{ - Version: "2.35.10", - Items: []Item{{InvenIndex: 1, ID: 2, Type: 8, Count: 3}}, - Costumes: []Costume{{InvenIndex: 4, ID: 5, UseChar: 6}}, - Characters: []Character{{InvenIndex: 6, ID: 7, HP: 8, Level: 1, UseCostume: 4, ConnectPotentialCostume: 5}}, - FieldCharControlDeckType: 2, - } - path := filepath.Join(t.TempDir(), "starter.json") - if err := seed.Write(path); err != nil { - t.Fatal(err) - } - loaded, err := Load(path) - if err != nil { - t.Fatal(err) - } - for _, test := range []struct { - path string - code int - proto []byte - }{ - {"/ItemInfo", 21, []byte{0x0a, 8, 0x08, 1, 0x10, 2, 0x18, 8, 0x20, 3}}, - {"/CostumeInfo", 40, []byte{0x0a, 6, 0x08, 4, 0x10, 5, 0x20, 6}}, - {"/CharInfo", 9, []byte{0x0a, 12, 0x08, 6, 0x10, 7, 0x18, 8, 0x20, 1, 0x38, 4, 0x68, 5, 0x10, 2}}, - } { - t.Run(test.path, func(t *testing.T) { - code, proto, handled, err := loaded.Handle(test.path, wire.AppendVarint(nil, 1, 900001)) - if err != nil || !handled || code != test.code || !bytes.Equal(proto, test.proto) { - t.Fatalf("code=%d proto=%x handled=%v err=%v", code, proto, handled, err) - } - for _, request := range [][]byte{nil, {0x08, 0}, {0x08, 0x80}} { - if _, _, handled, err := loaded.Handle(test.path, request); !handled || err == nil { - t.Fatal("invalid request accepted") - } - } - }) - } -} diff --git a/go/internal/server/player/store_test.go b/go/internal/server/player/store_test.go deleted file mode 100644 index 0405fec..0000000 --- a/go/internal/server/player/store_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package player - -import ( - "path/filepath" - "sync" - - "bd2server/internal/server/stateio" -) - -var testStores sync.Map - -func testStore(path string) stateio.Store { - dir := filepath.Dir(path) - store, _ := testStores.LoadOrStore(dir, stateio.NewMemory()) - return store.(stateio.Store) -} diff --git a/go/internal/server/player/story_characters_test.go b/go/internal/server/player/story_characters_test.go deleted file mode 100644 index 3b0361f..0000000 --- a/go/internal/server/player/story_characters_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package player - -import ( - "bd2server/internal/server/stateio" - "testing" -) - -func TestEnsureStoryCharactersPersistsWithoutCollectionGrant(t *testing.T) { - storage := stateio.NewMemory() - inventory, err := OpenInventory(storage, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - owned := Character{InvenIndex: 3, ID: 30, Level: 1} - store, err := OpenCharacterStore(storage, []Character{owned}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - story := Character{InvenIndex: StoryCharacterIndexBase + 1, ID: 10383, HP: 777, Level: 15, CostumeID: 3801, UseCostume: 2} - if err := store.EnsureStoryCharacters([]Character{story}); err != nil { - t.Fatal(err) - } - if err := store.SetCurrentHealth(story.InvenIndex, 7); err != nil { - t.Fatal(err) - } - if err := store.EnsureStoryCharacters([]Character{story}); err != nil { - t.Fatal(err) - } - loaded, err := OpenCharacterStore(storage, []Character{owned}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - current, ok := loaded.Find(story.InvenIndex) - if !ok || current.HP != 7 || current.Level != 15 { - t.Fatalf("reloaded story %+v/%v", current, ok) - } - collision := story - collision.ID++ - if err := loaded.EnsureStoryCharacters([]Character{collision}); err == nil { - t.Fatal("accepted conflicting story instance") - } - if err := loaded.EnsureStoryCharacters([]Character{owned}); err == nil { - t.Fatal("accepted owned instance as story") - } -} diff --git a/go/internal/server/player/talent_charm.go b/go/internal/server/player/talent_charm.go deleted file mode 100644 index 53c8688..0000000 --- a/go/internal/server/player/talent_charm.go +++ /dev/null @@ -1,98 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "encoding/json" - "fmt" - "strconv" - "time" -) - -const CharmCharacterIndexBase uint64 = 1 << 59 - -func IsCharmCharacter(c Character) bool { - return c.InvenIndex >= CharmCharacterIndexBase && c.InvenIndex < StoryCharacterIndexBase -} -func CharacterExpired(c Character, now time.Time) bool { - return c.ExpiryTime > 0 && c.ExpiryTime <= uint64(now.UnixMilli()) -} - -// Charm instances are field companions. They live only in the character domain -// with an expiry, never grant a permanent collection character or costume. -func (s *TalentUseService) applyCharm(_ string, _ Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) { - pack, _, _, err := s.context() - if err != nil { - return nil, err - } - npcs, err := s.design.NPCs(pack) - if err != nil { - return nil, err - } - npc := npcs[targets[0]] - if s.design.CharmCharacter == nil || len(rule.Values) < 2 { - return nil, fmt.Errorf("player: charm character design unavailable") - } - var chars []Character - expiry := uint64(s.now().UnixMilli() + int64(rule.Values[1])*1000) - for _, id := range npc.CharmCharacters { - d, e := s.design.CharmCharacter(id) - if e != nil { - return nil, e - } - if d.CharacterID == 0 || d.CostumeID == 0 || d.HP == 0 { - return nil, fmt.Errorf("player: invalid charm companion design") - } - if pack <= 0 || pack >= 65536 || d.CharacterID >= 1<<32 { - return nil, fmt.Errorf("player: charm instance namespace overflow") - } - index := CharmCharacterIndexBase | uint64(pack)<<32 | d.CharacterID - chars = append(chars, Character{InvenIndex: index, ID: d.CharacterID, Level: d.Level, HP: d.HP, CostumeID: d.CostumeID, TalentLevel: 1, ExpiryTime: expiry}) - } - if err = s.characters.ensureCharmCharacters(chars); err != nil { - return nil, err - } - var out []byte - for _, c := range chars { - out = wire.AppendBytes(out, 5, CharacterWire(c)) - } - return out, nil -} -func (s *CharacterStore) ensureCharmCharacters(chars []Character) error { - s.mu.Lock() - defer s.mu.Unlock() - next := append([]Character(nil), s.characters...) - for _, c := range chars { - if !IsCharmCharacter(c) || c.ExpiryTime == 0 { - return fmt.Errorf("player: invalid charm instance") - } - found := false - for i, old := range next { - if old.InvenIndex == c.InvenIndex { - if old.ID != c.ID { - return fmt.Errorf("player: charm instance collision") - } - next[i] = c - found = true - break - } - } - if !found { - next = append(next, c) - } - } - if err := s.persist(next); err != nil { - return err - } - s.characters = next - for _, c := range chars { - raw, err := json.Marshal(c.HP) - if err != nil { - return err - } - if err = s.store.PutEntry("characters", "current_hp", strconv.FormatUint(c.InvenIndex, 10), raw); err != nil { - return err - } - } - return nil -} diff --git a/go/internal/server/player/talent_dispatch.go b/go/internal/server/player/talent_dispatch.go deleted file mode 100644 index 74fbeee..0000000 --- a/go/internal/server/player/talent_dispatch.go +++ /dev/null @@ -1,255 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/json" - "fmt" - "math" - "math/big" - "slices" - "sync" - "time" -) - -type talentDispatchRow struct { - ID uint64 `json:"id"` - Start int64 `json:"start"` - End int64 `json:"end"` - Identity string `json:"identity"` - Rewards []gamedata.Reward `json:"rewards"` - Claimed bool `json:"claimed"` - Bundle []byte `json:"bundle"` -} -type talentDispatchState struct { - Claims map[string]talentDispatchClaim `json:"claims"` - Rows map[uint64]talentDispatchRow `json:"rows"` - Starts map[string][]byte `json:"starts"` -} -type talentDispatchClaim struct { - Digest string `json:"digest"` - Body []byte `json:"body"` -} - -type TalentDispatchService struct { - session string - mu sync.Mutex - store stateio.Store - design map[uint64]gamedata.TalentDispatchDesign - now func() time.Time - draw func(uint64) (uint64, error) - economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) - } -} - -func OpenTalentDispatch(store stateio.Store, d map[uint64]gamedata.TalentDispatchDesign, economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -}) (*TalentDispatchService, error) { - if store == nil || len(d) == 0 || economy == nil { - return nil, fmt.Errorf("dispatch: invalid configuration") - } - return &TalentDispatchService{store: store, design: d, economy: economy, now: time.Now, draw: func(n uint64) (uint64, error) { - if n == 0 { - return 0, fmt.Errorf("dispatch: empty pool") - } - v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n)) - if e != nil { - return 0, e - } - return v.Uint64(), nil - }}, nil -} -func (s *TalentDispatchService) load() (talentDispatchState, error) { - st := talentDispatchState{Rows: map[uint64]talentDispatchRow{}, Starts: map[string][]byte{}, Claims: map[string]talentDispatchClaim{}} - b, e := s.store.Load("talent_dispatch") - if e != nil || b == nil { - return st, e - } - if e = stateio.RequireExactJSONObject(b, "rows", "starts", "claims"); e != nil { - return st, e - } - if e = json.Unmarshal(b, &st); e != nil { - return st, e - } - if st.Rows == nil || st.Starts == nil || st.Claims == nil { - return st, fmt.Errorf("dispatch: invalid state") - } - for id, r := range st.Rows { - if _, ok := s.design[id]; !ok || r.ID != id || r.Start <= 0 || r.End <= r.Start || r.Identity == "" { - return st, fmt.Errorf("dispatch: invalid row") - } - } - return st, nil -} -func (s *TalentDispatchService) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - s.session = id -} -func (s *TalentDispatchService) save(st talentDispatchState) error { - b, e := json.Marshal(st) - if e != nil { - return e - } - return s.store.Save("talent_dispatch", b) -} -func (s *TalentDispatchService) rowWire(r talentDispatchRow) []byte { - b := wire.AppendVarint(nil, 1, r.ID) - b = wire.AppendVarint(b, 2, uint64(s.now().UnixMilli())) - return wire.AppendVarint(b, 3, uint64(r.End)) -} - -// Start returns extra TalentSkillUseResponse fields. Its caller owns talent -// cost/experience/cooldown and the encompassing account transaction. -func (s *TalentDispatchService) Start(identity string, character Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - if identity == "" || rule.Class != 18 || character.InvenIndex == 0 || len(targets) == 0 { - return nil, fmt.Errorf("dispatch: invalid start") - } - st, e := s.load() - if e != nil { - return nil, e - } - if b, ok := st.Starts[identity]; ok { - return append([]byte(nil), b...), nil - } - allowed := map[uint64]bool{} - for _, v := range rule.Values { - if v > 0 { - allowed[uint64(v)] = true - } - } - prefabs := map[string]bool{} - for id, r := range st.Rows { - if !r.Claimed { - prefabs[s.design[id].Prefab] = true - } - } - var rows []talentDispatchRow - for _, id := range targets { - d, ok := s.design[id] - if !ok || !allowed[id] || prefabs[d.Prefab] { - return nil, fmt.Errorf("dispatch: unavailable dispatch %d", id) - } - prefabs[d.Prefab] = true - rs, e := d.Roll(s.draw) - if e != nil { - return nil, e - } - r := talentDispatchRow{ID: id, Start: s.now().UnixMilli(), Identity: identity} - r.End = d.EndTime(time.UnixMilli(r.Start)).UnixMilli() - for _, reward := range rs { - r.Rewards = append(r.Rewards, gamedata.Reward(reward)) - } - rows = append(rows, r) - } - var b []byte - for _, r := range rows { - st.Rows[r.ID] = r - b = wire.AppendBytes(b, 8, s.rowWire(r)) - } - st.Starts[identity] = b - return b, s.save(st) -} -func (s *TalentDispatchService) Handle(path string, req []byte) (int, []byte, bool, error) { - if path != "/DispatchInfo" && path != "/DispatchReward" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - seq, ok, e := wire.Varint(req, 1) - if e != nil || !ok || seq == 0 || seq > math.MaxInt32 { - return 0, nil, true, fmt.Errorf("dispatch: missing sequence") - } - st, e := s.load() - if e != nil { - return 0, nil, true, e - } - if path == "/DispatchInfo" { - var b []byte - ids := []uint64{} - for id, r := range st.Rows { - if !r.Claimed { - ids = append(ids, id) - } - } - slices.Sort(ids) - for _, id := range ids { - b = wire.AppendBytes(b, 1, s.rowWire(st.Rows[id])) - } - return 0, b, true, nil - } - if s.session == "" { - return 108, nil, true, fmt.Errorf("dispatch: claim session unavailable") - } - var ids []uint64 - e = wire.Walk(req, func(f wire.Field) error { - if f.Number != 2 { - return nil - } - if f.Type != 0 && f.Type != 2 { - return fmt.Errorf("dispatch: invalid ids") - } - b := f.Value - for len(b) > 0 { - n, k := binary.Uvarint(b) - if k <= 0 || n == 0 || n > math.MaxInt32 || len(ids) >= len(s.design) { - return fmt.Errorf("dispatch: invalid packed ids") - } - ids = append(ids, n) - b = b[k:] - } - return nil - }) - if e != nil || len(ids) == 0 { - return 0, nil, true, fmt.Errorf("dispatch: missing reward ids") - } - digest := sha256.Sum256(req) - key := fmt.Sprintf("%s:%d", s.session, seq) - if receipt, ok := st.Claims[key]; ok { - if receipt.Digest != fmt.Sprintf("%x", digest) { - return 0, nil, true, fmt.Errorf("dispatch: conflicting claim sequence") - } - return 108, append([]byte(nil), receipt.Body...), true, nil - } - seen := map[uint64]bool{} - for _, id := range ids { - r, ok := st.Rows[id] - if !ok || seen[id] || s.now().UnixMilli() < r.End { - return 0, nil, true, fmt.Errorf("dispatch: reward not ready") - } - seen[id] = true - } - var body []byte - for _, id := range ids { - r := st.Rows[id] - if r.Claimed { - continue - } - if !r.Claimed { - bundle, e := s.economy.Apply(r.Identity+fmt.Sprintf(":dispatch:%d", id), nil, r.Rewards) - if e != nil { - return 0, nil, true, e - } - r.Bundle = bundle - r.Claimed = true - st.Rows[id] = r - } - if e = wire.Walk(r.Bundle, func(f wire.Field) error { - if f.Number == 1 && f.Type == 2 { - body = wire.AppendBytes(body, 1, f.Value) - } - return nil - }); e != nil { - return 0, nil, true, e - } - } - st.Claims[key] = talentDispatchClaim{Digest: fmt.Sprintf("%x", digest), Body: body} - return 108, body, true, s.save(st) -} diff --git a/go/internal/server/player/talent_dispatch_test.go b/go/internal/server/player/talent_dispatch_test.go deleted file mode 100644 index d93e3d4..0000000 --- a/go/internal/server/player/talent_dispatch_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "os" - "testing" - "time" -) - -type dispatchEconomyFake struct { - seen map[string]bool - grants int -} - -func (e *dispatchEconomyFake) Apply(id string, c, r []gamedata.Reward) ([]byte, error) { - if !e.seen[id] { - e.seen[id] = true - e.grants++ - } - return wire.AppendBytes(nil, 1, wire.AppendVarint(nil, 3, 4)), nil -} -func TestTalentDispatchReadyRestartRewardReplayAndPrefabExclusion(t *testing.T) { - root := "../../../../data/resources/GameData" - if _, e := os.Stat(root); e != nil { - t.Skip("installed GameData missing") - } - d, e := gamedata.LoadTalentDispatchDesign(root, "20260923193640") - if e != nil { - t.Fatal(e) - } - st := stateio.NewMemory() - eco := &dispatchEconomyFake{seen: map[string]bool{}} - s, e := OpenTalentDispatch(st, d, eco) - if e != nil { - t.Fatal(e) - } - now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC) - s.now = func() time.Time { return now } - s.BeginSession("dispatch") - s.draw = func(uint64) (uint64, error) { return 0, nil } - rule := gamedata.TalentUseRule{Class: 18, Values: []float64{1, 2}} - char := Character{InvenIndex: 99} - if _, e = s.Start("foreign", char, rule, []uint64{11}); e == nil { - t.Fatal("foreign dispatch accepted") - } - if _, e = s.Start("duplicate-prefab", char, rule, []uint64{1, 2}); e == nil { - t.Fatal("duplicate prefab accepted") - } - b, e := s.Start("start1", char, rule, []uint64{1}) - if e != nil { - t.Fatal(e) - } - b2, e := s.Start("start1", char, rule, []uint64{1}) - if e != nil || !bytes.Equal(b, b2) { - t.Fatal("start replay", e) - } - if _, e = s.Start("start2", char, rule, []uint64{2}); e == nil { - t.Fatal("active prefab reused") - } - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1) - if _, _, _, e = s.Handle("/DispatchReward", req); e == nil { - t.Fatal("claimed before end") - } - now = d[1].EndTime(now) - reopened, e := OpenTalentDispatch(st, d, eco) - if e != nil { - t.Fatal(e) - } - reopened.now = s.now - reopened.BeginSession("dispatch") - _, reward, _, e := reopened.Handle("/DispatchReward", req) - if e != nil { - t.Fatal(e) - } - _, replay, _, e := reopened.Handle("/DispatchReward", req) - if e != nil || !bytes.Equal(reward, replay) || eco.grants != 1 { - t.Fatal("reward replay", e, eco.grants) - } - newClaim := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1) - _, again, _, e := reopened.Handle("/DispatchReward", newClaim) - if e != nil || len(again) != 0 || eco.grants != 1 { - t.Fatal("new sequence emitted already claimed item deltas") - } - _, info, _, e := reopened.Handle("/DispatchInfo", wire.AppendVarint(nil, 1, 2)) - if e != nil || len(info) != 0 { - t.Fatal("claimed dispatch still active", e) - } - if _, e = reopened.Start("start3", char, rule, []uint64{2}); e != nil { - t.Fatal("claimed prefab cannot restart", e) - } -} diff --git a/go/internal/server/player/talent_healing.go b/go/internal/server/player/talent_healing.go deleted file mode 100644 index e4b66ec..0000000 --- a/go/internal/server/player/talent_healing.go +++ /dev/null @@ -1,192 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "crypto/sha256" - "encoding/binary" - "encoding/json" - "fmt" - "math" -) - -func (s *TalentUseService) charHealing(request []byte) (int, []byte, bool, error) { - return s.healing(request, "healing") -} - -func (s *TalentUseService) healing(request []byte, operation string) (int, []byte, bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - fail := func(e error) (int, []byte, bool, error) { return 62, nil, true, e } - seq, ok, err := wire.Varint(request, 1) - if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.session == "" { - return fail(fmt.Errorf("player: invalid healing sequence")) - } - index, _, err := wire.Varint(request, 2) - if err != nil || index > math.MaxInt64 { - return fail(fmt.Errorf("player: invalid healing caster")) - } - food, _, err := wire.Varint(request, 4) - if err != nil || food > math.MaxInt64 || (index == 0) == (food == 0) { - return fail(fmt.Errorf("player: healing requires caster or food")) - } - state, err := s.load() - if err != nil { - return fail(err) - } - key := s.session + ":" + operation + ":" + fmt.Sprint(seq) - digest := fmt.Sprintf("%x", sha256.Sum256(request)) - if prior, ok := state.Receipts[key]; ok { - if prior.Digest != digest { - return fail(fmt.Errorf("player: changed healing replay")) - } - return 62, prior.Body, true, nil - } - var ids []uint64 - seen := map[uint64]bool{} - err = wire.Walk(request, func(f wire.Field) error { - if f.Number != 3 { - return nil - } - if f.Type != 0 && f.Type != 2 { - return fmt.Errorf("player: invalid healing targets") - } - for raw := f.Value; len(raw) > 0; { - id, n := binary.Uvarint(raw) - if n <= 0 || id == 0 || id > math.MaxInt64 || seen[id] || len(ids) >= 4096 { - return fmt.Errorf("player: invalid healing target") - } - raw = raw[n:] - seen[id] = true - ids = append(ids, id) - } - return nil - }) - if err != nil || len(ids) == 0 { - return fail(fmt.Errorf("player: missing healing targets")) - } - if s.context == nil { - return fail(fmt.Errorf("player: healing field context unavailable")) - } - pack, _, battle, err := s.context() - if err != nil { - return fail(err) - } - if battle { - return fail(fmt.Errorf("player: healing unavailable during battle")) - } - var character Character - var rule gamedata.TalentUseRule - var item Item - if food > 0 { - for _, v := range s.inventory.All() { - if v.InvenIndex == food && v.Type == 5 { - item = v - break - } - } - g, ok := s.design.Foods[item.ID] - if !ok { - return fail(fmt.Errorf("player: invalid revival food")) - } - rule, ok = s.design.Rules[[2]uint64{g, 1}] - if !ok { - return fail(fmt.Errorf("player: missing revival food talent")) - } - item.Count = uint64(len(ids)) - if err = s.inventory.CanConsume([]Item{item}); err != nil { - return fail(err) - } - } else { - var owned bool - character, owned = s.characters.Find(index) - meta, ok := s.design.Characters[character.ID] - if !owned || !ok || meta.BannedPacks[pack] { - return fail(fmt.Errorf("player: invalid healing caster")) - } - rule, ok = s.design.Rules[[2]uint64{meta.Group, character.TalentLevel}] - if !ok { - return fail(fmt.Errorf("player: missing recovery level")) - } - } - if rule.Class != 10 || len(rule.Values) == 0 || rule.Values[0] > 100 { - return fail(fmt.Errorf("player: invalid fatigue recovery skill")) - } - var targets []Character - for _, id := range ids { - c, owned := s.characters.Find(id) - if !owned { - return fail(fmt.Errorf("player: revival target not owned")) - } - hp, e := s.characters.CurrentHealth(id) - if e != nil { - return fail(e) - } - if hp != 0 { - return fail(fmt.Errorf("player: revival target is not fatigued")) - } - maximum, e := s.characters.MaxHealth(id) - if e != nil { - return fail(e) - } - c.HP = 1 - if rule.Values[0] > 0 { - c.HP = uint64(float64(maximum) * rule.Values[0] / 100) - if c.HP == 0 { - c.HP = 1 - } - } - targets = append(targets, c) - } - identity := "talent-healing:" + key - if food > 0 { - if err = s.inventory.Consume([]Item{item}); err != nil { - return fail(err) - } - } else { - if rule.Catalyst > math.MaxUint64/uint64(len(ids)) { - return fail(fmt.Errorf("player: recovery cost overflow")) - } - if rule.Catalyst > 0 { - if rule.Catalyst > 0 { - if _, err = s.wallet.SpendCatalystOnce(identity, rule.Catalyst*uint64(len(ids))); err != nil { - return fail(err) - } - } - } - } - var out []byte - for _, c := range targets { - if err = s.characters.SetCurrentHealth(c.InvenIndex, c.HP); err != nil { - return fail(err) - } - out = wire.AppendBytes(out, 1, CharacterWire(c)) - } - if food == 0 && rule.Experience > 0 { - maximum, e := s.experienceMaximum(character) - if e != nil { - return fail(e) - } - gain := rule.Experience * uint64(len(ids)) - if character.TalentExp >= maximum { - gain = 0 - } else if gain > maximum-character.TalentExp { - gain = maximum - character.TalentExp - } - if gain > 0 { - if _, e = s.characters.AddTalentExperience(index, gain, maximum); e != nil { - return fail(e) - } - out = wire.AppendVarint(out, 2, gain) - } - } - state.Receipts[key] = talentUseReceipt{Digest: digest, Body: out} - b, err := json.Marshal(state) - if err != nil { - return fail(err) - } - if err = s.store.Save("talentuse", b); err != nil { - return fail(err) - } - return 62, out, true, nil -} diff --git a/go/internal/server/player/talent_upgrade.go b/go/internal/server/player/talent_upgrade.go deleted file mode 100644 index eb28723..0000000 --- a/go/internal/server/player/talent_upgrade.go +++ /dev/null @@ -1,193 +0,0 @@ -package player - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math" - "strconv" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -const talentSkillUpgradePacketCode = 44 - -func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool, error) { - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > math.MaxInt32 { - return 0, nil, true, errors.New("player: TalentSkillUpgrade missing sequence") - } - index, found, err := wire.Varint(request, 2) - if err != nil || !found || index == 0 || index > math.MaxInt64 { - return 0, nil, true, errors.New("player: TalentSkillUpgrade missing character") - } - materials, err := equipmentRequestItems(request, 3, "TalentSkillUpgrade") - if err != nil { - return 0, nil, true, err - } - for _, material := range materials { - if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 || - material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 || - material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 { - return 0, nil, true, errors.New("player: TalentSkillUpgrade material exceeds protocol range") - } - } - - s.mu.Lock() - defer s.mu.Unlock() - digestBytes := sha256.Sum256(request) - digest := hex.EncodeToString(digestBytes[:]) - sessionID := s.sessionID - if sessionID == "" { - sessionID = "__direct__" - } - if s.talentReplies[sessionID] == nil { - s.talentReplies[sessionID] = make(map[string]talentUpgradeReply) - } - replies := s.talentReplies[sessionID] - cacheKey := "seq:" + strconv.FormatUint(seq, 10) - if reply, ok := replies[cacheKey]; ok { - if reply.Digest != digest { - return 0, nil, true, errors.New("player: TalentSkillUpgrade sequence reused with different request") - } - return reply.Code, append([]byte(nil), reply.Body...), true, nil - } - if s.talentGrowth == nil || s.wallet == nil || s.inventory == nil { - return 0, nil, true, errors.New("player: talent upgrade unavailable") - } - - position := -1 - var current Character - fromCollection := false - for i, character := range s.characters { - if character.InvenIndex == index { - position, current = i, character - break - } - } - if position < 0 && s.collection != nil { - current, fromCollection = s.collection.FindCharacter(index) - } - if position < 0 && !fromCollection { - return 0, nil, true, fmt.Errorf("player: unknown talent character inventory index %d", index) - } - previousLedgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10) - if reply, ok := s.talentApplied[previousLedgerKey]; ok && reply.Digest == digest { - replies[cacheKey] = reply - return reply.Code, append([]byte(nil), reply.Body...), true, nil - } - rule, err := s.talentGrowth.UpgradeRule(current.ID, current.TalentLevel) - if err != nil { - return 0, nil, true, fmt.Errorf("player: resolve talent upgrade: %w", err) - } - if current.TalentExp < rule.RequiredTotalExp { - return 0, nil, true, fmt.Errorf("player: character %d talent experience %d is below required %d", index, current.TalentExp, rule.RequiredTotalExp) - } - - items, gold, err := validateTalentUpgradeMaterials(rule.Costs, materials) - if err != nil { - return 0, nil, true, err - } - if gold != 0 && !s.wallet.CanSpendGold(gold) { - return 0, nil, true, errors.New("player: insufficient gold for talent upgrade") - } - if len(items) != 0 { - if err := s.inventory.CanConsume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: validate talent upgrade items: %w", err) - } - } - - previousID := current.ID - current.TalentLevel++ - ledgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10) - if _, exists := s.talentApplied[ledgerKey]; exists { - return 0, nil, true, fmt.Errorf("player: talent upgrade ledger already contains target %s", ledgerKey) - } - if fromCollection { - if err := s.collection.CanUpdateCharacter(previousID, current); err != nil { - return 0, nil, true, fmt.Errorf("player: validate collection talent upgrade: %w", err) - } - } - identity := "talent-upgrade:" + sessionID + ":" + cacheKey + ":character:" + strconv.FormatUint(index, 10) + ":level:" + strconv.FormatUint(rule.CurrentLevel, 10) - if gold != 0 { - if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil { - return 0, nil, true, fmt.Errorf("player: consume talent upgrade gold: %w", err) - } - } - if len(items) != 0 { - if err := s.inventory.Consume(items); err != nil { - return 0, nil, true, fmt.Errorf("player: consume talent upgrade items: %w", err) - } - } - reply := talentUpgradeReply{Digest: digest, Code: talentSkillUpgradePacketCode} - ledgerPayload, err := json.Marshal(reply) - if err != nil { - return 0, nil, true, err - } - ledgerChange := stateio.EntryMutation{Bucket: "talent_upgrades", Key: ledgerKey, Payload: ledgerPayload} - if fromCollection { - if err := s.collection.UpdateCharacter(previousID, current); err != nil { - return 0, nil, true, fmt.Errorf("player: persist collection talent upgrade: %w", err) - } - if err := s.store.SaveWithEntries("characters", nil, []stateio.EntryMutation{ledgerChange}); err != nil { - return 0, nil, true, fmt.Errorf("player: persist talent upgrade replay ledger: %w", err) - } - } else { - next := append([]Character(nil), s.characters...) - next[position] = current - if err := s.persistWithChanges(next, []stateio.EntryMutation{ledgerChange}); err != nil { - return 0, nil, true, fmt.Errorf("player: persist talent upgrade: %w", err) - } - s.characters = next - } - // TalentSkillUpgradeResponse.item_info is an optional grant list. Current - // GameData defines no refund, so the correct protobuf response is empty. - s.talentApplied[ledgerKey] = reply - replies[cacheKey] = reply - return talentSkillUpgradePacketCode, nil, true, nil -} - -func validateTalentUpgradeMaterials(costs []gamedata.PromotionCost, materials []Item) ([]Item, uint64, error) { - want := make(map[[2]uint64]uint64, len(costs)) - for _, cost := range costs { - if cost.Type == 0 || cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type != 4 && cost.ID == 0) { - return nil, 0, errors.New("player: invalid GameData talent upgrade cost") - } - key := [2]uint64{cost.Type, cost.ID} - if want[key] > math.MaxUint64-cost.Count { - return nil, 0, errors.New("player: talent upgrade GameData cost overflow") - } - want[key] += cost.Count - } - got := make(map[[2]uint64]uint64, len(materials)) - var items []Item - var gold uint64 - for _, material := range materials { - key := [2]uint64{material.Type, material.ID} - if got[key] > math.MaxUint64-material.Count { - return nil, 0, errors.New("player: talent upgrade material overflow") - } - got[key] += material.Count - if material.Type == 4 { - if gold != 0 || material.InvenIndex != 0 || material.ID != 0 { - return nil, 0, errors.New("player: invalid talent upgrade currency") - } - gold = material.Count - } else { - items = append(items, material) - } - } - if len(got) != len(want) { - return nil, 0, errors.New("player: talent upgrade material kinds mismatch") - } - for key, count := range want { - if got[key] != count { - return nil, 0, fmt.Errorf("player: talent upgrade material %d/%d=%d want=%d", key[0], key[1], got[key], count) - } - } - return items, gold, nil -} diff --git a/go/internal/server/player/talent_upgrade_test.go b/go/internal/server/player/talent_upgrade_test.go deleted file mode 100644 index a36d8a4..0000000 --- a/go/internal/server/player/talent_upgrade_test.go +++ /dev/null @@ -1,447 +0,0 @@ -package player - -import ( - "math" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func testTalentGrowthDesign() *gamedata.TalentGrowthDesign { - return &gamedata.TalentGrowthDesign{ - Characters: map[uint64]gamedata.CharacterTalent{ - 140: {TalentID: 904, GrowthGroup: 904, MaxLevel: 5}, - }, - Levels: map[[2]uint64]gamedata.TalentGrowthLevel{ - {904, 1}: {Level: 1, NeedExp: 14, Costs: []gamedata.PromotionCost{{Type: 8, ID: 3, Count: 1}, {Type: 4, Count: 1000}}}, - {904, 2}: {Level: 2, NeedExp: 28, Costs: []gamedata.PromotionCost{{Type: 8, ID: 4, Count: 1}, {Type: 4, Count: 2000}}}, - {904, 3}: {Level: 3, NeedExp: 404, Costs: []gamedata.PromotionCost{{Type: 8, ID: 5, Count: 1}, {Type: 4, Count: 4000}}}, - {904, 4}: {Level: 4, NeedExp: 1008, Costs: []gamedata.PromotionCost{{Type: 8, ID: 6, Count: 1}, {Type: 4, Count: 10000}}}, - }, - } -} - -func talentUpgradeRequest(seq, character uint64, materials ...Item) []byte { - request := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, character) - for _, material := range materials { - request = wire.AppendBytes(request, 3, ItemWire(material)) - } - return request -} - -func TestTalentSkillUpgradeConsumesExactCostsPersistsAndReplays(t *testing.T) { - dir := t.TempDir() - store := testStore(filepath.Join(dir, "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("talent-books", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 2}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 5000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - if err := characters.AttachTalentGrowth(testTalentGrowthDesign()); err != nil { - t.Fatal(err) - } - characters.BeginSession("login-a") - book := books[0] - book.Count = 1 - request := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 1000}) - code, response, handled, err := characters.Handle("/TalentSkillUpgrade", request) - if err != nil || !handled || code != talentSkillUpgradePacketCode || len(response) != 0 { - t.Fatalf("upgrade code=%d handled=%v response=%x err=%v", code, handled, response, err) - } - got, found := characters.Find(77) - if !found || got.TalentLevel != 2 || got.TalentExp != 14 { - t.Fatalf("upgraded character=%+v found=%v", got, found) - } - if wallet.Snapshot().Gold != 4000 { - t.Fatalf("gold=%d", wallet.Snapshot().Gold) - } - if err := inventory.CanConsume([]Item{book}); err != nil { - t.Fatalf("one talent book should remain: %v", err) - } - // Session dispatch activates SessionAware handlers before every request. - // Re-activating the same session must retain the sequence replay result. - characters.BeginSession("login-a") - code, response, handled, err = characters.Handle("/TalentSkillUpgrade", request) - if err != nil || !handled || code != talentSkillUpgradePacketCode || len(response) != 0 { - t.Fatalf("replay code=%d handled=%v response=%x err=%v", code, handled, response, err) - } - if wallet.Snapshot().Gold != 4000 { - t.Fatal("replay charged gold again") - } - if err := inventory.CanConsume([]Item{book}); err != nil { - t.Fatal("replay consumed the remaining talent book") - } - reloaded, err := OpenCharacterStore(store, nil, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if got, found := reloaded.Find(77); !found || got.TalentLevel != 2 || got.TalentExp != 14 { - t.Fatalf("reloaded character=%+v found=%v", got, found) - } - if err := reloaded.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - if err := reloaded.AttachTalentGrowth(testTalentGrowthDesign()); err != nil { - t.Fatal(err) - } - reloaded.BeginSession("login-after-restart") - if code, body, handled, err := reloaded.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 { - t.Fatalf("restart replay code=%d handled=%v body=%x err=%v", code, handled, body, err) - } - if got, _ := reloaded.Find(77); got.TalentLevel != 2 || wallet.Snapshot().Gold != 4000 { - t.Fatalf("restart replay duplicated upgrade: character=%+v gold=%d", got, wallet.Snapshot().Gold) - } -} - -func TestTalentSkillUpgradeRejectsSameSessionSequenceWithDifferentRequest(t *testing.T) { - store := testStore(filepath.Join(t.TempDir(), "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("books", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 2}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 5000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(testTalentGrowthDesign()) - characters.BeginSession("login-a") - book := books[0] - book.Count = 1 - request := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 1000}) - if _, _, _, err := characters.Handle("/TalentSkillUpgrade", request); err != nil { - t.Fatal(err) - } - different := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 999}) - if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", different); err == nil || !handled { - t.Fatalf("different request replay handled=%v err=%v", handled, err) - } - if got, _ := characters.Find(77); got.TalentLevel != 2 || wallet.Snapshot().Gold != 4000 { - t.Fatalf("different replay mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold) - } -} - -func TestTalentSkillUpgradeRetainsSequenceDigestAcrossInterleavedSessions(t *testing.T) { - store := testStore(filepath.Join(t.TempDir(), "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("interleaved-books", []gamedata.BattleReward{ - {Type: 8, ID: 3, Count: 1}, - {Type: 8, ID: 4, Count: 1}, - }) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 5000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 42}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(testTalentGrowthDesign()) - characters.BeginSession("login-a") - first := talentUpgradeRequest(9, 77, books[0], Item{Type: 4, Count: 1000}) - if _, _, _, err := characters.Handle("/TalentSkillUpgrade", first); err != nil { - t.Fatal(err) - } - characters.BeginSession("login-b") - characters.BeginSession("login-a") - second := talentUpgradeRequest(9, 77, books[1], Item{Type: 4, Count: 2000}) - if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", second); err == nil || !handled { - t.Fatalf("interleaved session forgot sequence digest: handled=%v err=%v", handled, err) - } - if got, _ := characters.Find(77); got.TalentLevel != 2 || got.TalentExp != 42 || wallet.Snapshot().Gold != 4000 { - t.Fatalf("reused sequence mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold) - } - if err := inventory.CanConsume([]Item{books[1]}); err != nil { - t.Fatalf("reused sequence consumed second-rank material: %v", err) - } -} - -func TestTalentSkillUpgradeRejectsOutOfRangeItemMetadata(t *testing.T) { - store := testStore(filepath.Join(t.TempDir(), "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("range-book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(testTalentGrowthDesign()) - book := books[0] - book.Count = 1 - book.ExpiryTime = math.MaxInt64 + 1 - request := talentUpgradeRequest(1, 77, book, Item{Type: 4, Count: 1000}) - if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", request); err == nil || !handled { - t.Fatalf("out-of-range metadata accepted: handled=%v err=%v", handled, err) - } - if got, _ := characters.Find(77); got.TalentLevel != 1 || wallet.Snapshot().Gold != 1000 { - t.Fatalf("out-of-range request mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold) - } -} - -func TestTalentSkillUpgradeConsumesOneMaterialAcrossMultipleStacks(t *testing.T) { - store := testStore(filepath.Join(t.TempDir(), "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("split-books", []gamedata.BattleReward{ - {Type: 8, ID: 3, Count: 1}, - {Type: 8, ID: 3, Count: 1}, - }) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - design := testTalentGrowthDesign() - level := design.Levels[[2]uint64{904, 1}] - level.Costs = []gamedata.PromotionCost{{Type: 8, ID: 3, Count: 2}, {Type: 4, Count: 1000}} - design.Levels[[2]uint64{904, 1}] = level - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(design) - request := talentUpgradeRequest(1, 77, books[0], books[1], Item{Type: 4, Count: 1000}) - if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 { - t.Fatalf("split-stack upgrade code=%d handled=%v body=%x err=%v", code, handled, body, err) - } - if got, _ := characters.Find(77); got.TalentLevel != 2 || got.TalentExp != 14 || wallet.Snapshot().Gold != 0 { - t.Fatalf("split-stack result character=%+v gold=%d", got, wallet.Snapshot().Gold) - } - if err := inventory.CanConsume([]Item{books[0]}); err == nil { - t.Fatal("first material stack survived consumption") - } - if err := inventory.CanConsume([]Item{books[1]}); err == nil { - t.Fatal("second material stack survived consumption") - } -} - -func TestTalentSkillUpgradeLedgerSurvivesSQLiteCloseAndReopen(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repository, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - inventory, err := OpenInventory(repository, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("sqlite-talent-book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(repository, Currency{Gold: 5000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(repository, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, characters.EnsurePersisted} { - if err := ensure(); err != nil { - t.Fatal(err) - } - } - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(testTalentGrowthDesign()) - characters.BeginSession("before-restart") - request := talentUpgradeRequest(9, 77, books[0], Item{Type: 4, Count: 1000}) - operation, err := repository.BeginOperation() - if err != nil { - t.Fatal(err) - } - if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 { - _ = operation.Rollback() - t.Fatalf("upgrade code=%d handled=%v body=%x err=%v", code, handled, body, err) - } - if err := operation.Commit(); err != nil { - t.Fatal(err) - } - if err := repository.Close(); err != nil { - t.Fatal(err) - } - - repository, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repository.Close(); err != nil { - t.Error(err) - } - }() - inventory, err = OpenInventory(repository, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err = OpenWallet(repository, Currency{}) - if err != nil { - t.Fatal(err) - } - characters, err = OpenCharacterStore(repository, nil, inventory, "", "") - if err != nil { - t.Fatal(err) - } - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(testTalentGrowthDesign()) - characters.BeginSession("after-restart") - operation, err = repository.BeginOperation() - if err != nil { - t.Fatal(err) - } - if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 { - _ = operation.Rollback() - t.Fatalf("replay code=%d handled=%v body=%x err=%v", code, handled, body, err) - } - if err := operation.Commit(); err != nil { - t.Fatal(err) - } - if current, found := characters.Find(77); !found || current.TalentLevel != 2 || current.TalentExp != 14 || wallet.Snapshot().Gold != 4000 { - t.Fatalf("restarted state character=%+v found=%v gold=%d", current, found, wallet.Snapshot().Gold) - } -} - -func TestTalentSkillUpgradeRejectsInvalidStateWithoutCharging(t *testing.T) { - tests := []struct { - name string - level uint64 - experience uint64 - materialID uint64 - gold uint64 - }{ - {name: "experience", level: 1, experience: 13, materialID: 3, gold: 1000}, - {name: "wrong-book", level: 1, experience: 14, materialID: 4, gold: 1000}, - {name: "wrong-gold", level: 1, experience: 14, materialID: 3, gold: 999}, - {name: "maximum", level: 5, experience: 1454, materialID: 6, gold: 10000}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - dir := t.TempDir() - store := testStore(filepath.Join(dir, "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("books", []gamedata.BattleReward{{Type: 8, ID: test.materialID, Count: 2}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 20000}) - if err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: test.level, TalentExp: test.experience}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(testTalentGrowthDesign()) - book := books[0] - book.Count = 1 - request := talentUpgradeRequest(1, 77, book, Item{Type: 4, Count: test.gold}) - if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", request); err == nil || !handled { - t.Fatalf("invalid upgrade accepted: handled=%v err=%v", handled, err) - } - if wallet.Snapshot().Gold != 20000 { - t.Fatal("invalid upgrade charged gold") - } - if err := inventory.CanConsume([]Item{books[0]}); err != nil { - t.Fatalf("invalid upgrade consumed a book: %v", err) - } - if current, _ := characters.Find(77); current.TalentLevel != test.level || current.TalentExp != test.experience { - t.Fatalf("invalid upgrade changed character: %+v", current) - } - }) - } -} - -func TestTalentSkillUpgradePersistsCollectionCharacter(t *testing.T) { - dir := t.TempDir() - store := testStore(filepath.Join(dir, "state.json")) - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - books, err := inventory.GrantOnce("book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}}) - if err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Gold: 1000}) - if err != nil { - t.Fatal(err) - } - collection, err := OpenCollectionStore(store, nil) - if err != nil { - t.Fatal(err) - } - next := cloneCollection(collection.data) - next.Characters = []Character{{InvenIndex: 920000021, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}} - if err := collection.commit(next); err != nil { - t.Fatal(err) - } - characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, TalentLevel: 1}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - _ = characters.AttachCollection(collection) - _ = characters.AttachWallet(wallet) - _ = characters.AttachTalentGrowth(testTalentGrowthDesign()) - request := talentUpgradeRequest(4, 920000021, books[0], Item{Type: 4, Count: 1000}) - if code, _, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode { - t.Fatalf("collection upgrade code=%d handled=%v err=%v", code, handled, err) - } - reloaded, err := OpenCollectionStore(store, nil) - if err != nil { - t.Fatal(err) - } - if got, found := reloaded.FindCharacter(920000021); !found || got.TalentLevel != 2 || got.TalentExp != 14 { - t.Fatalf("reloaded collection character=%+v found=%v", got, found) - } -} diff --git a/go/internal/server/player/talent_use.go b/go/internal/server/player/talent_use.go deleted file mode 100644 index 12f148d..0000000 --- a/go/internal/server/player/talent_use.go +++ /dev/null @@ -1,521 +0,0 @@ -package player - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "crypto/rand" - "crypto/sha256" - "encoding/binary" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "math/big" - "slices" - "sort" - "sync" - "time" -) - -type TalentUseContext func() (pack int, mapID uint64, battle bool, err error) -type TalentUseEffect func(identity string, character Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) -type TalentUseEconomy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} -type talentUseState struct { - End int64 - Count uint64 - Day string - Level uint64 - Settled bool -} -type talentUseReceipt struct { - Digest string - Body []byte -} -type talentUseSnapshot struct { - Skills map[uint64]talentUseState - NPCs map[string]int64 - Receipts map[string]talentUseReceipt - Discounts map[string]uint64 -} -type TalentUseService struct { - mu sync.Mutex - design *gamedata.TalentUseDesign - store stateio.Store - characters *CharacterStore - inventory *Inventory - wallet *Wallet - economy TalentUseEconomy - context TalentUseContext - effects map[uint64]TalentUseEffect - session string - now func() time.Time -} - -func NewTalentUseService(d *gamedata.TalentUseDesign, store stateio.Store, characters *CharacterStore, inventory *Inventory, wallet *Wallet, economy TalentUseEconomy) (*TalentUseService, error) { - if d == nil || store == nil || characters == nil || inventory == nil || wallet == nil || economy == nil { - return nil, fmt.Errorf("player: missing talent use dependencies") - } - return &TalentUseService{design: d, store: store, characters: characters, inventory: inventory, wallet: wallet, economy: economy, effects: map[uint64]TalentUseEffect{}, now: time.Now}, nil -} -func (s *TalentUseService) AttachContext(f TalentUseContext) { s.context = f } -func (s *TalentUseService) AttachEffect(class uint64, f TalentUseEffect) { s.effects[class] = f } -func (s *TalentUseService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id } -func (s *TalentUseService) load() (talentUseSnapshot, error) { - v := talentUseSnapshot{Skills: map[uint64]talentUseState{}, NPCs: map[string]int64{}, Receipts: map[string]talentUseReceipt{}, Discounts: map[string]uint64{}} - b, e := s.store.Load("talentuse") - if e != nil { - return v, e - } - if b != nil { - if e = stateio.RequireExactJSONObject(b, "Skills", "NPCs", "Receipts", "Discounts"); e != nil { - return v, e - } - if e = json.Unmarshal(b, &v); e != nil || v.Skills == nil || v.NPCs == nil || v.Receipts == nil || v.Discounts == nil { - return v, fmt.Errorf("player: invalid talent use state") - } - } - return v, nil -} -func (s *TalentUseService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path == "/CharHealing" { - return s.charHealing(request) - } - if path != "/TalentSkillUse" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - fail := func(e error) (int, []byte, bool, error) { return 43, nil, true, e } - seq, ok, err := wire.Varint(request, 1) - if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.session == "" { - return fail(fmt.Errorf("player: invalid talent request sequence")) - } - index, _, err := wire.Varint(request, 2) - if err != nil || index > math.MaxInt64 { - return fail(fmt.Errorf("player: invalid talent caster")) - } - food, _, err := wire.Varint(request, 4) - if err != nil || food > math.MaxInt64 || (index == 0) == (food == 0) { - return fail(fmt.Errorf("player: talent requires a character or a skill food")) - } - var targets []uint64 - seen := map[uint64]bool{} - err = wire.Walk(request, func(f wire.Field) error { - if f.Number != 3 { - return nil - } - if f.Type != 0 && f.Type != 2 { - return fmt.Errorf("player: invalid talent target wire") - } - for raw := f.Value; len(raw) > 0; { - v, n := binary.Uvarint(raw) - if n <= 0 || v == 0 || v > math.MaxInt32 || seen[v] || len(targets) >= 4096 { - return fmt.Errorf("player: invalid or duplicate talent target") - } - raw = raw[n:] - seen[v] = true - targets = append(targets, v) - } - return nil - }) - if err != nil { - return fail(err) - } - state, err := s.load() - if err != nil { - return fail(err) - } - digest := fmt.Sprintf("%x", sha256.Sum256(request)) - key := s.session + ":" + fmt.Sprint(seq) - if prior, ok := state.Receipts[key]; ok { - if prior.Digest != digest { - return fail(fmt.Errorf("player: changed talent replay")) - } - return 43, prior.Body, true, nil - } - var character Character - var meta gamedata.TalentUseCharacter - var foodItem Item - if food > 0 { - for _, item := range s.inventory.All() { - if item.InvenIndex == food && item.Type == 5 && item.Count > 0 { - foodItem = item - break - } - } - group, exists := s.design.Foods[foodItem.ID] - if !exists { - return fail(fmt.Errorf("player: unavailable skill food")) - } - meta = gamedata.TalentUseCharacter{Group: group, MaxLevel: 1} - character.TalentLevel = 1 - } else { - var owned bool - character, owned = s.characters.Find(index) - if !owned || character.TalentLevel == 0 { - return fail(fmt.Errorf("player: talent caster unavailable")) - } - var exists bool - meta, exists = s.design.Characters[character.ID] - if !exists || character.TalentLevel > meta.MaxLevel { - return fail(fmt.Errorf("player: character talent design unavailable")) - } - } - rule, ok := s.design.Rules[[2]uint64{meta.Group, character.TalentLevel}] - if !ok { - return fail(fmt.Errorf("player: talent level design unavailable")) - } - if s.context == nil { - return fail(fmt.Errorf("player: talent field context unavailable")) - } - pack, mapID, battle, err := s.context() - if err != nil { - return fail(err) - } - if battle || meta.BannedPacks[pack] { - return fail(fmt.Errorf("player: talent unavailable in current field")) - } - if rule.Class == 7 || rule.Class == 8 || rule.Class == 9 || rule.Class == 10 || rule.Class == 14 { - return fail(fmt.Errorf("player: talent class %d uses its dedicated crafting/recovery packet", rule.Class)) - } - day := s.now().UTC().Add(9*time.Hour - s.design.ResetSchedule.DailyReset).Format("2006-01-02") - current := state.Skills[rule.Group] - dailyLimit := rule.Class == 3 || rule.Class == 4 || rule.Class == 20 - if (rule.Reset == 1 || dailyLimit) && current.Day != day { - current.Count = 0 - current.Day = day - } - if dailyLimit && (len(rule.Values) == 0 || current.Count >= uint64(rule.Values[0])) { - return fail(fmt.Errorf("player: talent daily usage limit reached")) - } - cooldown := talentHasCooldown(rule.Class) - if cooldown && current.End > s.now().UnixMilli() { - return fail(fmt.Errorf("player: talent effect is already active")) - } - identity := "talent-use:" + hex.EncodeToString(sha256Sum([]byte(key))) - multiplier := uint64(1) - if rule.Class != 4 && rule.Class != 20 && len(targets) > 0 { - multiplier = uint64(len(targets)) - } - if food > 0 { - rule.Catalyst = 0 - rule.Experience = 0 - foodItem.Count = 1 - if err = s.inventory.CanConsume([]Item{foodItem}); err != nil { - return fail(err) - } - } - if rule.Catalyst > math.MaxUint64/multiplier || rule.Catalyst > 0 && !s.wallet.CanSpendCatalyst(rule.Catalyst*multiplier) { - return fail(fmt.Errorf("player: insufficient talent catalyst")) - } - var extra []byte - success := true - if talentNPCClass(rule.Class) { - if len(targets) != 1 || s.design.NPCs == nil { - return fail(fmt.Errorf("player: talent requires one NPC")) - } - npcs, e := s.design.NPCs(pack) - if e != nil { - return fail(e) - } - npc, ok := npcs[targets[0]] - if !ok || npc.MapID != mapID { - return fail(fmt.Errorf("player: talent NPC outside current map")) - } - matched := -1 - for i, g := range npc.Groups { - if g == rule.Group { - matched = i - break - } - } - if matched < 0 { - return fail(fmt.Errorf("player: NPC does not support talent")) - } - npcKey := fmt.Sprintf("%d/%d/%d", pack, npc.ID, rule.Group) - if rule.Class != 16 && state.NPCs[npcKey] > s.now().UnixMilli() { - return fail(fmt.Errorf("player: NPC talent cooldown active")) - } - if rule.Class == 1 || rule.Class == 16 { - probability := 100.0 - if len(rule.Values) > 0 { - probability = rule.Values[0] - } - draw, e := rand.Int(rand.Reader, big.NewInt(1000000)) - if e != nil { - return fail(e) - } - success = float64(draw.Int64()) < probability*10000 - } - if success && npc.Rewards[matched] > 0 { - rewards, ok := s.design.Rewards[npc.Rewards[matched]] - if !ok { - return fail(fmt.Errorf("player: missing NPC talent reward")) - } - bundle, e := s.economy.Apply(identity, nil, rewards) - if e != nil { - return fail(e) - } - extra, e = talentBundleFields(bundle) - if e != nil { - return fail(e) - } - } - if rule.Class == 19 && len(npc.CharmCharacters) > 0 { - b, e := s.applyCharm(identity, character, rule, targets) - if e != nil { - return fail(e) - } - extra = append(extra, b...) - } - end := s.now().UnixMilli() - if rule.Class == 16 { - if len(rule.Values) < 2 || rule.Values[1] > 100 { - return fail(fmt.Errorf("player: invalid bargain discount")) - } - if success { - state.Discounts[s.session+":"+fmt.Sprintf("%d/%d", pack, npc.ID)] = uint64(rule.Values[1]) - } - } else if rule.Class == 1 && rule.Reset != 0 { - end = s.nextNPCReset(rule.Reset).UnixMilli() - } else if len(rule.Values) > 1 { - end += int64(rule.Values[1]) * 1000 - } - state.NPCs[npcKey] = end - row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, npc.ID), 2, rule.Group), 3, uint64(end)) - extra = wire.AppendBytes(extra, 2, row) - if !success && rule.Reputation > 0 { - effect := s.effects[100] - if effect == nil { - return fail(fmt.Errorf("player: failed talent reputation executor unavailable")) - } - b, e := effect(identity, character, rule, targets) - if e != nil { - return fail(e) - } - extra = append(extra, b...) - } - } else if effect := s.effects[rule.Class]; effect != nil { - extra, err = effect(identity, character, rule, targets) - if err != nil { - return fail(err) - } - } else if rule.Class == 4 || rule.Class == 18 || rule.Class == 20 { - return fail(fmt.Errorf("player: field talent executor unavailable")) - } else if len(targets) > 0 && rule.Class != 3 && rule.Class != 11 { - return fail(fmt.Errorf("player: unexpected talent targets")) - } - if food > 0 { - if err = s.inventory.Consume([]Item{foodItem}); err != nil { - return fail(err) - } - } - if rule.Catalyst > 0 { - if rule.Catalyst > math.MaxUint64/multiplier { - return fail(fmt.Errorf("player: talent cost overflow")) - } - if _, err = s.wallet.SpendCatalystOnce(identity, rule.Catalyst*multiplier); err != nil { - return fail(err) - } - } - gain := rule.Experience - if gain > 0 { - maximum, e := s.experienceMaximum(character) - if e != nil { - return fail(e) - } - if character.TalentExp >= maximum { - gain = 0 - } else if gain > maximum-character.TalentExp { - gain = maximum - character.TalentExp - } - if gain > 0 { - if _, e = s.characters.AddTalentExperience(index, gain, maximum); e != nil { - return fail(e) - } - } - } - current.Count++ - current.Settled = false - current.Level = rule.Level - current.Day = day - current.End = s.now().UnixMilli() - if cooldown && len(rule.Values) > 1 { - current.End += int64(rule.Values[1]) * 1000 - } - state.Skills[rule.Group] = current - out := append([]byte(nil), extra...) - out = wire.AppendBytes(out, 1, talentUseWire(rule.Group, current, rule)) - if gain > 0 { - out = wire.AppendVarint(out, 10, gain) - } - if success { - out = wire.AppendVarint(out, 11, 1) - } - state.Receipts[key] = talentUseReceipt{Digest: digest, Body: out} - b, err := json.Marshal(state) - if err != nil { - return fail(err) - } - if err = s.store.Save("talentuse", b); err != nil { - return fail(err) - } - return 43, out, true, nil -} -func sha256Sum(b []byte) []byte { v := sha256.Sum256(b); return v[:] } -func talentNPCClass(class uint64) bool { return class == 1 || class == 5 || class == 16 || class == 19 } -func talentHasCooldown(class uint64) bool { - return class == 2 || class == 6 || class == 12 || class == 13 || class == 15 || class == 17 -} -func talentUseWire(group uint64, state talentUseState, rule gamedata.TalentUseRule) []byte { - b := wire.AppendVarint(nil, 1, group) - if state.End > 0 { - b = wire.AppendVarint(b, 2, uint64(state.End)) - } - if talentHasCooldown(rule.Class) && len(rule.Values) > 1 { - b = wire.AppendVarint(b, 3, uint64(rule.Values[1])) - } - return wire.AppendVarint(b, 4, state.Count) -} -func (s *TalentUseService) experienceMaximum(c Character) (uint64, error) { - if s.design.Growth == nil { - return 0, fmt.Errorf("player: talent growth design missing") - } - meta, ok := s.design.Growth.Characters[c.ID] - if !ok { - return 0, fmt.Errorf("player: missing character growth") - } - var total uint64 - for level := uint64(1); level <= c.TalentLevel; level++ { - v, ok := s.design.Growth.Levels[[2]uint64{meta.GrowthGroup, level}] - if !ok { - return 0, fmt.Errorf("player: missing talent growth level") - } - total += v.NeedExp - } - return total, nil -} -func talentBundleFields(bundle []byte) ([]byte, error) { - var out []byte - err := wire.Walk(bundle, func(f wire.Field) error { - to := map[int]int{1: 3, 2: 5, 3: 6, 4: 4}[f.Number] - if to > 0 { - out = wire.AppendBytes(out, to, f.Value) - } - return nil - }) - return out, err -} -func (s *TalentUseService) PackInfo(pack int) ([]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.load() - if e != nil { - return nil, e - } - var groups []uint64 - for g := range v.Skills { - groups = append(groups, g) - } - slices.Sort(groups) - var b []byte - for _, g := range groups { - state := v.Skills[g] - rule := s.design.Rules[[2]uint64{g, state.Level}] - day := s.now().UTC().Add(9*time.Hour - s.design.ResetSchedule.DailyReset).Format("2006-01-02") - if (rule.Reset == 1 || rule.Class == 3 || rule.Class == 4 || rule.Class == 20) && state.Day != day { - state.Count = 0 - } - b = wire.AppendBytes(b, 13, talentUseWire(g, state, rule)) - } - var npcKeys []string - for key := range v.NPCs { - npcKeys = append(npcKeys, key) - } - sort.Strings(npcKeys) - for _, key := range npcKeys { - end := v.NPCs[key] - var p int - var npc, g uint64 - if _, e = fmt.Sscanf(key, "%d/%d/%d", &p, &npc, &g); e != nil { - return nil, e - } - if p == pack { - row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, npc), 2, g), 3, uint64(end)) - b = wire.AppendBytes(b, 5, row) - } - } - return b, nil -} - -func (s *TalentUseService) nextNPCReset(reset uint64) time.Time { - shift := 9*time.Hour - s.design.ResetSchedule.DailyReset - clock := s.now().UTC().Add(shift) - next := time.Date(clock.Year(), clock.Month(), clock.Day()+1, 0, 0, 0, 0, time.UTC) - if reset == 2 { - for next.Weekday() != s.design.ResetSchedule.WeeklyDay { - next = next.AddDate(0, 0, 1) - } - } - return next.Add(-shift) -} - -func (s *TalentUseService) ShopDiscount(pack, npc uint64) (uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - v, e := s.load() - if e != nil { - return 0, e - } - return v.Discounts[s.session+":"+fmt.Sprintf("%d/%d", pack, npc)], nil -} - -// ConsumeOverwhelm authorizes the follow-up result for one successful cast. -// The one-minute handoff window is this server's request policy, not a claimed -// official skill duration; the field domain validates every actual monster. -func (s *TalentUseService) ConsumeOverwhelm(identity string, count uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - if identity == "" || count == 0 { - return fmt.Errorf("player: invalid overwhelm settlement") - } - v, err := s.load() - if err != nil { - return err - } - receiptKey := "overwhelm:" + identity - digest := fmt.Sprint(count) - if prior, ok := v.Receipts[receiptKey]; ok { - if prior.Digest != digest { - return fmt.Errorf("player: changed overwhelm settlement") - } - return nil - } - var chosen uint64 - var latest int64 - for group, state := range v.Skills { - rule, ok := s.design.Rules[[2]uint64{group, state.Level}] - if !ok || rule.Class != 3 || state.Settled || state.Count == 0 { - continue - } - if state.End <= s.now().UnixMilli() && s.now().UnixMilli()-state.End <= 60000 && state.End > latest { - chosen = group - latest = state.End - } - } - if chosen == 0 { - return fmt.Errorf("player: overwhelm result has no pending successful cast") - } - state := v.Skills[chosen] - state.Settled = true - v.Skills[chosen] = state - v.Receipts[receiptKey] = talentUseReceipt{Digest: digest} - b, err := json.Marshal(v) - if err != nil { - return err - } - return s.store.Save("talentuse", b) -} diff --git a/go/internal/server/player/talent_use_test.go b/go/internal/server/player/talent_use_test.go deleted file mode 100644 index 876f531..0000000 --- a/go/internal/server/player/talent_use_test.go +++ /dev/null @@ -1,428 +0,0 @@ -package player - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/json" - "errors" - "path/filepath" - "testing" - "time" -) - -type talentEconomyTest struct { - inventory *Inventory - calls int -} - -func (e *talentEconomyTest) Apply(identity string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) { - e.calls++ - var rewards []gamedata.BattleReward - for _, v := range r { - rewards = append(rewards, gamedata.BattleReward{Type: v.Type, ID: v.ID, Count: v.Count}) //nolint:staticcheck // S1016 - } - items, err := e.inventory.GrantOnce(identity, rewards) - if err != nil { - return nil, err - } - var out []byte - for _, v := range items { - out = wire.AppendBytes(out, 1, ItemWire(v)) - } - return out, nil -} -func talentTest(t *testing.T, store stateio.Store, class uint64) (*TalentUseService, *CharacterStore, *Wallet, *talentEconomyTest) { - t.Helper() - inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - chars, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100, TalentLevel: 1}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err = chars.EnsurePersisted(); err != nil { - t.Fatal(err) - } - wallet, err := OpenWallet(store, Currency{Catalyst: 50}) - if err != nil { - t.Fatal(err) - } - if err = wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - d := &gamedata.TalentUseDesign{Characters: map[uint64]gamedata.TalentUseCharacter{350: {Group: 42, MaxLevel: 5}}, Rules: map[[2]uint64]gamedata.TalentUseRule{{42, 1}: {Group: 42, Level: 1, Class: class, Catalyst: 5, Experience: 3, Values: []float64{100, 10, 0}}}, Rewards: map[uint64][]gamedata.Reward{9: {{Type: 8, ID: 987, Count: 2}}}, Growth: &gamedata.TalentGrowthDesign{Characters: map[uint64]gamedata.CharacterTalent{350: {GrowthGroup: 6, MaxLevel: 5}}, Levels: map[[2]uint64]gamedata.TalentGrowthLevel{{6, 1}: {Level: 1, NeedExp: 10}}}, NPCs: func(int) (map[uint64]gamedata.TalentNPC, error) { - return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{9}}}, nil - }} - e := &talentEconomyTest{inventory: inventory} - s, err := NewTalentUseService(d, store, chars, inventory, wallet, e) - if err != nil { - t.Fatal(err) - } - s.BeginSession("test") - s.now = func() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) } - s.AttachContext(func() (int, uint64, bool, error) { return 21, 211, false, nil }) - return s, chars, wallet, e -} -func talentRequest(seq uint64, targets ...uint64) []byte { - b := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, 77) - for _, id := range targets { - b = wire.AppendVarint(b, 3, id) - } - return b -} - -func TestTalentNPCRewardCostExperienceAndRestartReplay(t *testing.T) { - store := stateio.NewMemory() - s, chars, wallet, e := talentTest(t, store, 1) - b := talentRequest(1, 11) - code, body, handled, err := s.Handle("/TalentSkillUse", b) - if err != nil || code != 43 || !handled { - t.Fatal(err) - } - c, _ := chars.Find(77) - if wallet.Snapshot().Catalyst != 45 || c.TalentExp != 3 || e.calls != 1 { - t.Fatal("talent did not charge/grant experience/reward") - } - if reward, ok, _ := wire.Bytes(body, 3); !ok || len(reward) == 0 { - t.Fatal("talent omitted actual reward") - } - next, _, _, _ := talentTest(t, store, 1) - _, again, _, err := next.Handle("/TalentSkillUse", b) - if err != nil || !bytes.Equal(body, again) { - t.Fatal("restart lost exact talent replay") - } - if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(2, 11)); err == nil { - t.Fatal("NPC cooldown permitted repeat theft") - } - if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(1, 12)); err == nil { - t.Fatal("changed sequence accepted") - } -} - -func TestTalentDurationAndDailyLimitsComeFromDesign(t *testing.T) { - s, _, _, _ := talentTest(t, stateio.NewMemory(), 13) - now := s.now() - s.now = func() time.Time { return now } - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1)); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2)); err == nil { - t.Fatal("duration skill reused while active") - } - now = now.Add(10 * time.Second) - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(3)); err != nil { - t.Fatal("duration did not expire") - } - s, _, _, _ = talentTest(t, stateio.NewMemory(), 4) - now = s.now() - s.now = func() time.Time { return now } - r := s.design.Rules[[2]uint64{42, 1}] - r.Values[0] = 1 - s.design.Rules[[2]uint64{42, 1}] = r - s.AttachEffect(4, func(string, Character, gamedata.TalentUseRule, []uint64) ([]byte, error) { return nil, nil }) - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 3)); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2, 3)); err == nil { - t.Fatal("daily skill limit ignored") - } - now = now.Add(24 * time.Hour) - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(3, 3)); err != nil { - t.Fatal("daily limit did not reset") - } -} - -type talentFailStore struct{ stateio.Store } - -func (s talentFailStore) Save(name string, b []byte) error { - if name == "talentuse" { - return errors.New("receipt write failed") - } - return s.Store.Save(name, b) -} -func TestTalentReceiptFailureRollsBackRewardCostAndExperience(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s, _, _, _ := talentTest(t, repo, 1) - s.store = talentFailStore{repo} - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/TalentSkillUse", talentRequest(1, 11)); err == nil { - t.Fatal("receipt failure ignored") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - next, chars, wallet, e := talentTest(t, repo, 1) - c, _ := chars.Find(77) - if c.TalentExp != 0 || wallet.Snapshot().Catalyst != 50 || len(e.inventory.All()) != 0 { - t.Fatal("partial talent mutation survived rollback") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(1, 11)); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } -} - -func TestTalentSkillFoodConsumesOneWithoutChargingCasterResources(t *testing.T) { - s, _, wallet, _ := talentTest(t, stateio.NewMemory(), 13) - s.design.Foods = map[uint64]uint64{22: 42} - items, err := s.inventory.GrantOnce("test-food", []gamedata.BattleReward{{Type: 5, ID: 22, Count: 2}}) - if err != nil { - t.Fatal(err) - } - b := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 4, items[0].InvenIndex) - _, reply, _, err := s.Handle("/TalentSkillUse", b) - if err != nil { - t.Fatal(err) - } - if s.inventory.All()[0].Count != 1 || wallet.Snapshot().Catalyst != 50 { - t.Fatal("skill food duplicated catalyst charge or did not consume one") - } - _, again, _, err := s.Handle("/TalentSkillUse", b) - if err != nil || !bytes.Equal(reply, again) || s.inventory.All()[0].Count != 1 { - t.Fatal("skill food retry consumed twice") - } -} - -func TestCharmCompanionsExpireWithoutPermanentCollectionGrant(t *testing.T) { - s, chars, _, _ := talentTest(t, stateio.NewMemory(), 19) - s.now = func() time.Time { return time.Now() } - s.design.NPCs = func(int) (map[uint64]gamedata.TalentNPC, error) { - return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{0}, CharmCharacters: []uint64{999}}}, nil - } - s.design.CharmCharacter = func(uint64) (gamedata.StoryCharacterDesign, error) { - return gamedata.StoryCharacterDesign{CharacterID: 999, Level: 1, CostumeID: 111, HP: 100}, nil - } - _, reply, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 11)) - if err != nil { - t.Fatal(err) - } - w, ok, _ := wire.Bytes(reply, 5) - if !ok { - t.Fatal("charm did not return companion") - } - index, _, _ := wire.Varint(w, 1) - c, found := chars.Find(index) - if !found || !IsCharmCharacter(c) || c.ExpiryTime == 0 || len(chars.All()) != 2 { - t.Fatal("charm was not a temporary visible character") - } - c.ExpiryTime = uint64(time.Now().Add(-time.Second).UnixMilli()) - if err = chars.ensureCharmCharacters([]Character{c}); err != nil { - t.Fatal(err) - } - if _, found = chars.Find(index); found || len(chars.All()) != 1 { - t.Fatal("expired charm companion remained playable") - } - if chars.collection != nil { - t.Fatal("charm created a collection grant") - } -} - -func TestCharHealingRevivesFatigueChargesPerTargetAndReplays(t *testing.T) { - s, chars, wallet, _ := talentTest(t, stateio.NewMemory(), 10) - r := s.design.Rules[[2]uint64{42, 1}] - r.Values[0] = 0 - s.design.Rules[[2]uint64{42, 1}] = r - if err := chars.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - b := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, 77) - code, response, _, err := s.Handle("/CharHealing", b) - if err != nil || code != 62 { - t.Fatal(err) - } - hp, _ := chars.CurrentHealth(77) - if hp != 1 || wallet.Snapshot().Catalyst != 45 { - t.Fatal("fatigue revival did not restore1HP with exact catalyst cost") - } - _, again, _, err := s.Handle("/CharHealing", b) - if err != nil || !bytes.Equal(response, again) || wallet.Snapshot().Catalyst != 45 { - t.Fatal("revival replay charged twice") - } - if _, _, _, err = s.Handle("/CharHealing", wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77), 3, 77)); err == nil { - t.Fatal("living character revived again") - } -} - -func TestBargainPercentIsSessionDiscountAndNotCooldownDuration(t *testing.T) { - s, _, _, _ := talentTest(t, stateio.NewMemory(), 16) - r := s.design.Rules[[2]uint64{42, 1}] - r.Values[1] = 35 - s.design.Rules[[2]uint64{42, 1}] = r - s.design.NPCs = func(int) (map[uint64]gamedata.TalentNPC, error) { - return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{0}}}, nil - } - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 11)); err != nil { - t.Fatal(err) - } - discount, err := s.ShopDiscount(21, 11) - if err != nil || discount != 35 { - t.Fatal("bargain did not grant35 percent") - } - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2, 11)); err != nil { - t.Fatal("bargain percent incorrectly treated as35 second cooldown") - } - s.BeginSession("other") - discount, err = s.ShopDiscount(21, 11) - if err != nil || discount != 0 { - t.Fatal("bargain leaked to new login session") - } -} - -func TestPackTalentProjectionUsesActualLevelAndResetsUsageOnNextDay(t *testing.T) { - s, _, _, _ := talentTest(t, stateio.NewMemory(), 13) - s.design.Rules[[2]uint64{42, 2}] = gamedata.TalentUseRule{Group: 42, Level: 2, Class: 13, Values: []float64{0.75, 99}} - b := []byte(`{"Skills":{"42":{"End":1,"Count":1,"Day":"2026-10-04","Level":2}},"NPCs":{},"Receipts":{},"Discounts":{}}`) - if err := s.store.Save("talentuse", b); err != nil { - t.Fatal(err) - } - projection, err := s.PackInfo(21) - if err != nil { - t.Fatal(err) - } - row, _, _ := wire.Bytes(projection, 13) - cool, _, _ := wire.Varint(row, 3) - if cool != 99 { - t.Fatal("projection downgraded actual talent duration to level1") - } - r := s.design.Rules[[2]uint64{42, 2}] - r.Class = 4 - s.design.Rules[[2]uint64{42, 2}] = r - projection, err = s.PackInfo(21) - if err != nil { - t.Fatal(err) - } - row, _, _ = wire.Bytes(projection, 13) - count, _, _ := wire.Varint(row, 4) - if count != 0 { - t.Fatal("next-day client projection kept exhausted daily count") - } -} - -func TestCharmSQLiteRestartAndNewCharmRefreshDamagedHealth(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s, chars, _, _ := talentTest(t, repo, 19) - s.now = time.Now - s.design.NPCs = func(int) (map[uint64]gamedata.TalentNPC, error) { - return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{0}, CharmCharacters: []uint64{999}}}, nil - } - s.design.CharmCharacter = func(uint64) (gamedata.StoryCharacterDesign, error) { - return gamedata.StoryCharacterDesign{CharacterID: 999, Level: 1, CostumeID: 111, HP: 100}, nil - } - _, response, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 11)) - if err != nil { - t.Fatal(err) - } - row, _, _ := wire.Bytes(response, 5) - index, _, _ := wire.Varint(row, 1) - if err = chars.SetCurrentHealth(index, 5); err != nil { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - next, chars, _, _ := talentTest(t, repo, 19) - next.design = s.design - next.now = time.Now - c, found := chars.Find(index) - if !found || c.ExpiryTime == 0 || c.HP != 5 { - t.Fatal("restart lost temporary companion expiration or currentHP") - } - c.ExpiryTime = uint64(time.Now().Add(-time.Second).UnixMilli()) - if err = chars.ensureCharmCharacters([]Character{c}); err != nil { - t.Fatal(err) - } - state, err := next.load() - if err != nil { - t.Fatal(err) - } - state.NPCs = map[string]int64{} - b, err := json.Marshal(state) - if err != nil { - t.Fatal(err) - } - if err = repo.Save("talentuse", b); err != nil { - t.Fatal(err) - } - if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(2, 11)); err != nil { - t.Fatal(err) - } - c, found = chars.Find(index) - if !found || c.HP != 100 { - t.Fatal("new charm reused old companion damage") - } -} - -func TestOverwhelmRequiresPendingCastAndConsumesAuthorizationOnce(t *testing.T) { - s, _, _, _ := talentTest(t, stateio.NewMemory(), 3) - if err := s.ConsumeOverwhelm("first", 2); err == nil { - t.Fatal("unearned overwhelm result accepted") - } - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1)); err != nil { - t.Fatal(err) - } - if err := s.ConsumeOverwhelm("first", 2); err != nil { - t.Fatal(err) - } - if err := s.ConsumeOverwhelm("first", 2); err != nil { - t.Fatal("exact overwhelm replay rejected") - } - if err := s.ConsumeOverwhelm("first", 3); err == nil { - t.Fatal("changed monster count accepted") - } - if err := s.ConsumeOverwhelm("second", 2); err == nil { - t.Fatal("one cast authorized two independent settlements") - } - if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2)); err != nil { - t.Fatal(err) - } - now := s.now().Add(61 * time.Second) - s.now = func() time.Time { return now } - if err := s.ConsumeOverwhelm("expired", 2); err == nil { - t.Fatal("expired handoff authorization accepted") - } -} diff --git a/go/internal/server/player/wallet_test.go b/go/internal/server/player/wallet_test.go deleted file mode 100644 index 75104d2..0000000 --- a/go/internal/server/player/wallet_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package player - -import ( - "math" - "path/filepath" - "testing" - - "bd2server/internal/server/gamedata" -) - -func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) { - path := filepath.Join(t.TempDir(), "wallet.json") - wallet, err := OpenWallet(testStore(path), Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - rewards := []gamedata.Reward{{Type: 2, Count: 100000000}, {Type: 4, Count: 1500}, {Type: 3, Count: 70}, {Type: 12, Count: 45}, {Type: 25, ID: 210, Count: 1}} - if _, err := wallet.GrantQuestOnce("quest:1", rewards); err != nil { - t.Fatal(err) - } - wallet, err = OpenWallet(testStore(path), Currency{}) - if err != nil { - t.Fatal(err) - } - if _, err := wallet.GrantQuestOnce("quest:1", rewards); err != nil { - t.Fatal(err) - } - got := wallet.Snapshot() - if got.Jewelry != 100000000 || got.Gold != 1600 || got.FreeJewelry != 70 || got.Catalyst != 45 { - t.Fatalf("wallet=%+v", got) - } -} - -func TestWalletPaidJewelryOverflowDoesNotCommitGrant(t *testing.T) { - wallet, err := OpenWallet(testStore(filepath.Join(t.TempDir(), "wallet.json")), Currency{Jewelry: math.MaxUint64}) - if err != nil { - t.Fatal(err) - } - if _, err := wallet.GrantQuestOnce("paid-overflow", []gamedata.Reward{{Type: 4, Count: 10}, {Type: 2, Count: 1}}); err == nil { - t.Fatal("paid jewelry overflow accepted") - } - if got := wallet.Snapshot(); got.Jewelry != math.MaxUint64 || got.Gold != 0 || wallet.WasGranted("paid-overflow") { - t.Fatalf("failed grant changed wallet: %+v", got) - } -} diff --git a/go/internal/server/progress/chests_test.go b/go/internal/server/progress/chests_test.go deleted file mode 100644 index b7297b5..0000000 --- a/go/internal/server/progress/chests_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package progress - -import ( - "bd2server/internal/server/stateio" - "reflect" - "testing" -) - -func TestOpenedFieldRewardPeriodsValidatesAndOwnsSnapshot(t *testing.T) { - storage := stateio.NewMemory() - s, err := OpenStore(storage) - if err != nil { - t.Fatal(err) - } - for _, row := range []struct { - pack, id int - period string - }{{21, 71, "once"}, {22, 71, "2026-10-06"}, {22, 72, "event:777"}} { - if err := s.MarkFieldRewardOpened(row.pack, row.id, row.period); err != nil { - t.Fatal(err) - } - } - want := map[int]map[int]string{21: {71: "once"}, 22: {71: "2026-10-06", 72: "event:777"}} - got, err := s.OpenedFieldRewardPeriods() - if err != nil || !reflect.DeepEqual(got, want) { - t.Fatalf("opened periods=%v err=%v", got, err) - } - got[21][71] = "modified" - fresh, err := s.OpenedFieldRewardPeriods() - if err != nil || !reflect.DeepEqual(fresh, want) { - t.Fatalf("caller modified persisted snapshot: %v err=%v", fresh, err) - } - for _, row := range []struct { - key string - raw []byte - }{ - {"0:71", []byte("once")}, {"21:0", []byte("once")}, {"21:71:1", []byte("once")}, - {"pack:71", []byte("once")}, {"21:object", []byte("once")}, {"021:71", []byte("once")}, - {"21:071", []byte("once")}, {"21:73", nil}, - } { - t.Run(row.key, func(t *testing.T) { - if err := storage.PutEntry("progress", "field_rewards", row.key, row.raw); err != nil { - t.Fatal(err) - } - if _, err := s.OpenedFieldRewardPeriods(); err == nil { - t.Fatal("malformed field reward entry accepted") - } - if _, err := storage.DeleteEntry("progress", "field_rewards", row.key); err != nil { - t.Fatal(err) - } - }) - } -} diff --git a/go/internal/server/progress/field_buffs.go b/go/internal/server/progress/field_buffs.go deleted file mode 100644 index 14475ac..0000000 --- a/go/internal/server/progress/field_buffs.go +++ /dev/null @@ -1,81 +0,0 @@ -package progress - -import ( - "bd2server/internal/server/wire" - "fmt" - "slices" - "strconv" -) - -func (s *Store) SaveFieldBuff(id uint64, raw []byte) error { - s.mu.Lock() - defer s.mu.Unlock() - storedID, _, err := wire.Varint(raw, 1) - if err != nil || id == 0 || id > 0x7fffffff || storedID != id { - return fmt.Errorf("progress: invalid field buff identity") - } - store, err := s.fieldRewardEntries() - if err != nil { - return err - } - return store.PutEntry("progress", "field_buffs", strconv.FormatUint(id, 10), raw) -} - -func (s *Store) FieldBuffs() ([][]byte, error) { - s.mu.Lock() - defer s.mu.Unlock() - store, err := s.fieldRewardEntries() - if err != nil { - return nil, err - } - entries, err := store.ListEntries("progress", "field_buffs") - if err != nil { - return nil, err - } - ids := make([]uint64, 0, len(entries)) - for key, raw := range entries { - id, err := strconv.ParseUint(key, 10, 32) - storedID, _, parseErr := wire.Varint(raw, 1) - if err != nil || parseErr != nil || id == 0 || id > 0x7fffffff || id != storedID { - return nil, fmt.Errorf("progress: invalid saved field buff") - } - ids = append(ids, id) - } - slices.Sort(ids) - var out [][]byte - for _, id := range ids { - out = append(out, entries[strconv.FormatUint(id, 10)]) - } - return out, nil -} - -func (s *Store) RemoveFieldBuff(id uint64) error { - s.mu.Lock() - defer s.mu.Unlock() - store, err := s.fieldRewardEntries() - if err != nil { - return err - } - _, err = store.DeleteEntry("progress", "field_buffs", strconv.FormatUint(id, 10)) - return err -} - -func (s *Store) ClaimFieldBuffBattle(identity string) (bool, error) { - s.mu.Lock() - defer s.mu.Unlock() - if identity == "" { - return false, fmt.Errorf("progress: empty field buff battle identity") - } - store, err := s.fieldRewardEntries() - if err != nil { - return false, err - } - _, found, err := store.LoadEntry("progress", "field_buff_battles", identity) - if err != nil || found { - return false, err - } - if err = store.PutEntry("progress", "field_buff_battles", identity, []byte("used")); err != nil { - return false, err - } - return true, nil -} diff --git a/go/internal/server/progress/persistence_test.go b/go/internal/server/progress/persistence_test.go deleted file mode 100644 index 02c7a46..0000000 --- a/go/internal/server/progress/persistence_test.go +++ /dev/null @@ -1,172 +0,0 @@ -package progress - -import ( - "encoding/json" - "testing" - - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestStorePersistsProgressAcrossRestart(t *testing.T) { - storage := stateio.NewMemory() - store, err := OpenStore(storage) - if err != nil { - t.Fatal(err) - } - if data, _ := storage.Load("progress"); data != nil { - t.Fatalf("unexpected initial save: %s", data) - } - position := wire.AppendVarint(nil, 2, 21) - position = wire.AppendString(position, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3}}`) - if err := store.SaveUserPosition(position); err != nil { - t.Fatal(err) - } - if err := store.ClearTutorial(wire.AppendVarint(nil, 2, 2001)); err != nil { - t.Fatal(err) - } - quest := wire.AppendVarint(nil, 2, 12) - quest = wire.AppendVarint(quest, 3, 21) - quest = wire.AppendBytes(quest, 4, []byte{121}) - if _, err := store.UpdateQuest(quest); err != nil { - t.Fatal(err) - } - reopened, err := OpenStore(storage) - if err != nil { - t.Fatal(err) - } - pos, found := reopened.Position() - if !found || pos.Position.MapID != 211 || pos.PackID != 21 || !reopened.TutorialCleared(2001) { - t.Fatalf("lost persisted progress: %+v", pos) - } - if saved, found := reopened.Quest(12); !found || saved.PackID != 21 || len(saved.Values) != 1 || saved.Values[0] != 121 { - t.Fatalf("lost quest progress: %+v/%v", saved, found) - } -} - -func TestLastPlayedPackTracksPersistedPositionAcrossRestart(t *testing.T) { - storage := stateio.NewMemory() - store, err := OpenStore(storage) - if err != nil { - t.Fatal(err) - } - if id, err := store.LastPlayedPackID(); err != nil || id != 0 { - t.Fatalf("new account pack=%d err=%v", id, err) - } - for _, pack := range []uint64{21, 3001} { - request := wire.AppendVarint(nil, 2, pack) - request = wire.AppendString(request, 3, `{"MapId":30011,"PlayerPosition":{"x":1,"y":2,"z":3}}`) - if err := store.SaveUserPosition(request); err != nil { - t.Fatal(err) - } - store, err = OpenStore(storage) - if err != nil { - t.Fatal(err) - } - if id, err := store.LastPlayedPackID(); err != nil || id != pack { - t.Fatalf("restored pack=%d want=%d err=%v", id, pack, err) - } - } -} - -func TestStoreKeepsPackOverlap(t *testing.T) { - storage := stateio.NewMemory() - initial := `{"version":3,"selections":{},"start_pack_id":21,"active_pack_id":21,"position":{"PackID":0,"Position":{"MapId":0,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null},"RawJSON":""},"tutorials":[],"quests":{"21:0:1":{"QuestID":1,"PackID":21,"Values":[7]}},"cleared_quests":{"21:0:1":true}}` - if err := storage.Save("progress", []byte(initial)); err != nil { - t.Fatal(err) - } - store, err := OpenStore(storage) - if err != nil { - t.Fatal(err) - } - if !store.QuestCleared(1, 21) || store.QuestCleared(1, 22) { - t.Fatal("pack21 clear was not loaded with its pack identity") - } - request := wire.AppendVarint(nil, 2, 1) - request = wire.AppendVarint(request, 3, 22) - request = wire.AppendVarint(request, 4, 9) - if _, err := store.UpdateQuest(request); err != nil { - t.Fatal(err) - } - if err := store.ClearQuest(1, 22); err != nil { - t.Fatal(err) - } - if !store.QuestCleared(1, 21) || !store.QuestCleared(1, 22) { - t.Fatal("same quest id did not remain independently cleared in both packs") - } - if _, found := store.Quest(1); found { - t.Fatal("pack-less lookup accepted an ambiguous quest id") - } - for _, packID := range []int{21, 22} { - if quest, found := store.QuestInPack(1, packID); !found || quest.PackID != packID { - t.Fatalf("pack%d quest missing: %+v found=%v", packID, quest, found) - } - } - data, err := storage.Load("progress") - if err != nil { - t.Fatal(err) - } - var saved map[string]json.RawMessage - if err := json.Unmarshal(data, &saved); err != nil { - t.Fatal(err) - } - var version int - if err := json.Unmarshal(saved["version"], &version); err != nil || version != snapshotVersion { - t.Fatalf("saved version=%d err=%v", version, err) - } - var cleared map[string]bool - if err := json.Unmarshal(saved["cleared_quests"], &cleared); err != nil || !cleared["21:0:1"] || !cleared["22:0:1"] { - t.Fatalf("saved clears=%v err=%v", cleared, err) - } -} - -func TestStoreRejectsBrokenSave(t *testing.T) { - storage := stateio.NewMemory() - if err := storage.Save("progress", []byte(`{"version":3,"selections":{},"start_pack_id":21,"active_pack_id":21,"quests":{"12":{"QuestID":5,"PackID":21}}}`)); err != nil { - t.Fatal(err) - } - if _, err := OpenStore(storage); err == nil { - t.Fatal("accepted inconsistent save") - } -} - -func TestStoreRejectsLegacySave(t *testing.T) { - storage := stateio.NewMemory() - _ = storage.Save("progress", []byte(`{"quests":{"1":{"QuestID":1,"PackID":21}}}`)) - if _, err := OpenStore(storage); err == nil { - t.Fatal("accepted unversioned legacy state") - } -} - -func TestDifficultyPositionAndActivePackPersistIndependently(t *testing.T) { - storage := stateio.NewMemory() - s, err := OpenStore(storage) - if err != nil { - t.Fatal(err) - } - if err := s.SetStartPack(1); err != nil { - t.Fatal(err) - } - if err := s.SetActivePackID(21); err != nil { - t.Fatal(err) - } - if err := s.SelectQuest(21, QuestSelection{QuestID: 4, Difficulty: 1}); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 2, 21) - request = wire.AppendString(request, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3}}`) - if err := s.SaveUserPosition(request); err != nil { - t.Fatal(err) - } - if err := s.SetActivePackID(22); err != nil { - t.Fatal(err) - } - s, err = OpenStore(storage) - if err != nil { - t.Fatal(err) - } - saved, _ := s.Position() - if saved.Difficulty != 1 || saved.PackID != 21 || s.ActivePackID() != 22 || s.StartPackID() != 1 { - t.Fatalf("state lost %+v", saved) - } -} diff --git a/go/internal/server/progress/progress_test.go b/go/internal/server/progress/progress_test.go deleted file mode 100644 index a5684b3..0000000 --- a/go/internal/server/progress/progress_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package progress - -import ( - "encoding/binary" - "errors" - "reflect" - "testing" - - "bd2server/internal/server/wire" -) - -func TestSaveUserPosition(t *testing.T) { - request := wire.AppendVarint(nil, 1, 119) - request = wire.AppendVarint(request, 2, 21) - request = wire.AppendString(request, 3, `{"MapId":211,"PlayerPosition":{"x":1.5,"y":0.0,"z":-2.5},"ColleaguePositions":null}`) - store := NewStore() - if err := store.SaveUserPosition(request); err != nil { - t.Fatal(err) - } - position, found := store.Position() - if !found || position.PackID != 21 || position.Position.MapID != 211 || position.Position.PlayerPosition.Z != -2.5 { - t.Fatalf("unexpected saved position: %+v found=%v", position, found) - } -} - -func TestSaveUserPositionRejectsBadJSON(t *testing.T) { - request := wire.AppendVarint(nil, 2, 21) - request = wire.AppendString(request, 3, `{bad`) - if err := NewStore().SaveUserPosition(request); !errors.Is(err, ErrInvalidPosition) { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestClearTutorialIsIdempotent(t *testing.T) { - request := wire.AppendVarint(nil, 1, 116) - request = wire.AppendVarint(request, 2, 2001) - store := NewStore() - if err := store.ClearTutorial(request); err != nil { - t.Fatal(err) - } - if err := store.ClearTutorial(request); err != nil { - t.Fatal(err) - } - if !store.TutorialCleared(2001) { - t.Fatal("tutorial completion was not retained") - } -} - -func TestTutorialInfoReturnsPersistedClears(t *testing.T) { - store := NewStore() - for seq, id := range []uint64{2001, 10030, 15} { - request := wire.AppendVarint(nil, 1, uint64(seq+1)) - request = wire.AppendVarint(request, 2, id) - if err := store.ClearTutorial(request); err != nil { - t.Fatal(err) - } - } - code, response, ok, err := store.Handle("/TutorialInfo", wire.AppendVarint(nil, 1, 99)) - if err != nil || !ok || code != 101 { - t.Fatalf("info: code=%d ok=%v err=%v", code, ok, err) - } - packed, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("clears missing: %v", err) - } - var got []uint64 - for len(packed) > 0 { - value, n := binary.Uvarint(packed) - if n <= 0 { - t.Fatal("bad packed tutorials") - } - got = append(got, value) - packed = packed[n:] - } - want := []uint64{15, 2001, 10030} - if !reflect.DeepEqual(got, want) { - t.Fatalf("tutorials=%v want=%v", got, want) - } -} - -func TestUpdateQuestPackedValues(t *testing.T) { - request := wire.AppendVarint(nil, 1, 120) - request = wire.AppendVarint(request, 2, 12) - request = wire.AppendVarint(request, 3, 21) - request = wire.AppendBytes(request, 4, []byte{121, 2}) // packed 121, 2 - store := NewStore() - updated, err := store.UpdateQuest(request) - if err != nil { - t.Fatal(err) - } - quest, found := store.Quest(12) - if updated != 12 || !found || quest.PackID != 21 || len(quest.Values) != 2 || quest.Values[0] != 121 || quest.Values[1] != 2 { - t.Fatalf("unexpected quest: updated=%d found=%v quest=%+v", updated, found, quest) - } -} diff --git a/go/internal/server/calendar/readonly.go b/go/internal/server/protocol/calendar/staticdata.go similarity index 83% rename from go/internal/server/calendar/readonly.go rename to go/internal/server/protocol/calendar/staticdata.go index 91551bb..d37446b 100644 --- a/go/internal/server/calendar/readonly.go +++ b/go/internal/server/protocol/calendar/staticdata.go @@ -1,7 +1,8 @@ -package calendar +package calendaradapter import ( - "bd2server/internal/server/readonly" + "bd2server/internal/server/domain/events/calendar" + "bd2server/internal/server/protocol/staticdata" "fmt" "maps" ) @@ -16,16 +17,16 @@ func flag(n int, b bool) readonly.Field { } return scalar(n, 0) } -func seasonFields(s Season) []readonly.Field { - a, _ := timestamp(s.Start) - b, _ := timestamp(s.End) +func seasonFields(s calendar.Season) []readonly.Field { + a, _ := calendar.ParseTimestamp(s.Start) + b, _ := calendar.ParseTimestamp(s.End) return []readonly.Field{scalar(1, s.ID), scalar(2, a), scalar(3, b), flag(4, s.Error), flag(5, s.Return), scalar(6, s.RankRewardGroupID)} } // ApplyReadonly returns a separate seed with every calendar endpoint encoded // entirely from the loaded files. Commerce supplies reset times from its // current reset rules and clock when serving CashShopInfo. -func (s *Set) ApplyReadonly(seed *readonly.Seed) (*readonly.Seed, error) { +func ApplyStaticData(s *calendar.Set, seed *readonly.Seed) (*readonly.Seed, error) { if s == nil || seed == nil { return nil, fmt.Errorf("calendar: missing calendar/static seed") } @@ -34,7 +35,7 @@ func (s *Set) ApplyReadonly(seed *readonly.Seed) (*readonly.Seed, error) { var hunt []readonly.Field if h := s.MonsterHunt; h != nil { for _, v := range h.Seasons { - end, _ := timestamp(v.CalculateEndAt) + end, _ := calendar.ParseTimestamp(v.CalculateEndAt) f := []readonly.Field{message(1, seasonFields(v.Season)), scalar(2, v.HuntID), scalar(3, v.InfoOpenDay), scalar(4, end), flag(5, v.ErrorFlag), flag(6, v.IndependentFlag), scalar(7, v.RankRewardGroupID)} for _, id := range v.CostumeBanIDs { f = append(f, scalar(8, id)) @@ -54,19 +55,19 @@ func (s *Set) ApplyReadonly(seed *readonly.Seed) (*readonly.Seed, error) { for _, v := range s.CashProducts { var a, b uint64 if v.Start != "" { - a, _ = timestamp(v.Start) + a, _ = calendar.ParseTimestamp(v.Start) } if v.End != "" { - b, _ = timestamp(v.End) + b, _ = calendar.ParseTimestamp(v.End) } cash = append(cash, message(1, []readonly.Field{scalar(1, v.GroupID), scalar(2, v.ProductID), scalar(3, v.SaleGroup), scalar(4, a), scalar(5, b), scalar(6, v.EndDelayMinutes), scalar(8, v.EventIndex)})) } result.Responses["/CashShopInfo"] = readonly.Response{PacketCode: 60, Fields: cash} var hubs []readonly.Field for _, v := range s.EventHubs { - a, _ := timestamp(v.Start) - b, _ := timestamp(v.PlayEnd) - c, _ := timestamp(v.End) + a, _ := calendar.ParseTimestamp(v.Start) + b, _ := calendar.ParseTimestamp(v.PlayEnd) + c, _ := calendar.ParseTimestamp(v.End) f := []readonly.Field{scalar(1, v.UID), scalar(2, v.HubID), scalar(3, a), scalar(4, b), scalar(5, c)} for _, setting := range v.Settings { sf := []readonly.Field{scalar(1, setting.Slot), scalar(2, setting.ProgressType)} diff --git a/go/internal/server/cryptox/cryptox.go b/go/internal/server/protocol/cryptox/cryptox.go similarity index 100% rename from go/internal/server/cryptox/cryptox.go rename to go/internal/server/protocol/cryptox/cryptox.go diff --git a/go/internal/server/feature/bootstrap.go b/go/internal/server/protocol/defaults/bootstrap.go similarity index 90% rename from go/internal/server/feature/bootstrap.go rename to go/internal/server/protocol/defaults/bootstrap.go index b6b2993..f93a416 100644 --- a/go/internal/server/feature/bootstrap.go +++ b/go/internal/server/protocol/defaults/bootstrap.go @@ -1,6 +1,6 @@ package feature -import "bd2server/internal/server/wire" +import "bd2server/internal/server/protocol/wire" // initialResponses are locally constructed, typed defaults for the // new-player account. These do not reuse recorded response bytes. Stateful diff --git a/go/internal/server/feature/feature.go b/go/internal/server/protocol/defaults/feature.go similarity index 89% rename from go/internal/server/feature/feature.go rename to go/internal/server/protocol/defaults/feature.go index 0143341..2f71c32 100644 --- a/go/internal/server/feature/feature.go +++ b/go/internal/server/protocol/defaults/feature.go @@ -6,11 +6,12 @@ package feature import ( + "bd2server/internal/server/domain/command" "errors" "fmt" "maps" - "bd2server/internal/server/wire" + "bd2server/internal/server/protocol/wire" ) // ErrInvalidRequest means a known endpoint was sent a malformed request. @@ -30,7 +31,6 @@ var emptyResponses = map[string]int{ "/EquipPresetInfo": 253, "/EvilCastleDailyRewardState": 441, "/EvilCastleTowerInfo": 200, - "/FieldTrapInfo": 171, "/FishingCollectionInfo": 465, "/FishingTrapInfo": 457, "/FriendInfoList": 204, @@ -51,7 +51,7 @@ var emptyResponses = map[string]int{ // request sequence in field 1. Unknown paths return ok=false and no response. // proto is nil on success: protobuf's canonical empty-message encoding is zero // bytes. -func Handle(path string, request []byte) (packetCode int, proto []byte, ok bool, err error) { +func Handle(ctx command.Context, path string, request []byte) (packetCode int, proto []byte, ok bool, err error) { packetCode, ok = emptyResponses[path] if !ok { packetCode, proto, ok = initialResponse(path) @@ -78,8 +78,8 @@ func Handle(path string, request []byte) (packetCode int, proto []byte, ok bool, // Service adapts this registry to session.Handler without importing session. type Service struct{} -func (Service) Handle(path string, request []byte) (int, []byte, bool, error) { - return Handle(path, request) +func (Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + return Handle(ctx, path, request) } // EmptyPacketCodes returns a detached copy for diagnostics and protocol tests. diff --git a/go/internal/server/feature/standalone.go b/go/internal/server/protocol/defaults/standalone.go similarity index 100% rename from go/internal/server/feature/standalone.go rename to go/internal/server/protocol/defaults/standalone.go diff --git a/go/internal/server/protocol/login/auto_revive_settings.go b/go/internal/server/protocol/login/auto_revive_settings.go new file mode 100644 index 0000000..f27116c --- /dev/null +++ b/go/internal/server/protocol/login/auto_revive_settings.go @@ -0,0 +1,18 @@ +package login + +import ( + "bd2server/internal/server/domain/command" + "fmt" +) + +type AutoReviveSettingsProvider interface { + AutoReviveSettings(command.Context) (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 +} diff --git a/go/internal/server/protocol/login/login.go b/go/internal/server/protocol/login/login.go new file mode 100644 index 0000000..0146c21 --- /dev/null +++ b/go/internal/server/protocol/login/login.go @@ -0,0 +1,231 @@ +// Package login adapts player projections to LoginUser responses. +// It deliberately stores decoded protobuf data, never a captured HTTP reply +// or a captured session key. +package login + +import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/platform/versionconfig" + "errors" +) + +func StateVersion() string { return versionconfig.State() } + +var ( + ErrInvalidSeed = errors.New("account: invalid LoginUser seed") +) + +// LoginSeed is the versioned, decoded representation of a LoginUser response. +// UserInfo is UserDBInfo protobuf field 1 with its field 3 (user_key) +// deliberately absent. ResponseFields contains the remaining top-level +// protobuf fields (such as client-notification state), never an HTTP envelope. +type LoginSeed struct { + Version string + PacketCode int + UserInfo []byte + ResponseFields []byte + currencies CurrencyProvider + purchaseCounts PurchaseCountProvider + presetSlots PresetSlotProvider + inventorySlots InventorySlotProvider + firstGacha FirstGachaProvider + friendshipAP FriendshipAPProvider + lastPlayedPack LastPlayedPackProvider + achievementExp AchievementExperienceProvider + autoReviveSettings interface { + AutoReviveSettings(command.Context) (bool, uint64, error) + } + portrait interface{ PortraitCostume() uint64 } + levelReward LevelRewardProvider + huntingAP HuntingAPProvider + monsterHuntSlots PresetSlotProvider + additionalCurrencies interface { + AdditionalCurrencies(command.Context) (map[int]uint64, error) + } + newbieStep interface{ NewbieStep() uint64 } +} + +func (s *LoginSeed) AttachNewbieStep(provider interface{ NewbieStep() uint64 }) error { + if provider == nil { + return errors.New("account: missing newbie step provider") + } + s.newbieStep = provider + return nil +} + +// AttachAdditionalCurrencies supplies balances owned by optional gameplay +// domains without altering the stable wallet representation. +func (s *LoginSeed) AttachAdditionalCurrencies(provider interface { + AdditionalCurrencies(command.Context) (map[int]uint64, error) +}) error { + if provider == nil { + return errors.New("account: missing additional currency provider") + } + s.additionalCurrencies = provider + return nil +} + +func (s *LoginSeed) AttachMonsterHuntSlots(provider PresetSlotProvider) error { + if provider == nil { + return errors.New("account: missing monster hunt preset provider") + } + s.monsterHuntSlots = provider + return nil +} + +type HuntingAPProvider interface { + HuntingAP(command.Context) (free, bonus uint64, err error) +} + +func (s *LoginSeed) AttachHuntingAP(provider HuntingAPProvider) error { + if provider == nil { + return errors.New("account: missing hunting AP provider") + } + s.huntingAP = provider + return nil +} + +type LevelRewardProvider interface { + LevelRewardCount() (uint64, error) +} + +func (s *LoginSeed) AttachLevelReward(provider LevelRewardProvider) error { + if provider == nil { + return errors.New("account: missing level reward provider") + } + s.levelReward = provider + return nil +} + +type AchievementExperienceProvider interface { + AchievementExperience() (uint64, error) +} + +func (s *LoginSeed) AttachAchievementExperience(provider AchievementExperienceProvider) error { + if provider == nil { + return errors.New("account: missing achievement experience provider") + } + s.achievementExp = provider + return nil +} + +// LastPlayedPackProvider reads the persisted return destination. A zero value +// means there is no saved destination yet, so a new account uses its seed. +type LastPlayedPackProvider interface { + LastPlayedPackID(command.Context) (uint64, error) +} + +func (s *LoginSeed) AttachLastPlayedPack(provider LastPlayedPackProvider) error { + if provider == nil { + return errors.New("account: missing last played pack provider") + } + s.lastPlayedPack = provider + return nil +} + +// FirstGachaProvider reads the mutable account flag for every login, including +// a login after confirming the starter draw without restarting the server. +type FirstGachaProvider interface { + FirstGachaCompleted() bool +} + +func (s *LoginSeed) AttachFirstGacha(provider FirstGachaProvider) error { + if provider == nil { + return errors.New("account: missing first gacha provider") + } + s.firstGacha = provider + return nil +} + +// CurrencyProvider supplies the authoritative mutable UserDBInfo wallet. +type CurrencyProvider interface { + Currencies(command.Context) (gold, freeJewelry, jewelry, mileage uint64) +} + +type HopePowderProvider interface { + HopePowderBalance(command.Context) uint64 +} + +type CatalystProvider interface { + CatalystBalance(command.Context) uint64 +} + +type EquipmentMileageProvider interface { + EquipmentMileageBalances(command.Context) (mileage, exchangeGage uint64) +} + +// PurchaseCountProvider supplies the current PurchaseCountDBInfo messages for +// UserDBInfo field 26. Implementations must derive them from authoritative +// account state rather than the immutable login seed. +type PurchaseCountProvider interface { + PurchaseCountDBInfos(command.Context) [][]byte +} + +// PresetSlotProvider supplies the authoritative number of ordinary party +// preset slots for UserDBInfo field 28. The deck domain owns both purchased +// slot state and the preset records stored in those slots. +type PresetSlotProvider interface { + PresetSlotCount() uint64 +} + +// InventorySlotProvider owns the four mutable UserDBInfo capacity fields. +// Development overrides are applied by the provider as a login-time view; +// the immutable account seed is never rewritten. +type InventorySlotProvider interface { + UserInventorySlots(command.Context) (items, storage, equipment, equipmentStorage uint64, err error) +} + +// FriendshipAPProvider supplies the account's remaining daily counseling +// points; LoginUser must not restore points from its immutable seed. +type FriendshipAPProvider interface { + FriendshipAP() (uint64, error) +} + +func (s *LoginSeed) AttachFriendshipAP(provider FriendshipAPProvider) error { + if provider == nil { + return errors.New("account: nil friendship AP provider") + } + s.friendshipAP = provider + return nil +} + +func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error { + if provider == nil { + return errors.New("account: nil currency provider") + } + s.currencies = provider + return nil +} + +func (s *LoginSeed) AttachPurchaseCounts(provider PurchaseCountProvider) error { + if provider == nil { + return errors.New("account: nil purchase count provider") + } + s.purchaseCounts = provider + return nil +} + +func (s *LoginSeed) AttachPresetSlots(provider PresetSlotProvider) error { + if provider == nil { + return errors.New("account: nil preset slot provider") + } + s.presetSlots = provider + return nil +} + +func (s *LoginSeed) AttachInventorySlots(provider InventorySlotProvider) error { + if provider == nil { + return errors.New("account: nil inventory slot provider") + } + s.inventorySlots = provider + return nil +} + +type diskSeed struct { + Version string `json:"version"` + PacketCode int `json:"packet_code"` + UserInfoBase64 string `json:"user_info_base64"` + ResponseFieldsBase64 string `json:"response_fields_base64,omitempty"` +} + +// Write stores a seed in the portable JSON form used by the one-shot importer. diff --git a/go/internal/server/account/portrait.go b/go/internal/server/protocol/login/portrait.go similarity index 95% rename from go/internal/server/account/portrait.go rename to go/internal/server/protocol/login/portrait.go index 543b8da..e3725c9 100644 --- a/go/internal/server/account/portrait.go +++ b/go/internal/server/protocol/login/portrait.go @@ -1,4 +1,4 @@ -package account +package login import "fmt" diff --git a/go/internal/server/account/login.go b/go/internal/server/protocol/login/protocol_adapter.go similarity index 62% rename from go/internal/server/account/login.go rename to go/internal/server/protocol/login/protocol_adapter.go index fa86288..a0586a3 100644 --- a/go/internal/server/account/login.go +++ b/go/internal/server/protocol/login/protocol_adapter.go @@ -1,9 +1,9 @@ -// Package account owns the local-account representation used by LoginUser. -// It deliberately stores decoded protobuf data, never a captured HTTP reply -// or a captured session key. -package account +package login import ( + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/cryptox" + "bd2server/internal/server/protocol/wire" "encoding/base64" "encoding/json" "errors" @@ -12,86 +12,8 @@ import ( "os" "path/filepath" "time" - - "bd2server/internal/server/cryptox" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" ) -func StateVersion() string { return versionconfig.State() } - -var ( - ErrInvalidSeed = errors.New("account: invalid LoginUser seed") -) - -// LoginSeed is the versioned, decoded representation of a LoginUser response. -// UserInfo is UserDBInfo protobuf field 1 with its field 3 (user_key) -// deliberately absent. ResponseFields contains the remaining top-level -// protobuf fields (such as client-notification state), never an HTTP envelope. -type LoginSeed struct { - Version string - PacketCode int - UserInfo []byte - ResponseFields []byte - currencies CurrencyProvider - purchaseCounts PurchaseCountProvider - presetSlots PresetSlotProvider - inventorySlots InventorySlotProvider - firstGacha FirstGachaProvider - friendshipAP FriendshipAPProvider - lastPlayedPack LastPlayedPackProvider - achievementExp AchievementExperienceProvider - autoReviveSettings interface{ AutoReviveSettings() (bool, uint64, error) } - portrait interface{ PortraitCostume() uint64 } - levelReward LevelRewardProvider - huntingAP HuntingAPProvider - monsterHuntSlots PresetSlotProvider - additionalCurrencies interface { - AdditionalCurrencies() (map[int]uint64, error) - } - newbieStep interface{ NewbieStep() uint64 } -} - -func (s *LoginSeed) AttachNewbieStep(provider interface{ NewbieStep() uint64 }) error { - if provider == nil { - return errors.New("account: missing newbie step provider") - } - s.newbieStep = provider - return nil -} - -// AttachAdditionalCurrencies supplies balances owned by optional gameplay -// domains without altering the stable wallet representation. -func (s *LoginSeed) AttachAdditionalCurrencies(provider interface { - AdditionalCurrencies() (map[int]uint64, error) -}) error { - if provider == nil { - return errors.New("account: missing additional currency provider") - } - s.additionalCurrencies = provider - return nil -} - -func (s *LoginSeed) AttachMonsterHuntSlots(provider PresetSlotProvider) error { - if provider == nil { - return errors.New("account: missing monster hunt preset provider") - } - s.monsterHuntSlots = provider - return nil -} - -type HuntingAPProvider interface { - HuntingAP() (free, bonus uint64, err error) -} - -func (s *LoginSeed) AttachHuntingAP(provider HuntingAPProvider) error { - if provider == nil { - return errors.New("account: missing hunting AP provider") - } - s.huntingAP = provider - return nil -} - func (s *LoginSeed) SeedHuntingAP() (free, bonus uint64, err error) { if err = s.Validate(); err != nil { return 0, 0, err @@ -103,142 +25,6 @@ func (s *LoginSeed) SeedHuntingAP() (free, bonus uint64, err error) { return } -type LevelRewardProvider interface { - LevelRewardCount() (uint64, error) -} - -func (s *LoginSeed) AttachLevelReward(provider LevelRewardProvider) error { - if provider == nil { - return errors.New("account: missing level reward provider") - } - s.levelReward = provider - return nil -} - -type AchievementExperienceProvider interface { - AchievementExperience() (uint64, error) -} - -func (s *LoginSeed) AttachAchievementExperience(provider AchievementExperienceProvider) error { - if provider == nil { - return errors.New("account: missing achievement experience provider") - } - s.achievementExp = provider - return nil -} - -// LastPlayedPackProvider reads the persisted return destination. A zero value -// means there is no saved destination yet, so a new account uses its seed. -type LastPlayedPackProvider interface { - LastPlayedPackID() (uint64, error) -} - -func (s *LoginSeed) AttachLastPlayedPack(provider LastPlayedPackProvider) error { - if provider == nil { - return errors.New("account: missing last played pack provider") - } - s.lastPlayedPack = provider - return nil -} - -// FirstGachaProvider reads the mutable account flag for every login, including -// a login after confirming the starter draw without restarting the server. -type FirstGachaProvider interface { - FirstGachaCompleted() bool -} - -func (s *LoginSeed) AttachFirstGacha(provider FirstGachaProvider) error { - if provider == nil { - return errors.New("account: missing first gacha provider") - } - s.firstGacha = provider - return nil -} - -// CurrencyProvider supplies the authoritative mutable UserDBInfo wallet. -type CurrencyProvider interface { - Currencies() (gold, freeJewelry, jewelry, mileage uint64) -} - -type HopePowderProvider interface { - HopePowderBalance() uint64 -} - -type CatalystProvider interface { - CatalystBalance() uint64 -} - -type EquipmentMileageProvider interface { - EquipmentMileageBalances() (mileage, exchangeGage uint64) -} - -// PurchaseCountProvider supplies the current PurchaseCountDBInfo messages for -// UserDBInfo field 26. Implementations must derive them from authoritative -// account state rather than the immutable login seed. -type PurchaseCountProvider interface { - PurchaseCountDBInfos() [][]byte -} - -// PresetSlotProvider supplies the authoritative number of ordinary party -// preset slots for UserDBInfo field 28. The deck domain owns both purchased -// slot state and the preset records stored in those slots. -type PresetSlotProvider interface { - PresetSlotCount() uint64 -} - -// InventorySlotProvider owns the four mutable UserDBInfo capacity fields. -// Development overrides are applied by the provider as a login-time view; -// the immutable account seed is never rewritten. -type InventorySlotProvider interface { - UserInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error) -} - -// FriendshipAPProvider supplies the account's remaining daily counseling -// points; LoginUser must not restore points from its immutable seed. -type FriendshipAPProvider interface { - FriendshipAP() (uint64, error) -} - -func (s *LoginSeed) AttachFriendshipAP(provider FriendshipAPProvider) error { - if provider == nil { - return errors.New("account: nil friendship AP provider") - } - s.friendshipAP = provider - return nil -} - -func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error { - if provider == nil { - return errors.New("account: nil currency provider") - } - s.currencies = provider - return nil -} - -func (s *LoginSeed) AttachPurchaseCounts(provider PurchaseCountProvider) error { - if provider == nil { - return errors.New("account: nil purchase count provider") - } - s.purchaseCounts = provider - return nil -} - -func (s *LoginSeed) AttachPresetSlots(provider PresetSlotProvider) error { - if provider == nil { - return errors.New("account: nil preset slot provider") - } - s.presetSlots = provider - return nil -} - -func (s *LoginSeed) AttachInventorySlots(provider InventorySlotProvider) error { - if provider == nil { - return errors.New("account: nil inventory slot provider") - } - s.inventorySlots = provider - return nil -} - func (s *LoginSeed) SeedInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error) { if err = s.Validate(); err != nil { return 0, 0, 0, 0, err @@ -317,13 +103,6 @@ func (s *LoginSeed) SeedEquipmentMileage() (mileage, exchangeGage uint64, err er return mileage, exchangeGage, nil } -type diskSeed struct { - Version string `json:"version"` - PacketCode int `json:"packet_code"` - UserInfoBase64 string `json:"user_info_base64"` - ResponseFieldsBase64 string `json:"response_fields_base64,omitempty"` -} - // Load reads one versioned seed file. Relative paths are intentionally left to // the caller; production code can therefore choose an explicit local asset. func Load(path string) (*LoginSeed, error) { @@ -350,7 +129,6 @@ func Load(path string) (*LoginSeed, error) { return seed, nil } -// Write stores a seed in the portable JSON form used by the one-shot importer. func (s *LoginSeed) Write(path string) error { if err := s.Validate(); err != nil { return err @@ -394,11 +172,11 @@ func (s *LoginSeed) Validate() error { // Encode makes a fresh encrypted LoginUser HTTP envelope. sessionKey belongs // to the new local session; no value from a capture is used at runtime. -func (s *LoginSeed) Encode(sessionKey string, now time.Time) ([]byte, error) { +func (s *LoginSeed) Encode(ctx command.Context, sessionKey string, now time.Time) ([]byte, error) { if err := s.Validate(); err != nil { return nil, err } - proto, err := s.Login(nil, []byte(sessionKey)) + proto, err := s.Login(ctx, nil, []byte(sessionKey)) if err != nil { return nil, err } @@ -424,7 +202,7 @@ func (s *LoginSeed) Encode(sessionKey string, now time.Time) ([]byte, error) { // session. request is the already-decrypted LoginUser protobuf. The reply // is deliberately protobuf-only so the session/transport layer owns its // packet-code and HTTP envelope policy. -func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { +func (s *LoginSeed) Login(ctx command.Context, request, sessionKey []byte) ([]byte, error) { if err := s.Validate(); err != nil { return nil, err } @@ -452,7 +230,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { } } if s.additionalCurrencies != nil { - values, err := s.additionalCurrencies.AdditionalCurrencies() + values, err := s.additionalCurrencies.AdditionalCurrencies(ctx) if err != nil { return nil, err } @@ -477,7 +255,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { } } if s.huntingAP != nil { - free, bonus, err := s.huntingAP.HuntingAP() + free, bonus, err := s.huntingAP.HuntingAP(ctx) if err != nil { return nil, fmt.Errorf("account: hunting AP: %w", err) } @@ -517,7 +295,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { } } if s.lastPlayedPack != nil { - packID, err := s.lastPlayedPack.LastPlayedPackID() + packID, err := s.lastPlayedPack.LastPlayedPackID(ctx) if err != nil { return nil, fmt.Errorf("account: last played pack: %w", err) } @@ -528,7 +306,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { } } if s.currencies != nil { - gold, freeJewelry, jewelry, mileage := s.currencies.Currencies() + gold, freeJewelry, jewelry, mileage := s.currencies.Currencies(ctx) var err error if user, _, err = wire.ReplaceVarint(user, 7, gold); err != nil { return nil, fmt.Errorf("account: replace gold: %w", err) @@ -543,17 +321,17 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { return nil, fmt.Errorf("account: replace mileage: %w", err) } if provider, ok := s.currencies.(CatalystProvider); ok { - if user, _, err = wire.ReplaceVarint(user, 11, provider.CatalystBalance()); err != nil { + if user, _, err = wire.ReplaceVarint(user, 11, provider.CatalystBalance(ctx)); err != nil { return nil, fmt.Errorf("account: replace catalyst: %w", err) } } if provider, ok := s.currencies.(HopePowderProvider); ok { - if user, _, err = wire.ReplaceVarint(user, 24, provider.HopePowderBalance()); err != nil { + if user, _, err = wire.ReplaceVarint(user, 24, provider.HopePowderBalance(ctx)); err != nil { return nil, fmt.Errorf("account: replace hope powder: %w", err) } } if provider, ok := s.currencies.(EquipmentMileageProvider); ok { - equipMileage, exchangeGage := provider.EquipmentMileageBalances() + equipMileage, exchangeGage := provider.EquipmentMileageBalances(ctx) if user, _, err = wire.ReplaceVarint(user, 67, equipMileage); err != nil { return nil, fmt.Errorf("account: replace equipment mileage: %w", err) } @@ -574,7 +352,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { } if s.purchaseCounts != nil { var err error - if user, err = replaceRepeatedBytes(user, 26, s.purchaseCounts.PurchaseCountDBInfos()); err != nil { + if user, err = replaceRepeatedBytes(user, 26, s.purchaseCounts.PurchaseCountDBInfos(ctx)); err != nil { return nil, fmt.Errorf("account: replace purchase counts: %w", err) } } @@ -585,7 +363,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { } } if s.inventorySlots != nil { - items, storage, equipment, equipmentStorage, err := s.inventorySlots.UserInventorySlots() + items, storage, equipment, equipmentStorage, err := s.inventorySlots.UserInventorySlots(ctx) if err != nil { return nil, fmt.Errorf("account: inventory slots: %w", err) } @@ -618,7 +396,7 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { return nil, err } } - projected, projectionErr := s.projectAutoRevive(user) + projected, projectionErr := s.projectAutoRevive(ctx, user) if projectionErr != nil { return nil, projectionErr } @@ -640,3 +418,26 @@ func replaceRepeatedBytes(data []byte, number int, values [][]byte) ([]byte, err } return result, nil } + +func (s *LoginSeed) projectAutoRevive(ctx command.Context, user []byte) ([]byte, error) { + if s.autoReviveSettings == nil { + return user, nil + } + on, index, e := s.autoReviveSettings.AutoReviveSettings(ctx) + 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 +} diff --git a/go/internal/server/protocol/ownership/codec.go b/go/internal/server/protocol/ownership/codec.go new file mode 100644 index 0000000..1295a64 --- /dev/null +++ b/go/internal/server/protocol/ownership/codec.go @@ -0,0 +1,190 @@ +package ownership + +import "bd2server/internal/server/protocol/wire" + +func add(dst []byte, field int, value uint64) []byte { + if value != 0 { + return wire.AppendVarint(dst, field, value) + } + return dst +} + +type Item struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Type uint64 `json:"type"` + Count uint64 `json:"count"` + KeepFlag uint64 `json:"keep_flag,omitempty"` + TimeValue uint64 `json:"time_value,omitempty"` + ExpiryTime uint64 `json:"expiry_time,omitempty"` + Pictorialbook *ItemPictorial `json:"pictorialbook,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + UseCount uint64 `json:"use_count,omitempty"` +} + +type ItemPictorial struct { + ID uint64 `json:"id"` + GroupID uint64 `json:"group_id"` +} + +type Equipment struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level"` + UseChar uint64 `json:"use_char,omitempty"` + KeepFlag uint64 `json:"keep_flag,omitempty"` + LockFlag uint64 `json:"lock_flag,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + Mark string `json:"mark,omitempty"` + MainOption []EquipmentOption `json:"main_option,omitempty"` + SubOption []EquipmentOption `json:"sub_option,omitempty"` + PrivateOption *EquipmentOption `json:"private_option,omitempty"` + Rank []uint64 `json:"rank,omitempty"` +} + +type EquipmentOption struct { + GroupID uint64 `json:"group_id"` + ID uint64 `json:"id"` +} + +type Character struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + HP uint64 `json:"hp,omitempty"` + Level uint64 `json:"level,omitempty"` + CostumeID uint64 `json:"costume_id,omitempty"` + Exp uint64 `json:"exp,omitempty"` + UseCostume uint64 `json:"use_costume,omitempty"` + TalentLevel uint64 `json:"talent_level,omitempty"` + TalentExp uint64 `json:"talent_exp,omitempty"` + SolidarityReward uint64 `json:"solidarity_reward,omitempty"` + ExpiryTime uint64 `json:"expiry_time,omitempty"` + ConnectPotentialCostume uint64 `json:"connect_potential_costume,omitempty"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` +} + +type Costume struct { + InvenIndex uint64 `json:"inven_index"` + ID uint64 `json:"id"` + Level uint64 `json:"level,omitempty"` + UseChar uint64 `json:"use_char,omitempty"` + SortID uint64 `json:"sort_id,omitempty"` + PotentialIDs []uint64 `json:"-"` + DesignID uint64 `json:"design_id,omitempty"` + BurstLevel uint64 `json:"burst_level,omitempty"` + TimeValue uint64 `json:"time_value,omitempty"` + Pictorialbook []Pictorial `json:"pictorialbook,omitempty"` +} + +type Pictorial struct { + ID uint64 `json:"id"` + GroupID uint64 `json:"group_id"` +} + +func EncodeItem(item Item) []byte { + var b []byte + for _, f := range []struct { + n int + v uint64 + }{{1, item.InvenIndex}, {2, item.ID}, {3, item.Type}, {4, item.Count}, {5, item.KeepFlag}, {6, item.TimeValue}} { + if f.v != 0 { + b = wire.AppendVarint(b, f.n, f.v) + } + } + if item.ExpiryTime != 0 { + b = wire.AppendVarint(b, 8, item.ExpiryTime) + } + if item.SortID != 0 { + b = wire.AppendVarint(b, 9, item.SortID) + } + if item.UseCount != 0 { + b = wire.AppendVarint(b, 10, item.UseCount) + } + return b +} + +func EncodeEquipment(entry Equipment) []byte { + base := wire.AppendVarint(nil, 1, entry.ID) + if entry.Level != 0 { + base = wire.AppendVarint(base, 2, entry.Level) + } + for _, option := range entry.MainOption { + base = wire.AppendBytes(base, 3, EncodeEquipmentOption(option)) + } + for _, option := range entry.SubOption { + base = wire.AppendBytes(base, 4, EncodeEquipmentOption(option)) + } + // The native ResolvedPrivateOption getter dereferences PrivateOption before + // checking IsValid. A present empty message selects its GameData fallback; + // an omitted field leaves the protobuf object null and crashes the UI. + var private []byte + if entry.PrivateOption != nil { + private = EncodeEquipmentOption(*entry.PrivateOption) + } + base = wire.AppendBytes(base, 5, private) + for _, rank := range entry.Rank { + base = wire.AppendVarint(base, 6, rank) + } + var out []byte + out = wire.AppendVarint(out, 1, entry.InvenIndex) + if entry.UseChar != 0 { + out = wire.AppendVarint(out, 2, entry.UseChar) + } + if entry.KeepFlag != 0 { + out = wire.AppendVarint(out, 3, entry.KeepFlag) + } + if entry.LockFlag != 0 { + out = wire.AppendVarint(out, 4, entry.LockFlag) + } + out = wire.AppendBytes(out, 5, base) + if entry.SortID != 0 { + out = wire.AppendVarint(out, 7, entry.SortID) + } + if entry.Mark != "" { + out = wire.AppendBytes(out, 8, []byte(entry.Mark)) + } + return out +} + +func EncodeEquipmentOption(option EquipmentOption) []byte { + out := wire.AppendVarint(nil, 1, option.GroupID) + return wire.AppendVarint(out, 2, option.ID) +} + +func EncodeCharacter(c Character) []byte { + fields := []struct { + n int + v uint64 + }{{1, c.InvenIndex}, {2, c.ID}, {3, c.HP}, {4, c.Level}, {5, c.CostumeID}, {6, c.Exp}, {7, c.UseCostume}, {8, c.TalentLevel}, {9, c.TalentExp}, {10, c.SolidarityReward}, {11, c.ExpiryTime}, {13, c.ConnectPotentialCostume}} + var out []byte + for _, field := range fields { + if field.v != 0 { + out = wire.AppendVarint(out, field.n, field.v) + } + } + for _, p := range c.Pictorialbook { + book := wire.AppendVarint(wire.AppendVarint(nil, 1, p.ID), 2, p.GroupID) + out = wire.AppendBytes(out, 12, book) + } + return out +} + +func EncodeCostume(entry Costume) []byte { + var costume []byte + costume = add(costume, 1, entry.InvenIndex) + costume = add(costume, 2, entry.ID) + costume = add(costume, 3, entry.Level) + costume = add(costume, 4, entry.UseChar) + for _, p := range entry.Pictorialbook { + book := add(add(nil, 1, p.ID), 2, p.GroupID) + costume = wire.AppendBytes(costume, 5, book) + } + costume = add(costume, 6, entry.SortID) + for _, id := range entry.PotentialIDs { + costume = wire.AppendVarint(costume, 8, id) + } + costume = add(costume, 9, entry.DesignID) + costume = add(costume, 10, entry.BurstLevel) + costume = add(costume, 12, entry.TimeValue) + return costume +} diff --git a/go/internal/server/protocol/protocol.go b/go/internal/server/protocol/protocol.go index 69eddd9..c19f4bc 100644 --- a/go/internal/server/protocol/protocol.go +++ b/go/internal/server/protocol/protocol.go @@ -9,7 +9,7 @@ import ( "fmt" "strings" - "bd2server/internal/server/cryptox" + "bd2server/internal/server/protocol/cryptox" ) type Envelope struct { diff --git a/go/internal/server/protocol/protocol_test.go b/go/internal/server/protocol/protocol_test.go deleted file mode 100644 index 11a79d2..0000000 --- a/go/internal/server/protocol/protocol_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package protocol - -import ( - "encoding/base64" - "encoding/json" - "testing" - - "bd2server/internal/server/cryptox" -) - -func TestEncode(t *testing.T) { - key := []byte("0123456789abcdef0123456789abcdef") - proto := []byte{8, 42} - raw, err := Encode(19, proto, key, 123) - if err != nil { - t.Fatal(err) - } - var envelope Envelope - if err := json.Unmarshal(raw, &envelope); err != nil { - t.Fatal(err) - } - decoded, err := cryptox.DecryptBase64Payload(envelope.Data, key) - if err != nil || string(decoded) != string(proto) || envelope.PacketCode != 19 || envelope.Length != 4 || envelope.ServerNowTime != 123 { - t.Fatalf("unexpected encoded response: %+v data=%v err=%v", envelope, decoded, err) - } -} - -func TestNotifyIsPlainBase64SeparateFromEncryptedResponse(t *testing.T) { - key := []byte("0123456789abcdef0123456789abcdef") - notify := []byte{0x12, 6, 8, 42, 16, 7, 24, 1} - raw, err := EncodeWithNotify(167, nil, key, 123, notify) - if err != nil { - t.Fatal(err) - } - var envelope Envelope - if err := json.Unmarshal(raw, &envelope); err != nil { - t.Fatal(err) - } - decoded, err := base64.StdEncoding.DecodeString(envelope.Notify) - if err != nil || string(decoded) != string(notify) { - t.Fatalf("notify=%x err=%v", decoded, err) - } - response, err := cryptox.DecryptBase64Payload(envelope.Data, key) - if err != nil || len(response) != 0 { - t.Fatalf("response=%x err=%v", response, err) - } -} - -func TestBatchPreservesOrderAndRejectsDuplicates(t *testing.T) { - key := []byte("0123456789abcdef0123456789abcdef") - input := []BatchRequest{{Path: "/B", RequestData: base64.StdEncoding.EncodeToString([]byte{8, 1})}, {Path: "/A", RequestData: base64.StdEncoding.EncodeToString([]byte{8, 2})}} - plain, _ := json.Marshal(input) - cipher, err := cryptox.EncryptBase64(plain, key) - if err != nil { - t.Fatal(err) - } - got, proto, err := DecodeBatchRequest([]byte(cipher), key) - if err != nil || got[0].Path != "/B" || got[1].Path != "/A" || proto[1][1] != 2 { - t.Fatalf("batch decode: %v %v %v", got, proto, err) - } - input[1].Path = "/B" - plain, _ = json.Marshal(input) - cipher, _ = cryptox.EncryptBase64(plain, key) - if _, _, err := DecodeBatchRequest([]byte(cipher), key); err == nil { - t.Fatal("accepted duplicate path") - } -} diff --git a/go/internal/server/protocol/schedule/adapter.go b/go/internal/server/protocol/schedule/adapter.go new file mode 100644 index 0000000..f968a03 --- /dev/null +++ b/go/internal/server/protocol/schedule/adapter.go @@ -0,0 +1,55 @@ +package scheduleadapter + +import ( + "bd2server/internal/server/design/schedule" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/protocol/wire" + "errors" +) + +type Service struct{ Schedule *schedule.Service } + +func (a *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { + s := a.Schedule + if path != "/ScheduleInfo" { + return 0, nil, false, nil + } + seq, found, err := wire.Varint(request, 1) + if err != nil || !found || seq == 0 { + return 0, nil, true, errors.New("schedule: invalid request sequence") + } + if s == nil || s.CalculateMilliseconds == 0 || len(s.Contents) == 0 { + return 0, nil, true, errors.New("schedule: unavailable calendar") + } + response := wire.AppendVarint(nil, 1, s.CalculateMilliseconds) + for _, content := range s.Contents { + entry := wire.AppendVarint(nil, 1, content.ID) + entry = wire.AppendBytes(entry, 2, encodeSeason(content.Current)) + entry = wire.AppendBytes(entry, 3, encodeSeason(content.Next)) + response = wire.AppendBytes(response, 2, entry) + } + for _, regular := range s.Regular { + entry := wire.AppendVarint(nil, 1, regular.ContentID) + if regular.Season != 0 { + entry = wire.AppendVarint(entry, 2, regular.Season) + } + response = wire.AppendBytes(response, 3, entry) + } + return 117, response, true, nil +} + +func encodeSeason(season schedule.Season) []byte { + result := wire.AppendVarint(nil, 1, season.ID) + result = wire.AppendVarint(result, 2, season.StartMilliseconds) + result = wire.AppendVarint(result, 3, season.EndMilliseconds) + if season.Error { + result = wire.AppendVarint(result, 4, 1) + } + if season.Return { + result = wire.AppendVarint(result, 5, 1) + } + if season.RankRewardGroupID != 0 { + result = wire.AppendVarint(result, 6, season.RankRewardGroupID) + } + return result +} diff --git a/go/internal/server/readonly/readonly.go b/go/internal/server/protocol/staticdata/readonly.go similarity index 93% rename from go/internal/server/readonly/readonly.go rename to go/internal/server/protocol/staticdata/readonly.go index 80e56da..64b12c0 100644 --- a/go/internal/server/readonly/readonly.go +++ b/go/internal/server/protocol/staticdata/readonly.go @@ -6,14 +6,15 @@ package readonly import ( + "bd2server/internal/server/domain/command" "encoding/binary" "encoding/json" "errors" "fmt" "os" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" ) func StateVersion() string { return versionconfig.State() } @@ -147,7 +148,7 @@ func validateFields(fields []Field) error { // Handle re-encodes a response every time. It intentionally accepts any // structurally valid sequenced request, matching other initialization routes. -func (s *Seed) Handle(path string, request []byte) (int, []byte, bool, error) { +func (s *Seed) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { r, ok := s.Responses[path] if !ok { return 0, nil, false, nil @@ -197,9 +198,9 @@ func encodeFields(fields []Field) ([]byte, error) { // Service adapts a loaded seed to the session handler interface. type Service struct{ Seed *Seed } -func (s Service) Handle(path string, request []byte) (int, []byte, bool, error) { +func (s Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { if s.Seed == nil { return 0, nil, false, nil } - return s.Seed.Handle(path, request) + return s.Seed.Handle(ctx, path, request) } diff --git a/go/internal/server/wire/wire.go b/go/internal/server/protocol/wire/wire.go similarity index 100% rename from go/internal/server/wire/wire.go rename to go/internal/server/protocol/wire/wire.go diff --git a/go/internal/server/readonly/readonly_test.go b/go/internal/server/readonly/readonly_test.go deleted file mode 100644 index 3442f2f..0000000 --- a/go/internal/server/readonly/readonly_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package readonly - -import ( - "bytes" - "path/filepath" - "testing" - - "bd2server/internal/server/wire" -) - -func TestSeedProtocolEncodingAndRoundTrip(t *testing.T) { - seed := &Seed{Version: StateVersion(), Responses: map[string]Response{ - "/CashShopInfo": {PacketCode: 60, Fields: []Field{ - {Number: 1, Type: 2, Fields: []Field{{Number: 1, Type: 0, Varint: 150}, {Number: 2, Type: 0, Varint: 7}}}, - {Number: 2, Type: 1, Fixed64: 0x0807060504030201}, - {Number: 3, Type: 5, Fixed32: 0x04030201}, - {Number: 4, Type: 2, Bytes: []byte("shop")}, - {Number: 5, Type: 2}, - }}, - }} - path := filepath.Join(t.TempDir(), "readonly.json") - if err := seed.Write(path); err != nil { - t.Fatal(err) - } - loaded, err := Load(path) - if err != nil { - t.Fatal(err) - } - want := []byte{0x0a, 5, 0x08, 0x96, 1, 0x10, 7, 0x11, 1, 2, 3, 4, 5, 6, 7, 8, 0x1d, 1, 2, 3, 4, 0x22, 4, 's', 'h', 'o', 'p', 0x2a, 0} - code, got, handled, err := loaded.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 999)) - if err != nil || !handled || code != 60 || !bytes.Equal(got, want) { - t.Fatalf("code=%d proto=%x handled=%v err=%v", code, got, handled, err) - } - got[0] = 0 - _, again, _, err := loaded.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1000)) - if err != nil || !bytes.Equal(again, want) { - t.Fatal("encoded response aliases seed data") - } - for _, request := range [][]byte{nil, {0x08, 0}, {0x08, 0x80}} { - if _, _, handled, err := loaded.Handle("/CashShopInfo", request); !handled || err == nil { - t.Fatal("invalid request accepted") - } - } - if _, _, handled, err := loaded.Handle("/Unknown", nil); handled || err != nil { - t.Fatal("unknown endpoint accepted") - } -} - -func TestCashProductEventIndexIsUniqueSemanticSeedFact(t *testing.T) { - seed := &Seed{Version: StateVersion(), Responses: map[string]Response{ - "/CashShopInfo": {PacketCode: 60, Fields: []Field{{Number: 1, Type: 2, Fields: []Field{ - {Number: 1, Type: 0, Varint: 1100001}, {Number: 2, Type: 0, Varint: 9100033}, {Number: 8, Type: 0, Varint: 1171}, - }}}}, - }} - event, err := seed.CashProductEventIndex(1100001, 9100033) - if err != nil || event != 1171 { - t.Fatalf("event=%d err=%v", event, err) - } - if _, err := seed.CashProductEventIndex(1100001, 999); err == nil { - t.Fatal("missing cash product event was accepted") - } - duplicate := seed.Responses["/CashShopInfo"] - for _, field := range duplicate.Fields { - var group, product uint64 - for _, nested := range field.Fields { - if nested.Number == 1 { - group = nested.Varint - } - if nested.Number == 2 { - product = nested.Varint - } - } - if group == 1100001 && product == 9100033 { - duplicate.Fields = append(duplicate.Fields, field) - break - } - } - seed.Responses["/CashShopInfo"] = duplicate - if _, err := seed.CashProductEventIndex(1100001, 9100033); err == nil { - t.Fatal("duplicate cash product event was accepted") - } -} diff --git a/go/internal/server/readonly/root_sort_test.go b/go/internal/server/readonly/root_sort_test.go deleted file mode 100644 index 6ea3625..0000000 --- a/go/internal/server/readonly/root_sort_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package readonly - -import ( - "path/filepath" - "testing" - - "bd2server/internal/server/wire" -) - -func TestRootSortUsesVersionedServerOrdering(t *testing.T) { - seed := &Seed{Version: StateVersion(), Responses: map[string]Response{"/RootSortIdInfo": {PacketCode: 367, Fields: []Field{ - {Number: 1, Type: 2, Fields: []Field{{Number: 1, Type: 0, Varint: 4}, {Number: 2, Type: 0, Varint: 907}, {Number: 3, Type: 0, Varint: 8}}}, - }}}} - path := filepath.Join(t.TempDir(), "readonly.json") - if err := seed.Write(path); err != nil { - t.Fatal(err) - } - loaded, err := Load(path) - if err != nil { - t.Fatal(err) - } - code, payload, handled, err := loaded.Handle("/RootSortIdInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || !handled || code != 367 { - t.Fatalf("response: %d %v %v", code, handled, err) - } - entry, _, err := wire.Bytes(payload, 1) - id, _, _ := wire.Varint(entry, 2) - order, _, _ := wire.Varint(entry, 3) - if err != nil || id != 907 || order != 8 { - t.Fatalf("ordering id=%d sort=%d err=%v", id, order, err) - } -} diff --git a/go/internal/server/resourcefetch/fetch_test.go b/go/internal/server/resourcefetch/fetch_test.go deleted file mode 100644 index 165619d..0000000 --- a/go/internal/server/resourcefetch/fetch_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package resourcefetch - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func TestCatalogPathsAreUniqueSortedAndSafe(t *testing.T) { - data := []byte(`{"m_InternalIds":["{BDNetwork.CdnInfo.Info}/StandaloneWindows64/HD/1/z.bundle","ignored","{BDNetwork.CdnInfo.Info}\\StandaloneWindows64\\HD\\1\\a.bundle","{BDNetwork.CdnInfo.Info}/StandaloneWindows64/HD/1/z.bundle"]}`) - got, err := catalogPaths(data) - if err != nil { - t.Fatal(err) - } - want := []string{"a.bundle", "z.bundle"} - if !reflect.DeepEqual(got, want) { - t.Fatalf("paths=%v want=%v", got, want) - } - if _, err := catalogPaths([]byte(`{"m_InternalIds":["{BDNetwork.CdnInfo.Info}/StandaloneWindows64/HD/1/../../escape"]}`)); err == nil { - t.Fatal("unsafe catalog path accepted") - } -} - -func TestValidUnityBundle(t *testing.T) { - path := filepath.Join(t.TempDir(), "bundle") - if err := os.WriteFile(path, []byte("UnityFSpayload"), 0o644); err != nil { - t.Fatal(err) - } - if size, ok := validUnityBundle(path); !ok || size != 14 { - t.Fatalf("size=%d ok=%v", size, ok) - } -} - -func TestFetchRejectsUntrustedVersionPathsBeforeNetwork(t *testing.T) { - _, err := Fetch(t.Context(), Options{OutputRoot: t.TempDir(), BundleVersion: "../../escape", GameDataVersion: "20260923193640"}) - if err == nil { - t.Fatal("unsafe resource version accepted") - } -} diff --git a/go/internal/server/resourcepolicy/config_test.go b/go/internal/server/resourcepolicy/config_test.go deleted file mode 100644 index 154ac57..0000000 --- a/go/internal/server/resourcepolicy/config_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package resourcepolicy - -import ( - "os" - "path/filepath" - "testing" -) - -func TestOfficialDefaultsAreCapturedOrigins(t *testing.T) { - cfg := Config{Mode: ModeOfficial} - if err := cfg.Validate(); err != nil { - t.Fatal(err) - } - public := cfg.Public("bundle", "game-data") - if public.ServerDataURL != OfficialServerDataURL || public.GameDataURL != OfficialGameDataURL { - t.Fatalf("official policy = %+v", public) - } -} - -func TestServerModeRequiresSafeCompleteOrigins(t *testing.T) { - cfg := Config{Mode: ModeServer, ServerDataURL: "https://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"} - if err := cfg.Validate(); err != nil { - t.Fatalf("server mode rejected: %v", err) - } - bad := []Config{ - {Mode: ModeServer, ServerDataURL: "https://cdn.example.com/ServerData"}, - {Mode: ModeServer, ServerDataURL: "http://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, - {Mode: ModeServer, ServerDataURL: "https://user:password@cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, - {Mode: ModeServer, ServerDataURL: "https://cdn.example.com/ServerData/", GameDataURL: "https://cdn.example.com/GameData"}, - {Mode: "self_hosted", ServerDataURL: "https://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, - {Mode: "reverse_proxy", ServerDataURL: "https://cdn.example.com/ServerData", GameDataURL: "https://cdn.example.com/GameData"}, - {Mode: "local", ServerDataURL: "C:\\bd2-resources\\ServerData", GameDataURL: "C:\\bd2-resources\\GameData"}, - } - for i, cfg := range bad { - if err := cfg.Validate(); err == nil { - t.Fatalf("unsafe policy %d accepted: %+v", i, cfg) - } - } -} - -func TestLoopbackHTTPIsDevelopmentOnly(t *testing.T) { - cfg := Config{Mode: ModeServer, ServerDataURL: "http://127.0.0.1:9000/ServerData", GameDataURL: "http://localhost:9000/GameData"} - if err := cfg.Validate(); err != nil { - t.Fatal(err) - } -} - -func TestLoadRejectsUnknownAndTrailingJSON(t *testing.T) { - for name, body := range map[string]string{ - "unknown": `{"mode":"official","cdn_url":"https://example.com"}`, - "trailing": `{"mode":"official"}{}`, - } { - t.Run(name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), FileName) - if err := os.WriteFile(path, []byte(body), 0o600); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Fatal("invalid policy accepted") - } - }) - } -} diff --git a/go/internal/server/resourcefetch/fetch.go b/go/internal/server/resources/fetch/fetch.go similarity index 99% rename from go/internal/server/resourcefetch/fetch.go rename to go/internal/server/resources/fetch/fetch.go index e5dc558..6c6f42d 100644 --- a/go/internal/server/resourcefetch/fetch.go +++ b/go/internal/server/resources/fetch/fetch.go @@ -18,8 +18,8 @@ import ( "strings" "time" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/resourcepolicy" + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/resources/policy" ) const catalogPlaceholder = "{BDNetwork.CdnInfo.Info}" diff --git a/go/internal/server/resourcepolicy/config.go b/go/internal/server/resources/policy/config.go similarity index 99% rename from go/internal/server/resourcepolicy/config.go rename to go/internal/server/resources/policy/config.go index 28e441d..ffeba51 100644 --- a/go/internal/server/resourcepolicy/config.go +++ b/go/internal/server/resources/policy/config.go @@ -12,7 +12,7 @@ import ( "path/filepath" "strings" - "bd2server/internal/server/configfile" + "bd2server/internal/server/platform/configfile" ) const ( diff --git a/go/internal/server/runtime/player/runtime.go b/go/internal/server/runtime/player/runtime.go new file mode 100644 index 0000000..6c63679 --- /dev/null +++ b/go/internal/server/runtime/player/runtime.go @@ -0,0 +1,429 @@ +// Package player owns the execution order and lifecycle of one account. +package player + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "sync/atomic" + "time" + + "bd2server/internal/server/domain/command" +) + +var ( + ErrMailboxFull = errors.New("player mailbox capacity exhausted") + ErrClosed = errors.New("player runtime is closing") + ErrUnavailable = errors.New("player runtime requires recovery") + ErrIdentityConflict = errors.New("request identity has a different digest") + ErrWrongAccount = errors.New("command belongs to another account") + ErrGenerationExpired = errors.New("accepted player command belongs to a recovered session generation") +) + +var runtimeGeneration atomic.Uint64 + +type Request struct { + Path string + Body []byte +} + +type Command struct { + Identity command.Identity + Digest [32]byte + Requests []Request + LoginSessionKey []byte + ExpectedGeneration uint64 + // Cancellation records the accepting caller's cancellation signal; Execute's + // separate context remains live until the accepted transaction is settled. + Cancellation context.Context +} + +type Response struct { + PacketCode int + Body []byte + Notification []byte +} + +type Timing struct { + Queue, Execute, Observer, Begin, Commit, Rollback time.Duration +} + +type Reply struct { + Responses []Response + Timing Timing + Generation uint64 +} + +// Execute must atomically persist assets and the identity/digest receipt before +// returning a successful reply. Its receipt survives runtime unload and restart. +type Executor interface { + Execute(context.Context, Command) (Reply, error) +} + +type RecoveryRequiredError interface { + error + RequiresRecovery() bool +} + +type Failure struct { + Cause error + RecoveryRequired bool + AlreadyRecovered bool +} + +func (f Failure) Error() string { + if f.Cause == nil { + return ErrUnavailable.Error() + } + return f.Cause.Error() +} +func (f Failure) Unwrap() error { return f.Cause } +func (f Failure) RequiresRecovery() bool { return f.RecoveryRequired } +func (f Failure) RecoveryComplete() bool { return f.AlreadyRecovered } + +type Recoverer interface { + Recover(context.Context) error +} + +type Closer interface { + Close() error +} + +type Limits struct { + MaxCommands int + MaxBytes int64 + CompletedReceipts int +} + +type Future struct { + done chan struct{} + reply Reply + err error +} + +// Wait cancels only this caller's wait. Accepted commands retain their outcome. +func (f *Future) Wait(ctx context.Context) (Reply, error) { + select { + case <-f.done: + return cloneReply(f.reply), f.err + case <-ctx.Done(): + return Reply{}, ctx.Err() + } +} + +type receipt struct { + digest [32]byte + future *Future + bytes int64 +} + +type work struct { + command Command + ctx context.Context + future *Future + bytes int64 + recovery bool + acceptedAt time.Time + generation uint64 +} + +type Runtime struct { + accountID string + executor Executor + limits Limits + mu sync.Mutex + queue chan work + receipts map[command.Identity]*receipt + completed []command.Identity + completedBytes int64 + pending int + bytes int64 + closing bool + failed error + done chan struct{} + closeErr error + generation atomic.Uint64 +} + +func New(accountID string, executor Executor, limits Limits) (*Runtime, error) { + if accountID == "" || executor == nil { + return nil, errors.New("player runtime requires account identity and executor") + } + if limits.MaxCommands == 0 { + limits.MaxCommands = 128 + } + if limits.MaxBytes == 0 { + limits.MaxBytes = 8 << 20 + } + if limits.CompletedReceipts == 0 { + limits.CompletedReceipts = 256 + } + if limits.MaxCommands < 1 || limits.MaxBytes < 1 || limits.CompletedReceipts < 1 { + return nil, errors.New("player runtime limits must be positive") + } + r := &Runtime{accountID: accountID, executor: executor, limits: limits, + queue: make(chan work, limits.MaxCommands), receipts: make(map[command.Identity]*receipt), done: make(chan struct{})} + r.generation.Store(runtimeGeneration.Add(1)) + go r.run() + return r, nil +} + +func (r *Runtime) Generation() uint64 { return r.generation.Load() } + +// Submit's successful return is the acceptance boundary, including for batches. +// Executor receives owned copies and a context detached from network cancellation. +func (r *Runtime) Submit(ctx context.Context, c Command) (*Future, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if c.Identity.AccountID != r.accountID { + return nil, ErrWrongAccount + } + if c.Identity.SessionID == "" || c.Identity.RequestID == "" || len(c.Requests) == 0 { + return nil, errors.New("command requires session, request identity and requests") + } + size := commandBytes(c) + r.mu.Lock() + defer r.mu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + if r.closing { + return nil, ErrClosed + } + if c.ExpectedGeneration != 0 && c.ExpectedGeneration != r.generation.Load() { + return nil, ErrGenerationExpired + } + if previous, exists := r.receipts[c.Identity]; exists { + if previous.digest != c.Digest { + return nil, ErrIdentityConflict + } + return previous.future, nil + } + if r.failed != nil { + return nil, errors.Join(ErrUnavailable, r.failed) + } + if r.pending >= r.limits.MaxCommands || size > r.limits.MaxBytes-r.bytes { + return nil, ErrMailboxFull + } + f := &Future{done: make(chan struct{})} + c.Cancellation = ctx + r.receipts[c.Identity] = &receipt{digest: c.Digest, future: f} + r.pending++ + r.bytes += size + r.queue <- work{command: cloneCommand(c), ctx: context.WithoutCancel(ctx), future: f, bytes: size, acceptedAt: time.Now(), generation: r.generation.Load()} + return f, nil +} + +// Recover runs on the owner goroutine, after any previously accepted commands. +func (r *Runtime) Recover(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + r.mu.Lock() + if err := ctx.Err(); err != nil { + r.mu.Unlock() + return err + } + if r.closing { + r.mu.Unlock() + return ErrClosed + } + const recoveryBytes = 160 + if r.pending >= r.limits.MaxCommands || recoveryBytes > r.limits.MaxBytes-r.bytes { + r.mu.Unlock() + return ErrMailboxFull + } + f := &Future{done: make(chan struct{})} + r.pending++ + r.bytes += recoveryBytes + r.queue <- work{ctx: context.WithoutCancel(ctx), future: f, recovery: true, bytes: recoveryBytes} + r.mu.Unlock() + _, err := f.Wait(ctx) + return err +} + +// Close rejects new commands and drains every accepted command. A cancelled +// caller can stop waiting; draining and executor disposal continue in the owner. +func (r *Runtime) Close(ctx context.Context) error { + r.mu.Lock() + if !r.closing { + r.closing = true + close(r.queue) + } + r.mu.Unlock() + select { + case <-r.done: + return r.closeErr + case <-ctx.Done(): + return ctx.Err() + } +} + +func (r *Runtime) run() { + defer close(r.done) + for w := range r.queue { + var reply Reply + var err error + queued := time.Since(w.acceptedAt) + if w.recovery { + err = r.recover(w.ctx) + } else { + r.mu.Lock() + failure := r.failed + r.mu.Unlock() + if w.generation != r.generation.Load() { + err = ErrGenerationExpired + } else if failure != nil { + err = errors.Join(ErrUnavailable, failure) + } else { + started := time.Now() + reply, err = safeExecute(r.executor, w.ctx, w.command) + if reply.Timing.Execute == 0 { + reply.Timing.Execute = time.Since(started) + } + if requiresRecovery(err) { + if !needsRecovery(err) { + r.generation.Store(runtimeGeneration.Add(1)) + } else { + r.mu.Lock() + r.failed = err + r.mu.Unlock() + if _, ok := r.executor.(Recoverer); ok { + if recoveryErr := r.recover(w.ctx); recoveryErr != nil { + err = errors.Join(err, recoveryErr) + } + } + } + } + } + } + if !w.recovery { + reply.Timing.Queue = queued + } + reply.Generation = r.generation.Load() + w.future.reply, w.future.err = cloneReply(reply), err + r.mu.Lock() + r.pending-- + r.bytes -= w.bytes + if !w.recovery { + if err == nil { + entry := r.receipts[w.command.Identity] + entry.bytes = w.bytes + replyBytes(reply) + r.completedBytes += entry.bytes + r.completed = append(r.completed, w.command.Identity) + for len(r.completed) > r.limits.CompletedReceipts || r.completedBytes > r.limits.MaxBytes { + r.completedBytes -= r.receipts[r.completed[0]].bytes + delete(r.receipts, r.completed[0]) + r.completed = slices.Delete(r.completed, 0, 1) + } + } else { + delete(r.receipts, w.command.Identity) + } + } + close(w.future.done) + r.mu.Unlock() + } + if closer, ok := r.executor.(Closer); ok { + r.closeErr = safeCall(closer.Close) + } +} + +func (r *Runtime) recover(ctx context.Context) error { + recoverer, ok := r.executor.(Recoverer) + if !ok { + return ErrUnavailable + } + err := safeCall(func() error { return recoverer.Recover(ctx) }) + r.mu.Lock() + r.failed = err + if err == nil { + r.generation.Store(runtimeGeneration.Add(1)) + } + r.mu.Unlock() + return err +} + +func safeExecute(executor Executor, ctx context.Context, c Command) (reply Reply, err error) { + defer func() { + if value := recover(); value != nil { + err = Failure{Cause: fmt.Errorf("player executor panic: %v", value), RecoveryRequired: true} + } + }() + return executor.Execute(ctx, c) +} + +func safeCall(fn func() error) (err error) { + defer func() { + if value := recover(); value != nil { + err = fmt.Errorf("player lifecycle panic: %v", value) + } + }() + return fn() +} + +func requiresRecovery(err error) bool { + return recoveryErrorMatches(err, func(failure RecoveryRequiredError) bool { return failure.RequiresRecovery() }) +} + +func needsRecovery(err error) bool { + return recoveryErrorMatches(err, func(failure RecoveryRequiredError) bool { + if !failure.RequiresRecovery() { + return false + } + completed, ok := failure.(interface{ RecoveryComplete() bool }) + return !ok || !completed.RecoveryComplete() + }) +} + +func recoveryErrorMatches(err error, matches func(RecoveryRequiredError) bool) bool { + if err == nil { + return false + } + if failure, ok := err.(RecoveryRequiredError); ok && matches(failure) { + return true + } + switch wrapped := err.(type) { + case interface{ Unwrap() []error }: + return slices.ContainsFunc(wrapped.Unwrap(), func(cause error) bool { return recoveryErrorMatches(cause, matches) }) + case interface{ Unwrap() error }: + return recoveryErrorMatches(wrapped.Unwrap(), matches) + } + return false +} + +func commandBytes(c Command) int64 { + bytes := int64(len(c.Identity.AccountID)) + int64(len(c.Identity.SessionID)) + int64(len(c.Identity.RequestID)) + int64(len(c.LoginSessionKey)) + 160 + for _, request := range c.Requests { + bytes += int64(len(request.Path)) + int64(len(request.Body)) + 48 + } + return bytes +} + +func cloneCommand(c Command) Command { + c.Requests = slices.Clone(c.Requests) + for i := range c.Requests { + c.Requests[i].Body = slices.Clone(c.Requests[i].Body) + } + c.LoginSessionKey = slices.Clone(c.LoginSessionKey) + return c +} + +func cloneReply(reply Reply) Reply { + reply.Responses = slices.Clone(reply.Responses) + for i := range reply.Responses { + reply.Responses[i].Body = slices.Clone(reply.Responses[i].Body) + reply.Responses[i].Notification = slices.Clone(reply.Responses[i].Notification) + } + return reply +} + +func replyBytes(reply Reply) int64 { + bytes := int64(80) + for _, response := range reply.Responses { + bytes += int64(len(response.Body)) + int64(len(response.Notification)) + 64 + } + return bytes +} diff --git a/go/internal/server/runtime/player/runtime_assets_test.go b/go/internal/server/runtime/player/runtime_assets_test.go new file mode 100644 index 0000000..ead1477 --- /dev/null +++ b/go/internal/server/runtime/player/runtime_assets_test.go @@ -0,0 +1,517 @@ +package player_test + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/json" + "errors" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + "time" + + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/domain/mail" + "bd2server/internal/server/platform/versionconfig" + "bd2server/internal/server/protocol/wire" + "bd2server/internal/server/runtime/player" + accountstate "bd2server/internal/server/storage/account" + "bd2server/internal/server/storage/stateio" +) + +// The fixture uses the server's explicit compensation-mail rule: issue a named +// gift once, grant its gold only when opened, and retain the claim across login. +// Runtime controls scheduling; real mail, wallet and SQLite own all asset logic. +type mailExecutor struct { + repository *accountstate.Repository + wallet *inventory.Wallet + items *inventory.Inventory + mail *mail.Service + gold uint64 + before func(player.Command) + failClaim atomic.Bool +} + +func newMailActor(t *testing.T, accountID, path string, gold uint64, limits player.Limits) (*player.Runtime, *mailExecutor) { + t.Helper() + repository, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + executor := &mailExecutor{repository: repository, gold: gold} + if err := executor.reload(context.Background()); err != nil { + _ = repository.Close() + t.Fatal(err) + } + runtime, err := player.New(accountID, executor, limits) + if err != nil { + _ = repository.Close() + t.Fatal(err) + } + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := runtime.Close(ctx); err != nil { + t.Errorf("drain actor: %v", err) + } + }) + return runtime, executor +} + +func (e *mailExecutor) reload(ctx context.Context) (err error) { + tx, err := e.repository.BeginCommand(ctx) + if err != nil { + return err + } + defer func() { + if err != nil { + err = errors.Join(err, tx.Rollback()) + } + }() + commandCtx := command.Context{Cancellation: ctx, State: tx} + scope := stateio.RootStore{} + wallet, err := inventory.OpenWallet(commandCtx, scope, inventory.Currency{}) + if err != nil { + return err + } + items, err := inventory.OpenInventory(commandCtx, scope, nil) + if err != nil { + return err + } + mailbox, err := mail.OpenService(commandCtx, scope, &mail.Starter{Version: versionconfig.State(), MailCount: 1}, items, wallet) + if err != nil { + return err + } + for _, persist := range []func(command.Context) error{wallet.EnsurePersisted, items.EnsurePersisted, mailbox.EnsurePersisted} { + if err := persist(commandCtx); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return err + } + e.wallet, e.items, e.mail = wallet, items, mailbox + return nil +} + +func (e *mailExecutor) Execute(ctx context.Context, c player.Command) (reply player.Reply, err error) { + if e.before != nil { + e.before(c) + } + tx, err := e.repository.BeginCommand(ctx) + if err != nil { + return reply, player.Failure{Cause: err, RecoveryRequired: true} + } + committed := false + defer func() { + if !committed { + err = errors.Join(err, tx.Rollback()) + } + }() + commandCtx := command.Context{Identity: c.Identity, Cancellation: c.Cancellation, State: tx} + for _, request := range c.Requests { + response := player.Response{} + switch request.Path { + case "/admin/compensation": + err = e.mail.EnqueueCompensation(commandCtx, string(request.Body), "Fixture compensation", "One claim per account", []gamedata.Reward{{Type: 4, Count: e.gold}}, time.Now().UTC()) + case "/MailOpen", "/MailInfo": + var owned bool + response.PacketCode, response.Body, owned, err = e.mail.Handle(commandCtx, request.Path, request.Body) + if err == nil && !owned { + err = errors.New("mail handler did not own its request") + } + case "/admin/spend": + _, err = e.wallet.SpendGoldOnce(commandCtx, c.Identity.RequestID, e.gold+1) + default: + err = errors.New("unsupported fixture command") + } + if err != nil { + if tx.Dirty() { + err = player.Failure{Cause: err, RecoveryRequired: true} + } + return player.Reply{}, err + } + reply.Responses = append(reply.Responses, response) + if request.Path == "/MailOpen" && e.failClaim.Swap(false) { + return player.Reply{}, player.Failure{Cause: errors.New("injected failure after wallet and mail writes, before commit"), RecoveryRequired: true} + } + } + if err = tx.Commit(); err != nil { + return player.Reply{}, player.Failure{Cause: err, RecoveryRequired: true} + } + committed = true + return reply, nil +} + +func (e *mailExecutor) Recover(ctx context.Context) error { + if err := e.reload(ctx); err != nil { + return err + } + return nil +} + +func (e *mailExecutor) Close() error { return e.repository.Close() } + +func assetCommand(accountID, requestID, path string, body []byte) player.Command { + return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "login-1", RequestID: requestID}, Digest: sha256.Sum256(append([]byte(path), body...)), Requests: []player.Request{{Path: path, Body: body}}} +} + +func openMail(accountID string, seq, mailID uint64) player.Command { + body := wire.AppendVarint(nil, 1, seq) + body = wire.AppendBytes(body, 2, binary.AppendUvarint(nil, mailID)) + return assetCommand(accountID, "open:"+strconv.FormatUint(seq, 10), "/MailOpen", body) +} + +func executeAsset(t *testing.T, runtime *player.Runtime, c player.Command) player.Reply { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*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 durableAssets(t *testing.T, repository *accountstate.Repository) (gold uint64, opened, histories int) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + tx, err := repository.BeginCommand(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := tx.Rollback(); err != nil { + t.Error(err) + } + }() + walletBytes, err := tx.Load("wallet") + if err != nil { + t.Fatal(err) + } + var wallet inventory.Currency + if err := json.Unmarshal(walletBytes, &wallet); err != nil { + t.Fatal(err) + } + mailBytes, err := tx.Load("mail") + if err != nil { + t.Fatal(err) + } + var mailbox struct { + Opened []uint64 `json:"opened"` + } + if err := json.Unmarshal(mailBytes, &mailbox); err != nil { + t.Fatal(err) + } + history, err := tx.ListEntries("mail", "history") + if err != nil { + t.Fatal(err) + } + return wallet.Gold, len(mailbox.Opened), len(history) +} + +func TestMailAssetsRemainOwnedAcrossCancellationDrainAndReconnect(t *testing.T) { + pathA := filepath.Join(t.TempDir(), "a", "state.db") + pathB := filepath.Join(t.TempDir(), "b", "state.db") + a, ownerA := newMailActor(t, "a", pathA, 101, player.Limits{}) + b, ownerB := newMailActor(t, "b", pathB, 207, player.Limits{}) + for _, account := range []struct { + id string + runtime *player.Runtime + executor *mailExecutor + }{{"a", a, ownerA}, {"b", b, ownerB}} { + executeAsset(t, account.runtime, assetCommand(account.id, "gift-1", "/admin/compensation", []byte("compensation-1"))) + executeAsset(t, account.runtime, assetCommand(account.id, "read-mail", "/MailInfo", wire.AppendVarint(nil, 1, 1))) + gold, opened, history := durableAssets(t, account.executor.repository) + if gold != 0 || opened != 0 || history != 0 { + t.Fatalf("unclaimed %s mail released assets: gold=%d opened=%d history=%d", account.id, gold, opened, history) + } + } + entered, release := make(chan struct{}), make(chan struct{}) + var gated atomic.Bool + ownerA.before = func(c player.Command) { + if c.Requests[0].Path == "/MailOpen" && gated.CompareAndSwap(false, true) { + close(entered) + <-release + } + } + defer func() { + select { + case <-release: + default: + close(release) + } + }() + waitCtx, cancelWait := context.WithCancel(context.Background()) + claim := openMail("a", 2, 1) + future, err := a.Submit(waitCtx, claim) + if err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("mail claim did not start") + } + cancelWait() + if _, err := future.Wait(waitCtx); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled caller: %v", err) + } + duplicate, err := a.Submit(context.Background(), claim) + if err != nil { + t.Fatal(err) + } + conflicting := claim + conflicting.Digest = sha256.Sum256([]byte("another mail attachment request")) + if _, err := a.Submit(context.Background(), conflicting); !errors.Is(err, player.ErrIdentityConflict) { + t.Fatalf("conflicting claim identity admitted: %v", err) + } + if _, err := a.Submit(context.Background(), openMail("b", 2, 1)); !errors.Is(err, player.ErrWrongAccount) { + t.Fatalf("foreign account claim admitted: %v", err) + } + // Player B must be able to claim while A's accepted asset operation is paused. + executeAsset(t, b, openMail("b", 2, 1)) + goldB, openedB, historyB := durableAssets(t, ownerB.repository) + if goldB != 207 || openedB != 1 || historyB != 1 { + t.Fatalf("B's independent claim: %d/%d/%d", goldB, openedB, historyB) + } + secondGift, err := a.Submit(context.Background(), assetCommand("a", "gift-2", "/admin/compensation", []byte("compensation-2"))) + if err != nil { + t.Fatal(err) + } + secondClaim, err := a.Submit(context.Background(), openMail("a", 3, 2)) + if err != nil { + t.Fatal(err) + } + closedCtx, cancelClose := context.WithCancel(context.Background()) + cancelClose() + if err := a.Close(closedCtx); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled drain waiter: %v", err) + } + if _, err := a.Submit(context.Background(), assetCommand("a", "late-gift", "/admin/compensation", []byte("late"))); !errors.Is(err, player.ErrClosed) { + t.Fatalf("closing actor admitted an asset command: %v", err) + } + close(release) + for _, accepted := range []*player.Future{future, duplicate, secondGift, secondClaim} { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _, err := accepted.Wait(ctx) + cancel() + if err != nil { + t.Fatal(err) + } + } + if err := a.Close(context.Background()); err != nil { + t.Fatal(err) + } + reconnected, recovered := newMailActor(t, "a", pathA, 101, player.Limits{CompletedReceipts: 1}) + goldA, openedA, historyA := durableAssets(t, recovered.repository) + if goldA != 202 || openedA != 2 || historyA != 2 { + t.Fatalf("drain lost accepted mail assets: %d/%d/%d", goldA, openedA, historyA) + } + claim.Identity.SessionID = "login-2" + executeAsset(t, reconnected, claim) + executeAsset(t, reconnected, assetCommand("a", "read-after-reconnect", "/MailInfo", wire.AppendVarint(nil, 1, 4))) + executeAsset(t, reconnected, claim) + goldA, openedA, historyA = durableAssets(t, recovered.repository) + if goldA != 202 || openedA != 2 || historyA != 2 { + t.Fatalf("reconnect/cache eviction duplicated mail rewards: %d/%d/%d", goldA, openedA, historyA) + } +} + +func TestMailClaimRollbackRecoversWithoutLosingOrDuplicatingAssets(t *testing.T) { + runtime, owner := newMailActor(t, "account", filepath.Join(t.TempDir(), "state.db"), 313, player.Limits{}) + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := runtime.Submit(cancelled, assetCommand("account", "cancel-before-accept", "/admin/compensation", []byte("not-issued"))); !errors.Is(err, context.Canceled) { + t.Fatalf("preaccept cancellation: %v", err) + } + executeAsset(t, runtime, assetCommand("account", "gift", "/admin/compensation", []byte("issued"))) + owner.failClaim.Store(true) + claim := openMail("account", 2, 1) + future, err := runtime.Submit(context.Background(), claim) + if err != nil { + t.Fatal(err) + } + ctx, stop := context.WithTimeout(context.Background(), 5*time.Second) + defer stop() + if _, err := future.Wait(ctx); err == nil { + t.Fatal("injected uncommitted mail claim succeeded") + } + gold, opened, history := durableAssets(t, owner.repository) + if gold != 0 || opened != 0 || history != 0 { + t.Fatalf("failed claim published partial assets: %d/%d/%d", gold, opened, history) + } + executeAsset(t, runtime, claim) + gold, opened, history = durableAssets(t, owner.repository) + if gold != 313 || opened != 1 || history != 1 { + t.Fatalf("retry did not preserve one complete mail award: %d/%d/%d", gold, opened, history) + } + invalidSpend := assetCommand("account", "spend-too-much", "/admin/spend", nil) + refused, err := runtime.Submit(ctx, invalidSpend) + if err != nil { + t.Fatal(err) + } + if _, err := refused.Wait(ctx); err == nil { + t.Fatal("insufficient funds were accepted") + } + executeAsset(t, runtime, assetCommand("account", "next-gift", "/admin/compensation", []byte("next"))) + gold, opened, history = durableAssets(t, owner.repository) + if gold != 313 || opened != 1 || history != 1 { + t.Fatalf("business refusal changed assets: %d/%d/%d", gold, opened, history) + } +} + +func TestMailboxAdmissionNeverLosesRejectedMailGifts(t *testing.T) { + for _, test := range []struct { + name string + limits player.Limits + }{ + {"command-count", player.Limits{MaxCommands: 1}}, + {"total-bytes", player.Limits{MaxCommands: 8, MaxBytes: 400}}, + } { + t.Run(test.name, func(t *testing.T) { + runtime, owner := newMailActor(t, "account", filepath.Join(t.TempDir(), "state.db"), 97, test.limits) + executeAsset(t, runtime, assetCommand("account", "gift-1", "/admin/compensation", []byte("gift-1"))) + entered, release := make(chan struct{}), make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + owner.before = func(c player.Command) { + if c.Identity.RequestID == "open:2" { + close(entered) + <-release + } + } + claim := openMail("account", 2, 1) + accepted, err := runtime.Submit(context.Background(), claim) + if err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("claim did not enter owner") + } + gift := assetCommand("account", "gift-2", "/admin/compensation", []byte("gift-2")) + if _, err := runtime.Submit(context.Background(), gift); !errors.Is(err, player.ErrMailboxFull) { + t.Fatalf("unbounded asset admission: %v", err) + } + if _, err := runtime.Submit(context.Background(), claim); err != nil { + t.Fatal(err) + } + close(release) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := accepted.Wait(ctx); err != nil { + t.Fatal(err) + } + executeAsset(t, runtime, gift) + executeAsset(t, runtime, openMail("account", 3, 2)) + gold, opened, histories := durableAssets(t, owner.repository) + if gold != 194 || opened != 2 || histories != 2 { + t.Fatalf("capacity rejection/retry lost or duplicated a gift: %d/%d/%d", gold, opened, histories) + } + }) + } +} + +func TestBatchMailClaimAndFailedSpendRollBackTogether(t *testing.T) { + runtime, owner := newMailActor(t, "account", filepath.Join(t.TempDir(), "state.db"), 127, player.Limits{}) + executeAsset(t, runtime, assetCommand("account", "gift", "/admin/compensation", []byte("batch-gift"))) + batch := openMail("account", 2, 1) + batch.Identity.RequestID = "batch-claim-and-spend" + batch.Requests = append(batch.Requests, player.Request{Path: "/admin/spend"}) + batch.Digest = sha256.Sum256([]byte("claim then unaffordable spend")) + future, err := runtime.Submit(context.Background(), batch) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := future.Wait(ctx); err == nil { + t.Fatal("batch with unaffordable spend committed its earlier mail claim") + } + gold, opened, histories := durableAssets(t, owner.repository) + if gold != 0 || opened != 0 || histories != 0 { + t.Fatalf("batch published a partial mail reward: %d/%d/%d", gold, opened, histories) + } + executeAsset(t, runtime, openMail("account", 3, 1)) + gold, opened, histories = durableAssets(t, owner.repository) + if gold != 127 || opened != 1 || histories != 1 { + t.Fatalf("failed batch lost the unclaimed mail: %d/%d/%d", gold, opened, histories) + } +} + +func TestRecoveryRejectsQueuedOldSessionMailClaimBeforeAnyAssetsExecute(t *testing.T) { + runtime, owner := newMailActor(t, "account", filepath.Join(t.TempDir(), "state.db"), 149, player.Limits{}) + executeAsset(t, runtime, assetCommand("account", "gift", "/admin/compensation", []byte("recovery-gift"))) + entered, release := make(chan struct{}), make(chan struct{}) + defer func() { + select { + case <-release: + default: + close(release) + } + }() + var gated atomic.Bool + owner.before = func(c player.Command) { + if c.Requests[0].Path == "/MailOpen" && gated.CompareAndSwap(false, true) { + close(entered) + <-release + } + } + owner.failClaim.Store(true) + oldGeneration := runtime.Generation() + failed, err := runtime.Submit(context.Background(), openMail("account", 2, 1)) + if err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("claim did not enter executor") + } + queued, err := runtime.Submit(context.Background(), openMail("account", 3, 1)) + if err != nil { + t.Fatal(err) + } + close(release) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := failed.Wait(ctx); err == nil { + t.Fatal("injected failed claim succeeded") + } + if _, err := queued.Wait(ctx); !errors.Is(err, player.ErrGenerationExpired) { + t.Fatalf("old-generation queued claim was not explicitly rejected: %v", err) + } + gold, opened, histories := durableAssets(t, owner.repository) + if gold != 0 || opened != 0 || histories != 0 { + t.Fatalf("old-session queued command executed after recovery: %d/%d/%d", gold, opened, histories) + } + retry := openMail("account", 4, 1) + retry.Identity.SessionID = "login-after-recovery" + stale := retry + stale.ExpectedGeneration = oldGeneration + if _, err := runtime.Submit(ctx, stale); !errors.Is(err, player.ErrGenerationExpired) { + t.Fatalf("stale authenticated generation was accepted during submit: %v", err) + } + retry.ExpectedGeneration = runtime.Generation() + executeAsset(t, runtime, retry) + gold, opened, histories = durableAssets(t, owner.repository) + if gold != 149 || opened != 1 || histories != 1 { + t.Fatalf("reconnected player lost claimable mail assets: %d/%d/%d", gold, opened, histories) + } +} diff --git a/go/internal/server/schedule/schedule_test.go b/go/internal/server/schedule/schedule_test.go deleted file mode 100644 index 01cf07e..0000000 --- a/go/internal/server/schedule/schedule_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package schedule - -import ( - "encoding/json" - "testing" - - "bd2server/internal/server/wire" -) - -func TestCalendarUsesInjectedIDsAndTimes(t *testing.T) { - for _, revision := range []struct { - id, start, end uint64 - raw string - }{ - {709, 101, 303, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":709,"start_milliseconds":101,"end_milliseconds":303},"next":{"id":710,"start_milliseconds":401,"end_milliseconds":501}}],"regular":[{"content_id":81,"season":9}]}`}, - {811, 701, 903, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":811,"start_milliseconds":701,"end_milliseconds":903},"next":{"id":812,"start_milliseconds":1001,"end_milliseconds":1201}}],"regular":[{"content_id":81,"season":11}]}`}, - } { - var service Service - if err := json.Unmarshal([]byte(revision.raw), &service); err != nil { - t.Fatal(err) - } - if err := service.Validate(); err != nil { - t.Fatal(err) - } - _, response, _, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - content, _, _ := wire.Bytes(response, 2) - current, _, _ := wire.Bytes(content, 2) - id, _, _ := wire.Varint(current, 1) - start, _, _ := wire.Varint(current, 2) - end, _, _ := wire.Varint(current, 3) - if id != revision.id || start != revision.start || end != revision.end { - t.Fatalf("season=%d times=%d/%d", id, start, end) - } - } -} - -func TestScheduleRejectsMissingSequence(t *testing.T) { - if _, _, handled, err := (&Service{}).Handle("/ScheduleInfo", nil); !handled || err == nil { - t.Fatalf("missing sequence handled=%v err=%v", handled, err) - } -} diff --git a/go/internal/server/session/achievement_notify_test.go b/go/internal/server/session/achievement_notify_test.go deleted file mode 100644 index f75cd46..0000000 --- a/go/internal/server/session/achievement_notify_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package session - -import ( - "encoding/base64" - "encoding/json" - "testing" - - "bd2server/internal/server/cryptox" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/protocol" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bd2server/internal/server/world" -) - -func TestAchievementUpdateEmitsAbsoluteNotificationWithoutChangingEmptyResponse(t *testing.T) { - design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{987: {0}}, Conditions: map[int]gamedata.AchievementCondition{987: {Type: 66}}} - counter, err := world.NewAchievementService(design, stateio.NewMemory()) - if err != nil { - t.Fatal(err) - } - observer, err := world.NewGameplayAchievementObserver(counter, &world.OwnedGameplayAchievementProvider{Design: design}) - if err != nil { - t.Fatal(err) - } - server, _ := NewServer(fakeLogin{}, counter) - if err := server.AttachResponseObserver(observer); err != nil { - t.Fatal(err) - } - logged := login(t, server) - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 987), 3, 1) - body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) - for attempt := range 2 { - reply, err := server.DispatchRaw("/AchievementUpdate", []byte(body), "s="+logged.Cookie) - if err != nil { - t.Fatal(err) - } - var envelope protocol.Envelope - if err := json.Unmarshal(reply.Body, &envelope); err != nil { - t.Fatal(err) - } - response, err := cryptox.DecryptBase64Payload(envelope.Data, server.KeyForTest()) - if err != nil || len(response) != 0 || envelope.PacketCode != 167 { - t.Fatalf("response=%x code=%d err=%v", response, envelope.PacketCode, err) - } - if attempt == 1 { - if envelope.Notify != "" { - t.Fatal("replayed update emitted another notification") - } - continue - } - notify, err := base64.StdEncoding.DecodeString(envelope.Notify) - if err != nil { - t.Fatal(err) - } - row, _, _ := wire.Bytes(notify, 2) - group, _, _ := wire.Varint(row, 1) - value, _, _ := wire.Varint(row, 2) - isSet, _, _ := wire.Varint(row, 3) - if group != 987 || value != 1 || isSet != 1 { - t.Fatalf("notify group=%d value=%d isSet=%d", group, value, isSet) - } - } - value, err := counter.AchievementValue(987) - if err != nil || value != 1 { - t.Fatalf("persisted value=%d err=%v", value, err) - } -} diff --git a/go/internal/server/session/attendance_transaction_test.go b/go/internal/server/session/attendance_transaction_test.go deleted file mode 100644 index 57938b5..0000000 --- a/go/internal/server/session/attendance_transaction_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package session - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "path/filepath" - "reflect" - "testing" - "time" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/cryptox" - "bd2server/internal/server/events" - "bd2server/internal/server/eventtasks" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/mail" - "bd2server/internal/server/player" - "bd2server/internal/server/protocol" - "bd2server/internal/server/wire" -) - -// This adapter writes the real normalized wallet and inventory grant ledgers. -type attendanceTransactionEconomy struct { - wallet *player.Wallet - inventory *player.Inventory - calls int -} - -func (e *attendanceTransactionEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) { - if len(costs) != 0 { - return nil, fmt.Errorf("unexpected attendance costs") - } - e.calls++ - if _, err := e.wallet.GrantQuestOnce(identity, rewards); err != nil { - return nil, err - } - var items []gamedata.BattleReward - for _, r := range rewards { - if r.Type == 9 || r.Type == 8 { - items = append(items, gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - } - } - if _, err := e.inventory.GrantOnce(identity, items); err != nil { - return nil, err - } - return wire.AppendBytes(nil, 1, nil), nil -} - -func TestAttendanceBatchRollsBackAndSuccessfulRetryGrantsOnce(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - now := time.Now().UTC() - registry := events.NewRegistry() - if err := registry.Replace([]events.Schedule{ - {UID: 1, Type: 0, ID: 1, Start: now.Add(-24 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()}, - {UID: 2, Type: 1, ID: 2, Start: now.Truncate(24 * time.Hour).UnixMilli(), End: now.Add(48 * time.Hour).UnixMilli()}, - }); err != nil { - t.Fatal(err) - } - design := &gamedata.EventTasksDesign{ - Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 1}}, - AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{1: {{ID: 1, Group: 1, Day: 1, Basic: gamedata.Reward{Type: 4, Count: 100}}}}, - LimitRewards: map[[2]uint64]uint64{{2, 1}: 987}, - } - open := func() (*accountstate.Repository, *player.Wallet, *player.Inventory, *eventtasks.Service, *mail.Service, *attendanceTransactionEconomy) { - t.Helper() - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12}) - if err != nil { - t.Fatal(err) - } - inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - eco := &attendanceTransactionEconomy{wallet: wallet, inventory: inv} - svc, err := eventtasks.Open(repo, design, registry, eco) - if err != nil { - t.Fatal(err) - } - mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, inv, wallet) - if err != nil { - t.Fatal(err) - } - if err := mailbox.AttachAttendanceRewardEconomy(eco); err != nil { - t.Fatal(err) - } - svc.AttachAttendanceMail(mailbox) - return repo, wallet, inv, svc, mailbox, eco - } - makeServer := func(repo *accountstate.Repository, svc *eventtasks.Service, mailbox *mail.Service) *Server { - t.Helper() - server, err := NewServer(fakeLogin{}, svc, mailbox, &mutatingDomain{store: repo}) - if err != nil { - t.Fatal(err) - } - if err := server.AttachStateStore(repo); err != nil { - t.Fatal(err) - } - return server - } - batch := func(server *Server, cookie, endpoint string, seq uint64, fail bool) error { - t.Helper() - request := wire.AppendVarint(nil, 1, seq) - if endpoint == "/MailOpen" { - request = wire.AppendVarint(request, 2, 1) - } - requests := []protocol.BatchRequest{{Path: endpoint, RequestData: base64.StdEncoding.EncodeToString(request)}} - if fail { - requests = append(requests, protocol.BatchRequest{Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, seq+1))}) - } - plain, err := json.Marshal(requests) - if err != nil { - t.Fatal(err) - } - body, err := cryptox.EncryptBase64(plain, server.KeyForTest()) - if err != nil { - t.Fatal(err) - } - _, err = server.DispatchRaw("/BatchRequest", []byte(body), "s="+cookie) - return err - } - mailState := func(repo *accountstate.Repository) map[string]any { - t.Helper() - result := map[string]any{} - core, err := repo.Load("mail") - if err != nil { - t.Fatal(err) - } - result["core"] = string(core) - for _, bucket := range []string{"dynamic", "issued", "history"} { - entries, err := repo.ListEntries("mail", bucket) - if err != nil { - t.Fatal(err) - } - result[bucket] = entries - } - return result - } - assertUnclaimed := func(wallet *player.Wallet, inv *player.Inventory) { - t.Helper() - if wallet.Snapshot().Gold != 12 || len(inv.All()) != 0 { - t.Fatalf("unclaimed mail credited rewards: wallet=%+v items=%+v", wallet.Snapshot(), inv.All()) - } - } - repo, wallet, inv, svc, mailbox, eco := open() - if err := wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := inv.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := svc.SetNewbieStep(0); err != nil { - t.Fatal(err) - } - before, err := repo.Load("eventtasks") - if err != nil { - t.Fatal(err) - } - beforeMail := mailState(repo) - server := makeServer(repo, svc, mailbox) - session := login(t, server) - if err := batch(server, session.Cookie, "/Attendance", 2, true); err == nil { - t.Fatal("failed attendance batch accepted") - } - if eco.calls != 0 { - t.Fatalf("attendance credited economy instead of mailing rewards: calls=%d", eco.calls) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - // Rollback fences the old domain objects; reopen every repository/domain. - repo, wallet, inv, svc, mailbox, eco = open() - after, err := repo.Load("eventtasks") - if err != nil { - t.Fatal(err) - } - if string(after) != string(before) { - t.Fatalf("attendance progress, claim markers or receipts survived rollback: %s", after) - } - if !reflect.DeepEqual(mailState(repo), beforeMail) { - t.Fatal("attendance mail or issued ledger survived rollback") - } - assertUnclaimed(wallet, inv) - server = makeServer(repo, svc, mailbox) - session = login(t, server) - if err := batch(server, session.Cookie, "/Attendance", 2, false); err != nil { - t.Fatal(err) - } - if err := batch(server, session.Cookie, "/Attendance", 3, false); err != nil { - t.Fatal(err) - } - if eco.calls != 0 { - t.Fatalf("attendance mail prematurely credited economy: calls=%d", eco.calls) - } - assertUnclaimed(wallet, inv) - issuedMail := mailState(repo) - if len(issuedMail["dynamic"].(map[string][]byte)) != 1 || len(issuedMail["issued"].(map[string][]byte)) != 1 { - t.Fatal("attendance did not issue exactly one mail") - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, wallet, inv, svc, mailbox, eco = open() - server = makeServer(repo, svc, mailbox) - session = login(t, server) - if err := batch(server, session.Cookie, "/Attendance", 2, false); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(mailState(repo), issuedMail) { - t.Fatal("relogin duplicated attendance mail") - } - assertUnclaimed(wallet, inv) - if err := batch(server, session.Cookie, "/MailOpen", 3, true); err == nil { - t.Fatal("failed mailbox batch accepted") - } - if eco.calls != 1 { - t.Fatalf("mail claim did not reach real economy before rollback: calls=%d", eco.calls) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, wallet, inv, svc, mailbox, _ = open() - if !reflect.DeepEqual(mailState(repo), issuedMail) { - t.Fatal("mail claim/history persisted after failed batch") - } - assertUnclaimed(wallet, inv) - server = makeServer(repo, svc, mailbox) - session = login(t, server) - if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil { - t.Fatal(err) - } - if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil { - t.Fatal(err) - } - if err := batch(server, session.Cookie, "/MailOpen", 3, false); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 112 || len(inv.All()) != 1 || inv.All()[0].Count != 1 { - t.Fatal("mail replay duplicated persisted rewards") - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, wallet, inv, svc, mailbox, _ = open() - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - items := inv.All() - if wallet.Snapshot().Gold != 112 || len(items) != 1 || items[0].Type != 9 || items[0].ID != 987 || items[0].Count != 1 { - t.Fatalf("successful attendance not persisted exactly once: wallet=%+v items=%+v", wallet.Snapshot(), items) - } - for _, domain := range []string{"wallet", "items"} { - grants, err := repo.ListEntries(domain, "granted") - if err != nil { - t.Fatal(err) - } - if len(grants) != 1 { - t.Fatalf("%s durable grant ledger duplicated or missing: %v", domain, grants) - } - } - claimedMail := mailState(repo) - var claimed struct { - Opened []uint64 `json:"opened"` - } - if err := json.Unmarshal([]byte(claimedMail["core"].(string)), &claimed); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(claimed.Opened, []uint64{1}) || len(claimedMail["history"].(map[string][]byte)) != 1 { - t.Fatal("mail claim/history was not persisted") - } - server = makeServer(repo, svc, mailbox) - session = login(t, server) - if err := batch(server, session.Cookie, "/MailOpen", 2, false); err != nil { - t.Fatal(err) - } - if wallet.Snapshot().Gold != 112 || len(inv.All()) != 1 || inv.All()[0].Count != 1 { - t.Fatal("reopened mail claim duplicated rewards") - } - var state struct { - Attendance map[string]struct { - Count uint64 - LastDay string - Obtained, History map[string]bool - } - LoginDays map[string]int64 - } - saved, err := repo.Load("eventtasks") - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(saved, &state); err != nil { - t.Fatal(err) - } - if len(state.Attendance) != 2 || len(state.LoginDays) != 1 { - t.Fatalf("successful attendance progress missing: %s", saved) - } - for uid, a := range state.Attendance { - if a.Count != 1 || a.LastDay == "" || len(a.Obtained) != 1 || len(a.History) != 1 { - t.Fatalf("claim markers missing for %s: %+v", uid, a) - } - } -} diff --git a/go/internal/server/session/level_reward_transaction_test.go b/go/internal/server/session/level_reward_transaction_test.go deleted file mode 100644 index c7ee86e..0000000 --- a/go/internal/server/session/level_reward_transaction_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package session - -import ( - "encoding/base64" - "encoding/json" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/cryptox" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/missions" - "bd2server/internal/server/player" - "bd2server/internal/server/protocol" - "bd2server/internal/server/wire" -) - -func TestLevelRewardBatchRollsBackWalletInventoryAndClaimTogether(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repo, player.Currency{Gold: 12}) - if err != nil { - t.Fatal(err) - } - if err := inv.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := wallet.EnsurePersisted(); err != nil { - t.Fatal(err) - } - design := &gamedata.MissionDesign{} - m, err := missions.Open(repo, design, inv) - if err != nil { - t.Fatal(err) - } - if err := m.AttachWallet(wallet); err != nil { - t.Fatal(err) - } - levels := &gamedata.AchievementLevelDesign{Levels: []gamedata.AchievementLevel{{ID: 1, NeedEXP: 10, Rewards: []gamedata.Reward{{Type: 4, Count: 100}, {Type: 8, ID: 987, Count: 3}}}}} - if err := m.AttachUserLevelRewards(levels); err != nil { - t.Fatal(err) - } - server, err := NewServer(fakeLogin{}, m, &mutatingDomain{store: repo}) - if err != nil { - t.Fatal(err) - } - if err := server.AttachStateStore(repo); err != nil { - t.Fatal(err) - } - session := login(t, server) - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1) - batch := []protocol.BatchRequest{ - {Path: "/UserLevelReward", RequestData: base64.StdEncoding.EncodeToString(request)}, - {Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))}, - } - plain, _ := json.Marshal(batch) - body, _ := cryptox.EncryptBase64(plain, server.KeyForTest()) - if _, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+session.Cookie); err == nil { - t.Fatal("failing batch accepted") - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - reopened, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := reopened.Close(); err != nil { - t.Error(err) - } - }() - inv, err = player.OpenInventory(reopened, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - wallet, err = player.OpenWallet(reopened, player.Currency{}) - if err != nil { - t.Fatal(err) - } - m, err = missions.Open(reopened, design, inv) - if err != nil { - t.Fatal(err) - } - if err := m.AttachUserLevelRewards(levels); err != nil { - t.Fatal(err) - } - if got, err := m.LevelRewardCount(); err != nil || got != 0 { - t.Fatalf("claim survived rollback: %d %v", got, err) - } - if len(inv.All()) != 0 || wallet.Snapshot().Gold != 12 { - t.Fatalf("rewards survived rollback: %+v %+v", inv.All(), wallet.Snapshot()) - } -} diff --git a/go/internal/server/session/observer.go b/go/internal/server/session/observer.go deleted file mode 100644 index 78c0713..0000000 --- a/go/internal/server/session/observer.go +++ /dev/null @@ -1,24 +0,0 @@ -package session - -import "fmt" - -func (s *Server) dispatchObserved(path string, request []byte) (int, []byte, []byte, error) { - for _, observer := range s.observers { - if err := observer.BeforeDispatch(path, request); err != nil { - return 0, nil, nil, fmt.Errorf("%s before response observation: %w", path, err) - } - } - code, response, err := s.dispatch(path, request) - if err != nil { - return 0, nil, nil, err - } - var notify []byte - for _, observer := range s.observers { - fields, err := observer.AfterDispatch(path, request, response) - if err != nil { - return 0, nil, nil, fmt.Errorf("%s after response observation: %w", path, err) - } - notify = append(notify, fields...) - } - return code, response, notify, nil -} diff --git a/go/internal/server/session/observer_test.go b/go/internal/server/session/observer_test.go deleted file mode 100644 index f0d90b2..0000000 --- a/go/internal/server/session/observer_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package session - -import ( - "database/sql" - "encoding/base64" - "encoding/json" - "errors" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/cryptox" - "bd2server/internal/server/protocol" - "bd2server/internal/server/wire" -) - -type testProgressObserver struct { - store *accountstate.Repository - fail bool - session string -} - -func (o *testProgressObserver) BeginSession(id string) { o.session = id } -func (o *testProgressObserver) BeforeDispatch(string, []byte) error { return nil } -func (o *testProgressObserver) AfterDispatch(path string, request, _ []byte) ([]byte, error) { - if o.session == "" { - return nil, errors.New("observer missing session") - } - seq, _, _ := wire.Varint(request, 1) - if err := o.store.PutEntry("missions", "test_observation", path, []byte{byte(seq)}); err != nil { - return nil, err - } - if o.fail { - return nil, errors.New("observation failed after write") - } - return wire.AppendBytes(nil, 2, wire.AppendVarint(nil, 2, seq)), nil -} - -func TestObserverNotificationsAndFailuresShareRequestTransaction(t *testing.T) { - statePath := filepath.Join(t.TempDir(), "state.db") - repository, err := accountstate.Open(statePath) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repository.Close(); err != nil { - t.Error(err) - } - }() - server, _ := NewServer(fakeLogin{}, fakeDomain{}, &mutatingDomain{store: repository}) - if err := server.AttachStateStore(repository); err != nil { - t.Fatal(err) - } - observer := &testProgressObserver{store: repository} - if err := server.AttachResponseObserver(observer); err != nil { - t.Fatal(err) - } - logged := login(t, server) - requests := []protocol.BatchRequest{ - {Path: "/EmptyInfo", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}, - {Path: "/MutateTwoFiles", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))}, - } - plain, _ := json.Marshal(requests) - body, _ := cryptox.EncryptBase64(plain, server.KeyForTest()) - reply, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+logged.Cookie) - if err != nil { - t.Fatal(err) - } - var results []protocol.BatchResponse - if err := json.Unmarshal(reply.Body, &results); err != nil { - t.Fatal(err) - } - for i, result := range results { - notify, err := base64.StdEncoding.DecodeString(result.ResponseData.Notify) - row, _, _ := wire.Bytes(notify, 2) - value, _, _ := wire.Varint(row, 2) - if err != nil || value != uint64(i+2) { - t.Fatalf("batch notify value=%d index=%d err=%v", value, i, err) - } - } - observer.fail = true - body, _ = cryptox.EncryptBase64Payload(wire.AppendVarint(nil, 1, 4), server.KeyForTest()) - if _, err := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+logged.Cookie); err == nil { - t.Fatal("observer failure accepted") - } - verified, err := sql.Open("sqlite", statePath) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := verified.Close(); err != nil { - t.Error(err) - } - }() - var value []byte - err = verified.QueryRow("SELECT payload FROM domain_entry WHERE domain_name='missions' AND bucket='test_observation' AND entry_key='/MutateTwoFiles'").Scan(&value) - if err != nil || len(value) != 1 || value[0] != 3 { - t.Fatalf("rolled back observer value=%v err=%v", value, err) - } -} diff --git a/go/internal/server/session/scoped_handler_test.go b/go/internal/server/session/scoped_handler_test.go deleted file mode 100644 index 9595879..0000000 --- a/go/internal/server/session/scoped_handler_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package session - -import ( - "encoding/base64" - "encoding/json" - "testing" - - "bd2server/internal/server/cryptox" - "bd2server/internal/server/protocol" - "bd2server/internal/server/wire" -) - -type scopedHandler struct{ sessions []string } - -func (h *scopedHandler) Handle(string, []byte) (int, []byte, bool, error) { - panic("session handler fell back to unscoped dispatch") -} -func (h *scopedHandler) HandleSession(path string, request []byte, sessionID string) (int, []byte, bool, error) { - h.sessions = append(h.sessions, sessionID) - return 77, nil, true, nil -} - -func TestScopedDispatchUsesAuthorizedSessionInsideBatch(t *testing.T) { - h := &scopedHandler{} - s, err := NewServer(fakeLogin{}, h) - if err != nil { - t.Fatal(err) - } - first := login(t, s) - firstGame := s.sessions[sessionTokenKey(first.Cookie)] - second := login(t, s) - secondGame := s.sessions[sessionTokenKey(second.Cookie)] - for _, test := range []struct { - cookie string - game *gameSession - }{{first.Cookie, firstGame}, {second.Cookie, secondGame}, {first.Cookie, firstGame}} { - requests := []protocol.BatchRequest{{Path: "/Scoped", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}, {Path: "/ScopedSecond", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))}} - plain, _ := json.Marshal(requests) - body, _ := cryptox.EncryptBase64(plain, test.game.key) - if _, err := s.DispatchRaw("/BatchRequest", []byte(body), "s="+test.cookie); err != nil { - t.Fatal(err) - } - for _, got := range h.sessions[len(h.sessions)-2:] { - if got != test.game.id { - t.Fatalf("session %q want %q", got, test.game.id) - } - } - } -} diff --git a/go/internal/server/session/server.go b/go/internal/server/session/server.go deleted file mode 100644 index 3c4dce1..0000000 --- a/go/internal/server/session/server.go +++ /dev/null @@ -1,479 +0,0 @@ -// Package session owns authentication, encryption, and request routing for a -// local account. It deliberately has no capture/fixture dependency. -package session - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "log/slog" - "strings" - "sync" - "time" - - "bd2server/internal/server/cryptox" - "bd2server/internal/server/progress" - "bd2server/internal/server/protocol" - "bd2server/internal/server/stateio" - "bd2server/internal/server/transport" - "bd2server/internal/server/wire" -) - -const ( - gameSessionTTL = 24 * time.Hour - maxGameSessions = 1024 - maxCookieHeaderLen = 8 << 10 -) - -var errSessionRequired = errors.New("session login required") - -type LoginService interface { - Login(request, sessionKey []byte) ([]byte, error) -} - -type LoginAuthenticator interface { - AuthenticateLogin(request []byte) (accountID string, err error) -} - -// Handler is implemented by domain services. ok=false means the endpoint is -// not owned by that service; unknown endpoints fail closed. -type Handler interface { - Handle(path string, request []byte) (packetCode int, response []byte, ok bool, err error) -} - -// SessionHandler receives the same opaque login identity for requests within -// a batch, allowing durable receipts without mutable global session state. -type SessionHandler interface { - HandleSession(path string, request []byte, sessionID string) (packetCode int, response []byte, ok bool, err error) -} - -// SessionAware handlers use a login-scoped opaque ID when protobuf request -// sequences participate in durable idempotency keys. -type SessionAware interface { - BeginSession(id string) -} - -// ResponseObserver runs inside the same transaction as the authoritative -// domain operation. It can derive progress and encode response notifications; -// any observer error rolls the entire request or batch back. -type ResponseObserver interface { - BeforeDispatch(path string, request []byte) error - AfterDispatch(path string, request, response []byte) ([]byte, error) -} - -type gameSession struct { - key []byte - accountID string - id string - expiresAt time.Time - lastUsed time.Time -} - -type Server struct { - mu sync.Mutex - sessions map[[sha256.Size]byte]*gameSession - latestSessionToken [sha256.Size]byte - latestSessionSet bool - activeSessionID string - login LoginService - handlers []Handler - observers []ResponseObserver - progress *progress.Store - stateTx stateio.TransactionalStore - auth LoginAuthenticator - now func() time.Time - sessionTTL time.Duration - maxSessions int -} - -func (s *Server) AttachResponseObserver(observer ResponseObserver) error { - if observer == nil { - return errors.New("session response observer is nil") - } - s.mu.Lock() - defer s.mu.Unlock() - s.observers = append(s.observers, observer) - return nil -} - -func (s *Server) AttachLoginAuthenticator(authenticator LoginAuthenticator) error { - if authenticator == nil { - return errors.New("session login authenticator is nil") - } - s.mu.Lock() - defer s.mu.Unlock() - s.auth = authenticator - return nil -} - -// AttachStateStore wraps each authenticated request (the complete batch for -// BatchRequest) in one durable account database transaction. -func (s *Server) AttachStateStore(store stateio.TransactionalStore) error { - if store == nil { - return errors.New("session state store is nil") - } - s.mu.Lock() - defer s.mu.Unlock() - s.stateTx = store - return nil -} - -func NewServer(login LoginService, handlers ...Handler) (*Server, error) { - return NewServerWithProgress(login, progress.NewStore(), handlers...) -} - -// NewServerWithProgress uses a caller-owned player progress store; the serve -// command supplies a file-backed store so changes survive server restarts. -func NewServerWithProgress(login LoginService, player *progress.Store, handlers ...Handler) (*Server, error) { - if login == nil { - return nil, errors.New("session login service is nil") - } - if player == nil { - return nil, errors.New("session progress store is nil") - } - return &Server{ - sessions: make(map[[sha256.Size]byte]*gameSession), login: login, - handlers: append([]Handler(nil), handlers...), progress: player, - now: time.Now, sessionTTL: gameSessionTTL, maxSessions: maxGameSessions, - }, nil -} - -func (s *Server) DispatchRaw(path string, body []byte, cookie string) (transport.RawReply, error) { - s.mu.Lock() - defer s.mu.Unlock() - if s.stateTx != nil { - if err := s.stateTx.Check(); err != nil { - return transport.RawReply{}, fmt.Errorf("account state unavailable: %w", err) - } - } - if path == "/LoginUser" { - now := s.now() - s.pruneSessions(now) - request, err := cryptox.DecryptBase64Payload(string(body), cryptox.Key()) - if err != nil { - return transport.RawReply{}, fmt.Errorf("LoginUser decrypt: %w", err) - } - accountID := "local-owner" - if s.auth != nil { - accountID, err = s.auth.AuthenticateLogin(request) - if err != nil { - return transport.RawReply{}, fmt.Errorf("%w: %v", transport.ErrAccessCredentialInvalid, err) - } - if accountID == "" { - return transport.RawReply{}, errors.New("LoginUser authentication returned an empty account ID") - } - } - game, token, err := newGameSession(accountID, now, s.sessionTTL) - if err != nil { - return transport.RawReply{}, err - } - proto, err := s.login.Login(request, game.key) - if err != nil { - clear(game.key) - return transport.RawReply{}, fmt.Errorf("LoginUser: %w", err) - } - encoded, err := protocol.Encode(3, proto, cryptox.Key(), now.UnixMilli()) - if err != nil { - clear(game.key) - return transport.RawReply{}, err - } - s.makeSessionRoom() - tokenKey := sessionTokenKey(token) - s.sessions[tokenKey] = game - s.latestSessionToken = tokenKey - s.latestSessionSet = true - s.activate(game) - return transport.RawReply{Body: encoded, Cookie: token}, nil - } - game, err := s.authorize(cookie) - if err != nil { - return transport.RawReply{}, err - } - s.activate(game) - if path == "/BatchRequest" { - return s.withStateTransaction(func() (transport.RawReply, error) { - return s.handleBatch(body, game.key) - }) - } - request, err := cryptox.DecryptBase64Payload(string(body), game.key) - if err != nil { - return transport.RawReply{}, fmt.Errorf("%s decrypt: %w", path, err) - } - return s.withStateTransaction(func() (transport.RawReply, error) { - code, response, notify, err := s.dispatchObserved(path, request) - if err != nil { - return transport.RawReply{}, err - } - encoded, err := protocol.EncodeWithNotify(code, response, game.key, s.now().UnixMilli(), notify) - return transport.RawReply{Body: encoded}, err - }) -} - -func newGameSession(accountID string, now time.Time, ttl time.Duration) (*gameSession, string, error) { - keyBytes := make([]byte, 16) - if _, err := rand.Read(keyBytes); err != nil { - return nil, "", fmt.Errorf("create game session key: %w", err) - } - tokenBytes := make([]byte, 24) - if _, err := rand.Read(tokenBytes); err != nil { - return nil, "", fmt.Errorf("create game session token: %w", err) - } - idBytes := make([]byte, 12) - if _, err := rand.Read(idBytes); err != nil { - return nil, "", fmt.Errorf("create login request identity: %w", err) - } - return &gameSession{ - key: []byte(hex.EncodeToString(keyBytes)), - accountID: accountID, - id: hex.EncodeToString(idBytes), - expiresAt: now.Add(ttl), - lastUsed: now, - }, hex.EncodeToString(tokenBytes) + "|1", nil -} - -func sessionTokenKey(token string) [sha256.Size]byte { return sha256.Sum256([]byte(token)) } - -func (s *Server) pruneSessions(now time.Time) { - for token, game := range s.sessions { - if !now.Before(game.expiresAt) { - s.deleteSession(token, game) - } - } -} - -func (s *Server) makeSessionRoom() { - for len(s.sessions) >= s.maxSessions { - var oldestToken [sha256.Size]byte - var oldest *gameSession - for token, game := range s.sessions { - if oldest == nil || game.lastUsed.Before(oldest.lastUsed) { - oldestToken, oldest = token, game - } - } - if oldest == nil { - return - } - s.deleteSession(oldestToken, oldest) - } -} - -func (s *Server) deleteSession(token [sha256.Size]byte, game *gameSession) { - delete(s.sessions, token) - for i := range game.key { - game.key[i] = 0 - } - game.accountID = "" - game.id = "" - if s.latestSessionSet && token == s.latestSessionToken { - s.latestSessionSet = false - } -} - -func (s *Server) activate(game *gameSession) { - s.activeSessionID = game.id - for _, handler := range s.handlers { - if aware, ok := handler.(SessionAware); ok { - aware.BeginSession(game.id) - } - } - for _, observer := range s.observers { - if aware, ok := observer.(SessionAware); ok { - aware.BeginSession(game.id) - } - } -} - -func (s *Server) withStateTransaction(run func() (transport.RawReply, error)) (reply transport.RawReply, err error) { - if s.stateTx == nil { - return run() - } - operation, err := s.stateTx.BeginOperation() - if err != nil { - return transport.RawReply{}, fmt.Errorf("begin account transaction: %w", err) - } - finished := false - defer func() { - if finished { - return - } - rollbackErr := operation.Rollback() - if recovered := recover(); recovered != nil { - panic(recovered) - } - if rollbackErr != nil { - err = errors.Join(err, rollbackErr) - } - }() - reply, err = run() - if err != nil { - rollbackErr := operation.Rollback() - finished = true - if rollbackErr != nil { - return transport.RawReply{}, errors.Join(err, rollbackErr) - } - return transport.RawReply{}, err - } - if err := operation.Commit(); err != nil { - finished = true - return transport.RawReply{}, fmt.Errorf("commit account transaction: %w", err) - } - finished = true - return reply, nil -} - -func (s *Server) handleBatch(body, key []byte) (transport.RawReply, error) { - batchStarted := time.Now() - requests, decoded, err := protocol.DecodeBatchRequest(body, key) - if err != nil { - return transport.RawReply{}, err - } - items := make([]protocol.BatchResponse, 0, len(requests)) - for i, request := range requests { - itemStarted := time.Now() - code, response, notify, err := s.dispatchObserved(request.Path, decoded[i]) - if err != nil { - return transport.RawReply{}, fmt.Errorf("batch %s: %w", request.Path, err) - } - itemElapsed := time.Since(itemStarted) - if formationTimingPath(request.Path) { - slog.Info("formation batch item handled", "index", i, "path", request.Path, "duration_ms", float64(itemElapsed.Microseconds())/1000, "response_bytes", len(response)) - } else if itemElapsed >= 100*time.Millisecond { - slog.Warn("slow batch item", "index", i, "path", request.Path, "duration_ms", float64(itemElapsed.Microseconds())/1000) - } - envelope, err := protocol.EnvelopeWithNotify(code, response, key, time.Now().UnixMilli(), notify) - if err != nil { - return transport.RawReply{}, err - } - items = append(items, protocol.BatchResponse{Path: request.Path, ResponseData: envelope}) - } - encoded, err := json.Marshal(items) - batchElapsed := time.Since(batchStarted) - if batchElapsed >= time.Second { - slog.Warn("slow batch request", "items", len(requests), "duration_ms", float64(batchElapsed.Microseconds())/1000, "responseBytes", len(encoded)) - } - return transport.RawReply{Body: encoded}, err -} - -func formationTimingPath(path string) bool { - return strings.HasPrefix(path, "/Preset") || - strings.HasPrefix(path, "/Deck") || - strings.HasPrefix(path, "/FieldDeck") || - path == "/EquipBatchUse" -} - -func (s *Server) dispatch(path string, request []byte) (int, []byte, error) { - if _, found, err := wire.Varint(request, 1); err != nil || !found { - return 0, nil, fmt.Errorf("%s has no request sequence", path) - } - switch path { - case "/SaveUserPosition": - if err := s.progress.SaveUserPosition(request); err != nil { - return 0, nil, fmt.Errorf("%s: %w", path, err) - } - if saved, found := s.progress.Position(); found { - slog.Info("field position saved", "pack", saved.PackID, "map", saved.Position.MapID) - } - return 7, nil, nil - case "/TutorialClear": - if err := s.progress.ClearTutorial(request); err != nil { - return 0, nil, fmt.Errorf("%s: %w", path, err) - } - return 102, nil, nil - } - for _, handler := range s.handlers { - var code int - var response []byte - var ok bool - var err error - if scoped, supports := handler.(SessionHandler); supports { - if s.activeSessionID == "" { - return 0, nil, errSessionRequired - } - code, response, ok, err = scoped.HandleSession(path, request, s.activeSessionID) - } else { - code, response, ok, err = handler.Handle(path, request) - } - if err != nil { - return 0, nil, err - } - if ok { - return code, response, nil - } - } - return 0, nil, fmt.Errorf("%w: %s", transport.ErrNotImplemented, path) -} - -func (s *Server) authorize(cookie string) (*gameSession, error) { - token, err := parseSessionCookie(cookie) - if err != nil { - if errors.Is(err, errSessionRequired) { - return nil, fmt.Errorf("%w: %v", transport.ErrGameSessionExpired, err) - } - return nil, err - } - now := s.now() - s.pruneSessions(now) - game, ok := s.sessions[sessionTokenKey(token)] - if !ok { - return nil, fmt.Errorf("%w: cookie no longer names a live session", transport.ErrGameSessionExpired) - } - game.lastUsed = now - return game, nil -} - -func parseSessionCookie(cookie string) (string, error) { - if cookie == "" { - return "", errSessionRequired - } - if len(cookie) > maxCookieHeaderLen { - return "", errors.New("game session cookie header is too large") - } - var token string - seen := false - for value := range strings.SplitSeq(cookie, ";") { - name, candidate, found := strings.Cut(strings.TrimSpace(value), "=") - if !found || name != "s" { - continue - } - if seen { - return "", errors.New("duplicate game session cookie") - } - seen = true - token = candidate - } - if !seen { - return "", errSessionRequired - } - if len(token) != 50 || token[48:] != "|1" { - return "", errors.New("invalid game session cookie") - } - for _, char := range token[:48] { - if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') { //nolint:staticcheck // QF1001 - return "", errors.New("invalid game session cookie") - } - } - return token, nil -} - -// KeyForTest returns the most recently created session key when called without -// a token. Supplying a raw cookie value selects that client's isolated key. -func (s *Server) KeyForTest(token ...string) []byte { - s.mu.Lock() - defer s.mu.Unlock() - selected := s.latestSessionToken - if len(token) != 0 { - selected = sessionTokenKey(token[0]) - } else if !s.latestSessionSet { - return nil - } - game := s.sessions[selected] - if game == nil { - return nil - } - return append([]byte(nil), game.key...) -} - -func (s *Server) ProgressForTest() *progress.Store { return s.progress } diff --git a/go/internal/server/session/server_test.go b/go/internal/server/session/server_test.go deleted file mode 100644 index c1c20c0..0000000 --- a/go/internal/server/session/server_test.go +++ /dev/null @@ -1,537 +0,0 @@ -package session - -import ( - "database/sql" - "encoding/base64" - "encoding/json" - "errors" - "path/filepath" - "strings" - "testing" - "time" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/cryptox" - "bd2server/internal/server/protocol" - "bd2server/internal/server/stateio" - "bd2server/internal/server/transport" - "bd2server/internal/server/wire" -) - -type fakeLogin struct{} - -func (fakeLogin) Login(request, key []byte) ([]byte, error) { - if _, ok, err := wire.Varint(request, 1); err != nil || !ok { - return nil, errors.New("missing seq") - } - user := wire.AppendBytes(nil, 3, key) - return wire.AppendBytes(nil, 1, user), nil -} - -type fakeAuthenticator struct { - accountID string - err error - calls int - request []byte -} - -func (a *fakeAuthenticator) AuthenticateLogin(request []byte) (string, error) { - a.calls++ - a.request = append([]byte(nil), request...) - return a.accountID, a.err -} - -type fakeDomain struct{} - -type fakeStateGate struct{ err error } - -func (g *fakeStateGate) Check() error { return g.err } -func (g *fakeStateGate) Load(string) ([]byte, error) { return nil, nil } -func (g *fakeStateGate) Save(string, []byte) error { return nil } -func (g *fakeStateGate) Close() error { return nil } -func (g *fakeStateGate) BeginOperation() (stateio.RequestOperation, error) { - if g.err != nil { - return nil, g.err - } - return fakeOperation{}, nil -} - -type fakeOperation struct{} - -func (fakeOperation) Commit() error { return nil } -func (fakeOperation) Rollback() error { return nil } - -func TestFormationTimingPath(t *testing.T) { - for _, path := range []string{"/PresetInfo", "/PresetSave", "/DeckSave", "/DeckCostumeSettingSave", "/FieldDeckInfo", "/EquipBatchUse"} { - if !formationTimingPath(path) { - t.Fatalf("formation path %s is not timed", path) - } - } - for _, path := range []string{"/LoginUser", "/GachaInfo", "/EquipInfo"} { - if formationTimingPath(path) { - t.Fatalf("unrelated path %s is marked as formation timing", path) - } - } -} - -type mutatingDomain struct { - store stateio.Store - fail bool -} - -func (d *mutatingDomain) Handle(path string, request []byte) (int, []byte, bool, error) { - if path == "/InjectedFailure" { - return 0, nil, true, errors.New("injected domain failure") - } - if path != "/MutateTwoFiles" { - return 0, nil, false, nil - } - if err := d.store.Save("wallet", []byte("new-wallet")); err != nil { - return 0, nil, true, err - } - if err := d.store.Save("items", []byte("new-items")); err != nil { - return 0, nil, true, err - } - if d.fail { - return 0, nil, true, errors.New("injected domain failure") - } - return 77, nil, true, nil -} - -func TestBatchUsesOneAccountTransaction(t *testing.T) { - root := t.TempDir() - statePath := filepath.Join(root, "state.db") - repository, err := accountstate.Open(statePath) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repository.Close(); err != nil { - t.Error(err) - } - }() - for name, content := range map[string]string{"wallet": "old-wallet", "items": "old-items"} { - if err := repository.Save(name, []byte(content)); err != nil { - t.Fatal(err) - } - } - server, _ := NewServer(fakeLogin{}, &mutatingDomain{store: repository}) - if err := server.AttachStateStore(repository); err != nil { - t.Fatal(err) - } - reply := login(t, server) - requests := []protocol.BatchRequest{ - {Path: "/MutateTwoFiles", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}, - {Path: "/InjectedFailure", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 3))}, - } - plain, _ := json.Marshal(requests) - body, _ := cryptox.EncryptBase64(plain, server.KeyForTest()) - if _, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie); err == nil { - t.Fatal("partially failing batch was accepted") - } - verified, err := sql.Open("sqlite", statePath) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := verified.Close(); err != nil { - t.Error(err) - } - }() - for name, want := range map[string]string{"wallet": "old-wallet", "items": "old-items"} { - var got []byte - err := verified.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got) - if err != nil || string(got) != want { - t.Fatalf("batch rollback %s=%q err=%v", name, got, err) - } - } -} - -func (fakeDomain) Handle(path string, request []byte) (int, []byte, bool, error) { - if path == "/EmptyInfo" { - return 77, nil, true, nil - } - return 0, nil, false, nil -} - -func login(t *testing.T, server *Server) transport.RawReply { - t.Helper() - request := wire.AppendVarint(nil, 1, 1) - return loginRequest(t, server, request) -} - -func loginRequest(t *testing.T, server *Server, request []byte) transport.RawReply { - t.Helper() - body, err := cryptox.EncryptBase64Payload(request, cryptox.Key()) - if err != nil { - t.Fatal(err) - } - reply, err := server.DispatchRaw("/LoginUser", []byte(body), "") - if err != nil { - t.Fatal(err) - } - var envelope protocol.Envelope - if err := json.Unmarshal(reply.Body, &envelope); err != nil { - t.Fatal(err) - } - proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key()) - if err != nil || envelope.PacketCode != 3 || len(proto) == 0 || reply.Cookie == "" { - t.Fatalf("login response: %+v proto=%d err=%v", envelope, len(proto), err) - } - return reply -} - -func TestLoginAuthenticatesBeforeCreatingGameSession(t *testing.T) { - server, err := NewServer(fakeLogin{}) - if err != nil { - t.Fatal(err) - } - authenticator := &fakeAuthenticator{accountID: "account-1"} - if err := server.AttachLoginAuthenticator(authenticator); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendString(request, 2, "short-lived-access-token") - reply := loginRequest(t, server, request) - if authenticator.calls != 1 { - t.Fatalf("AuthenticateLogin calls=%d, want 1", authenticator.calls) - } - accessToken, found, err := wire.Bytes(authenticator.request, 2) - if err != nil || !found || string(accessToken) != "short-lived-access-token" { - t.Fatalf("authenticated access token=%q found=%v err=%v", accessToken, found, err) - } - game := server.sessions[sessionTokenKey(reply.Cookie)] - if game == nil || game.accountID != "account-1" { - t.Fatalf("game session=%+v, want authenticated account", game) - } -} - -func TestLoginAuthenticationFailureCreatesNoSession(t *testing.T) { - server, err := NewServer(fakeLogin{}) - if err != nil { - t.Fatal(err) - } - authenticator := &fakeAuthenticator{err: errors.New("invalid access token")} - if err := server.AttachLoginAuthenticator(authenticator); err != nil { - t.Fatal(err) - } - request := wire.AppendVarint(nil, 1, 1) - body, err := cryptox.EncryptBase64Payload(request, cryptox.Key()) - if err != nil { - t.Fatal(err) - } - if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil { - t.Fatal("invalid access token was accepted") - } - if authenticator.calls != 1 || len(server.sessions) != 0 { - t.Fatalf("auth calls=%d sessions=%d", authenticator.calls, len(server.sessions)) - } - - authenticator.err = nil - authenticator.accountID = "" - if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil { - t.Fatal("empty authenticated account ID was accepted") - } - if len(server.sessions) != 0 { - t.Fatalf("empty account ID created %d sessions", len(server.sessions)) - } -} - -func TestGameSessionsHaveIndependentKeysAndCookies(t *testing.T) { - server, err := NewServer(fakeLogin{}, fakeDomain{}) - if err != nil { - t.Fatal(err) - } - first := login(t, server) - firstKey := server.KeyForTest(first.Cookie) - second := login(t, server) - secondKey := server.KeyForTest(second.Cookie) - if first.Cookie == second.Cookie { - t.Fatal("two logins received the same game session cookie") - } - if string(firstKey) == string(secondKey) { - t.Fatal("two logins received the same game session key") - } - - request := wire.AppendVarint(nil, 1, 2) - firstBody, err := cryptox.EncryptBase64Payload(request, firstKey) - if err != nil { - t.Fatal(err) - } - firstReply, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "other=value; s="+first.Cookie) - if err != nil { - t.Fatalf("first session stopped working after second login: %v", err) - } - var firstEnvelope protocol.Envelope - if err := json.Unmarshal(firstReply.Body, &firstEnvelope); err != nil { - t.Fatal(err) - } - if _, err := cryptox.DecryptBase64Payload(firstEnvelope.Data, firstKey); err != nil { - t.Fatalf("first response did not use first key: %v", err) - } - - secondBody, err := cryptox.EncryptBase64Payload(request, secondKey) - if err != nil { - t.Fatal(err) - } - if _, err := server.DispatchRaw("/EmptyInfo", []byte(secondBody), "s="+second.Cookie); err != nil { - t.Fatalf("second session request failed: %v", err) - } - if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+second.Cookie); err == nil { - t.Fatal("request encrypted with first key was accepted under second cookie") - } - if _, err := server.DispatchRaw("/EmptyInfo", []byte(secondBody), "s="+first.Cookie); err == nil { - t.Fatal("request encrypted with second key was accepted under first cookie") - } - if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+first.Cookie+"; s="+second.Cookie); err == nil { - t.Fatal("ambiguous duplicate session cookies were accepted") - } -} - -func TestGameSessionExpiresAndClearsKey(t *testing.T) { - server, err := NewServer(fakeLogin{}, fakeDomain{}) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) - server.now = func() time.Time { return now } - server.sessionTTL = time.Minute - reply := login(t, server) - game := server.sessions[sessionTokenKey(reply.Cookie)] - key := append([]byte(nil), game.key...) - now = now.Add(time.Minute) - request := wire.AppendVarint(nil, 1, 2) - body, err := cryptox.EncryptBase64Payload(request, key) - if err != nil { - t.Fatal(err) - } - if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil { - t.Fatal("expired game session was accepted") - } else if !errors.Is(err, transport.ErrGameSessionExpired) { - t.Fatalf("expired game session error=%v, want ErrGameSessionExpired", err) - } - if len(server.sessions) != 0 { - t.Fatalf("expired game session remains in map: %d", len(server.sessions)) - } - for i, value := range game.key { - if value != 0 { - t.Fatalf("expired session key byte %d was not cleared", i) - } - } -} - -func TestGameSessionLimitEvictsLeastRecentlyUsedAndClearsKey(t *testing.T) { - server, err := NewServer(fakeLogin{}, fakeDomain{}) - if err != nil { - t.Fatal(err) - } - now := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) - server.now = func() time.Time { return now } - server.maxSessions = 2 - first := login(t, server) - firstKey := server.KeyForTest(first.Cookie) - now = now.Add(time.Second) - second := login(t, server) - secondGame := server.sessions[sessionTokenKey(second.Cookie)] - - now = now.Add(time.Second) - request := wire.AppendVarint(nil, 1, 2) - firstBody, err := cryptox.EncryptBase64Payload(request, firstKey) - if err != nil { - t.Fatal(err) - } - if _, err := server.DispatchRaw("/EmptyInfo", []byte(firstBody), "s="+first.Cookie); err != nil { - t.Fatal(err) - } - now = now.Add(time.Second) - third := login(t, server) - if len(server.sessions) != 2 { - t.Fatalf("game sessions=%d, want cap 2", len(server.sessions)) - } - if _, ok := server.sessions[sessionTokenKey(first.Cookie)]; !ok { - t.Fatal("recently used first session was evicted") - } - if _, ok := server.sessions[sessionTokenKey(third.Cookie)]; !ok { - t.Fatal("new third session is missing") - } - if _, ok := server.sessions[sessionTokenKey(second.Cookie)]; ok { - t.Fatal("least recently used second session was not evicted") - } - for i, value := range secondGame.key { - if value != 0 { - t.Fatalf("evicted session key byte %d was not cleared", i) - } - } -} - -func TestSessionCookieParsingIsStrict(t *testing.T) { - valid := strings.Repeat("a", 48) + "|1" - if token, err := parseSessionCookie("other=value; s=" + valid); err != nil || token != valid { - t.Fatalf("valid cookie token=%q err=%v", token, err) - } - for name, cookie := range map[string]string{ - "missing": "other=value", - "empty": "s=", - "wrong length": "s=abcd|1", - "uppercase": "s=" + strings.Repeat("A", 48) + "|1", - "wrong version": "s=" + strings.Repeat("a", 48) + "|2", - "duplicate": "s=" + valid + "; s=" + valid, - "oversized": "x=" + strings.Repeat("a", maxCookieHeaderLen), - } { - t.Run(name, func(t *testing.T) { - if _, err := parseSessionCookie(cookie); err == nil { - t.Fatalf("accepted malformed cookie %q", cookie) - } - }) - } -} - -func TestNativeLoginAndBatch(t *testing.T) { - server, err := NewServer(fakeLogin{}, fakeDomain{}) - if err != nil { - t.Fatal(err) - } - reply := login(t, server) - requests := []protocol.BatchRequest{{Path: "/EmptyInfo", RequestData: base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2))}} - plain, _ := json.Marshal(requests) - body, _ := cryptox.EncryptBase64(plain, server.KeyForTest()) - batch, err := server.DispatchRaw("/BatchRequest", []byte(body), "s="+reply.Cookie) - if err != nil { - t.Fatal(err) - } - var items []protocol.BatchResponse - if err := json.Unmarshal(batch.Body, &items); err != nil || len(items) != 1 || items[0].Path != "/EmptyInfo" || items[0].ResponseData.PacketCode != 77 { - t.Fatalf("batch response: %+v err=%v", items, err) - } -} - -func TestSessionRejectsMissingCookieAndUnknownPath(t *testing.T) { - server, _ := NewServer(fakeLogin{}, fakeDomain{}) - if _, err := server.DispatchRaw("/EmptyInfo", nil, ""); !errors.Is(err, transport.ErrGameSessionExpired) { - t.Fatalf("missing cookie error=%v, want ErrGameSessionExpired", err) - } - unknown := strings.Repeat("a", 48) + "|1" - if _, err := server.DispatchRaw("/BatchRequest", nil, "s="+unknown); !errors.Is(err, transport.ErrGameSessionExpired) { - t.Fatalf("unknown cookie error=%v, want ErrGameSessionExpired", err) - } - if _, err := server.DispatchRaw("/EmptyInfo", nil, "s=malformed"); err == nil || - errors.Is(err, transport.ErrGameSessionExpired) { - t.Fatalf("malformed cookie error=%v, want ordinary rejection", err) - } - reply := login(t, server) - request := wire.AppendVarint(nil, 1, 99) - body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) - if _, err := server.DispatchRaw("/InventedPacket", []byte(body), "s="+reply.Cookie); !errors.Is(err, transport.ErrNotImplemented) { - t.Fatalf("unknown endpoint did not fail closed: %v", err) - } -} - -func TestStateGateStopsEveryRequestAfterPersistenceFailure(t *testing.T) { - server, _ := NewServer(fakeLogin{}, fakeDomain{}) - gate := &fakeStateGate{} - if err := server.AttachStateStore(gate); err != nil { - t.Fatal(err) - } - reply := login(t, server) - gate.err = errors.New("uncertain transaction") - request := wire.AppendVarint(nil, 1, 2) - body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) - if _, err := server.DispatchRaw("/EmptyInfo", []byte(body), "s="+reply.Cookie); err == nil { - t.Fatal("request passed a failed account state gate") - } - if _, err := server.DispatchRaw("/LoginUser", []byte(body), ""); err == nil { - t.Fatal("login passed a failed account state gate") - } -} - -func TestAuthenticatedRequestTransactionCommitsOrRollsBackAllFiles(t *testing.T) { - for _, test := range []struct { - name string - fail bool - want string - }{ - {"commit", false, "new-"}, - {"rollback", true, "old-"}, - } { - t.Run(test.name, func(t *testing.T) { - root := t.TempDir() - statePath := filepath.Join(root, "state.db") - repository, err := accountstate.Open(statePath) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repository.Close(); err != nil { - t.Error(err) - } - }() - for name, content := range map[string]string{"wallet": "old-wallet", "items": "old-items"} { - if err := repository.Save(name, []byte(content)); err != nil { - t.Fatal(err) - } - } - server, _ := NewServer(fakeLogin{}, &mutatingDomain{store: repository, fail: test.fail}) - if err := server.AttachStateStore(repository); err != nil { - t.Fatal(err) - } - reply := login(t, server) - request := wire.AppendVarint(nil, 1, 2) - body, _ := cryptox.EncryptBase64Payload(request, server.KeyForTest()) - _, requestErr := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie) - if test.fail && requestErr == nil || !test.fail && requestErr != nil { - t.Fatalf("request err=%v", requestErr) - } - for _, name := range []string{"wallet", "items"} { - var got []byte - if test.fail { - reader, openErr := sql.Open("sqlite", statePath) - if openErr != nil { - t.Fatal(openErr) - } - err = reader.QueryRow(`SELECT payload FROM domain_state WHERE name=?`, name).Scan(&got) - if err := reader.Close(); err != nil { - t.Error(err) - } - } else { - got, err = repository.Load(name) - } - if err != nil || string(got) != test.want+name { - t.Fatalf("%s=%q err=%v", name, got, err) - } - } - if test.fail { - if _, err := server.DispatchRaw("/MutateTwoFiles", []byte(body), "s="+reply.Cookie); err == nil { - t.Fatal("server continued after rolling disk back behind published domain memory") - } - } - }) - } -} - -func TestNativePositionAndQuestDomainRouting(t *testing.T) { - server, _ := NewServer(fakeLogin{}) - reply := login(t, server) - cookie := "s=" + reply.Cookie - position := wire.AppendVarint(nil, 1, 999001) - position = wire.AppendVarint(position, 2, 21) - position = wire.AppendString(position, 3, `{"MapId":211,"PlayerPosition":{"x":1,"y":2,"z":3},"ColleaguePositions":null}`) - body, _ := cryptox.EncryptBase64Payload(position, server.KeyForTest()) - if _, err := server.DispatchRaw("/SaveUserPosition", []byte(body), cookie); err != nil { - t.Fatal(err) - } - saved, found := server.ProgressForTest().Position() - if !found || saved.PackID != 21 || saved.Position.MapID != 211 { - t.Fatalf("position not stored: %+v", saved) - } - quest := wire.AppendVarint(nil, 1, 999002) - quest = wire.AppendVarint(quest, 2, 12) - quest = wire.AppendVarint(quest, 3, 21) - quest = wire.AppendBytes(quest, 4, []byte{121}) - body, _ = cryptox.EncryptBase64Payload(quest, server.KeyForTest()) - if _, err := server.DispatchRaw("/QuestUpdate", []byte(body), cookie); !errors.Is(err, transport.ErrNotImplemented) { - t.Fatalf("QuestUpdate without an owning world domain must fail closed: %v", err) - } - if stored, ok := server.ProgressForTest().Quest(12); ok { - t.Fatalf("unvalidated quest bypassed the world domain: %+v", stored) - } -} diff --git a/go/internal/server/stateio/entry_snapshot_test.go b/go/internal/server/stateio/entry_snapshot_test.go deleted file mode 100644 index 95fa318..0000000 --- a/go/internal/server/stateio/entry_snapshot_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package stateio_test - -import ( - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/stateio" -) - -func TestOptionalGameplaySnapshotsPreserveAccountInitializationOnRestart(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - domains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"} - for _, name := range domains { - if err := repo.Save(name, []byte("{}")); err != nil { - t.Fatal(err) - } - } - if err := repo.MarkInitializationComplete(); err != nil { - t.Fatal(err) - } - store := stateio.EntrySnapshotStore{Entries: repo, Domain: "missions", Bucket: "gameplay"} - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - for _, name := range []string{"hunting", "huntdispatch", "monsterhunt"} { - if err := store.Save(name, []byte("{\"version\":\"2.35.10\"}")); err != nil { - _ = op.Rollback() - t.Fatal(err) - } - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - reopened, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := reopened.Close(); err != nil { - t.Error(err) - } - }() - if status, err := reopened.InitializationState(domains...); err != nil || status != accountstate.InitializationComplete { - t.Fatalf("restart rejected optional gameplay state: %d %v", status, err) - } - store.Entries = reopened - for _, name := range []string{"hunting", "huntdispatch", "monsterhunt"} { - raw, err := store.Load(name) - if err != nil || string(raw) != "{\"version\":\"2.35.10\"}" { - t.Fatalf("%s lost after restart: %s %v", name, raw, err) - } - } -} diff --git a/go/internal/server/stateio/json_test.go b/go/internal/server/stateio/json_test.go deleted file mode 100644 index 4f1be0c..0000000 --- a/go/internal/server/stateio/json_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package stateio - -import "testing" - -func TestRequireExactJSONObject(t *testing.T) { - if err := RequireExactJSONObject([]byte(`{"version":1,"items":[]}`), "version", "items"); err != nil { - t.Fatal(err) - } - for _, payload := range []string{ - `{"version":1}`, - `{"version":1,"items":[],"legacy":true}`, - `[]`, - } { - if err := RequireExactJSONObject([]byte(payload), "version", "items"); err == nil { - t.Fatalf("accepted non-current layout %s", payload) - } - } -} diff --git a/go/internal/server/storage/account/atomic_entry.go b/go/internal/server/storage/account/atomic_entry.go new file mode 100644 index 0000000..8dbddb7 --- /dev/null +++ b/go/internal/server/storage/account/atomic_entry.go @@ -0,0 +1,26 @@ +package accountstate + +import "bd2server/internal/server/storage/stateio" + +func (t *CommandStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { + for _, change := range changes { + if err := validEntryKey(domain, change.Bucket, change.Key); err != nil { + return err + } + } + if core != nil { + if err := t.Save(domain, core); err != nil { + return err + } + } + for _, change := range changes { + if change.Delete { + if _, err := t.DeleteEntry(domain, change.Bucket, change.Key); err != nil { + return err + } + } else if err := t.PutEntry(domain, change.Bucket, change.Key, change.Payload); err != nil { + return err + } + } + return nil +} diff --git a/go/internal/server/accountstate/bootstrap.go b/go/internal/server/storage/account/bootstrap.go similarity index 87% rename from go/internal/server/accountstate/bootstrap.go rename to go/internal/server/storage/account/bootstrap.go index e269fa9..b0b2a58 100644 --- a/go/internal/server/accountstate/bootstrap.go +++ b/go/internal/server/storage/account/bootstrap.go @@ -2,13 +2,14 @@ package accountstate import ( "bytes" + "context" "encoding/json" "errors" "fmt" "slices" "sort" - "bd2server/internal/server/stateio" + "bd2server/internal/server/storage/stateio" ) const bootstrapDomain = "bootstrap" @@ -65,7 +66,12 @@ func (r *Repository) InitializationState(accountDomains ...string) (Initializati if !slices.Equal(found, want) { return InitializationCorrupt, fmt.Errorf("accountstate: domains %v, want %v", found, want) } - payload, err := r.Load(bootstrapDomain) + tx, err := r.BeginCommand(context.Background()) + if err != nil { + return InitializationCorrupt, err + } + defer func() { _ = tx.Rollback() }() + payload, err := tx.Load(bootstrapDomain) if err != nil { return InitializationCorrupt, err } @@ -75,15 +81,15 @@ func (r *Repository) InitializationState(accountDomains ...string) (Initializati return InitializationComplete, nil } -// MarkInitializationComplete joins the active startup transaction. It must be +// MarkInitializationComplete writes to the explicit startup command. It must be // written only after every account domain and first-login entitlement exists; // a rollback therefore leaves a schema-only database that can be retried. -func (r *Repository) MarkInitializationComplete() error { +func (t *CommandStore) MarkInitializationComplete() error { payload, err := json.Marshal(bootstrapSnapshot{Version: 1}) if err != nil { return err } - return r.Save(bootstrapDomain, payload) + return t.Save(bootstrapDomain, payload) } func validateBootstrap(payload []byte) error { diff --git a/go/internal/server/storage/account/command_invariants_test.go b/go/internal/server/storage/account/command_invariants_test.go new file mode 100644 index 0000000..4b04009 --- /dev/null +++ b/go/internal/server/storage/account/command_invariants_test.go @@ -0,0 +1,342 @@ +package accountstate + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "bd2server/internal/server/design/gamedata" + "bd2server/internal/server/domain/command" + assets "bd2server/internal/server/domain/inventory" + "bd2server/internal/server/storage/stateio" +) + +func openTestAccount(t *testing.T, path string) *Repository { + t.Helper() + r, err := Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := r.Close(); err != nil { + t.Error(err) + } + }) + return r +} +func beginTestCommand(t *testing.T, r *Repository) (*CommandStore, command.Context) { + t.Helper() + tx, err := r.BeginCommand(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := tx.Rollback(); err != nil { + t.Error(err) + } + }) + return tx, command.Context{Identity: command.Identity{AccountID: "account-under-test", SessionID: "authenticated-login"}, State: tx} +} +func openTestWallet(t *testing.T, ctx command.Context, initial uint64) *assets.Wallet { + t.Helper() + w, err := assets.OpenWallet(ctx, stateio.RootStore{}, assets.Currency{Gold: initial}) + if err != nil { + t.Fatal(err) + } + return w +} + +// Credits and debits form one command; a discarded candidate may never charge +// the account, issue a reward, or poison another account's independent command. +func TestAbortedAssetCommandDoesNotJoinAnotherAccount(t *testing.T) { + dir := t.TempDir() + a := openTestAccount(t, filepath.Join(dir, "a", "state.db")) + b := openTestAccount(t, filepath.Join(dir, "b", "state.db")) + txA, ctxA := beginTestCommand(t, a) + wa := openTestWallet(t, ctxA, 100) + if err := wa.EnsurePersisted(ctxA); err != nil { + t.Fatal(err) + } + if _, err := wa.GrantQuestOnce(ctxA, "reward", []gamedata.Reward{{Type: 4, Count: 240}}); err != nil { + t.Fatal(err) + } + if _, err := wa.SpendGoldOnce(ctxA, "cost", 35); err != nil { + t.Fatal(err) + } + txB, ctxB := beginTestCommand(t, b) + wb := openTestWallet(t, ctxB, 1000) + if err := wb.EnsurePersisted(ctxB); err != nil { + t.Fatal(err) + } + if _, err := wb.GrantQuestOnce(ctxB, "reward", []gamedata.Reward{{Type: 4, Count: 17}}); err != nil { + t.Fatal(err) + } + if err := txA.Rollback(); err != nil { + t.Fatal(err) + } + if err := a.Check(); err != nil { + t.Fatalf("a normal rollback must permit account recovery: %v", err) + } + // An escaped capability remains bound to the discarded transaction even while + // another account is still writing. Its reward cannot enter that transaction. + stale := make(chan error, 1) + go func() { + _, err := wa.GrantQuestOnce(ctxA, "escaped", []gamedata.Reward{{Type: 4, Count: 9999}}) + stale <- err + }() + if err := <-stale; !errors.Is(err, ErrClosed) { + t.Fatalf("escaped write: %v", err) + } + if err := txB.Commit(); err != nil { + t.Fatal(err) + } + checkA, readA := beginTestCommand(t, a) + recoveredA := openTestWallet(t, readA, 100) + if got := recoveredA.Snapshot(readA).Gold; got != 100 { + t.Fatalf("rollback violated asset conservation: %d", got) + } + if recoveredA.WasGranted("reward") || recoveredA.WasSpent("cost") { + t.Fatal("rollback left entitlement markers") + } + if err := checkA.Rollback(); err != nil { + t.Fatal(err) + } + _, readB := beginTestCommand(t, b) + recoveredB := openTestWallet(t, readB, 0) + if got := recoveredB.Snapshot(readB).Gold; got != 1000+17 { + t.Fatalf("account isolation violated conservation: %d", got) + } + if recoveredB.WasGranted("escaped") { + t.Fatal("escaped account a reward reached account b") + } +} + +// The helper exits without deferred cleanup to exercise real WAL recovery, +// both before the commit point and after commit but before a response exists. +func TestSQLiteCrashKeepsWholeAssetCommand(t *testing.T) { + if path := os.Getenv("BD2_COMMAND_CRASH_DATABASE"); path != "" { + r, err := Open(path) + if err != nil { + t.Fatal(err) + } + tx, err := r.BeginCommand(context.Background()) + if err != nil { + t.Fatal(err) + } + ctx := command.Context{State: tx} + w, err := assets.OpenWallet(ctx, stateio.RootStore{}, assets.Currency{}) + if err != nil { + t.Fatal(err) + } + if _, err = w.GrantQuestOnce(ctx, "crash-reward", []gamedata.Reward{{Type: 4, Count: 120}}); err != nil { + t.Fatal(err) + } + if _, err = w.SpendGoldOnce(ctx, "crash-cost", 35); err != nil { + t.Fatal(err) + } + if os.Getenv("BD2_COMMAND_CRASH_COMMIT") == "yes" { + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + } + os.Exit(0) + } + for _, commit := range []bool{false, true} { + t.Run(map[bool]string{false: "before_commit", true: "after_commit"}[commit], func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + r, err := Open(path) + if err != nil { + t.Fatal(err) + } + tx, ctx := beginTestCommand(t, r) + w := openTestWallet(t, ctx, 70) + if err := w.EnsurePersisted(ctx); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + helper := exec.Command(os.Args[0], "-test.run=^TestSQLiteCrashKeepsWholeAssetCommand$") + helper.Env = append(os.Environ(), "BD2_COMMAND_CRASH_DATABASE="+path, "BD2_COMMAND_CRASH_COMMIT="+map[bool]string{false: "no", true: "yes"}[commit]) + if output, err := helper.CombinedOutput(); err != nil { + t.Fatalf("crash helper: %v\n%s", err, output) + } + recovered := openTestAccount(t, path) + verify, read := beginTestCommand(t, recovered) + rw := openTestWallet(t, read, 0) + expected := uint64(70) + if commit { + expected += 120 - 35 + } + if got := rw.Snapshot(read).Gold; got != expected { + t.Fatalf("crash broke conservation: got %d want %d", got, expected) + } + if rw.WasGranted("crash-reward") != commit || rw.WasSpent("crash-cost") != commit { + t.Fatal("core and idempotency entries recovered different commands") + } + if commit { + if _, err := rw.GrantQuestOnce(read, "crash-reward", []gamedata.Reward{{Type: 4, Count: 120}}); err != nil { + t.Fatal(err) + } + if _, err := rw.SpendGoldOnce(read, "crash-cost", 35); err != nil { + t.Fatal(err) + } + if got := rw.Snapshot(read).Gold; got != expected { + t.Fatalf("lost response retry applied assets twice: %d", got) + } + } + if err := verify.Commit(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestDatabaseWriterOwnershipAndFailedStorageFence(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + r := openTestAccount(t, path) + if other, err := Open(path); !errors.Is(err, ErrWriterLocked) { + if other != nil { + _ = other.Close() + } + t.Fatalf("overlapping actor database: %v", err) + } + tx, ctx := beginTestCommand(t, r) + w := openTestWallet(t, ctx, 23) + if err := w.EnsurePersisted(ctx); err != nil { + t.Fatal(err) + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + var pages int + if err := r.db.QueryRow("PRAGMA page_count").Scan(&pages); err != nil { + t.Fatal(err) + } + if _, err := r.db.Exec("PRAGMA max_page_count = " + strconv.Itoa(pages)); err != nil { + t.Fatal(err) + } + failed, _ := beginTestCommand(t, r) + if err := failed.Save("large-write", make([]byte, 1<<20)); err == nil { + t.Fatal("SQLite capacity fault was not reached") + } + if err := r.Check(); !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatalf("disk storage failure was not fenced: %v", err) + } + if err := failed.Commit(); err == nil { + t.Fatal("failed SQL command was committed") + } + if _, err := r.BeginCommand(context.Background()); !errors.Is(err, stateio.ErrStateRecoveryRequired) { + t.Fatalf("failed database accepted another writer: %v", err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + reopened := openTestAccount(t, path) + _, read := beginTestCommand(t, reopened) + recovered := openTestWallet(t, read, 0) + if got := recovered.Snapshot(read).Gold; got != 23 { + t.Fatalf("failed disk write lost previously committed assets: %d", got) + } +} + +func TestRejectedRewardLedgerCannotCommitPartialAssets(t *testing.T) { + r := openTestAccount(t, filepath.Join(t.TempDir(), "state.db")) + initial, ctx := beginTestCommand(t, r) + wallet := openTestWallet(t, ctx, 50) + if err := wallet.EnsurePersisted(ctx); err != nil { + t.Fatal(err) + } + if err := initial.Commit(); err != nil { + t.Fatal(err) + } + // Fail after SaveWithEntries updates the balance but before the issuance + // marker. Even a caller attempting Commit must not publish half a reward. + if _, err := r.db.Exec(`CREATE TRIGGER reject_reward BEFORE INSERT ON domain_entry + WHEN NEW.domain_name='wallet' AND NEW.bucket='granted' + BEGIN SELECT RAISE(ABORT,'reject reward issuance'); END`); err != nil { + t.Fatal(err) + } + failed, attempt := beginTestCommand(t, r) + candidate := openTestWallet(t, attempt, 0) + if _, err := candidate.GrantQuestOnce(attempt, "rejected", []gamedata.Reward{{Type: 4, Count: 31}}); err == nil { + t.Fatal("issuance fault was not reached") + } + if err := failed.Commit(); !errors.Is(err, ErrCommandFailed) { + t.Fatalf("partial command accepted commit: %v", err) + } + if err := r.Check(); err != nil { + t.Fatalf("deterministic rejected command fenced database: %v", err) + } + if _, err := r.db.Exec(`DROP TRIGGER reject_reward`); err != nil { + t.Fatal(err) + } + verify, read := beginTestCommand(t, r) + recovered := openTestWallet(t, read, 0) + if recovered.WasGranted("rejected") || recovered.Snapshot(read).Gold != 50 { + t.Fatal("failed issuance published a balance or entitlement marker") + } + if _, err := recovered.GrantQuestOnce(read, "rejected", []gamedata.Reward{{Type: 4, Count: 31}}); err != nil { + t.Fatal(err) + } + if err := verify.Commit(); err != nil { + t.Fatal(err) + } + _, confirmed := beginTestCommand(t, r) + durable := openTestWallet(t, confirmed, 0) + if durable.Snapshot(confirmed).Gold != 50+31 || !durable.WasGranted("rejected") { + t.Fatal("account failed to recover and issue exactly one complete reward") + } +} +func TestFirstRejectedWalletWritePreservesCommittedAssets(t *testing.T) { + r := openTestAccount(t, filepath.Join(t.TempDir(), "state.db")) + initial, ctx := beginTestCommand(t, r) + wallet := openTestWallet(t, ctx, 73) + if err := wallet.EnsurePersisted(ctx); err != nil { + t.Fatal(err) + } + if err := initial.Commit(); err != nil { + t.Fatal(err) + } + if _, err := r.db.Exec(`CREATE TRIGGER reject_first_wallet BEFORE UPDATE OF payload ON domain_state + WHEN NEW.name='wallet' BEGIN SELECT RAISE(ABORT,'reject first wallet write'); END`); err != nil { + t.Fatal(err) + } + rejected, attempt := beginTestCommand(t, r) + candidate := openTestWallet(t, attempt, 0) + if _, err := candidate.SpendGoldOnce(attempt, "first-charge", 29); err == nil { + t.Fatal("first wallet write was not rejected") + } + if err := rejected.Rollback(); err != nil { + t.Fatal(err) + } + if _, err := r.db.Exec(`DROP TRIGGER reject_first_wallet`); err != nil { + t.Fatal(err) + } + retry, retryCtx := beginTestCommand(t, r) + recovered := openTestWallet(t, retryCtx, 0) + if recovered.Snapshot(retryCtx).Gold != 73 || recovered.WasSpent("first-charge") { + t.Fatal("rejected first write changed committed assets or retained its charge") + } + if _, err := recovered.SpendGoldOnce(retryCtx, "first-charge", 29); err != nil { + t.Fatal(err) + } + if _, err := recovered.GrantQuestOnce(retryCtx, "followup-reward", []gamedata.Reward{{Type: 4, Count: 11}}); err != nil { + t.Fatal(err) + } + if err := retry.Commit(); err != nil { + t.Fatal(err) + } + _, verify := beginTestCommand(t, r) + durable := openTestWallet(t, verify, 0) + if durable.Snapshot(verify).Gold != 73-29+11 || !durable.WasSpent("first-charge") || !durable.WasGranted("followup-reward") { + t.Fatal("recovering a rejected first charge violated asset conservation") + } +} diff --git a/go/internal/server/accountstate/entry.go b/go/internal/server/storage/account/entry.go similarity index 54% rename from go/internal/server/accountstate/entry.go rename to go/internal/server/storage/account/entry.go index 0784971..085fe3e 100644 --- a/go/internal/server/accountstate/entry.go +++ b/go/internal/server/storage/account/entry.go @@ -1,15 +1,14 @@ package accountstate import ( - "context" "database/sql" "errors" "fmt" - "bd2server/internal/server/stateio" + "bd2server/internal/server/storage/stateio" ) -var _ stateio.EntryStore = (*Repository)(nil) +var _ stateio.EntryStore = (*CommandStore)(nil) func validEntryScope(domain, bucket string) error { if domain == "" || bucket == "" { @@ -29,14 +28,14 @@ func validEntryKey(domain, bucket, key string) error { } // LoadEntry distinguishes a missing key from an existing empty payload. -func (t *Tx) LoadEntry(domain, bucket, key string) ([]byte, bool, error) { +func (t *CommandStore) LoadEntry(domain, bucket, key string) ([]byte, bool, error) { if err := validEntryKey(domain, bucket, key); err != nil { return nil, false, err } t.mu.Lock() defer t.mu.Unlock() - if t.done { - return nil, false, ErrClosed + if err := t.available(); err != nil { + return nil, false, err } var payload []byte err := t.tx.QueryRow(`SELECT payload FROM domain_entry @@ -45,24 +44,26 @@ func (t *Tx) LoadEntry(domain, bucket, key string) ([]byte, bool, error) { return nil, false, nil } if err != nil { + t.reject(err) return nil, false, fmt.Errorf("accountstate: load entry: %w", err) } return payload, true, nil } // ListEntries reads one domain-owned bucket from the transaction snapshot. -func (t *Tx) ListEntries(domain, bucket string) (map[string][]byte, error) { +func (t *CommandStore) ListEntries(domain, bucket string) (map[string][]byte, error) { if err := validEntryScope(domain, bucket); err != nil { return nil, err } t.mu.Lock() defer t.mu.Unlock() - if t.done { - return nil, ErrClosed + if err := t.available(); err != nil { + return nil, err } rows, err := t.tx.Query(`SELECT entry_key, payload FROM domain_entry WHERE domain_name = ? AND bucket = ?`, domain, bucket) if err != nil { + t.reject(err) return nil, fmt.Errorf("accountstate: list entries: %w", err) } defer func() { _ = rows.Close() }() @@ -71,137 +72,78 @@ func (t *Tx) ListEntries(domain, bucket string) (map[string][]byte, error) { var key string var payload []byte if err := rows.Scan(&key, &payload); err != nil { + t.reject(err) return nil, fmt.Errorf("accountstate: scan entry: %w", err) } entries[key] = payload } if err := rows.Err(); err != nil { + t.reject(err) return nil, fmt.Errorf("accountstate: list entries: %w", err) } return entries, nil } // PutEntry replaces one opaque value without rewriting its domain snapshot. -func (t *Tx) PutEntry(domain, bucket, key string, payload []byte) error { +func (t *CommandStore) PutEntry(domain, bucket, key string, payload []byte) error { if err := validEntryKey(domain, bucket, key); err != nil { return err } t.mu.Lock() defer t.mu.Unlock() - if t.done { - return ErrClosed + if err := t.available(); err != nil { + return err } if payload == nil { payload = []byte{} } - _, err := t.tx.Exec(`INSERT INTO domain_entry(domain_name, bucket, entry_key, payload, generation) + result, err := t.tx.Exec(`INSERT INTO domain_entry(domain_name, bucket, entry_key, payload, generation) VALUES (?, ?, ?, ?, 1) ON CONFLICT(domain_name, bucket, entry_key) DO UPDATE SET payload = excluded.payload, - generation = domain_entry.generation + 1`, + generation = domain_entry.generation + 1 + WHERE domain_entry.payload IS NOT excluded.payload`, domain, bucket, key, payload) if err != nil { + t.reject(err) return fmt.Errorf("accountstate: put entry: %w", err) } - t.dirty = true + changed, err := result.RowsAffected() + if err != nil { + t.reject(err) + return fmt.Errorf("accountstate: count saved entry: %w", err) + } + if changed != 0 { + t.dirty = true + t.repository.observationVersion.Add(1) + } return nil } // DeleteEntry reports whether a record was removed. -func (t *Tx) DeleteEntry(domain, bucket, key string) (bool, error) { +func (t *CommandStore) DeleteEntry(domain, bucket, key string) (bool, error) { if err := validEntryKey(domain, bucket, key); err != nil { return false, err } t.mu.Lock() defer t.mu.Unlock() - if t.done { - return false, ErrClosed + if err := t.available(); err != nil { + return false, err } result, err := t.tx.Exec(`DELETE FROM domain_entry WHERE domain_name = ? AND bucket = ? AND entry_key = ?`, domain, bucket, key) if err != nil { + t.reject(err) return false, fmt.Errorf("accountstate: delete entry: %w", err) } count, err := result.RowsAffected() if err != nil { + t.reject(err) return false, fmt.Errorf("accountstate: count deleted entries: %w", err) } if count != 0 { t.dirty = true + t.repository.observationVersion.Add(1) } return count != 0, nil } - -func (r *Repository) LoadEntry(domain, bucket, key string) ([]byte, bool, error) { - r.activeMu.RLock() - if r.active != nil { - payload, found, err := r.active.LoadEntry(domain, bucket, key) - r.activeMu.RUnlock() - return payload, found, err - } - r.activeMu.RUnlock() - tx, err := r.Begin(context.Background()) - if err != nil { - return nil, false, err - } - defer func() { _ = tx.Rollback() }() - return tx.LoadEntry(domain, bucket, key) -} - -func (r *Repository) ListEntries(domain, bucket string) (map[string][]byte, error) { - r.activeMu.RLock() - if r.active != nil { - entries, err := r.active.ListEntries(domain, bucket) - r.activeMu.RUnlock() - return entries, err - } - r.activeMu.RUnlock() - tx, err := r.Begin(context.Background()) - if err != nil { - return nil, err - } - defer func() { _ = tx.Rollback() }() - return tx.ListEntries(domain, bucket) -} - -func (r *Repository) PutEntry(domain, bucket, key string, payload []byte) error { - r.activeMu.RLock() - if r.active != nil { - err := r.active.PutEntry(domain, bucket, key, payload) - 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 := tx.PutEntry(domain, bucket, key, payload); err != nil { - return err - } - return tx.Commit() -} - -func (r *Repository) DeleteEntry(domain, bucket, key string) (bool, error) { - r.activeMu.RLock() - if r.active != nil { - deleted, err := r.active.DeleteEntry(domain, bucket, key) - r.activeMu.RUnlock() - return deleted, err - } - r.activeMu.RUnlock() - tx, err := r.Begin(context.Background()) - if err != nil { - return false, err - } - defer func() { _ = tx.Rollback() }() - deleted, err := tx.DeleteEntry(domain, bucket, key) - if err != nil { - return false, err - } - if err := tx.Commit(); err != nil { - return false, err - } - return deleted, nil -} diff --git a/go/internal/server/accountstate/repository.go b/go/internal/server/storage/account/repository.go similarity index 57% rename from go/internal/server/accountstate/repository.go rename to go/internal/server/storage/account/repository.go index 0ab7fd0..a04fc31 100644 --- a/go/internal/server/accountstate/repository.go +++ b/go/internal/server/storage/account/repository.go @@ -12,36 +12,43 @@ import ( "sort" "strconv" "sync" + "sync/atomic" - "bd2server/internal/server/stateio" + "bd2server/internal/server/storage/stateio" - _ "modernc.org/sqlite" + "modernc.org/sqlite" + sqlite3 "modernc.org/sqlite/lib" ) const schemaVersion = 3 +var ErrCommandFailed = errors.New("accountstate: command storage operation failed") + var ErrClosed = errors.New("accountstate: transaction already finished") var ErrFenced = stateio.ErrWriterFenced var ErrWriterLocked = errors.New("accountstate: state database is already owned by another writer") -// Repository owns one SQLite connection. A request or a complete batch holds -// that connection from Begin until Commit or Rollback, serializing writers. +// Repository owns one account database and its fenced SQLite connection. +// Only a command store can consume that connection; the repository is not a +// domain store and never routes calls into an ambient transaction. type Repository struct { db *sql.DB new bool writerEpoch int64 writerLock *writerLock - mu sync.Mutex - failed error - opMu sync.Mutex - activeMu sync.RWMutex - active *Tx - closeOnce sync.Once - closeErr error + mu sync.Mutex + failed error + closeOnce sync.Once + closeErr error + observationVersion atomic.Uint64 } -var _ stateio.TransactionalStore = (*Repository)(nil) +// ObservationVersion invalidates projections after every write and operation +// boundary, including rollback. It never represents a durable state version. +func (r *Repository) ObservationVersion() uint64 { return r.observationVersion.Load() } + +var _ stateio.CommandTransaction = (*CommandStore)(nil) // Open creates or opens state.db. SQLite's WAL handles interrupted writes and // FULL synchronous ensures a successful commit is durable before returning. @@ -126,7 +133,7 @@ func claimWriterEpoch(ctx context.Context, db *sql.DB) (int64, error) { return next, nil } -// SchemaVersion returns the on-disk version after all startup migrations. +// SchemaVersion returns the strictly validated current on-disk version. func (r *Repository) SchemaVersion() (int, error) { var raw string if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&raw); err != nil { @@ -167,31 +174,39 @@ func (r *Repository) RequireDomains(required ...string) error { return nil } -// Begin starts a request transaction. Callers must Commit or Rollback it. -// Begin may wait until the previous transaction releases the sole connection. -func (r *Repository) Begin(ctx context.Context) (*Tx, error) { +// BeginCommand binds all domain IO to one command snapshot. The owning actor +// must keep this store private, pass it explicitly to its owned services, and +// finish it before publishing a reply or accepting the next command. +func (r *Repository) BeginCommand(ctx context.Context) (*CommandStore, error) { if err := r.Check(); err != nil { return nil, err } tx, err := r.db.BeginTx(ctx, nil) if err != nil { + r.storageError(err) return nil, fmt.Errorf("accountstate: begin transaction: %w", err) } var rawEpoch string if err := tx.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&rawEpoch); err != nil { - _ = tx.Rollback() + r.storageError(err) + if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + r.fail(rollbackErr) + } return nil, fmt.Errorf("accountstate: verify writer epoch: %w", err) } epoch, parseErr := strconv.ParseInt(rawEpoch, 10, 64) if parseErr != nil || epoch != r.writerEpoch { _ = tx.Rollback() - return nil, fmt.Errorf("%w: process=%d database=%q", ErrFenced, r.writerEpoch, rawEpoch) + err := fmt.Errorf("%w: process=%d database=%q", ErrFenced, r.writerEpoch, rawEpoch) + r.fail(err) + return nil, r.Check() } if err := r.Check(); err != nil { _ = tx.Rollback() return nil, err } - return &Tx{repository: r, tx: tx}, nil + r.observationVersion.Add(1) + return &CommandStore{repository: r, tx: tx}, nil } // Check reports uncertain commit or rollback failures. Reopen the repository @@ -206,7 +221,7 @@ func (r *Repository) fail(err error) { r.mu.Lock() defer r.mu.Unlock() if r.failed == nil { - r.failed = fmt.Errorf("%w: accountstate transaction outcome uncertain; reopen database: %v", stateio.ErrStateRecoveryRequired, err) + r.failed = fmt.Errorf("%w: accountstate transaction outcome uncertain; reopen database: %w", stateio.ErrStateRecoveryRequired, err) } } @@ -219,198 +234,145 @@ func (r *Repository) Close() error { return r.closeErr } -// LoadContext reads a domain outside a request transaction. -func (r *Repository) LoadContext(ctx context.Context, name string) ([]byte, int64, bool, error) { - tx, err := r.Begin(ctx) - if err != nil { - return nil, 0, false, err - } - defer func() { _ = tx.Rollback() }() - return tx.Load(name) +// CommandStore is a capability bound permanently to one command transaction. +// It cannot be rebound or reused; references retained past its command fail. +type CommandStore struct { + repository *Repository + tx *sql.Tx + mu sync.Mutex + done bool + dirty bool + statementErr error } -// SaveContext writes a domain in its own transaction. Request handlers should -// use Tx.Save so all domains in one request or batch commit together. -func (r *Repository) SaveContext(ctx context.Context, name string, payload []byte) (int64, error) { - tx, err := r.Begin(ctx) - if err != nil { - return 0, err - } - defer func() { _ = tx.Rollback() }() - generation, err := tx.Save(name, payload) - if err != nil { - return 0, err - } - if err := tx.Commit(); err != nil { - return 0, err - } - return generation, nil -} - -// Load implements stateio.Store. Within BeginOperation it reads from the -// request transaction; outside a request it performs an independent read. -func (r *Repository) Load(name string) ([]byte, error) { - r.activeMu.RLock() - if r.active != nil { - data, _, _, err := r.active.Load(name) - r.activeMu.RUnlock() - return data, err - } - r.activeMu.RUnlock() - data, _, _, err := r.LoadContext(context.Background(), name) - return data, err -} - -// Save implements stateio.Store. Every write made during BeginOperation joins -// its transaction, including writes from different domain stores in a batch. -func (r *Repository) Save(name string, payload []byte) error { - r.activeMu.RLock() - if r.active != nil { - _, err := r.active.Save(name, payload) - r.activeMu.RUnlock() - return err - } - r.activeMu.RUnlock() - _, err := r.SaveContext(context.Background(), name, payload) - return err -} - -// BeginOperation starts the session request transaction. Session dispatch -// serializes requests; opMu also keeps direct callers from overlapping them. -func (r *Repository) BeginOperation() (stateio.RequestOperation, error) { - r.opMu.Lock() - tx, err := r.Begin(context.Background()) - if err != nil { - r.opMu.Unlock() - return nil, err - } - r.activeMu.Lock() - r.active = tx - r.activeMu.Unlock() - return &operation{repository: r, tx: tx}, nil -} - -type operation struct { - repository *Repository - tx *Tx - once sync.Once - err error -} - -func (o *operation) finish(commit bool) error { - o.once.Do(func() { - o.repository.activeMu.Lock() - if commit { - o.err = o.tx.Commit() - } else { - wrote := o.tx.isDirty() - o.err = o.tx.Rollback() - if wrote && o.err == nil { - o.repository.fail(errors.New("domain memory may differ after rollback")) - o.err = o.repository.Check() - } - } - o.repository.active = nil - o.repository.activeMu.Unlock() - o.repository.opMu.Unlock() - }) - return o.err -} - -func (o *operation) Commit() error { return o.finish(true) } -func (o *operation) Rollback() error { return o.finish(false) } - -// Tx is a SQLite transaction whose Load and Save operations share one snapshot. -type Tx struct { - repository *Repository - tx *sql.Tx - mu sync.Mutex - done bool - dirty bool -} - -func (t *Tx) isDirty() bool { +func (t *CommandStore) Dirty() bool { t.mu.Lock() defer t.mu.Unlock() return t.dirty } -// Load returns an owned copy of the payload, its generation, and whether it exists. -func (t *Tx) Load(name string) ([]byte, int64, bool, error) { +// Load returns an owned payload from this command snapshot. +func (t *CommandStore) Load(name string) ([]byte, error) { if name == "" { - return nil, 0, false, errors.New("accountstate: empty domain name") + return nil, errors.New("accountstate: empty domain name") } t.mu.Lock() defer t.mu.Unlock() - if t.done { - return nil, 0, false, ErrClosed + if err := t.available(); err != nil { + return nil, err } var payload []byte - var generation int64 - err := t.tx.QueryRow(`SELECT payload, generation FROM domain_state WHERE name = ?`, name). - Scan(&payload, &generation) + err := t.tx.QueryRow(`SELECT payload FROM domain_state WHERE name = ?`, name).Scan(&payload) if errors.Is(err, sql.ErrNoRows) { - return nil, 0, false, nil + return nil, nil } if err != nil { - return nil, 0, false, fmt.Errorf("accountstate: load %q: %w", name, err) + t.reject(err) + return nil, fmt.Errorf("accountstate: load %q: %w", name, err) } - return payload, generation, true, nil + return payload, nil } -// Save replaces one domain's opaque payload and advances its generation. -func (t *Tx) Save(name string, payload []byte) (int64, error) { +// Save replaces a changed domain payload and advances its storage generation. +func (t *CommandStore) Save(name string, payload []byte) error { if name == "" { - return 0, errors.New("accountstate: empty domain name") + return errors.New("accountstate: empty domain name") } t.mu.Lock() defer t.mu.Unlock() - if t.done { - return 0, ErrClosed + if err := t.available(); err != nil { + return err } if payload == nil { payload = []byte{} } - var generation int64 - err := t.tx.QueryRow(`INSERT INTO domain_state(name, payload, generation) + result, err := t.tx.Exec(`INSERT INTO domain_state(name, payload, generation) VALUES (?, ?, 1) ON CONFLICT(name) DO UPDATE SET payload = excluded.payload, generation = domain_state.generation + 1 - RETURNING generation`, name, payload).Scan(&generation) + WHERE domain_state.payload IS NOT excluded.payload`, name, payload) if err != nil { - return 0, fmt.Errorf("accountstate: save %q: %w", name, err) + t.reject(err) + return fmt.Errorf("accountstate: save %q: %w", name, err) } - t.dirty = true - return generation, nil + changed, err := result.RowsAffected() + if err != nil { + t.reject(err) + return fmt.Errorf("accountstate: count saved domain: %w", err) + } + if changed != 0 { + t.dirty = true + t.repository.observationVersion.Add(1) + } + return nil } // Commit makes every Save in the transaction visible at once. -func (t *Tx) Commit() error { +func (t *CommandStore) Commit() error { t.mu.Lock() defer t.mu.Unlock() if t.done { return ErrClosed } t.done = true + t.repository.observationVersion.Add(1) + if t.statementErr != nil { + rollbackErr := t.tx.Rollback() + if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + t.repository.fail(rollbackErr) + } + return errors.Join(t.statementErr, t.repository.Check()) + } if err := t.tx.Commit(); err != nil { t.repository.fail(err) - return fmt.Errorf("accountstate: commit transaction: %w", err) + return errors.Join(fmt.Errorf("accountstate: commit transaction: %w", err), t.repository.Check()) } return nil } // Rollback discards every Save in the transaction. It is safe to defer. -func (t *Tx) Rollback() error { +func (t *CommandStore) Rollback() error { t.mu.Lock() defer t.mu.Unlock() if t.done { return nil } t.done = true + t.repository.observationVersion.Add(1) if err := t.tx.Rollback(); err != nil { t.repository.fail(err) - return fmt.Errorf("accountstate: rollback transaction: %w", err) + return errors.Join(fmt.Errorf("accountstate: rollback transaction: %w", err), t.repository.Check()) } return nil } + +func (t *CommandStore) ObservationVersion() uint64 { return t.repository.ObservationVersion() } + +func (r *Repository) storageError(err error) { + var sqliteError *sqlite.Error + if !errors.As(err, &sqliteError) { + return + } + switch sqliteError.Code() & 0xff { + case sqlite3.SQLITE_IOERR, sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB, sqlite3.SQLITE_FULL, sqlite3.SQLITE_CANTOPEN, sqlite3.SQLITE_READONLY: + r.fail(err) + } +} + +func (t *CommandStore) available() error { + if t.done { + return ErrClosed + } + if t.statementErr != nil { + return t.statementErr + } + return t.repository.Check() +} +func (t *CommandStore) reject(err error) { + t.dirty = true + t.repository.storageError(err) + if t.statementErr == nil { + t.statementErr = errors.Join(ErrCommandFailed, err) + } +} diff --git a/go/internal/server/accountstate/schema.go b/go/internal/server/storage/account/schema.go similarity index 56% rename from go/internal/server/accountstate/schema.go rename to go/internal/server/storage/account/schema.go index 15115df..3fc9aff 100644 --- a/go/internal/server/accountstate/schema.go +++ b/go/internal/server/storage/account/schema.go @@ -9,28 +9,8 @@ import ( "strconv" ) -type migration struct { - from int - to int - up func(context.Context, *sql.Tx) error -} - -var schemaMigrations = []migration{ - {from: 1, to: 2, up: migrateV1ToV2}, - {from: 2, to: 3, up: migrateV2ToV3}, -} - -func migrateV2ToV3(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, `INSERT INTO metadata(key,value) VALUES('writer_epoch','0') ON CONFLICT(key) DO NOTHING`) - if err != nil { - return fmt.Errorf("add writer epoch: %w", err) - } - return nil -} - -// initialize creates schema v1 for a new database, applies every adjacent Go -// migration, validates the final schema and player state, and only then -// commits. A migration or validation error rolls the entire transaction back. +// initialize creates only the current schema. Unreleased older formats require +// an explicit offline repair; opening a database never migrates player data. func initialize(ctx context.Context, db *sql.DB, fresh bool) error { tx, err := db.BeginTx(ctx, nil) if err != nil { @@ -38,7 +18,7 @@ func initialize(ctx context.Context, db *sql.DB, fresh bool) error { } defer func() { _ = tx.Rollback() }() if fresh { - if err := createV1(ctx, tx); err != nil { + if err := createCurrent(ctx, tx); err != nil { return err } } @@ -46,35 +26,19 @@ func initialize(ctx context.Context, db *sql.DB, fresh bool) error { if err != nil { return err } - if version < 1 || version > schemaVersion { - return fmt.Errorf("accountstate: unsupported schema version %d (current %d)", version, schemaVersion) - } - if err := validateSchemaTables(tx, version); err != nil { - return err - } - startVersion := version - if err := runMigrations(ctx, tx, version, schemaVersion, schemaMigrations); err != nil { - return err + if version != schemaVersion { + return fmt.Errorf("accountstate: unsupported schema version %d (current %d); explicit repair required", version, schemaVersion) } if err := validateSchemaTables(tx, schemaVersion); err != nil { return err } - if startVersion < schemaVersion { - problems, err := validateState(tx) - if err != nil { - return fmt.Errorf("accountstate: validate migrated state: %w", err) - } - if len(problems) != 0 { - return validationError(problems) - } - } if err := tx.Commit(); err != nil { - return fmt.Errorf("accountstate: commit schema migration: %w", err) + return fmt.Errorf("accountstate: commit current schema: %w", err) } return nil } -func createV1(ctx context.Context, tx *sql.Tx) error { +func createCurrent(ctx context.Context, tx *sql.Tx) error { statements := []string{ `CREATE TABLE metadata ( key TEXT PRIMARY KEY NOT NULL, @@ -90,63 +54,19 @@ func createV1(ctx context.Context, tx *sql.Tx) error { bucket TEXT NOT NULL, entry_key TEXT NOT NULL, payload BLOB NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), PRIMARY KEY (domain_name, bucket, entry_key) ) WITHOUT ROWID`, - `INSERT INTO metadata(key, value) VALUES ('schema_version', '1')`, + `INSERT INTO metadata(key, value) VALUES ('schema_version', '3'), ('writer_epoch', '0')`, } for _, statement := range statements { if _, err := tx.ExecContext(ctx, statement); err != nil { - return fmt.Errorf("accountstate: create schema v1: %w", err) + return fmt.Errorf("accountstate: create current schema: %w", err) } } return nil } -func migrateV1ToV2(ctx context.Context, tx *sql.Tx) error { - columns, err := tableColumns(tx, "domain_entry") - if err != nil { - return err - } - if slices.ContainsFunc(columns, func(column schemaColumn) bool { return column.name == "generation" }) { - return nil - } - _, err = tx.ExecContext(ctx, `ALTER TABLE domain_entry - ADD COLUMN generation INTEGER NOT NULL DEFAULT 1 CHECK (generation > 0)`) - if err != nil { - return fmt.Errorf("add domain entry generation: %w", err) - } - return nil -} - -func runMigrations(ctx context.Context, tx *sql.Tx, from, target int, migrations []migration) error { - current := from - for current < target { - var step *migration - for i := range migrations { - if migrations[i].from == current { - if step != nil { - return fmt.Errorf("accountstate: duplicate migration from version %d", current) - } - step = &migrations[i] - } - } - if step == nil { - return fmt.Errorf("accountstate: missing migration %d->%d", current, current+1) - } - if step.to != current+1 { - return fmt.Errorf("accountstate: migration %d->%d is not adjacent", step.from, step.to) - } - if err := step.up(ctx, tx); err != nil { - return fmt.Errorf("accountstate: migrate %d->%d: %w", step.from, step.to, err) - } - if _, err := tx.ExecContext(ctx, `UPDATE metadata SET value = ? WHERE key = 'schema_version'`, strconv.Itoa(step.to)); err != nil { - return fmt.Errorf("accountstate: record schema version %d: %w", step.to, err) - } - current = step.to - } - return nil -} - func readSchemaVersion(ctx context.Context, tx *sql.Tx) (int, error) { var raw string err := tx.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&raw) @@ -194,9 +114,7 @@ func validateSchemaTables(tx *sql.Tx, version int) error { return fmt.Errorf("accountstate: schema v%d tables %v, want %v", version, names, want) } entryColumns := []schemaColumn{{"domain_name", "TEXT", 1, 1}, {"bucket", "TEXT", 1, 2}, {"entry_key", "TEXT", 1, 3}, {"payload", "BLOB", 1, 0}} - if version >= 2 { - entryColumns = append(entryColumns, schemaColumn{"generation", "INTEGER", 1, 0}) - } + entryColumns = append(entryColumns, schemaColumn{"generation", "INTEGER", 1, 0}) expected := map[string][]schemaColumn{ "metadata": {{"key", "TEXT", 1, 1}, {"value", "TEXT", 1, 0}}, "domain_state": {{"name", "TEXT", 1, 1}, {"payload", "BLOB", 1, 0}, {"generation", "INTEGER", 1, 0}}, diff --git a/go/internal/server/accountstate/server_policy.go b/go/internal/server/storage/account/server_policy.go similarity index 77% rename from go/internal/server/accountstate/server_policy.go rename to go/internal/server/storage/account/server_policy.go index 5df7b1d..d63d983 100644 --- a/go/internal/server/accountstate/server_policy.go +++ b/go/internal/server/storage/account/server_policy.go @@ -10,44 +10,39 @@ import ( const startingPackMetadataKey = "server_start_pack_id" // LockStartingPack checks the permanent server starting-pack policy inside the -// active startup operation. Only an empty database may initialize the policy; +// explicit startup command. Only an empty database may initialize the policy; // the operation's commit makes the first successful startup's choice durable. -func (r *Repository) LockStartingPack(configured int, initialize bool) (int, error) { +func (t *CommandStore) LockStartingPack(configured int, initialize bool) (int, error) { if configured != 1 && configured != 21 { return 0, fmt.Errorf("accountstate: invalid story.start_pack_id %d (want 1 or 21)", configured) } - if err := r.Check(); err != nil { - return 0, err - } - r.activeMu.RLock() - defer r.activeMu.RUnlock() - if r.active == nil { - return 0, errors.New("accountstate: starting pack policy requires an active startup operation") - } - t := r.active t.mu.Lock() defer t.mu.Unlock() - if t.done { - return 0, ErrClosed + if err := t.available(); err != nil { + return 0, err } var raw string err := t.tx.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw) if errors.Is(err, sql.ErrNoRows) { var populated bool if err := t.tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM domain_state) OR EXISTS(SELECT 1 FROM domain_entry)`).Scan(&populated); err != nil { + t.reject(err) return 0, fmt.Errorf("accountstate: inspect starting pack initialization: %w", err) } if !initialize || populated { return 0, errors.New("accountstate: server_start_pack_id is missing; existing state requires explicit repair") } if _, err := t.tx.Exec(`INSERT INTO metadata(key,value) VALUES(?,?)`, startingPackMetadataKey, strconv.Itoa(configured)); err != nil { + t.reject(err) return 0, fmt.Errorf("accountstate: initialize starting pack policy: %w", err) } - // Metadata changes do not mutate domain memory, so a rollback need not - // trigger the domain-memory recovery fence. + t.dirty = true + t.repository.observationVersion.Add(1) return configured, nil } if err != nil { + t.repository.storageError(err) + t.reject(err) return 0, fmt.Errorf("accountstate: read starting pack policy: %w", err) } locked, err := strconv.Atoi(raw) diff --git a/go/internal/server/accountstate/validate.go b/go/internal/server/storage/account/validate.go similarity index 95% rename from go/internal/server/accountstate/validate.go rename to go/internal/server/storage/account/validate.go index d886ba7..fc827ed 100644 --- a/go/internal/server/accountstate/validate.go +++ b/go/internal/server/storage/account/validate.go @@ -193,17 +193,9 @@ func parseIdentityIndex(identity string) uint64 { return index } -// Validate checks the repository snapshot. When called inside BeginOperation, -// it reads the same SQLite transaction as all startup writes and migrations. +// Validate checks a standalone snapshot outside command execution. func (r *Repository) Validate() ([]Problem, error) { - r.activeMu.RLock() - if r.active != nil { - problems, err := r.active.Validate() - r.activeMu.RUnlock() - return problems, err - } - r.activeMu.RUnlock() - tx, err := r.Begin(context.Background()) + tx, err := r.BeginCommand(context.Background()) if err != nil { return nil, err } @@ -211,13 +203,15 @@ func (r *Repository) Validate() ([]Problem, error) { return tx.Validate() } -func (t *Tx) Validate() ([]Problem, error) { +func (t *CommandStore) Validate() ([]Problem, error) { t.mu.Lock() defer t.mu.Unlock() - if t.done { - return nil, ErrClosed + if err := t.available(); err != nil { + return nil, err } - return validateState(t.tx) + problems, err := validateState(t.tx) + t.repository.storageError(err) + return problems, err } func validateState(tx *sql.Tx) ([]Problem, error) { @@ -472,11 +466,3 @@ func sortQuestKeys(keys []questKey) { return keys[i].quest < keys[j].quest }) } - -func validationError(problems []Problem) error { - parts := make([]string, 0, len(problems)) - for _, problem := range problems { - parts = append(parts, problem.Code+": "+problem.Message) - } - return fmt.Errorf("accountstate: state validation rejected: %s", strings.Join(parts, "; ")) -} diff --git a/go/internal/server/accountstate/writer_lock_unix.go b/go/internal/server/storage/account/writer_lock_unix.go similarity index 100% rename from go/internal/server/accountstate/writer_lock_unix.go rename to go/internal/server/storage/account/writer_lock_unix.go diff --git a/go/internal/server/accountstate/writer_lock_windows.go b/go/internal/server/storage/account/writer_lock_windows.go similarity index 100% rename from go/internal/server/accountstate/writer_lock_windows.go rename to go/internal/server/storage/account/writer_lock_windows.go diff --git a/go/internal/server/storage/identity/repository.go b/go/internal/server/storage/identity/repository.go new file mode 100644 index 0000000..77d687d --- /dev/null +++ b/go/internal/server/storage/identity/repository.go @@ -0,0 +1,386 @@ +package identitystore + +import ( + "bd2server/internal/server/domain/identity" + "context" + "crypto/subtle" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "time" +) + +type transaction struct { + store *Store + tx *sql.Tx +} + +func (s *Store) Now() time.Time { return s.now() } +func (s *Store) Begin() (identity.Transaction, error) { + tx, e := s.db.Begin() + if e != nil { + return nil, e + } + return &transaction{store: s, tx: tx}, nil +} +func (t *transaction) Commit() error { return t.tx.Commit() } +func (t *transaction) Rollback() error { return t.tx.Rollback() } +func changed(result sql.Result, err error) (bool, error) { + if err != nil { + return false, err + } + if result == nil { + return false, errors.New("auth: missing SQL result") + } + n, e := result.RowsAffected() + if e != nil { + return false, fmt.Errorf("auth: count affected rows: %w", e) + } + return n == 1, nil +} +func (s *Store) StartRecord(id, ticket string) (identity.StartRecord, error) { + var r identity.StartRecord + var hash []byte + e := s.db.QueryRow(`SELECT start_hash,provider,status,expires_at FROM devices WHERE id=?`, id).Scan(&hash, &r.Provider, &r.Status, &r.Expires) + r.Matches = subtle.ConstantTimeCompare(hash, s.digest("start-ticket", ticket)) == 1 + return r, e +} +func (s *Store) StartAuthorization(id, state, verifier, nonce string) (bool, error) { + v, e := s.seal(id, "pkce", []byte(verifier)) + if e != nil { + return false, e + } + n, e := s.seal(id, "nonce", []byte(nonce)) + if e != nil { + return false, e + } + return changed(s.db.Exec(`UPDATE devices SET state_hash=?,verifier_cipher=?,nonce_cipher=?,start_hash=X'',status='authorizing' WHERE id=? AND status='created'`, s.digest("oauth-state", state), v, n, id)) +} +func (s *Store) CancelAuthorization(provider, state string, now int64) (bool, error) { + return changed(s.db.Exec(`UPDATE devices SET status='failed',error_code='provider_cancelled',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE state_hash=? AND provider=? AND status='authorizing' AND expires_at>?`, s.digest("oauth-state", state), provider, now)) +} +func (s *Store) AuthorizationRecord(state string) (identity.AuthorizationRecord, error) { + var r identity.AuthorizationRecord + e := s.db.QueryRow(`SELECT id,provider,status,verifier_cipher,nonce_cipher,expires_at FROM devices WHERE state_hash=?`, s.digest("oauth-state", state)).Scan(&r.ID, &r.Provider, &r.Status, &r.VerifierCipher, &r.NonceCipher, &r.Expires) + return r, e +} +func (s *Store) AuthorizationSecrets(r identity.AuthorizationRecord) ([]byte, []byte, error) { + v, e := s.open(r.ID, "pkce", r.VerifierCipher) + if e != nil { + return nil, nil, e + } + n, e := s.open(r.ID, "nonce", r.NonceCipher) + if e != nil { + clear(v) + return nil, nil, e + } + return v, n, nil +} +func (s *Store) FailAuthorization(id string) error { + _, e := s.db.Exec(`UPDATE devices SET status='failed',error_code='provider_rejected',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND status='authorizing'`, id) + return e +} +func (t *transaction) CleanupExpired(now int64) error { + statements := []struct { + query string + args []any + }{ + {`DELETE FROM devices WHERE expires_at<=?`, []any{now}}, + {`DELETE FROM access_tokens WHERE expires_at<=? OR family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now, now}}, + {`DELETE FROM refresh_attempts WHERE expires_at<=? OR family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now, now}}, + {`DELETE FROM refresh_tokens WHERE family_id IN (SELECT id FROM families WHERE expires_at<=?)`, []any{now}}, + {`DELETE FROM families WHERE expires_at<=?`, []any{now}}, + } + for _, q := range statements { + if _, e := t.tx.Exec(q.query, q.args...); e != nil { + return e + } + } + return nil +} +func (t *transaction) PendingDevices(clientIP string, now int64) (int, error) { + var n int + e := t.tx.QueryRow(`SELECT COUNT(*) FROM devices WHERE client_hash=? AND status IN ('created','authorizing') AND expires_at>?`, t.store.digest("client-ip", clientIP), now).Scan(&n) + return n, e +} +func (t *transaction) InsertDevice(r identity.DeviceInsert) error { + _, e := t.tx.Exec(`INSERT INTO devices(id,client_hash,secret_hash,start_hash,provider,status,created_at,expires_at) VALUES(?,?,?,?,?,'created',?,?)`, r.ID, t.store.digest("client-ip", r.ClientIP), t.store.digest("device-secret", r.Secret), t.store.digest("start-ticket", r.StartTicket), r.Provider, r.Created, r.Expires) + return e +} +func (t *transaction) Identity(id identity.ProviderIdentity) (identity.AccountRecord, bool, error) { + var r identity.AccountRecord + e := t.tx.QueryRow(`SELECT i.account_id,a.status FROM identities i JOIN accounts a ON a.id=i.account_id WHERE i.issuer=? AND i.subject_hash=?`, id.Issuer, t.store.identityDigest(id.Issuer, id.Subject)).Scan(&r.ID, &r.Status) + if errors.Is(e, sql.ErrNoRows) { + return r, false, nil + } + return r, e == nil, e +} +func (t *transaction) AccountCount() (int, error) { + var n int + e := t.tx.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&n) + return n, e +} +func (t *transaction) RejectDevice(id, provider, code string) (bool, error) { + return changed(t.tx.Exec(`UPDATE devices SET status='failed',error_code=?,state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, code, id, provider)) +} +func (t *transaction) InsertAccount(accountID, provider string, id identity.ProviderIdentity, now int64) error { + if _, e := t.tx.Exec(`INSERT INTO accounts(id,status,created_at,last_login_at) VALUES(?,'active',?,?)`, accountID, now, now); e != nil { + return e + } + if _, err := allocateGameProfile(t.tx, accountID); err != nil { + return err + } + _, e := t.tx.Exec(`INSERT INTO identities(provider,issuer,subject_hash,account_id,created_at,last_login_at) VALUES(?,?,?,?,?,?)`, provider, id.Issuer, t.store.identityDigest(id.Issuer, id.Subject), accountID, now, now) + return e +} +func (t *transaction) TouchIdentity(id identity.ProviderIdentity, now int64) error { + _, e := t.tx.Exec(`UPDATE identities SET last_login_at=? WHERE issuer=? AND subject_hash=?`, now, id.Issuer, t.store.identityDigest(id.Issuer, id.Subject)) + return e +} +func (t *transaction) InsertFamily(r identity.FamilyInsert) error { + _, e := t.tx.Exec(`INSERT INTO families(id,account_id,provider,created_at,expires_at) VALUES(?,?,?,?,?)`, r.ID, r.AccountID, r.Provider, r.Created, r.Expires) + return e +} + +type tokenPayload struct { + Provider string `json:"provider"` + AccessToken string `json:"access_token"` + AccessExpiresIn int64 `json:"access_expires_in"` + RefreshToken string `json:"refresh_token"` + RefreshExpiresIn int64 `json:"refresh_expires_in"` +} + +func tokenPayloadOf(r identity.TokenSet) tokenPayload { + return tokenPayload{r.Provider, r.AccessToken, r.AccessExpiresIn, r.RefreshToken, r.RefreshExpiresIn} +} +func (r tokenPayload) tokens() identity.TokenSet { + return identity.TokenSet{Provider: r.Provider, AccessToken: r.AccessToken, AccessExpiresIn: r.AccessExpiresIn, RefreshToken: r.RefreshToken, RefreshExpiresIn: r.RefreshExpiresIn} +} +func (t *transaction) CompleteDevice(id, provider string, result identity.TokenSet) (bool, error) { + plain, e := json.Marshal(tokenPayloadOf(result)) + if e != nil { + return false, e + } + sealed, e := t.store.seal(id, "result", plain) + clear(plain) + if e != nil { + return false, e + } + return changed(t.tx.Exec(`UPDATE devices SET result_cipher=?,status='complete',state_hash=NULL,verifier_cipher=NULL,nonce_cipher=NULL WHERE id=? AND provider=? AND status='authorizing'`, sealed, id, provider)) +} +func (t *transaction) PollDevice(id, secret string) (identity.DeviceRecord, error) { + var r identity.DeviceRecord + var hash []byte + e := t.tx.QueryRow(`SELECT secret_hash,status,COALESCE(result_cipher,X''),COALESCE(error_code,''),expires_at FROM devices WHERE id=?`, id).Scan(&hash, &r.Status, &r.ResultCipher, &r.ErrorCode, &r.Expires) + r.Matches = subtle.ConstantTimeCompare(hash, t.store.digest("device-secret", secret)) == 1 + return r, e +} +func (s *Store) DecodeDeviceResult(id string, cipher []byte) (identity.TokenSet, error) { + plain, e := s.open(id, "result", cipher) + if e != nil { + return identity.TokenSet{}, e + } + defer clear(plain) + var r tokenPayload + e = json.Unmarshal(plain, &r) + return r.tokens(), e +} +func (t *transaction) ConsumeDevice(id string) (bool, error) { + return changed(t.tx.Exec(`UPDATE devices SET result_cipher=NULL,status='consumed' WHERE id=? AND status='complete'`, id)) +} +func (t *transaction) RefreshToken(token string) (identity.RefreshRecord, error) { + var r identity.RefreshRecord + var used, revoked sql.NullInt64 + e := t.tx.QueryRow(`SELECT r.family_id,f.account_id,f.provider,a.status,r.expires_at,f.expires_at,r.used_at,COALESCE(r.revoked_at,f.revoked_at) FROM refresh_tokens r JOIN families f ON f.id=r.family_id JOIN accounts a ON a.id=f.account_id WHERE r.token_hash=?`, t.store.digest("refresh-token", token)).Scan(&r.FamilyID, &r.AccountID, &r.Provider, &r.AccountStatus, &r.TokenExpires, &r.FamilyExpires, &used, &revoked) + r.Used = used.Valid + r.Revoked = revoked.Valid + return r, e +} + +type refreshPayload struct { + Provider string `json:"provider"` + AccessToken string `json:"access_token"` + AccessExpiresAt int64 `json:"access_expires_at"` + RefreshToken string `json:"refresh_token"` + RefreshExpiresAt int64 `json:"refresh_expires_at"` +} + +func refreshSealID(family string, hash []byte) string { + return family + ":" + base64.RawURLEncoding.EncodeToString(hash) +} +func (t *transaction) RefreshAttempt(familyID, attemptID, requestToken string) (identity.RefreshAttempt, bool, error) { + var r identity.RefreshAttempt + var requestHash, cipher []byte + hash := t.store.digest("refresh-attempt", attemptID) + e := t.tx.QueryRow(`SELECT request_token_hash,result_cipher FROM refresh_attempts WHERE family_id=? AND attempt_hash=?`, familyID, hash).Scan(&requestHash, &cipher) + if errors.Is(e, sql.ErrNoRows) { + return r, false, nil + } + if e != nil { + return r, false, e + } + r.Matches = subtle.ConstantTimeCompare(requestHash, t.store.digest("refresh-token", requestToken)) == 1 + if !r.Matches { + return r, true, nil + } + plain, e := t.store.open(refreshSealID(familyID, hash), "result", cipher) + if e != nil { + return r, true, e + } + defer clear(plain) + var p refreshPayload + e = json.Unmarshal(plain, &p) + r.Result = identity.StoredRefreshResult{Provider: p.Provider, AccessToken: p.AccessToken, AccessExpiresAt: p.AccessExpiresAt, RefreshToken: p.RefreshToken, RefreshExpiresAt: p.RefreshExpiresAt} + return r, true, e +} +func (t *transaction) ConsumeRefreshToken(token string, now int64) (bool, error) { + return changed(t.tx.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=? AND used_at IS NULL AND revoked_at IS NULL`, now, t.store.digest("refresh-token", token))) +} +func (t *transaction) RevokeFamily(id string, now int64) (bool, error) { + return changed(t.tx.Exec(`UPDATE families SET revoked_at=? WHERE id=? AND revoked_at IS NULL`, now, id)) +} +func (t *transaction) DeleteFamilyAccess(id string) error { + _, e := t.tx.Exec(`DELETE FROM access_tokens WHERE family_id=?`, id) + return e +} +func (t *transaction) InsertAccess(token, familyID, accountID string, created, expires int64) error { + _, e := t.tx.Exec(`INSERT INTO access_tokens(token_hash,family_id,account_id,created_at,expires_at) VALUES(?,?,?,?,?)`, t.store.digest("access-token", token), familyID, accountID, created, expires) + return e +} +func (t *transaction) InsertRefresh(token, familyID string, created, expires int64) error { + _, e := t.tx.Exec(`INSERT INTO refresh_tokens(token_hash,family_id,created_at,expires_at) VALUES(?,?,?,?)`, t.store.digest("refresh-token", token), familyID, created, expires) + return e +} +func (t *transaction) InsertRefreshAttempt(familyID, attemptID, requestToken string, r identity.StoredRefreshResult, created, expires int64) error { + hash := t.store.digest("refresh-attempt", attemptID) + p := refreshPayload{r.Provider, r.AccessToken, r.AccessExpiresAt, r.RefreshToken, r.RefreshExpiresAt} + plain, e := json.Marshal(p) + if e != nil { + return e + } + cipher, e := t.store.seal(refreshSealID(familyID, hash), "result", plain) + clear(plain) + if e != nil { + return e + } + _, e = t.tx.Exec(`INSERT INTO refresh_attempts(family_id,attempt_hash,request_token_hash,result_cipher,created_at,expires_at) VALUES(?,?,?,?,?,?)`, familyID, hash, t.store.digest("refresh-token", requestToken), cipher, created, expires) + return e +} + +type rowScanner interface{ Scan(...any) error } + +func accessRecord(row rowScanner) (identity.AccessRecord, error) { + var r identity.AccessRecord + var revoked sql.NullInt64 + e := row.Scan(&r.FamilyID, &r.AccountID, &r.AccountStatus, &r.Expires, &revoked) + r.Revoked = revoked.Valid + return r, e +} + +const accessQuery = `SELECT t.family_id,t.account_id,a.status,t.expires_at,COALESCE(t.revoked_at,f.revoked_at) FROM access_tokens t JOIN families f ON f.id=t.family_id JOIN accounts a ON a.id=t.account_id WHERE t.token_hash=?` + +func (s *Store) AccessToken(token string) (identity.AccessRecord, error) { + return accessRecord(s.db.QueryRow(accessQuery, s.digest("access-token", token))) +} +func (t *transaction) AccessToken(token string) (identity.AccessRecord, error) { + return accessRecord(t.tx.QueryRow(accessQuery, t.store.digest("access-token", token))) +} + +func (s *Store) OwnerAccountID(ctx context.Context) (string, error) { + var id string + err := s.db.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key='administrator_account_id'`).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err +} +func (s *Store) AdministratorAccountID() (string, error) { + return s.OwnerAccountID(context.Background()) +} +func (t *transaction) SetAdministratorAccount(accountID string) error { + _, err := t.tx.Exec(`INSERT INTO metadata(key,value) VALUES('administrator_account_id',?)`, accountID) + return err +} + +type GameProfile struct { + AccountID string + OwnerIndex int64 + UserID string +} + +func allocateGameProfile(tx *sql.Tx, accountID string) (GameProfile, error) { + var maximum int64 + if err := tx.QueryRow(`SELECT COALESCE(MAX(owner_index),0) FROM game_profiles`).Scan(&maximum); err != nil { + return GameProfile{}, err + } + if maximum == math.MaxInt64 { + return GameProfile{}, errors.New("auth: public game identity space exhausted") + } + index := maximum + 1 + userID := strconv.FormatInt(index, 10) + // The imported owner's display identity may be decimal without matching its + // numeric index. Reserved user IDs remain unavailable to subsequent players. + for { + var reserved int + if err := tx.QueryRow(`SELECT COUNT(*) FROM game_profiles WHERE user_id=?`, userID).Scan(&reserved); err != nil { + return GameProfile{}, err + } + if reserved == 0 { + break + } + if index == math.MaxInt64 { + return GameProfile{}, errors.New("auth: public game identity space exhausted") + } + index++ + userID = strconv.FormatInt(index, 10) + } + if _, err := tx.Exec(`INSERT INTO game_profiles(account_id,owner_index,user_id) VALUES(?,?,?)`, accountID, index, userID); err != nil { + return GameProfile{}, err + } + return GameProfile{AccountID: accountID, OwnerIndex: index, UserID: userID}, nil +} +func (s *Store) GameIdentity(ctx context.Context, accountID string) (GameProfile, error) { + if accountID == "" { + return GameProfile{}, errors.New("auth: public game identity requires account") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return GameProfile{}, err + } + defer func() { _ = tx.Rollback() }() + var profile GameProfile + profile.AccountID = accountID + err = tx.QueryRowContext(ctx, `SELECT owner_index,user_id FROM game_profiles WHERE account_id=?`, accountID).Scan(&profile.OwnerIndex, &profile.UserID) + if errors.Is(err, sql.ErrNoRows) { + var owner string + ownerErr := tx.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key='administrator_account_id'`).Scan(&owner) + if ownerErr != nil { + return GameProfile{}, ownerErr + } + if accountID == owner { + return GameProfile{}, errors.New("auth: original owner's public game profile missing; offline repair required") + } + var active string + if err = tx.QueryRowContext(ctx, `SELECT status FROM accounts WHERE id=?`, accountID).Scan(&active); err != nil { + return GameProfile{}, err + } + if active != "active" { + return GameProfile{}, errors.New("auth: public game identity account inactive") + } + profile, err = allocateGameProfile(tx, accountID) + } + if err != nil { + return GameProfile{}, err + } + if profile.OwnerIndex <= 0 || profile.UserID == "" { + return GameProfile{}, errors.New("auth: public game identity invalid") + } + if err = tx.Commit(); err != nil { + return GameProfile{}, err + } + return profile, nil +} diff --git a/go/internal/server/auth/store.go b/go/internal/server/storage/identity/store.go similarity index 76% rename from go/internal/server/auth/store.go rename to go/internal/server/storage/identity/store.go index 0783dbc..ef89088 100644 --- a/go/internal/server/auth/store.go +++ b/go/internal/server/storage/identity/store.go @@ -1,4 +1,4 @@ -package auth +package identitystore import ( "context" @@ -8,7 +8,6 @@ import ( "crypto/rand" "crypto/sha256" "database/sql" - "encoding/base64" "errors" "fmt" "os" @@ -18,15 +17,7 @@ import ( _ "modernc.org/sqlite" ) -const schemaVersion = 2 - -var ( - ErrUnauthorized = errors.New("auth: unauthorized") - ErrExpired = errors.New("auth: expired") - ErrPending = errors.New("auth: pending") - ErrConsumed = errors.New("auth: consumed") - ErrNotAllowed = errors.New("auth: identity is not allowed on this single-owner server") -) +const schemaVersion = 3 type Store struct { db *sql.DB @@ -68,12 +59,14 @@ func Open(path string, masterKey []byte) (*Store, error) { statements := []string{ `CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL) WITHOUT ROWID`, `CREATE TABLE IF NOT EXISTS accounts (id TEXT PRIMARY KEY NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL) WITHOUT ROWID`, + `CREATE TABLE IF NOT EXISTS game_profiles (account_id TEXT PRIMARY KEY NOT NULL REFERENCES accounts(id),owner_index INTEGER NOT NULL UNIQUE CHECK(owner_index>0),user_id TEXT NOT NULL UNIQUE CHECK(length(user_id)>0)) WITHOUT ROWID`, `CREATE TABLE IF NOT EXISTS identities (provider TEXT NOT NULL, issuer TEXT NOT NULL, subject_hash BLOB NOT NULL, account_id TEXT NOT NULL REFERENCES accounts(id), created_at INTEGER NOT NULL, last_login_at INTEGER NOT NULL, PRIMARY KEY(issuer,subject_hash)) WITHOUT ROWID`, `CREATE TABLE IF NOT EXISTS devices (id TEXT PRIMARY KEY NOT NULL, client_hash BLOB NOT NULL, secret_hash BLOB NOT NULL, start_hash BLOB NOT NULL, provider TEXT NOT NULL, state_hash BLOB, verifier_cipher BLOB, nonce_cipher BLOB, result_cipher BLOB, status TEXT NOT NULL, error_code TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL) WITHOUT ROWID`, `CREATE INDEX IF NOT EXISTS devices_client_pending ON devices(client_hash,status,expires_at)`, `CREATE UNIQUE INDEX IF NOT EXISTS devices_state ON devices(state_hash) WHERE state_hash IS NOT NULL`, `CREATE TABLE IF NOT EXISTS families (id TEXT PRIMARY KEY NOT NULL, account_id TEXT NOT NULL REFERENCES accounts(id), provider TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER) WITHOUT ROWID`, `CREATE TABLE IF NOT EXISTS refresh_tokens (token_hash BLOB PRIMARY KEY NOT NULL, family_id TEXT NOT NULL REFERENCES families(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER, revoked_at INTEGER) WITHOUT ROWID`, + `CREATE TABLE IF NOT EXISTS refresh_attempts (family_id TEXT NOT NULL REFERENCES families(id),attempt_hash BLOB NOT NULL,request_token_hash BLOB NOT NULL,result_cipher BLOB NOT NULL,created_at INTEGER NOT NULL,expires_at INTEGER NOT NULL,PRIMARY KEY(family_id,attempt_hash)) WITHOUT ROWID`, `CREATE TABLE IF NOT EXISTS access_tokens (token_hash BLOB PRIMARY KEY NOT NULL, family_id TEXT NOT NULL REFERENCES families(id), account_id TEXT NOT NULL REFERENCES accounts(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER) WITHOUT ROWID`, } for _, statement := range statements { @@ -84,36 +77,38 @@ func Open(path string, masterKey []byte) (*Store, error) { var version int err = tx.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version) if errors.Is(err, sql.ErrNoRows) { - if _, err = tx.Exec(`INSERT INTO metadata(key,value) VALUES('schema_version',1)`); err != nil { + var count int + if err = tx.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&count); err != nil { return nil, err } - version = 1 + if count != 0 { + return nil, errors.New("auth: existing accounts require offline identity metadata repair") + } + if _, err = tx.Exec(`INSERT INTO metadata(key,value) VALUES('schema_version',?)`, schemaVersion); err != nil { + return nil, err + } + version = schemaVersion } else if err != nil { return nil, err } - if version < 1 || version > schemaVersion { - return nil, fmt.Errorf("auth: schema version %d, want %d", version, schemaVersion) + if version != schemaVersion { + return nil, fmt.Errorf("auth: schema version %d, want %d; offline repair required", version, schemaVersion) } - for version < schemaVersion { - switch version { - case 1: - if _, err = tx.Exec(`CREATE TABLE refresh_attempts ( - family_id TEXT NOT NULL REFERENCES families(id), - attempt_hash BLOB NOT NULL, - request_token_hash BLOB NOT NULL, - result_cipher BLOB NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - PRIMARY KEY(family_id,attempt_hash) - ) WITHOUT ROWID`); err != nil { - return nil, fmt.Errorf("auth: migrate schema 1->2: %w", err) - } - version = 2 - default: - return nil, fmt.Errorf("auth: missing adjacent migration %d->%d", version, version+1) + var count int + if err = tx.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&count); err != nil { + return nil, err + } + if count > 0 { + var owner string + if err = tx.QueryRow(`SELECT value FROM metadata WHERE key='administrator_account_id'`).Scan(&owner); err != nil || owner == "" { + return nil, errors.New("auth: administrator identity metadata missing; offline repair required") } - if _, err = tx.Exec(`UPDATE metadata SET value=? WHERE key='schema_version'`, version); err != nil { - return nil, fmt.Errorf("auth: record schema version %d: %w", version, err) + var exists int + if err = tx.QueryRow(`SELECT COUNT(*) FROM accounts WHERE id=?`, owner).Scan(&exists); err != nil { + return nil, err + } + if exists != 1 { + return nil, errors.New("auth: administrator identity metadata invalid") } } if err = tx.Commit(); err != nil { @@ -162,14 +157,6 @@ func (s *Store) identityDigest(issuer, subject string) []byte { return mac.Sum(nil) } -func randomToken(bytes int) (string, error) { - value := make([]byte, bytes) - if _, err := rand.Read(value); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(value), nil -} - func (s *Store) seal(id, field string, plain []byte) ([]byte, error) { nonce := make([]byte, s.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { diff --git a/go/internal/server/stateio/entry_snapshot.go b/go/internal/server/storage/stateio/entry_snapshot.go similarity index 51% rename from go/internal/server/stateio/entry_snapshot.go rename to go/internal/server/storage/stateio/entry_snapshot.go index ff978be..5fd6196 100644 --- a/go/internal/server/stateio/entry_snapshot.go +++ b/go/internal/server/storage/stateio/entry_snapshot.go @@ -4,14 +4,13 @@ package stateio // account domain. It participates in the parent's SQLite request transaction // without adding cores to the nine-domain account initialization contract. type EntrySnapshotStore struct { - Entries EntryStore Domain, Bucket string } -func (s EntrySnapshotStore) Load(name string) ([]byte, error) { - payload, _, err := s.Entries.LoadEntry(s.Domain, s.Bucket, name) +func (s EntrySnapshotStore) Load(command AtomicEntryStore, name string) ([]byte, error) { + payload, _, err := command.LoadEntry(s.Domain, s.Bucket, name) return payload, err } -func (s EntrySnapshotStore) Save(name string, payload []byte) error { - return s.Entries.PutEntry(s.Domain, s.Bucket, name, payload) +func (s EntrySnapshotStore) Save(command AtomicEntryStore, name string, payload []byte) error { + return command.PutEntry(s.Domain, s.Bucket, name, payload) } diff --git a/go/internal/server/stateio/json.go b/go/internal/server/storage/stateio/json.go similarity index 100% rename from go/internal/server/stateio/json.go rename to go/internal/server/storage/stateio/json.go diff --git a/go/internal/server/stateio/stateio.go b/go/internal/server/storage/stateio/stateio.go similarity index 61% rename from go/internal/server/stateio/stateio.go rename to go/internal/server/storage/stateio/stateio.go index 376a702..129166d 100644 --- a/go/internal/server/stateio/stateio.go +++ b/go/internal/server/storage/stateio/stateio.go @@ -8,11 +8,11 @@ import ( ) var ErrWriterFenced = errors.New("state writer fenced by a newer server instance") -var ErrStateRecoveryRequired = errors.New("state transaction outcome requires process restart") +var ErrStateRecoveryRequired = errors.New("state transaction outcome requires database recovery") -func RequireNoEntries(store EntryStore, domain string, buckets ...string) error { +func RequireNoEntries(store ScopedEntryStore, command AtomicEntryStore, domain string, buckets ...string) error { for _, bucket := range buckets { - entries, err := store.ListEntries(domain, bucket) + entries, err := store.ListEntries(command, domain, bucket) if err != nil { return err } @@ -25,13 +25,51 @@ func RequireNoEntries(store EntryStore, domain string, buckets ...string) error // Store loads and saves opaque domain payloads. A missing name returns nil, // nil; Save must install a complete payload before returning successfully. -type Store interface { +type SnapshotStore interface { Load(name string) ([]byte, error) Save(name string, payload []byte) error } -// EntryStore holds independently updated values owned by a domain. Calls made -// within a request operation participate in the same transaction as Store. +// Store is an immutable storage scope. The command capability is explicit on +// every call; a scope retains neither a repository nor a transaction. +type Store interface { + Load(command AtomicEntryStore, name string) ([]byte, error) + Save(command AtomicEntryStore, name string, payload []byte) error +} + +type ScopedEntryStore interface { + Store + LoadEntry(command AtomicEntryStore, domain, bucket, key string) ([]byte, bool, error) + ListEntries(command AtomicEntryStore, domain, bucket string) (map[string][]byte, error) + PutEntry(command AtomicEntryStore, domain, bucket, key string, payload []byte) error + DeleteEntry(command AtomicEntryStore, domain, bucket, key string) (bool, error) + SaveWithEntries(command AtomicEntryStore, domain string, core []byte, changes []EntryMutation) error +} + +type RootStore struct{} + +func (RootStore) Load(tx AtomicEntryStore, name string) ([]byte, error) { return tx.Load(name) } +func (RootStore) Save(tx AtomicEntryStore, name string, payload []byte) error { + return tx.Save(name, payload) +} +func (RootStore) LoadEntry(tx AtomicEntryStore, domain, bucket, key string) ([]byte, bool, error) { + return tx.LoadEntry(domain, bucket, key) +} +func (RootStore) ListEntries(tx AtomicEntryStore, domain, bucket string) (map[string][]byte, error) { + return tx.ListEntries(domain, bucket) +} +func (RootStore) PutEntry(tx AtomicEntryStore, domain, bucket, key string, payload []byte) error { + return tx.PutEntry(domain, bucket, key, payload) +} +func (RootStore) DeleteEntry(tx AtomicEntryStore, domain, bucket, key string) (bool, error) { + return tx.DeleteEntry(domain, bucket, key) +} +func (RootStore) SaveWithEntries(tx AtomicEntryStore, domain string, core []byte, changes []EntryMutation) error { + return tx.SaveWithEntries(domain, core, changes) +} + +// EntryStore holds independently updated values owned by a domain. Its IO is +// bound to the same explicit command capability as Store. type EntryStore interface { LoadEntry(domain, bucket, key string) ([]byte, bool, error) ListEntries(domain, bucket string) (map[string][]byte, error) @@ -49,21 +87,20 @@ type EntryMutation struct { // AtomicEntryStore applies a domain core and its entry changes together. // A nil core leaves the existing core unchanged. type AtomicEntryStore interface { - Store + SnapshotStore EntryStore SaveWithEntries(domain string, core []byte, changes []EntryMutation) error } -type RequestOperation interface { +// CommandTransaction is a private, single-command storage capability. Domains +// keep the capability supplied at construction; its owner finishes it exactly +// once and discards the candidate graph on rollback. It must not be handed to +// another actor or goroutine. +type CommandTransaction interface { + AtomicEntryStore Commit() error Rollback() error -} - -type TransactionalStore interface { - Store - Check() error - BeginOperation() (RequestOperation, error) - Close() error + Dirty() bool } // Memory is a detached in-memory Store, useful for tests and ephemeral state. diff --git a/go/internal/server/todayquest/todayquest.go b/go/internal/server/todayquest/todayquest.go deleted file mode 100644 index 64cb4dc..0000000 --- a/go/internal/server/todayquest/todayquest.go +++ /dev/null @@ -1,469 +0,0 @@ -// Package todayquest implements the NPC commission board from TodayQuestTable. -package todayquest - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "crypto/sha256" - "encoding/binary" - "encoding/json" - "fmt" - "sort" - "sync" - "time" -) - -type Economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -} -type Inventory interface { - GrantOnce(string, []gamedata.BattleReward) ([]player.Item, error) - GrantedItems(string) []player.Item -} -type Active struct { - ID int `json:"id"` - Value int `json:"value"` - Objects []uint64 `json:"objects"` -} -type snapshot struct { - Period string `json:"period"` - Offered []int `json:"offered"` - Active map[int]Active `json:"active"` - Cleared map[int]bool `json:"cleared"` - Responses map[int][]byte `json:"responses"` - ScoreAwards map[string]uint64 `json:"score_awards"` -} -type Service struct { - mu sync.Mutex - store stateio.Store - design *gamedata.TodayQuestCatalog - economy Economy - inventory Inventory - now func() time.Time - unlocked func(int) bool - CompleteReputation func(string, int, uint64) ([]byte, error) - CompleteAchievement func(string) error -} - -// Request dispatch owns the account transaction; economy/inventory writes and -// this snapshot are committed together by session.BeginOperation. -func Open(store stateio.Store, design *gamedata.TodayQuestCatalog, economy Economy, inventory Inventory, unlocked func(int) bool) (*Service, error) { - if store == nil || design == nil || len(design.Quests) == 0 || design.Limit <= 0 || design.PostCount <= 0 || economy == nil || inventory == nil || unlocked == nil { - return nil, fmt.Errorf("todayquest: invalid configuration") - } - return &Service{store: store, design: design, economy: economy, inventory: inventory, unlocked: unlocked, now: time.Now}, nil -} -func (s *Service) load() (snapshot, error) { - p, e := s.design.Reset.Period(3, s.now()) - if e != nil { - return snapshot{}, e - } - raw, e := s.store.Load("today_quests") - if e != nil { - return snapshot{}, e - } - st := snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: map[string]uint64{}} - if raw != nil { - if e = stateio.RequireExactJSONObject(raw, "period", "offered", "active", "cleared", "responses", "score_awards"); e != nil { - return st, e - } - if e = json.Unmarshal(raw, &st); e != nil { - return st, e - } - if st.Period == "" || st.Active == nil || st.Cleared == nil || st.Responses == nil || st.ScoreAwards == nil { - return st, fmt.Errorf("todayquest: invalid saved state") - } - } - if st.Period != p { - st = snapshot{Period: p, Active: map[int]Active{}, Cleared: map[int]bool{}, Responses: map[int][]byte{}, ScoreAwards: st.ScoreAwards} - } - for id, a := range st.Active { - q, ok := s.design.Quests[id] - if !ok || a.ID != id || st.Cleared[id] || a.Value < 0 || a.Value > q.ConditionCount || len(a.Objects) > q.ConditionCount { - return st, fmt.Errorf("todayquest: invalid active progress") - } - seen := map[uint64]bool{} - for _, object := range a.Objects { - allowed := false - for _, v := range q.MagicValues { - if object == v { - allowed = true - } - } - if !allowed || seen[object] { - return st, fmt.Errorf("todayquest: invalid saved object") - } - seen[object] = true - } - if q.PriorID != 0 && !st.Cleared[q.PriorID] { - return st, fmt.Errorf("todayquest: missing prior clear") - } - } - for id, cleared := range st.Cleared { - if _, ok := s.design.Quests[id]; !ok || !cleared { - return st, fmt.Errorf("todayquest: invalid cleared node") - } - } - for id, b := range st.Responses { - if _, ok := s.design.Quests[id]; !ok || len(b) == 0 { - return st, fmt.Errorf("todayquest: invalid clear receipt") - } - } - // Deterministic sampling is a local server policy. Persisted offers are stable - // across restarts, with PostCount independent roots for every unlocked pack. - byPack := map[int][]int{} - for id, q := range s.design.Quests { - if q.PriorID == 0 && s.unlocked(q.PackID) { - byPack[q.PackID] = append(byPack[q.PackID], id) - } - } - existing := map[int]bool{} - for _, id := range st.Offered { - q, ok := s.design.Quests[id] - if !ok || q.PriorID != 0 { - return st, fmt.Errorf("todayquest: invalid offered root") - } - existing[q.PackID] = true - } - packs := []int{} - for p := range byPack { - packs = append(packs, p) - } - sort.Ints(packs) - for _, pack := range packs { - if existing[pack] { - continue - } - ids := byPack[pack] - sort.Slice(ids, func(i, j int) bool { - a := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[i]))) - b := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", p, ids[j]))) - return string(a[:]) < string(b[:]) - }) - n := min(s.design.PostCount, len(ids)) - st.Offered = append(st.Offered, ids[:n]...) - } - sort.Ints(st.Offered) - return st, nil -} -func (s *Service) save(st snapshot) error { - b, e := json.Marshal(st) - if e != nil { - return e - } - return s.store.Save("today_quests", b) -} -func (s *Service) identity(st snapshot, id int, part string) string { - return fmt.Sprintf("todayquest:%s:%d:%s", st.Period, id, part) -} -func (s *Service) root(id int) int { - for s.design.Quests[id].PriorID != 0 { - id = s.design.Quests[id].PriorID - } - return id -} -func (s *Service) questWire(a Active) []byte { - q := s.design.Quests[a.ID] - b := wire.AppendVarint(nil, 1, uint64(a.ID)) - b = wire.AppendVarint(b, 2, uint64(a.Value)) - for _, id := range a.Objects { - b = wire.AppendVarint(b, 3, id) - } - return wire.AppendVarint(b, 6, uint64(q.PackID)) -} -func (s *Service) give(st snapshot, id int) ([]player.Item, error) { - var rs []gamedata.BattleReward - for _, item := range s.design.Quests[id].GiveItemIDs { - rs = append(rs, gamedata.BattleReward{Type: 13, ID: item, Count: 1}) - } - if len(rs) == 0 { - return nil, nil - } - key := s.identity(st, id, "give") - items, e := s.inventory.GrantOnce(key, rs) - if e == nil && len(items) == 0 { - items = s.inventory.GrantedItems(key) - } - return items, e -} -func (s *Service) secondsLeft(st snapshot) uint64 { - base, _ := time.Parse("2006-01-02", st.Period) - end := base.AddDate(0, 0, 7).Add(s.design.Reset.DailyReset - 9*time.Hour) - n := int64(end.Sub(s.now()).Seconds()) - if n < 0 { - return 0 - } - return uint64(n) -} -func (s *Service) Info(pack int) ([][]byte, []int, error) { - s.mu.Lock() - defer s.mu.Unlock() - st, e := s.load() - if e != nil { - return nil, nil, e - } - if e = s.save(st); e != nil { - return nil, nil, e - } - var rows [][]byte - var cleared []int - ids := []int{} - for id := range st.Active { - ids = append(ids, id) - } - sort.Ints(ids) - for _, id := range ids { - if s.design.Quests[id].PackID == pack { - rows = append(rows, s.questWire(st.Active[id])) - } - } - for id := range st.Cleared { - if s.design.Quests[id].PackID == pack { - cleared = append(cleared, id) - } - } - sort.Ints(cleared) - return rows, cleared, nil -} -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/TodayQuestInfo" && path != "/QuestAccept" && path != "/QuestUpdate" && path != "/QuestClear" && path != "/QuestGiveUp" { - return 0, nil, false, nil - } - id := uint64(0) - var e error - if path != "/TodayQuestInfo" { - id, _, e = wire.Varint(request, 2) - if e != nil { - return 0, nil, true, e - } - if _, ok := s.design.Quests[int(id)]; !ok { - return 0, nil, false, nil - } - } - s.mu.Lock() - defer s.mu.Unlock() - seq, found, e := wire.Varint(request, 1) - if e != nil || !found || seq == 0 { - return 0, nil, true, fmt.Errorf("todayquest: missing sequence") - } - st, e := s.load() - if e != nil { - return 0, nil, true, e - } - if path == "/TodayQuestInfo" { - var b []byte - ids := []int{} - for id := range st.Active { - ids = append(ids, id) - } - sort.Ints(ids) - for _, id := range ids { - b = wire.AppendBytes(b, 1, s.questWire(st.Active[id])) - } - ids = nil - for id := range st.Cleared { - ids = append(ids, id) - } - sort.Ints(ids) - for _, id := range ids { - b = wire.AppendVarint(b, 2, uint64(id)) - } - b = wire.AppendVarint(b, 3, s.secondsLeft(st)) - for _, id := range st.Offered { - b = wire.AppendVarint(b, 4, uint64(id)) - } - return 64, b, true, s.save(st) - } - q := s.design.Quests[int(id)] - pack, _, e := wire.Varint(request, 3) - if e != nil || int(pack) != q.PackID || !s.unlocked(q.PackID) { - return 0, nil, true, fmt.Errorf("todayquest: unavailable pack") - } - a, active := st.Active[q.ID] - switch path { - case "/QuestAccept": - level, _, e := wire.Varint(request, 4) - opt, _, optErr := wire.Varint(request, 5) - if e != nil || level != 0 || optErr != nil || opt != 0 { - return 0, nil, true, fmt.Errorf("todayquest: invalid difficulty") - } - if !active { - offered := false - for _, x := range st.Offered { - if x == q.ID { - offered = true - } - } - completed := 0 - for x := range st.Cleared { - if s.design.Quests[x].NextID == 0 { - completed++ - } - } - if q.PriorID != 0 || !offered || st.Cleared[q.ID] || completed+len(st.Active) >= s.design.Limit { - return 0, nil, true, fmt.Errorf("todayquest: root not available or limit reached") - } - a = Active{ID: q.ID} - st.Active[q.ID] = a - } - items, e := s.give(st, q.ID) - if e != nil { - return 0, nil, true, e - } - b := wire.AppendBytes(nil, 1, s.questWire(a)) - for _, it := range items { - b = wire.AppendBytes(b, 4, player.ItemWire(it)) - } - return 17, b, true, s.save(st) - case "/QuestGiveUp": - if !active { - return 0, nil, true, fmt.Errorf("todayquest: quest is not active") - } - delete(st.Active, q.ID) - root := s.root(q.ID) - for x := range st.Cleared { - if s.root(x) == root { - delete(st.Cleared, x) - } - } - return 20, wire.AppendVarint(nil, 1, id), true, s.save(st) - case "/QuestUpdate": - if !active { - return 0, nil, true, fmt.Errorf("todayquest: quest is not active") - } - vs, e := values(request, 4) - if e != nil || len(vs) == 0 { - return 0, nil, true, fmt.Errorf("todayquest: missing progress") - } - if q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18 { - for _, v := range vs { - allowed := false - for _, x := range q.MagicValues { - if x == v { - allowed = true - } - } - if !allowed { - return 0, nil, true, fmt.Errorf("todayquest: foreign quest object") - } - seen := false - for _, x := range a.Objects { - if x == v { - seen = true - } - } - if !seen { - a.Objects = append(a.Objects, v) - } - } - } else { - if len(vs) != 1 || vs[0] > uint64(q.ConditionCount) { - return 0, nil, true, fmt.Errorf("todayquest: invalid progress") - } - if int(vs[0]) > a.Value { - a.Value = int(vs[0]) - } - } - st.Active[q.ID] = a - return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, id), 2, nil), true, s.save(st) - case "/QuestClear": - if b, ok := st.Responses[q.ID]; ok && st.Cleared[q.ID] { - return 18, b, true, nil - } - if !active || (q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && len(a.Objects) < q.ConditionCount || !(q.ConditionType == 2 || q.ConditionType == 9 || q.ConditionType == 18) && a.Value < q.ConditionCount { //nolint:staticcheck // QF1001 - return 0, nil, true, fmt.Errorf("todayquest: incomplete quest") - } - if q.ReputationCompleteID != 0 && s.CompleteReputation == nil { - return 0, nil, true, fmt.Errorf("todayquest: reputation provider unavailable") - } - if q.NextID == 0 && s.CompleteAchievement == nil { - return 0, nil, true, fmt.Errorf("todayquest: achievement provider unavailable") - } - bundle, e := s.economy.Apply(s.identity(st, q.ID, "reward"), nil, q.Rewards) - if e != nil { - return 0, nil, true, e - } - b := wire.AppendBytes(nil, 1, bundle) - b = wire.AppendVarint(b, 3, id) - delete(st.Active, q.ID) - st.Cleared[q.ID] = true - if q.ReputationCompleteID != 0 { - rep, e := s.CompleteReputation(s.identity(st, q.ID, "reputation"), q.PackID, uint64(q.ReputationCompleteID)) - if e != nil { - return 0, nil, true, e - } - b = wire.AppendBytes(b, 8, rep) - } - if q.NextID != 0 { - next := Active{ID: q.NextID} - st.Active[next.ID] = next - b = wire.AppendBytes(b, 2, s.questWire(next)) - items, e := s.give(st, next.ID) - if e != nil { - return 0, nil, true, e - } - for _, it := range items { - b = wire.AppendBytes(b, 6, player.ItemWire(it)) - } - } else { - b = wire.AppendBytes(b, 2, nil) - if e = s.CompleteAchievement(s.identity(st, q.ID, "achievement")); e != nil { - return 0, nil, true, e - } - key := s.identity(st, q.ID, "score") - if _, ok := st.ScoreAwards[key]; !ok { - st.ScoreAwards[key] = uint64(s.design.AchievementScore) - } - } - st.Responses[q.ID] = b - return 18, b, true, s.save(st) - } - return 0, nil, false, nil -} - -// Score projects durable commission points. These are deliberately separate -// from user experience: no current QuestClear/Notify protocol supports an Exp -// update, and the board's score label alone does not establish that meaning. -func (s *Service) Score() (uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - st, e := s.load() - if e != nil { - return 0, e - } - var total uint64 - for _, n := range st.ScoreAwards { - if n == 0 || n > 2147483647 || total > 2147483647-n { - return 0, fmt.Errorf("todayquest: invalid score awards") - } - total += n - } - return total, nil -} -func values(raw []byte, number int) ([]uint64, error) { - var out []uint64 - err := wire.Walk(raw, func(f wire.Field) error { - if f.Number != number { - return nil - } - if f.Type != 0 && f.Type != 2 { - return fmt.Errorf("todayquest: invalid progress type") - } - b := f.Value - for len(b) > 0 { - n, k := binary.Uvarint(b) - if k <= 0 { - return fmt.Errorf("todayquest: invalid packed progress") - } - out = append(out, n) - b = b[k:] - if f.Type == 0 && len(b) > 0 { - return fmt.Errorf("todayquest: invalid scalar progress") - } - } - return nil - }) - return out, err -} diff --git a/go/internal/server/todayquest/todayquest_test.go b/go/internal/server/todayquest/todayquest_test.go deleted file mode 100644 index 0380974..0000000 --- a/go/internal/server/todayquest/todayquest_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package todayquest - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/binary" - "fmt" - "path/filepath" - "testing" - "time" -) - -type economyFake struct { - seen map[string]bool - grants int -} - -func (e *economyFake) Apply(id string, c, r []gamedata.Reward) ([]byte, error) { - if !e.seen[id] { - e.seen[id] = true - if len(r) > 0 { - e.grants++ - } - } - return nil, nil -} - -type itemsFake struct{} - -func (itemsFake) GrantOnce(string, []gamedata.BattleReward) ([]player.Item, error) { return nil, nil } -func (itemsFake) GrantedItems(string) []player.Item { return nil } -func packet(id int, vs ...uint64) []byte { - b := wire.AppendVarint(nil, 1, 1) - b = wire.AppendVarint(b, 2, uint64(id)) - b = wire.AppendVarint(b, 3, 7) - var p []byte - for _, v := range vs { - p = binary.AppendUvarint(p, v) - } - if len(p) > 0 { - b = wire.AppendBytes(b, 4, p) - } - return b -} -func fixture(t *testing.T) (*Service, *economyFake, stateio.Store) { - t.Helper() - d := &gamedata.TodayQuestCatalog{Quests: map[int]gamedata.TodayQuest{101: {ID: 101, PackID: 7, NextID: 102, ConditionType: 19, ConditionCount: 1}, 102: {ID: 102, PackID: 7, PriorID: 101, ConditionType: 2, ConditionCount: 2, MagicValues: []uint64{71, 72}, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}, ReputationCompleteID: 1}}, Limit: 1, PostCount: 1, Reset: gamedata.FieldResetSchedule{WeeklyDay: time.Monday, DailyReset: 9 * time.Hour}} - e := &economyFake{seen: map[string]bool{}} - store := stateio.NewMemory() - s, err := Open(store, d, e, itemsFake{}, func(int) bool { return true }) - if err != nil { - t.Fatal(err) - } - s.now = func() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) } - s.CompleteReputation = func(string, int, uint64) ([]byte, error) { return wire.AppendVarint(nil, 2, 2), nil } - s.design.AchievementScore = 7 - s.CompleteAchievement = func(string) error { return nil } - return s, e, store -} -func call(t *testing.T, s *Service, path string, id int, vs ...uint64) []byte { - t.Helper() - _, b, ok, e := s.Handle(path, packet(id, vs...)) - if e != nil || !ok { - t.Fatalf("%s %d: %v", path, id, e) - } - return b -} -func TestChainCompletionRestartReplayAndWeeklyReset(t *testing.T) { - s, e, store := fixture(t) - call(t, s, "/TodayQuestInfo", 0) - if _, _, _, err := s.Handle("/QuestAccept", packet(102)); err == nil { - t.Fatal("accepted arbitrary continuation") - } - call(t, s, "/QuestAccept", 101) - if _, _, _, err := s.Handle("/QuestClear", packet(101)); err == nil { - t.Fatal("cleared without progress") - } - call(t, s, "/QuestUpdate", 101, 1) - call(t, s, "/QuestClear", 101) - if _, _, _, err := s.Handle("/QuestUpdate", packet(102, 999)); err == nil { - t.Fatal("foreign object accepted") - } - call(t, s, "/QuestUpdate", 102, 71) - call(t, s, "/QuestUpdate", 102, 71) - if _, _, _, err := s.Handle("/QuestClear", packet(102)); err == nil { - t.Fatal("duplicate object counted twice") - } - call(t, s, "/QuestUpdate", 102, 72) - b := call(t, s, "/QuestClear", 102) - if e.grants != 1 { - t.Fatal(e.grants) - } - reopened, err := Open(store, s.design, e, itemsFake{}, func(int) bool { return true }) - if err != nil { - t.Fatal(err) - } - reopened.now = s.now - reopened.CompleteReputation = s.CompleteReputation - reopened.CompleteAchievement = s.CompleteAchievement - if got := call(t, reopened, "/QuestClear", 102); !bytes.Equal(got, b) || e.grants != 1 { - t.Fatal("restart reward replay changed") - } - if n, err := reopened.Score(); err != nil || n != 7 { - t.Fatalf("score %d %v", n, err) - } - if _, _, _, err := s.Handle("/QuestAccept", packet(101)); err == nil { - t.Fatal("completed root accepted") - } - reopened.now = func() time.Time { return time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC) } - call(t, reopened, "/TodayQuestInfo", 0) - call(t, reopened, "/QuestAccept", 101) - if n, err := reopened.Score(); err != nil || n != 7 { - t.Fatalf("weekly reset lost score %d %v", n, err) - } -} -func TestGiveUpRestoresQuotaAndClearsPriorChain(t *testing.T) { - s, _, _ := fixture(t) - call(t, s, "/QuestAccept", 101) - call(t, s, "/QuestUpdate", 101, 1) - call(t, s, "/QuestClear", 101) - call(t, s, "/QuestGiveUp", 102) - call(t, s, "/QuestAccept", 101) - st, err := s.load() - if err != nil || st.Cleared[101] { - t.Fatalf("prior clear retained: %v", err) - } -} - -func TestPackInfoResetsExpiredSQLiteCommissionAtWeeklyBoundary(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - s, economy, _ := fixture(t) - s.store = stateio.EntrySnapshotStore{Entries: repo, Domain: "missions", Bucket: "gameplay"} - now := time.Date(2026, 10, 11, 23, 59, 59, 0, time.UTC) - s.now = func() time.Time { return now } - call(t, s, "/QuestAccept", 101) - call(t, s, "/QuestUpdate", 101, 1) - call(t, s, "/QuestClear", 101) - call(t, s, "/QuestUpdate", 102, 71, 72) - call(t, s, "/QuestClear", 102) - st, err := s.load() - if err != nil || s.secondsLeft(st) != 1 { - t.Fatalf("weekly countdown: %d %v", s.secondsLeft(st), err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - reopened, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := reopened.Close(); err != nil { - t.Error(err) - } - }() - s.store = stateio.EntrySnapshotStore{Entries: reopened, Domain: "missions", Bucket: "gameplay"} - now = now.Add(time.Second) - // PackInGameInfo calls Info without an intervening TodayQuestInfo request. - rows, cleared, err := s.Info(7) - if err != nil || len(rows) != 0 || len(cleared) != 0 { - t.Fatalf("expired pack tasks: %v %v %v", rows, cleared, err) - } - st, err = s.load() - if err != nil || s.secondsLeft(st) != 7*24*60*60 { - t.Fatalf("new weekly countdown: %d %v", s.secondsLeft(st), err) - } - if score, err := s.Score(); err != nil || score != 7 { - t.Fatalf("prior score: %d %v", score, err) - } - call(t, s, "/QuestAccept", 101) - call(t, s, "/QuestUpdate", 101, 1) - call(t, s, "/QuestClear", 101) - call(t, s, "/QuestUpdate", 102, 71, 72) - call(t, s, "/QuestClear", 102) - if score, err := s.Score(); err != nil || score != 14 || economy.grants != 2 { - t.Fatalf("new week score/reward: %d %d %v", score, economy.grants, err) - } -} - -func TestCurrentCommissionConditionsRestoreProgressAndRejectForeignObjects(t *testing.T) { - // Proto.Net.Define_QuestConditionType and PackManager.RefreshQuestCondition: - // Hunt/ TalkManual use Value; Collection/ObjectMove/FieldResearchObject use ObjectId. - for _, condition := range []int{1, 2, 9, 18, 19} { - t.Run(fmt.Sprint(condition), func(t *testing.T) { - s, _, _ := fixture(t) - q := s.design.Quests[101] - q.ConditionType, q.ConditionCount = condition, 2 - q.MagicValues = []uint64{71, 72} - s.design.Quests[101] = q - call(t, s, "/QuestAccept", 101) - objectCondition := condition == 2 || condition == 9 || condition == 18 - if objectCondition { - call(t, s, "/QuestUpdate", 101, 71) - call(t, s, "/QuestUpdate", 101, 71) - if _, _, _, err := s.Handle("/QuestUpdate", packet(101, 999)); err == nil { - t.Fatal("foreign object accepted") - } - } else { - call(t, s, "/QuestUpdate", 101, 1) - call(t, s, "/QuestUpdate", 101, 0) - if _, _, _, err := s.Handle("/QuestUpdate", packet(101, 3)); err == nil { - t.Fatal("progress exceeded condition count") - } - } - rows, _, err := s.Info(7) - if err != nil || len(rows) != 1 { - t.Fatalf("restored rows: %v %v", rows, err) - } - if objectCondition { - object, _, _ := wire.Varint(rows[0], 3) - if object != 71 { - t.Fatalf("object progress %d", object) - } - } else { - value, _, _ := wire.Varint(rows[0], 2) - if value != 1 { - t.Fatalf("scalar progress %d", value) - } - } - if _, _, _, err := s.Handle("/QuestClear", packet(101)); err == nil { - t.Fatal("partial progress cleared") - } - if objectCondition { - call(t, s, "/QuestUpdate", 101, 72) - } else { - call(t, s, "/QuestUpdate", 101, 2) - } - call(t, s, "/QuestClear", 101) - }) - } -} diff --git a/go/internal/server/transport/commerce_test.go b/go/internal/server/transport/commerce_test.go deleted file mode 100644 index 957acef..0000000 --- a/go/internal/server/transport/commerce_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package transport - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestCommerceManifestIsReadOnlyAndUnavailableWithoutPolicy(t *testing.T) { - without := httptest.NewRecorder() - HTTP{}.Handler().ServeHTTP(without, httptest.NewRequest(http.MethodGet, "/client/commerce", nil)) - if without.Code != http.StatusServiceUnavailable { - t.Fatalf("missing policy status=%d", without.Code) - } - calls := 0 - h := HTTP{CommerceManifest: func() any { - calls++ - return map[string]any{"schema_version": 1, "products": []any{map[string]any{"sku": "test", "item_type": 2, "amount": 1000}}} - }}.Handler() - post := httptest.NewRecorder() - h.ServeHTTP(post, httptest.NewRequest(http.MethodPost, "/client/commerce", strings.NewReader(`{"amount":0}`))) - if post.Code != http.StatusMethodNotAllowed || calls != 0 { - t.Fatalf("policy accepted mutation: status=%d calls=%d", post.Code, calls) - } - get := httptest.NewRecorder() - h.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/client/commerce", nil)) - if get.Code != http.StatusOK || calls != 1 || get.Header().Get("Cache-Control") != "no-store" || - !strings.Contains(get.Body.String(), `"amount":1000`) { - t.Fatalf("manifest status=%d calls=%d headers=%v body=%s", get.Code, calls, get.Header(), get.Body.String()) - } -} diff --git a/go/internal/server/transport/http_test.go b/go/internal/server/transport/http_test.go deleted file mode 100644 index 83e70af..0000000 --- a/go/internal/server/transport/http_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package transport - -import ( - "encoding/base64" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "bd2server/internal/server/authconfig" - "bd2server/internal/server/bootstrap" - "bd2server/internal/server/lifecycle" - "bd2server/internal/server/resourcepolicy" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestDrainingServerRejectsGameRequestsWithReconnectMarker(t *testing.T) { - gate := lifecycle.NewGate() - handler := HTTP{Availability: gate, InstanceID: "instance-a"}.Handler() - ready := httptest.NewRecorder() - handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil)) - if ready.Code != http.StatusOK { - t.Fatalf("initial readiness=%d", ready.Code) - } - gate.Drain() - ready = httptest.NewRecorder() - handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/readyz", nil)) - if ready.Code != http.StatusServiceUnavailable { - t.Fatalf("draining readiness=%d", ready.Code) - } - response := httptest.NewRecorder() - handler.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted"))) - if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" || - response.Header().Get("X-BD2-Reconnect-Reason") != "rolling-restart" || - response.Header().Get("Retry-After") != "1" || response.Header().Get("Connection") != "close" { - t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String()) - } - health := httptest.NewRecorder() - handler.ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil)) - if health.Code != http.StatusServiceUnavailable { - t.Fatalf("readiness alias during drain=%d", health.Code) - } - live := httptest.NewRecorder() - handler.ServeHTTP(live, httptest.NewRequest(http.MethodGet, "/livez", nil)) - if live.Code != http.StatusOK { - t.Fatalf("liveness during drain=%d", live.Code) - } - runtime := httptest.NewRecorder() - handler.ServeHTTP(runtime, httptest.NewRequest(http.MethodGet, "/client/runtime", nil)) - var status struct { - Status string `json:"status"` - ID string `json:"instance_id"` - } - if err := json.Unmarshal(runtime.Body.Bytes(), &status); err != nil || status.Status != "draining" || status.ID != "instance-a" { - t.Fatalf("runtime=%+v err=%v", status, err) - } -} - -func TestDrainingServerRejectsAuthenticationMutationBeforeHandler(t *testing.T) { - gate := lifecycle.NewGate() - called := false - handler := HTTP{ - Availability: gate, - AuthenticationHandler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - called = true - w.WriteHeader(http.StatusNoContent) - }), - }.Handler() - gate.Drain() - response := httptest.NewRecorder() - handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, "/auth/session/refresh", strings.NewReader(`{}`))) - if called || response.Code != http.StatusServiceUnavailable || - response.Header().Get("X-BD2-Reconnect") != "1" { - t.Fatalf("called=%v status=%d headers=%v", called, response.Code, response.Header()) - } -} - -func TestBootstrapRoundTrip(t *testing.T) { - cfg := bootstrap.Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: "test-client", BundleVer: "test-bundle"} - now := func() time.Time { return time.UnixMilli(12345) } - h := HTTP{Dispatcher: Bootstrap{Config: cfg, Now: now}, Now: now}.Handler() - for _, path := range []string{"/MaintenanceInfo", "/ServerInfo", "/NoticeInfo", "/ServerNowTime"} { - request := base64.StdEncoding.EncodeToString(wire.AppendVarint(nil, 1, 2)) - req := httptest.NewRequest(http.MethodPut, "/game"+path, strings.NewReader(request)) - res := httptest.NewRecorder() - h.ServeHTTP(res, req) - if res.Code != http.StatusOK { - t.Fatalf("%s: %d %s", path, res.Code, res.Body.String()) - } - var envelope Envelope - if err := json.Unmarshal(res.Body.Bytes(), &envelope); err != nil || envelope.ErrorType != 0 || envelope.ServerNowTime != 12345 { - t.Fatalf("%s: %v %+v", path, err, envelope) - } - if _, err := base64.StdEncoding.DecodeString(envelope.Data); err != nil { - t.Fatalf("%s: %v", path, err) - } - } -} - -func TestAuthenticationConfig(t *testing.T) { - h := HTTP{Authentication: authconfig.Config{ - Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", - Providers: map[string]authconfig.ProviderConfig{ - "discord": {ClientID: "d", ClientSecretEnv: "DS"}, - "google": {ClientID: "g", ClientSecretEnv: "GS"}, - }, - }}.Handler() - res := httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/auth/config", nil)) - if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("status=%d cache=%q body=%q", res.Code, res.Header().Get("Cache-Control"), res.Body.String()) - } - var config authconfig.Public - if err := json.Unmarshal(res.Body.Bytes(), &config); err != nil || config.Mode != "oauth" || len(config.Providers) != 2 { - t.Fatalf("config=%+v err=%v", config, err) - } - - res = httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodPost, "/auth/config", nil)) - if res.Code != http.StatusMethodNotAllowed { - t.Fatalf("POST status=%d", res.Code) - } -} - -type cookieRawDispatcher struct{} - -func (cookieRawDispatcher) DispatchRaw(string, []byte, string) (RawReply, error) { - return RawReply{Body: []byte(`{}`), Cookie: "0123456789abcdef0123456789abcdef0123456789abcdef|1"}, nil -} - -type failedRawDispatcher struct { - err error -} - -func (d failedRawDispatcher) DispatchRaw(string, []byte, string) (RawReply, error) { - return RawReply{}, d.err -} - -func TestExpiredGameSessionUsesDedicatedHTTPMarker(t *testing.T) { - h := HTTP{Raw: failedRawDispatcher{err: ErrGameSessionExpired}}.Handler() - request := httptest.NewRequest(http.MethodPut, "/game/BatchRequest", strings.NewReader("encrypted")) - request.Header.Set("Cookie", "s=0123456789abcdef0123456789abcdef0123456789abcdef|1") - response := httptest.NewRecorder() - h.ServeHTTP(response, request) - - if response.Code != http.StatusUnauthorized || response.Header().Get("X-BD2-Session-Expired") != "1" || - response.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("status=%d marker=%q cache=%q body=%q", response.Code, - response.Header().Get("X-BD2-Session-Expired"), response.Header().Get("Cache-Control"), response.Body.String()) - } -} - -func TestInvalidGameAccessCredentialUsesDedicatedHTTPMarker(t *testing.T) { - h := HTTP{Raw: failedRawDispatcher{err: ErrAccessCredentialInvalid}}.Handler() - response := httptest.NewRecorder() - h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/LoginUser", strings.NewReader("encrypted"))) - if response.Code != http.StatusUnauthorized || response.Header().Get("X-BD2-Access-Expired") != "1" || - response.Header().Get("WWW-Authenticate") != `Bearer error="invalid_token"` || - response.Header().Get("X-BD2-Session-Expired") != "" { - t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String()) - } -} - -func TestDomainFailureDoesNotUseExpiredSessionMarker(t *testing.T) { - h := HTTP{Raw: failedRawDispatcher{err: errors.New("mail seed is invalid")}}.Handler() - response := httptest.NewRecorder() - h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted"))) - if response.Code != http.StatusBadRequest || response.Header().Get("X-BD2-Session-Expired") != "" { - t.Fatalf("status=%d marker=%q body=%q", response.Code, - response.Header().Get("X-BD2-Session-Expired"), response.Body.String()) - } -} - -func TestFencedWriterRequestsReconnectInsteadOfDomainFailure(t *testing.T) { - h := HTTP{Raw: failedRawDispatcher{err: stateio.ErrWriterFenced}}.Handler() - response := httptest.NewRecorder() - h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted"))) - if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" || - response.Header().Get("X-BD2-Reconnect-Reason") != "fenced" { - t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String()) - } -} - -func TestUncertainStateTransactionRequestsProcessRecovery(t *testing.T) { - h := HTTP{Raw: failedRawDispatcher{err: stateio.ErrStateRecoveryRequired}}.Handler() - response := httptest.NewRecorder() - h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/MailInfo", strings.NewReader("encrypted"))) - if response.Code != http.StatusServiceUnavailable || response.Header().Get("X-BD2-Reconnect") != "1" || - response.Header().Get("X-BD2-Reconnect-Reason") != "state-recovery" { - t.Fatalf("status=%d headers=%v body=%q", response.Code, response.Header(), response.Body.String()) - } -} - -func TestOAuthGameSessionCookieIsHostOnlySecureAndGameScoped(t *testing.T) { - h := HTTP{ - Raw: cookieRawDispatcher{}, - Authentication: authconfig.Config{ - Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", - Providers: map[string]authconfig.ProviderConfig{ - "discord": {ClientID: "d", ClientSecretEnv: "DS"}, - }, - }, - }.Handler() - response := httptest.NewRecorder() - h.ServeHTTP(response, httptest.NewRequest(http.MethodPut, "/game/LoginUser", strings.NewReader("request"))) - cookies := response.Result().Cookies() - if len(cookies) != 1 { - t.Fatalf("cookies=%v", cookies) - } - cookie := cookies[0] - if cookie.Name != "s" || cookie.Path != "/game/" || !cookie.HttpOnly || !cookie.Secure || - cookie.SameSite != http.SameSiteLaxMode || cookie.Domain != "" { - t.Fatalf("unsafe game session cookie: %+v", cookie) - } -} - -func TestUnknownPacketFailsClosed(t *testing.T) { - h := HTTP{Dispatcher: Bootstrap{}}.Handler() - res := httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/game/InventedPacket", strings.NewReader("AA=="))) - if res.Code != http.StatusNotImplemented { - t.Fatalf("unknown packet status: %d", res.Code) - } -} - -func TestClientResourcePolicyUsesPUTAndDoesNotCache(t *testing.T) { - policy := resourcepolicy.Public{ - Mode: resourcepolicy.ModeServer, ServerDataURL: "https://cdn.example.com/ServerData", - GameDataURL: "https://cdn.example.com/GameData", BundleVersion: "bundle", GameDataVersion: "game-data", - } - h := HTTP{ResourcePolicy: policy}.Handler() - res := httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(`{"cdn_mode":"server"}`))) - if res.Code != http.StatusOK || res.Header().Get("Cache-Control") != "no-store" { - t.Fatalf("status=%d cache=%q body=%q", res.Code, res.Header().Get("Cache-Control"), res.Body.String()) - } - var got resourcepolicy.Public - if err := json.Unmarshal(res.Body.Bytes(), &got); err != nil || got != policy { - t.Fatalf("policy=%+v err=%v", got, err) - } - - res = httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/client/resources", nil)) - if res.Code != http.StatusMethodNotAllowed { - t.Fatalf("GET status=%d", res.Code) - } -} - -func TestClientResourcePolicyRejectsInvalidOrMismatchedSelection(t *testing.T) { - policy := resourcepolicy.Public{ - Mode: resourcepolicy.ModeServer, ServerDataURL: "https://cdn.example.com/ServerData", - GameDataURL: "https://cdn.example.com/GameData", BundleVersion: "bundle", GameDataVersion: "game-data", - } - h := HTTP{ResourcePolicy: policy}.Handler() - for name, test := range map[string]struct { - body string - status int - }{ - "missing": {`{}`, http.StatusBadRequest}, - "unknown": {`{"cdn_mode":"server","url":"https://evil.example"}`, http.StatusBadRequest}, - "trailing": {`{"cdn_mode":"server"}{}`, http.StatusBadRequest}, - "official": {`{"cdn_mode":"official"}`, http.StatusBadRequest}, - "local": {`{"cdn_mode":"local"}`, http.StatusBadRequest}, - "legacy-self-hosted": {`{"cdn_mode":"self_hosted"}`, http.StatusBadRequest}, - "legacy-reverse-proxy": {`{"cdn_mode":"reverse_proxy"}`, http.StatusBadRequest}, - "too-large": {`{"cdn_mode":"server","padding":"` + strings.Repeat("x", 16<<10) + `"}`, http.StatusRequestEntityTooLarge}, - } { - t.Run(name, func(t *testing.T) { - res := httptest.NewRecorder() - h.ServeHTTP(res, httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(test.body))) - if res.Code != test.status { - t.Fatalf("status=%d want=%d body=%q", res.Code, test.status, res.Body.String()) - } - }) - } -} - -func TestClientResourcePolicyRejectsServerSelectionWhenServerUsesOfficialResources(t *testing.T) { - policy := resourcepolicy.Public{ - Mode: resourcepolicy.ModeOfficial, ServerDataURL: resourcepolicy.OfficialServerDataURL, - GameDataURL: resourcepolicy.OfficialGameDataURL, BundleVersion: "bundle", GameDataVersion: "game-data", - } - res := httptest.NewRecorder() - HTTP{ResourcePolicy: policy}.Handler().ServeHTTP( - res, - httptest.NewRequest(http.MethodPut, "/client/resources", strings.NewReader(`{"cdn_mode":"server"}`)), - ) - if res.Code != http.StatusConflict { - t.Fatalf("status=%d want=%d body=%q", res.Code, http.StatusConflict, res.Body.String()) - } -} diff --git a/go/internal/server/versionconfig/config_test.go b/go/internal/server/versionconfig/config_test.go deleted file mode 100644 index 6109737..0000000 --- a/go/internal/server/versionconfig/config_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package versionconfig - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadAndResolve(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, FileName) - data := []byte(`{ - "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, data, 0o644); err != nil { - t.Fatal(err) - } - cfg, err := Load(path) - if err != nil { - t.Fatal(err) - } - if cfg.GameVersion != "2.35.10" || cfg.ClientVersion != "2.35.10+client.0.1.0" || cfg.ServerVersion != "2.35.10+server.0.1.0" || cfg.Plugins.LoginUI != "0.1.0" { - t.Fatalf("unexpected config: %+v", cfg) - } - want := filepath.Join(dir, "go", "seed", "v2_35_10") - if got := cfg.Resolve(cfg.SeedDirectory); got != want { - t.Fatalf("Resolve()=%q, want %q", got, want) - } -} - -func TestLoadRejectsUnknownAndEscapingFields(t *testing.T) { - for name, body := range map[string]string{ - "unknown": `{"game_version":"2.35.10","unknown":true}`, - "escape": `{"game_version":"2.35.10","client_version":"2.35.10+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"../seed","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`, - "client-prefix": `{"game_version":"2.35.10","client_version":"2.36.0+client.0.1.0","server_version":"2.35.10+server.0.1.0","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"seed","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`, - } { - t.Run(name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), FileName) - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } - if _, err := Load(path); err == nil { - t.Fatal("accepted invalid version config") - } - }) - } -} - -func TestFindUsesEnvironmentOverride(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, FileName) - data := []byte(`{ - "game_version":"9.8.7", - "client_version":"9.8.7+client.1.2.3", - "server_version":"9.8.7+server.4.5.6", - "game_data_version":"20260102030405", - "bundle_version":"20260504030201", - "seed_directory":"seed/current", - "plugins":{"local_identity":"1.2.3","capture_environment":"4.5.6","login_ui":"7.8.9"} -}`) - if err := os.WriteFile(path, data, 0o644); err != nil { - t.Fatal(err) - } - t.Setenv("BD2_VERSION_CONFIG", path) - cfg, err := Find() - if err != nil { - t.Fatal(err) - } - if cfg.SourcePath != path || cfg.GameVersion != "9.8.7" || cfg.ClientVersion != "9.8.7+client.1.2.3" || cfg.ServerVersion != "9.8.7+server.4.5.6" || cfg.GameDataVersion != "20260102030405" { - t.Fatalf("Find() ignored environment override: %+v", cfg) - } -} diff --git a/go/internal/server/wire/wire_test.go b/go/internal/server/wire/wire_test.go deleted file mode 100644 index afc8f4e..0000000 --- a/go/internal/server/wire/wire_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package wire - -import ( - "bytes" - "errors" - "testing" -) - -func TestWalkAndReplace(t *testing.T) { - original := AppendVarint(nil, 1, 42) - original = AppendString(original, 2, "old") - original = AppendBytes(original, 15, []byte{0, 1, 2}) - modified, replaced, err := ReplaceBytes(original, 2, []byte("longer")) - if err != nil || !replaced { - t.Fatalf("replace: %v, %v", replaced, err) - } - v, ok, err := Varint(modified, 1) - if err != nil || !ok || v != 42 { - t.Fatalf("preserved varint: %d %v %v", v, ok, err) - } - x, ok, err := Bytes(modified, 2) - if err != nil || !ok || string(x) != "longer" { - t.Fatalf("replacement: %q %v %v", x, ok, err) - } - unknown, _, _ := Bytes(modified, 15) - if !bytes.Equal(unknown, []byte{0, 1, 2}) { - t.Fatalf("unknown field changed: %x", unknown) - } -} - -func TestTruncated(t *testing.T) { - if err := Walk([]byte{0x12, 0x03, 0x01}, func(Field) error { return nil }); !errors.Is(err, ErrMalformed) { - t.Fatalf("expected malformed: %v", err) - } -} - -func TestReplaceVarintAndAppendMissing(t *testing.T) { - original := AppendVarint(nil, 1, 7) - original = AppendString(original, 2, "preserved") - modified, replaced, err := ReplaceVarint(original, 1, 300) - if err != nil || !replaced { - t.Fatalf("replace existing: replaced=%v err=%v", replaced, err) - } - value, found, err := Varint(modified, 1) - if err != nil || !found || value != 300 { - t.Fatalf("existing value=%d found=%v err=%v", value, found, err) - } - modified, replaced, err = ReplaceVarint(modified, 9, 0) - if err != nil || replaced { - t.Fatalf("append missing: replaced=%v err=%v", replaced, err) - } - value, found, err = Varint(modified, 9) - if err != nil || !found || value != 0 { - t.Fatalf("appended zero=%d found=%v err=%v", value, found, err) - } -} diff --git a/go/internal/server/world/achievement_events.go b/go/internal/server/world/achievement_events.go deleted file mode 100644 index 885ad7a..0000000 --- a/go/internal/server/world/achievement_events.go +++ /dev/null @@ -1,217 +0,0 @@ -package world - -import ( - "encoding/json" - "fmt" - "math" - "sort" - "strconv" - - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type GameplayAchievementRecordedEvent struct { - Identity string - Type, SubType, Count uint64 -} - -// ApplyGameplayProgress keeps one observer boundary on one validated counter -// snapshot. Conditions are applied first and events retain their original -// order. Event receipts and all counter changes are written atomically; the -// next boundary loads fresh state from its transaction, including after retry. -func (s *AchievementService) ApplyGameplayProgress(conditions []GameplayAchievementCondition, events []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - state, err := s.load() - if err != nil { - return nil, nil, err - } - values := func() map[int]uint64 { - out := map[int]uint64{} - for key, value := range state.Counts { - group, _ := strconv.Atoi(key) // load validated every key. - out[group] = uint64(value) - } - return out - } - before := values() - groups := map[[2]uint64][]int{} - for group, condition := range s.design.Conditions { - key := [2]uint64{condition.Type, condition.SubType} - groups[key] = append(groups[key], group) - } - for key := range groups { - sort.Ints(groups[key]) - } - var changes []stateio.EntryMutation - set := func(group int, value int64) { - key := strconv.Itoa(group) - state.Counts[key] = value - raw, _ := json.Marshal(value) - changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw}) - } - for _, condition := range conditions { - if condition.Value > math.MaxInt64 { - return nil, nil, fmt.Errorf("achievement: invalid absolute value") - } - for _, group := range groups[[2]uint64{condition.Type, condition.SubType}] { - if state.Counts[strconv.Itoa(group)] != int64(condition.Value) { - set(group, int64(condition.Value)) - } - } - } - pending := map[string]string{} - for _, event := range events { - if event.Identity == "" || event.Count == 0 || event.Count > math.MaxInt64 { - return nil, nil, fmt.Errorf("achievement: invalid event") - } - digest := fmt.Sprintf("%d/%d/%d", event.Type, event.SubType, event.Count) - raw, found, err := s.store.LoadEntry("missions", "achievement_events", event.Identity) - if err != nil { - return nil, nil, err - } - if prior, exists := pending[event.Identity]; exists { - raw, found = []byte(prior), true - } - if found { - if string(raw) != digest { - return nil, nil, fmt.Errorf("achievement: event replay conflicts") - } - continue - } - for _, group := range groups[[2]uint64{event.Type, event.SubType}] { - old := state.Counts[strconv.Itoa(group)] - if old > math.MaxInt64-int64(event.Count) { - return nil, nil, fmt.Errorf("achievement: event overflow") - } - set(group, old+int64(event.Count)) - } - pending[event.Identity] = digest - changes = append(changes, stateio.EntryMutation{Bucket: "achievement_events", Key: event.Identity, Payload: []byte(digest)}) - } - if len(changes) != 0 { - if err := s.store.SaveWithEntries("missions", nil, changes); err != nil { - return nil, nil, err - } - } - return before, values(), nil -} - -func (s *AchievementService) AchievementValue(groupID uint64) (uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - state, err := s.load() - if err != nil { - return 0, err - } - return uint64(state.Counts[strconv.FormatUint(groupID, 10)]), nil -} - -// RecordEvent is an authoritative event increment, independently replayable -// by a domain operation identity. It does not infer cumulative counts from -// inventory balances, which lose consumed and discarded items. -func (s *AchievementService) RecordEvent(identity string, conditionType, subType, count uint64) ([][]byte, error) { - if identity == "" || count == 0 || count > math.MaxInt64 { - return nil, fmt.Errorf("achievement: invalid event") - } - s.mu.Lock() - defer s.mu.Unlock() - digest := fmt.Sprintf("%d/%d/%d", conditionType, subType, count) - if raw, found, err := s.store.LoadEntry("missions", "achievement_events", identity); err != nil { - return nil, err - } else if found { - if string(raw) != digest { - return nil, fmt.Errorf("achievement: event replay conflicts") - } - return nil, nil - } - state, err := s.load() - if err != nil { - return nil, err - } - var groups []int - for group, c := range s.design.Conditions { - if c.Type == conditionType && c.SubType == subType { - groups = append(groups, group) - } - } - sort.Ints(groups) - changes := []stateio.EntryMutation{{Bucket: "achievement_events", Key: identity, Payload: []byte(digest)}} - var updates [][]byte - for _, group := range groups { - key := strconv.Itoa(group) - if state.Counts[key] > math.MaxInt64-int64(count) { - return nil, fmt.Errorf("achievement: event overflow") - } - value := state.Counts[key] + int64(count) - raw, _ := json.Marshal(value) - changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw}) - update := wire.AppendVarint(nil, 1, uint64(group)) - update = wire.AppendVarint(update, 2, uint64(value)) - update = wire.AppendVarint(update, 3, 1) - updates = append(updates, update) - } - if err := s.store.SaveWithEntries("missions", nil, changes); err != nil { - return nil, err - } - return updates, nil -} - -// SetCondition only accepts exact current-state conditions; cumulative events use RecordEvent. -func (s *AchievementService) SetCondition(conditionType, subType, value uint64) ([][]byte, error) { - if value > math.MaxInt64 { - return nil, fmt.Errorf("achievement: invalid absolute value") - } - s.mu.Lock() - defer s.mu.Unlock() - state, err := s.load() - if err != nil { - return nil, err - } - var groups []int - for group, c := range s.design.Conditions { - if c.Type == conditionType && c.SubType == subType { - groups = append(groups, group) - } - } - sort.Ints(groups) - var changes []stateio.EntryMutation - var updates [][]byte - for _, group := range groups { - key := strconv.Itoa(group) - if state.Counts[key] == int64(value) { - continue - } - raw, _ := json.Marshal(value) - changes = append(changes, stateio.EntryMutation{Bucket: "achievement_counts", Key: key, Payload: raw}) - update := wire.AppendVarint(nil, 1, uint64(group)) - update = wire.AppendVarint(update, 2, value) - updates = append(updates, wire.AppendVarint(update, 3, 1)) - } - if len(changes) == 0 { - return nil, nil - } - if err := s.store.SaveWithEntries("missions", nil, changes); err != nil { - return nil, err - } - return updates, nil -} - -func (s *AchievementService) CounterValues() (map[int]uint64, error) { - s.mu.Lock() - defer s.mu.Unlock() - state, err := s.load() - if err != nil { - return nil, err - } - out := map[int]uint64{} - for key, value := range state.Counts { - group, err := strconv.Atoi(key) - if err != nil { - return nil, err - } - out[group] = uint64(value) - } - return out, nil -} diff --git a/go/internal/server/world/achievement_events_test.go b/go/internal/server/world/achievement_events_test.go deleted file mode 100644 index 8e885b2..0000000 --- a/go/internal/server/world/achievement_events_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -func TestAchievementEventsMatchDesignAndReplayAcrossReopen(t *testing.T) { - store := stateio.NewMemory() - design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{987: {0, 1}, 654: {0}}, Conditions: map[int]gamedata.AchievementCondition{987: {Type: 54}, 654: {Type: 54, SubType: 2}}} - s, err := NewAchievementService(design, store) - if err != nil { - t.Fatal(err) - } - updates, err := s.RecordEvent("draw:1", 54, 0, 10) - if err != nil || len(updates) != 1 { - t.Fatalf("updates=%d err=%v", len(updates), err) - } - value, _, _ := wire.Varint(updates[0], 2) - isSet, _, _ := wire.Varint(updates[0], 3) - if value != 10 || isSet != 1 { - t.Fatal("notify not absolute") - } - s, err = NewAchievementService(design, store) - if err != nil { - t.Fatal(err) - } - if updates, err := s.RecordEvent("draw:1", 54, 0, 10); err != nil || len(updates) != 0 { - t.Fatalf("replay=%v err=%v", updates, err) - } - if _, err := s.RecordEvent("draw:1", 54, 0, 9); err == nil { - t.Fatal("replay mismatch accepted") - } - if v, err := s.AchievementValue(987); err != nil || v != 10 { - t.Fatalf("value=%d err=%v", v, err) - } - if v, _ := s.AchievementValue(654); v != 0 { - t.Fatal("wrong subtype incremented") - } - if updates, err := s.SetCondition(54, 2, 7); err != nil || len(updates) != 1 { - t.Fatal(err) - } - if updates, err := s.SetCondition(54, 2, 7); err != nil || len(updates) != 0 { - t.Fatal("absolute unchanged notified") - } -} diff --git a/go/internal/server/world/achievement_gameplay.go b/go/internal/server/world/achievement_gameplay.go deleted file mode 100644 index ba8bfe1..0000000 --- a/go/internal/server/world/achievement_gameplay.go +++ /dev/null @@ -1,440 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "fmt" - "sort" - "strconv" -) - -// GameplayAchievementCounter persists each event in the request transaction. -type GameplayAchievementCounter interface { - RecordEvent(string, uint64, uint64, uint64) ([][]byte, error) - SetCondition(uint64, uint64, uint64) ([][]byte, error) - CounterValues() (map[int]uint64, error) -} - -type gameplayAchievementProgressBatch interface { - ApplyGameplayProgress([]GameplayAchievementCondition, []GameplayAchievementRecordedEvent) (map[int]uint64, map[int]uint64, error) -} -type GameplayAchievementEvent struct { - Type, SubType, Count uint64 - Identity string - StableIdentity bool -} -type GameplayAchievementCondition struct{ Type, SubType, Value uint64 } -type GameplayAchievementSnapshot struct { - Characters map[uint64]player.Character - Costumes map[uint64]player.Costume - Equipment map[uint64]player.Equipment - Items map[[2]uint64]uint64 - Conditions []GameplayAchievementCondition - FieldObjects map[string]gamedata.FieldRewardObject - GachaGrants map[string]uint64 -} -type GameplayAchievementProvider interface { - Snapshot() (GameplayAchievementSnapshot, error) - Events(string, []byte, []byte, GameplayAchievementSnapshot, GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error) -} -type GameplayAchievementObserver struct { - counter GameplayAchievementCounter - provider GameplayAchievementProvider - session string - before GameplayAchievementSnapshot - counters map[int]uint64 -} - -func NewGameplayAchievementObserver(counter GameplayAchievementCounter, provider GameplayAchievementProvider) (*GameplayAchievementObserver, error) { - if counter == nil || provider == nil { - return nil, fmt.Errorf("achievement: gameplay provider unavailable") - } - return &GameplayAchievementObserver{counter: counter, provider: provider}, nil -} -func (s *GameplayAchievementObserver) BeginSession(id string) { s.session = id; s.counters = nil } - -// SyncRecordedHistory must run within the startup account transaction. Retained -// real grants establish a lower bound; inventory never substitutes for history. -func (s *GameplayAchievementObserver) SyncRecordedHistory() error { - snapshot, err := s.provider.Snapshot() - if err != nil { - return err - } - var ids []string - for identity := range snapshot.GachaGrants { - ids = append(ids, identity) - } - sort.Strings(ids) - var events []GameplayAchievementRecordedEvent - for _, identity := range ids { - count := snapshot.GachaGrants[identity] - if count == 0 { - continue - } - events = append(events, GameplayAchievementRecordedEvent{Identity: "gacha-grant:" + identity, Type: 54, Count: count}) - } - if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok { - _, _, err := batch.ApplyGameplayProgress(snapshot.Conditions, events) - return err - } - for _, condition := range snapshot.Conditions { - if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil { - return err - } - } - for _, event := range events { - if _, err := s.counter.RecordEvent(event.Identity, event.Type, event.SubType, event.Count); err != nil { - return err - } - } - return nil -} -func (s *GameplayAchievementObserver) BeforeDispatch(_ string, _ []byte) error { - var err error - s.before, err = s.provider.Snapshot() - if err != nil { - return err - } - if batch, ok := s.counter.(gameplayAchievementProgressBatch); ok { - s.counters, _, err = batch.ApplyGameplayProgress(s.before.Conditions, nil) - return err - } - s.counters, err = s.counter.CounterValues() - if err != nil { - return err - } - for _, condition := range s.before.Conditions { - if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil { - return err - } - } - return nil -} -func (s *GameplayAchievementObserver) AfterDispatch(path string, request, response []byte) ([]byte, error) { - after, err := s.provider.Snapshot() - if err != nil { - return nil, err - } - batch, batches := s.counter.(gameplayAchievementProgressBatch) - if !batches { - for _, condition := range after.Conditions { - if _, err := s.counter.SetCondition(condition.Type, condition.SubType, condition.Value); err != nil { - return nil, err - } - } - } - events, err := s.provider.Events(path, request, response, s.before, after) - if err != nil { - return nil, err - } - var recorded []GameplayAchievementRecordedEvent - if len(events) > 0 { - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || s.session == "" { - return nil, fmt.Errorf("achievement: gameplay event sequence unavailable") - } - for i, event := range events { - identity := fmt.Sprintf("%s/%s/%d/%d/%s", s.session, path, seq, i, event.Identity) - if event.StableIdentity { - identity = event.Identity - } - recorded = append(recorded, GameplayAchievementRecordedEvent{Identity: identity, Type: event.Type, SubType: event.SubType, Count: event.Count}) - } - } - var values map[int]uint64 - if batches { - _, values, err = batch.ApplyGameplayProgress(after.Conditions, recorded) - } else { - for _, event := range recorded { - if _, err = s.counter.RecordEvent(event.Identity, event.Type, event.SubType, event.Count); err != nil { - return nil, err - } - } - values, err = s.counter.CounterValues() - } - if err != nil { - return nil, err - } - var groups []int - for group, value := range values { - if s.counters[group] != value { - groups = append(groups, group) - } - } - sort.Ints(groups) - var notify []byte - for _, group := range groups { - row := wire.AppendVarint(nil, 1, uint64(group)) - row = wire.AppendVarint(row, 2, values[group]) - row = wire.AppendVarint(row, 3, 1) - notify = wire.AppendBytes(notify, 2, row) - } - return notify, nil -} - -type GameplayCharacterSource interface{ RawAll() []player.Character } -type GameplayGachaSource interface{ GachaGrantSummary() map[string]uint64 } -type GameplayCostumeSource interface{ Costumes() []player.Costume } -type GameplayEquipmentSource interface{ All() []player.Equipment } -type GameplayItemSource interface{ All() []player.Item } - -// OwnedGameplayAchievementProvider uses before/after authoritative ownership. -// GET conditions are event counters: existing inventory is never a historical -// acquisition total. Conditions are supplied only by proven state projections. -type OwnedGameplayAchievementProvider struct { - Gacha GameplayGachaSource - Characters GameplayCharacterSource - Costumes GameplayCostumeSource - Equipment GameplayEquipmentSource - Items GameplayItemSource - Design *gamedata.AchievementCounterDesign - CharacterGrades, EquipmentGrades map[uint64]uint64 - Conditions func() ([]GameplayAchievementCondition, error) - FieldObjects func() (map[string]gamedata.FieldRewardObject, error) -} - -func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapshot, error) { - s, err := p.InventorySnapshot() - if err != nil { - return s, err - } - s.Characters = map[uint64]player.Character{} - if p.Characters != nil { - for _, v := range p.Characters.RawAll() { - if player.IsCharmCharacter(v) || player.IsStoryCharacter(v) { - continue - } - s.Characters[v.InvenIndex] = v - } - } - if p.Conditions != nil { - s.Conditions, err = p.Conditions() - } - if err == nil && p.FieldObjects != nil { - s.FieldObjects, err = p.FieldObjects() - } - if p.Gacha != nil { - s.GachaGrants = p.Gacha.GachaGrantSummary() - } - return s, err -} - -// InventorySnapshot projects the three authoritative domains used by event -// missions. It reads a fresh before/after view, without querying unrelated -// field-object, main-quest, character or gacha-history projections. -func (p *OwnedGameplayAchievementProvider) InventorySnapshot() (GameplayAchievementSnapshot, error) { - s := GameplayAchievementSnapshot{Costumes: map[uint64]player.Costume{}, Equipment: map[uint64]player.Equipment{}, Items: map[[2]uint64]uint64{}} - if p.Costumes != nil { - for _, v := range p.Costumes.Costumes() { - s.Costumes[v.InvenIndex] = v - } - } - if p.Equipment != nil { - for _, v := range p.Equipment.All() { - s.Equipment[v.InvenIndex] = v - } - } - if p.Items != nil { - for _, v := range p.Items.All() { - s.Items[[2]uint64{v.Type, v.ID}] += v.Count - } - } - return s, nil -} -func (p *OwnedGameplayAchievementProvider) Events(path string, _ []byte, _ []byte, before, after GameplayAchievementSnapshot) ([]GameplayAchievementEvent, error) { - var events []GameplayAchievementEvent - conditions := map[[2]uint64]bool{} - if p.Design != nil { - for _, condition := range p.Design.Conditions { - conditions[[2]uint64{condition.Type, condition.SubType}] = true - } - } - emit := func(kind, sub, count uint64, id string) { - if count == 0 { - return - } - if p.Design != nil && !conditions[[2]uint64{kind, sub}] { - return - } - events = append(events, GameplayAchievementEvent{Type: kind, SubType: sub, Count: count, Identity: id}) - } - for index, v := range after.Characters { - old, exists := before.Characters[index] - id := "char:" + strconv.FormatUint(index, 10) - if !exists { - emit(19, 0, 1, id) - if grade := p.CharacterGrades[v.ID]; grade != 0 { - emit(20, grade, 1, id) - } - } - if exists && v.Level > old.Level { - emit(22, 0, v.Level-old.Level, id) - } - } - for index, v := range after.Costumes { - old, exists := before.Costumes[index] - id := "costume:" + strconv.FormatUint(index, 10) - if !exists { - emit(26, 0, 1, id) - emit(26, v.ID, 1, id) - } - if exists && v.Level > old.Level { - emit(27, 0, v.Level-old.Level, id) - } - } - for index, v := range after.Equipment { - old, exists := before.Equipment[index] - id := "equipment:" + strconv.FormatUint(index, 10) - if !exists { - emit(7, 0, 1, id) - if grade := p.EquipmentGrades[v.ID]; grade != 0 { - emit(7, grade, 1, id) - } - } - if exists && v.UpgradeAttempts > old.UpgradeAttempts { - successes := uint64(0) - if v.Level > old.Level { - successes = v.Level - old.Level - } - attempts := v.UpgradeAttempts - old.UpgradeAttempts - if successes > attempts { - return nil, fmt.Errorf("achievement: equipment upgrade delta exceeds attempts") - } - if successes > 0 { - emit(9, p.EquipmentGrades[v.ID], successes, id) - } - emit(10, 0, attempts-successes, id) - } - } - if path == "/EatFood" || path == "/EatFoodAuto" { - for key, count := range before.Items { - if key[0] == 5 && count > after.Items[key] { - emit(55, 0, count-after.Items[key], "food:"+strconv.FormatUint(key[1], 10)) - } - } - } - for key, obj := range after.FieldObjects { - if _, exists := before.FieldObjects[key]; !exists { - switch obj.Type { - case 1: - emit(28, 0, 1, key) - case 2: - emit(31, 0, 1, key) - case 5: - emit(30, 0, 1, key) - } - } - } - for identity, count := range after.GachaGrants { - if _, exists := before.GachaGrants[identity]; !exists { - emit(54, 0, count, "gacha-grant:"+identity) - if len(events) > 0 && events[len(events)-1].Identity == "gacha-grant:"+identity { - events[len(events)-1].StableIdentity = true - } - } - } - sort.Slice(events, func(i, j int) bool { - a, b := events[i], events[j] - if a.Type != b.Type { - return a.Type < b.Type - } - if a.SubType != b.SubType { - return a.SubType < b.SubType - } - return a.Identity < b.Identity - }) - return events, nil -} - -// GameplayAchievementProvider exposes only authoritative owned instances and -// persisted quest/object state. It does not manufacture past acquisition counts. -func (s *Service) GameplayAchievementProvider(design *gamedata.AchievementCounterDesign, grades gamedata.GameplayAchievementGrades) *OwnedGameplayAchievementProvider { - p := &OwnedGameplayAchievementProvider{Design: design, CharacterGrades: grades.Characters, EquipmentGrades: grades.Equipment} - if s.characters != nil { - p.Characters = s.characters - } - if s.collection != nil { - p.Costumes = s.collection - p.Gacha = s.collection - } - if s.equipment != nil { - p.Equipment = s.equipment - } - if s.inventory != nil { - p.Items = s.inventory - } - p.Conditions = func() ([]GameplayAchievementCondition, error) { - var conditions []GameplayAchievementCondition - seen := map[[2]uint64]bool{} - if s.storyCatalog == nil { - return nil, nil - } - for _, c := range design.Conditions { - if c.Type < 14 || c.Type > 16 { - continue - } - key := [2]uint64{c.Type, c.SubType} - if seen[key] { - continue - } - seen[key] = true - pack, known := s.storyCatalog.Packs[int(c.SubType)] - if !known || len(pack.MainQuestIDs) == 0 { - continue - } - complete := true - for _, qid := range pack.MainQuestIDs { - if !s.state.QuestCleared(qid, pack.ID, int(c.Type-14)) { - complete = false - break - } - } - if complete { - conditions = append(conditions, GameplayAchievementCondition{Type: c.Type, SubType: c.SubType, Value: 1}) - } - } - return conditions, nil - } - p.FieldObjects = func() (map[string]gamedata.FieldRewardObject, error) { - objects := map[string]gamedata.FieldRewardObject{} - openedPeriods, err := s.state.OpenedFieldRewardPeriods() - if err != nil { - return nil, err - } - // Opened IDs are persisted independently of quest difficulty. Only loaded - // packs with real opened entries require their reward design to be resolved. - packs := map[int]bool{} - for id := range s.packs { - packs[id] = true - } - for id := range s.fieldPacks { - packs[id] = true - } - for pack := range packs { - periods := openedPeriods[pack] - if len(periods) == 0 { - continue - } - d, err := s.fieldObjectDesign(pack) - if err != nil { - return nil, err - } - for id, openedPeriod := range periods { - obj, known := d.Objects[id] - if !known { - return nil, fmt.Errorf("achievement: opened field design absent") - } - period, err := s.fieldObjectPeriodFor(pack, obj) - if err != nil { - continue - } - if openedPeriod == period { - objects[fmt.Sprintf("field:%d:%d:%s", pack, id, period)] = obj - } - } - } - return objects, nil - } - return p -} diff --git a/go/internal/server/world/achievement_gameplay_test.go b/go/internal/server/world/achievement_gameplay_test.go deleted file mode 100644 index f427af6..0000000 --- a/go/internal/server/world/achievement_gameplay_test.go +++ /dev/null @@ -1,332 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "errors" - "path/filepath" - "reflect" - "testing" - "time" -) - -type fieldSnapshotCountStore struct { - *accountstate.Repository - lists, loads int -} - -func (s *fieldSnapshotCountStore) ListEntries(domain, bucket string) (map[string][]byte, error) { - if domain == "progress" && bucket == "field_rewards" { - s.lists++ - } - return s.Repository.ListEntries(domain, bucket) -} -func (s *fieldSnapshotCountStore) LoadEntry(domain, bucket, key string) ([]byte, bool, error) { - if domain == "progress" && bucket == "field_rewards" { - s.loads++ - } - return s.Repository.LoadEntry(domain, bucket, key) -} - -func TestGameplayFieldSnapshotReadsOneBucketAndRestoresAfterRollback(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = repo.Close() }() - storage := &fieldSnapshotCountStore{Repository: repo} - s := testService() - s.state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s.packs = map[int]map[int]gamedata.QuestDesign{} - for pack := 1; pack <= 40; pack++ { - s.packs[pack] = nil - } - now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) - s.monsterNow = func() time.Time { return now } - s.fieldReset = gamedata.FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday} - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{ - 21: {Objects: map[int]gamedata.FieldRewardObject{71: {ID: 71, Type: 2, ResetType: 1}, 72: {ID: 72, Type: 2, ResetType: 0}, 73: {ID: 73, Type: 2, ResetType: 3}}}, - 22: {Objects: map[int]gamedata.FieldRewardObject{71: {ID: 71, Type: 2, ResetType: 0}}}, - }) - for _, row := range []struct { - pack, id int - period string - }{{21, 71, "once"}, {21, 72, "2026-10-05"}, {21, 73, "2026-10-05"}, {22, 71, "2026-10-06"}} { - if err := s.state.MarkFieldRewardOpened(row.pack, row.id, row.period); err != nil { - t.Fatal(err) - } - } - p := s.GameplayAchievementProvider(&gamedata.AchievementCounterDesign{}, gamedata.GameplayAchievementGrades{}) - assert := func(want map[string]gamedata.FieldRewardObject) { - t.Helper() - storage.lists, storage.loads = 0, 0 - got, err := p.Snapshot() - if err != nil || !reflect.DeepEqual(got.FieldObjects, want) { - t.Fatalf("field snapshot=%v err=%v want=%v", got.FieldObjects, err, want) - } - if storage.lists != 1 || storage.loads != 0 { - t.Fatalf("snapshot queried bucket %d times and objects %d times", storage.lists, storage.loads) - } - } - want := map[string]gamedata.FieldRewardObject{"field:21:71:once": s.fieldObjects[21].Objects[71], "field:21:73:2026-10-05": s.fieldObjects[21].Objects[73], "field:22:71:2026-10-06": s.fieldObjects[22].Objects[71]} - assert(want) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if err := s.state.MarkFieldRewardOpened(21, 72, "2026-10-06"); err != nil { - t.Fatal(err) - } - want["field:21:72:2026-10-06"] = s.fieldObjects[21].Objects[72] - assert(want) - if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatalf("dirty rollback=%v", err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - storage.Repository = repo - s.state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - delete(want, "field:21:72:2026-10-06") - assert(want) - now = now.AddDate(0, 0, 7) - delete(want, "field:21:73:2026-10-05") - delete(want, "field:22:71:2026-10-06") - assert(want) - if err := s.state.MarkFieldRewardOpened(21, 999, "once"); err != nil { - t.Fatal(err) - } - if _, err := p.Snapshot(); err == nil { - t.Fatal("unknown opened object skipped validation") - } -} - -type gameplayTestSource struct { - characters []player.Character - costumes []player.Costume - equipment []player.Equipment - items []player.Item -} - -func (s *gameplayTestSource) RawAll() []player.Character { return s.characters } - -type gameplayCostumes struct{ s *gameplayTestSource } - -func (s gameplayCostumes) Costumes() []player.Costume { return s.s.costumes } - -type gameplayEquipment struct{ s *gameplayTestSource } - -func (s gameplayEquipment) All() []player.Equipment { return s.s.equipment } - -type gameplayItems struct{ s *gameplayTestSource } - -func (s gameplayItems) All() []player.Item { return s.s.items } - -func TestInventorySnapshotProjectsMissionInputsWithoutUnrelatedReads(t *testing.T) { - source := &gameplayTestSource{costumes: []player.Costume{{ID: 1, InvenIndex: 101, Level: 2}}, equipment: []player.Equipment{{ID: 2, InvenIndex: 201, Level: 3}}, items: []player.Item{{Type: 5, ID: 4, Count: 7}, {Type: 5, ID: 4, Count: 2}}} - p := &OwnedGameplayAchievementProvider{Costumes: gameplayCostumes{source}, Equipment: gameplayEquipment{source}, Items: gameplayItems{source}, Conditions: func() ([]GameplayAchievementCondition, error) { - t.Fatal("inventory projection read quest conditions") - return nil, nil - }, FieldObjects: func() (map[string]gamedata.FieldRewardObject, error) { - t.Fatal("inventory projection queried field objects") - return nil, nil - }} - before, err := p.InventorySnapshot() - if err != nil || before.Items[[2]uint64{5, 4}] != 9 || before.Costumes[101].Level != 2 || before.Equipment[201].Level != 3 { - t.Fatalf("inventory projection=%+v err=%v", before, err) - } - source.costumes[0].Level = 4 - source.equipment[0].Level = 5 - source.items[0].Count = 1 - after, err := p.InventorySnapshot() - if err != nil || after.Items[[2]uint64{5, 4}] != 3 || after.Costumes[101].Level != 4 || after.Equipment[201].Level != 5 { - t.Fatalf("changed inventory projection=%+v err=%v", after, err) - } - if before.Items[[2]uint64{5, 4}] != 9 || before.Costumes[101].Level != 2 || before.Equipment[201].Level != 3 { - t.Fatal("new snapshot modified event baseline") - } -} -func TestTemporaryPartyMembersCannotGrantPermanentAcquisitionAchievements(t *testing.T) { - source := &gameplayTestSource{} - p := &OwnedGameplayAchievementProvider{Characters: source} - before, e := p.Snapshot() - if e != nil { - t.Fatal(e) - } - source.characters = []player.Character{{ID: 10, InvenIndex: player.CharmCharacterIndexBase + 1, Level: 1}, {ID: 11, InvenIndex: player.StoryCharacterIndexBase + 1, Level: 1}, {ID: 12, InvenIndex: 77, Level: 1}} - after, e := p.Snapshot() - if e != nil { - t.Fatal(e) - } - events, e := p.Events("/TalentSkillUse", nil, nil, before, after) - if e != nil { - t.Fatal(e) - } - if len(after.Characters) != 1 || len(events) != 1 || events[0].Type != 19 || events[0].Count != 1 { - t.Fatalf("temporary ownership inflated achievements: %+v", events) - } -} -func TestGameplayAchievementProjectsChangesWithoutFakeHistoricalGets(t *testing.T) { - design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{701: {0}, 702: {0}, 703: {0}, 704: {0}, 705: {0}}, Conditions: map[int]gamedata.AchievementCondition{701: {Type: 7, SubType: 4}, 702: {Type: 22}, 703: {Type: 26}, 704: {Type: 55}, 705: {Type: 14, SubType: 909}}} - service, err := NewAchievementService(design, stateio.NewMemory()) - if err != nil { - t.Fatal(err) - } - source := &gameplayTestSource{characters: []player.Character{{ID: 1, InvenIndex: 1, Level: 3}}, costumes: []player.Costume{{ID: 2, InvenIndex: 2}}, equipment: []player.Equipment{{ID: 3, InvenIndex: 3}}, items: []player.Item{{Type: 5, ID: 8, Count: 10}}} - provider := &OwnedGameplayAchievementProvider{Characters: source, Costumes: gameplayCostumes{source}, Equipment: gameplayEquipment{source}, Items: gameplayItems{source}, Design: design, EquipmentGrades: map[uint64]uint64{3: 4}} - observer, err := NewGameplayAchievementObserver(service, provider) - if err != nil { - t.Fatal(err) - } - observer.BeginSession("test") - req := wire.AppendVarint(nil, 1, 1) - if err := observer.BeforeDispatch("/AchievementInfo", req); err != nil { - t.Fatal(err) - } - notify, err := observer.AfterDispatch("/AchievementInfo", req, nil) - if err != nil || len(notify) != 0 { - t.Fatalf("invented historical counts notify=%x err=%v", notify, err) - } - if err := observer.BeforeDispatch("/CharGrowth", req); err != nil { - t.Fatal(err) - } - source.characters[0].Level = 5 - notify, err = observer.AfterDispatch("/CharGrowth", req, nil) - if err != nil { - t.Fatal(err) - } - row, _, _ := wire.Bytes(notify, 2) - group, _, _ := wire.Varint(row, 1) - value, _, _ := wire.Varint(row, 2) - if group != 702 || value != 2 { - t.Fatalf("absolute level update group=%d value=%d", group, value) - } - if err := observer.BeforeDispatch("/CharGrowth", req); err != nil { - t.Fatal(err) - } - notify, err = observer.AfterDispatch("/CharGrowth", req, nil) - if err != nil || len(notify) != 0 { - t.Fatal("replay emitted increment") - } - req = wire.AppendVarint(nil, 1, 2) - if err := observer.BeforeDispatch("/FieldObjectReward", req); err != nil { - t.Fatal(err) - } - source.equipment = append(source.equipment, player.Equipment{ID: 3, InvenIndex: 4}) - if _, err := observer.AfterDispatch("/FieldObjectReward", req, nil); err != nil { - t.Fatal(err) - } - values, err := service.CounterValues() - if err != nil || values[701] != 1 { - t.Fatalf("new equipment values=%v err=%v", values, err) - } -} - -func TestGameplayAchievementUsesChangedPackIDsAndDoesNotReplayChest(t *testing.T) { - s := testService() - s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{808: {ID: 808, MainQuestIDs: []int{111, 222}}}} - design := &gamedata.AchievementCounterDesign{Conditions: map[int]gamedata.AchievementCondition{99: {Type: 15, SubType: 808}}} - p := s.GameplayAchievementProvider(design, gamedata.GameplayAchievementGrades{}) - if err := s.state.ClearQuest(111, 808, 1); err != nil { - t.Fatal(err) - } - if err := s.state.ClearQuest(222, 808, 1); err != nil { - t.Fatal(err) - } - snapshot, err := p.Snapshot() - if err != nil { - t.Fatal(err) - } - if len(snapshot.Conditions) != 1 || snapshot.Conditions[0].SubType != 808 || snapshot.Conditions[0].Type != 15 || snapshot.Conditions[0].Value != 1 { - t.Fatalf("pack projection=%+v", snapshot.Conditions) - } - before := GameplayAchievementSnapshot{FieldObjects: map[string]gamedata.FieldRewardObject{}} - after := GameplayAchievementSnapshot{FieldObjects: map[string]gamedata.FieldRewardObject{"chest": {Type: 2}}} - p.Design = nil - events, err := p.Events("/FieldObjectReward", nil, nil, before, after) - if err != nil || len(events) != 1 || events[0].Type != 31 { - t.Fatalf("chest events=%+v err=%v", events, err) - } - events, err = p.Events("/FieldObjectReward", nil, nil, after, after) - if err != nil || len(events) != 0 { - t.Fatal("chest replay increments") - } -} - -func TestGameplayGachaEventsFollowNewDurableGrantKeys(t *testing.T) { - p := &OwnedGameplayAchievementProvider{} - before := GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10}} - after := GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10, "new": 3}} - events, err := p.Events("/GachaBuy", nil, nil, before, after) - if err != nil || len(events) != 1 || events[0].Type != 54 || events[0].Count != 3 { - t.Fatalf("new grant=%+v err=%v", events, err) - } - events, err = p.Events("/GachaBuy", nil, []byte{1, 2, 3}, after, after) - if err != nil || len(events) != 0 { - t.Fatal("cached response counted again") - } -} - -type gameplayHistoryProvider struct { - snapshot GameplayAchievementSnapshot - OwnedGameplayAchievementProvider -} - -func (p *gameplayHistoryProvider) Snapshot() (GameplayAchievementSnapshot, error) { - return p.snapshot, nil -} -func TestGameplayRecordedHistoryAndFutureShareStableGrantReceipts(t *testing.T) { - store := stateio.NewMemory() - design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{808: {0}}, Conditions: map[int]gamedata.AchievementCondition{808: {Type: 54}}} - service, err := NewAchievementService(design, store) - if err != nil { - t.Fatal(err) - } - provider := &gameplayHistoryProvider{snapshot: GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10}}} - observer, err := NewGameplayAchievementObserver(service, provider) - if err != nil { - t.Fatal(err) - } - if err := observer.SyncRecordedHistory(); err != nil { - t.Fatal(err) - } - observer.BeginSession("login1") - req := wire.AppendVarint(nil, 1, 1) - if err := observer.BeforeDispatch("/GachaBuy", req); err != nil { - t.Fatal(err) - } - provider.snapshot = GameplayAchievementSnapshot{GachaGrants: map[string]uint64{"old": 10, "new": 3}} - if _, err := observer.AfterDispatch("/GachaBuy", req, nil); err != nil { - t.Fatal(err) - } - service, err = NewAchievementService(design, store) - if err != nil { - t.Fatal(err) - } - observer, err = NewGameplayAchievementObserver(service, provider) - if err != nil { - t.Fatal(err) - } - if err := observer.SyncRecordedHistory(); err != nil { - t.Fatal(err) - } - values, err := service.CounterValues() - if err != nil || values[808] != 13 { - t.Fatalf("history/future duplicated values=%v err=%v", values, err) - } -} diff --git a/go/internal/server/world/achievement_progress_batch_test.go b/go/internal/server/world/achievement_progress_batch_test.go deleted file mode 100644 index 579e86b..0000000 --- a/go/internal/server/world/achievement_progress_batch_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "errors" - "fmt" - "math" - "path/filepath" - "reflect" - "testing" -) - -type achievementQueryStore struct { - *accountstate.Repository - lists, loads, saves int -} - -func (s *achievementQueryStore) ListEntries(domain, bucket string) (map[string][]byte, error) { - s.lists++ - return s.Repository.ListEntries(domain, bucket) -} -func (s *achievementQueryStore) LoadEntry(domain, bucket, key string) ([]byte, bool, error) { - s.loads++ - return s.Repository.LoadEntry(domain, bucket, key) -} -func (s *achievementQueryStore) SaveWithEntries(domain string, core []byte, entries []stateio.EntryMutation) error { - s.saves++ - return s.Repository.SaveWithEntries(domain, core, entries) -} - -func TestAchievementProgressBoundaryIsAtomicOrderedAndReplayable(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = repo.Close() }() - store := &achievementQueryStore{Repository: repo} - design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{1: {0}, 2: {0}}, Conditions: map[int]gamedata.AchievementCondition{1: {Type: 14, SubType: 21}, 2: {Type: 7}}} - s, err := NewAchievementService(design, store) - if err != nil { - t.Fatal(err) - } - s.BeginSession("test") - conditions := []GameplayAchievementCondition{{Type: 14, SubType: 21, Value: 1}} - events := []GameplayAchievementRecordedEvent{{Identity: "a", Type: 14, SubType: 21, Count: 3}, {Identity: "b", Type: 14, SubType: 21, Count: 4}, {Identity: "b", Type: 14, SubType: 21, Count: 4}} - before, after, err := s.ApplyGameplayProgress(conditions, events) - if err != nil || len(before) != 0 || !reflect.DeepEqual(after, map[int]uint64{1: 8}) { - t.Fatalf("ordered boundary before=%v after=%v err=%v", before, after, err) - } - if store.lists != 1 || store.saves != 1 { - t.Fatalf("boundary lists=%d saves=%d", store.lists, store.saves) - } - if _, after, err = s.ApplyGameplayProgress(nil, events); err != nil || after[1] != 8 { - t.Fatalf("receipt replay after=%v err=%v", after, err) - } - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - // A conflicting event after a new event must install neither event nor - // the preceding condition changes, even before outer rollback executes. - bad := append([]GameplayAchievementRecordedEvent{{Identity: "unwritten", Type: 7, Count: 5}}, GameplayAchievementRecordedEvent{Identity: "a", Type: 14, SubType: 21, Count: 9}) - if _, _, err := s.ApplyGameplayProgress([]GameplayAchievementCondition{{Type: 7, Value: 2}}, bad); err == nil { - t.Fatal("receipt conflict accepted") - } - if err := op.Rollback(); err != nil { - t.Fatal(err) - } - if _, found, err := repo.LoadEntry("missions", "achievement_events", "unwritten"); err != nil || found { - t.Fatalf("failed boundary persisted event=%v err=%v", found, err) - } - if got, err := s.CounterValues(); err != nil || !reflect.DeepEqual(got, map[int]uint64{1: 8}) { - t.Fatalf("failed boundary installed partial counts=%v err=%v", got, err) - } - if _, _, err := s.ApplyGameplayProgress([]GameplayAchievementCondition{{Type: 7, Value: math.MaxInt64}}, nil); err != nil { - t.Fatal(err) - } - if _, _, err := s.ApplyGameplayProgress(conditions, []GameplayAchievementRecordedEvent{{Identity: "overflow", Type: 7, Count: 1}}); err == nil { - t.Fatal("overflow accepted") - } - if value, err := s.AchievementValue(1); err != nil || value != 8 { - t.Fatalf("overflow installed preceding condition=%d err=%v", value, err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, err := s.ApplyGameplayProgress(nil, []GameplayAchievementRecordedEvent{{Identity: "rolled-back", Type: 14, SubType: 21, Count: 2}}); err != nil { - t.Fatal(err) - } - if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatalf("dirty rollback=%v", err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository = repo - s, err = NewAchievementService(design, store) - if err != nil { - t.Fatal(err) - } - _, after, err = s.ApplyGameplayProgress(nil, []GameplayAchievementRecordedEvent{{Identity: "rolled-back", Type: 14, SubType: 21, Count: 2}}) - if err != nil || after[1] != 10 { - t.Fatalf("rollback poisoned retry counts=%v err=%v", after, err) - } -} - -// This comparison uses the prior public counter operations as the baseline, -// and the optimized boundary against the same synthetic SQLite counter set. -// The 57-read case models login: no events, with 20 completed pack conditions. -func BenchmarkAchievementProgressBoundary(b *testing.B) { - for _, eventCount := range []int{0, 20} { - for _, bulk := range []bool{false, true} { - name := fmt.Sprintf("events%d/bulk%v", eventCount, bulk) - b.Run(name, func(b *testing.B) { - repo, err := accountstate.Open(filepath.Join(b.TempDir(), "state.db")) - if err != nil { - b.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - b.Error(err) - } - }() - store := &achievementQueryStore{Repository: repo} - design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{}, Conditions: map[int]gamedata.AchievementCondition{}} - for id := 1; id <= 500; id++ { - design.Groups[id] = []int{0} - design.Conditions[id] = gamedata.AchievementCondition{Type: 14, SubType: uint64(id)} - } - s, err := NewAchievementService(design, store) - if err != nil { - b.Fatal(err) - } - s.BeginSession("benchmark") - var conditions []GameplayAchievementCondition - for id := 1; id <= 20; id++ { - conditions = append(conditions, GameplayAchievementCondition{Type: 14, SubType: uint64(id), Value: 1}) - } - var seed []stateio.EntryMutation - for id := 1; id <= 500; id++ { - seed = append(seed, stateio.EntryMutation{Bucket: "achievement_counts", Key: fmt.Sprint(id), Payload: []byte("1")}) - } - if err := repo.SaveWithEntries("missions", nil, seed); err != nil { - b.Fatal(err) - } - store.lists, store.loads, store.saves = 0, 0, 0 - b.ReportAllocs() - b.ResetTimer() - for iteration := range b.N { - op, err := repo.BeginOperation() - if err != nil { - b.Fatal(err) - } - for request := range 57 { - if bulk { - _, _, err = s.ApplyGameplayProgress(conditions, nil) - } else { - _, err = s.CounterValues() - for _, c := range conditions { - if err == nil { - _, err = s.SetCondition(c.Type, c.SubType, c.Value) - } - } - } - if err != nil { - b.Fatal(err) - } - var events []GameplayAchievementRecordedEvent - for id := range eventCount { - events = append(events, GameplayAchievementRecordedEvent{Identity: fmt.Sprintf("%d/%d/%d", iteration, request, id), Type: 14, SubType: uint64(id + 1), Count: 1}) - } - if bulk { - _, _, err = s.ApplyGameplayProgress(conditions, events) - } else { - for _, c := range conditions { - if err == nil { - _, err = s.SetCondition(c.Type, c.SubType, c.Value) - } - } - for _, e := range events { - if err == nil { - _, err = s.RecordEvent(e.Identity, e.Type, e.SubType, e.Count) - } - } - if err == nil { - _, err = s.CounterValues() - } - } - if err != nil { - b.Fatal(err) - } - } - if err := op.Commit(); err != nil { - b.Fatal(err) - } - } - b.StopTimer() - b.ReportMetric(float64(store.lists)/float64(b.N), "bucket-reads/op") - b.ReportMetric(float64(store.loads)/float64(b.N), "entry-reads/op") - b.ReportMetric(float64(store.saves)/float64(b.N), "entry-writes/op") - }) - } - } -} diff --git a/go/internal/server/world/achievements.go b/go/internal/server/world/achievements.go deleted file mode 100644 index d50722b..0000000 --- a/go/internal/server/world/achievements.go +++ /dev/null @@ -1,225 +0,0 @@ -package world - -import ( - "encoding/json" - "fmt" - "math" - "sort" - "strconv" - "strings" - "sync" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -type AchievementService struct { - mu sync.Mutex - design *gamedata.AchievementCounterDesign - store stateio.AtomicEntryStore - session string - claims AchievementClaimSource -} -type AchievementClaimSource interface { - ClaimedAchievementIDs() map[gamedata.AchievementKey]bool -} -type achievementReceipt struct { - Sequence uint64 `json:"sequence"` - Group int `json:"group"` - Add int `json:"add"` -} -type achievementSnapshot struct { - Counts map[string]int64 `json:"counts"` - Receipts map[string]achievementReceipt `json:"receipts"` -} - -func NewAchievementService(design *gamedata.AchievementCounterDesign, store stateio.Store, claims ...AchievementClaimSource) (*AchievementService, error) { - if design == nil || len(design.Groups) == 0 || store == nil { - return nil, fmt.Errorf("achievement: missing design or storage") - } - entries, ok := store.(stateio.AtomicEntryStore) - if !ok { - return nil, fmt.Errorf("achievement: storage requires atomic entries") - } - service := &AchievementService{design: design, store: entries} - if len(claims) > 0 { - service.claims = claims[0] - } - return service, nil -} -func (s *AchievementService) BeginSession(id string) { - s.mu.Lock() - defer s.mu.Unlock() - s.session = id -} - -// Load each request from the transaction snapshot. A rolled-back request must -// never leave an increment or replay receipt in an in-memory cache. -func (s *AchievementService) load() (achievementSnapshot, error) { - state := achievementSnapshot{Counts: map[string]int64{}, Receipts: map[string]achievementReceipt{}} - counts, err := s.store.ListEntries("missions", "achievement_counts") - if err != nil { - return state, err - } - for key, raw := range counts { - var value int64 - if err = json.Unmarshal(raw, &value); err != nil { - return state, fmt.Errorf("achievement: invalid counter: %w", err) - } - state.Counts[key] = value - } - if s.session != "" { - raw, found, err := s.store.LoadEntry("missions", "achievement_receipts", s.session) - if err != nil { - return state, err - } - if found { - var receipt achievementReceipt - if err = json.Unmarshal(raw, &receipt); err != nil { - return state, fmt.Errorf("achievement: invalid receipt: %w", err) - } - state.Receipts[s.session] = receipt - } - } - if state.Counts == nil || state.Receipts == nil { - return state, fmt.Errorf("achievement: incomplete state") - } - for key, value := range state.Counts { - group, err := strconv.Atoi(key) - if err != nil || len(s.design.Groups[group]) == 0 || value < 0 { - return state, fmt.Errorf("achievement: invalid persisted counter") - } - } - return state, nil -} - -func (s *AchievementService) Handle(path string, request []byte) (int, []byte, bool, error) { - if path != "/AchievementInfo" && path != "/AchievementUpdate" { - return 0, nil, false, nil - } - s.mu.Lock() - defer s.mu.Unlock() - fail := func(err error) (int, []byte, bool, error) { return 0, nil, true, err } - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > math.MaxInt32 { - return fail(ErrInvalidRequest) - } - state, err := s.load() - if err != nil { - return fail(err) - } - if path == "/AchievementInfo" { - claimed := map[gamedata.AchievementKey]bool{} - if s.claims != nil { - claimed = s.claims.ClaimedAchievementIDs() - } - for key := range claimed { - group := strconv.Itoa(int(key.GroupID)) - if _, found := state.Counts[group]; !found && len(s.design.Groups[int(key.GroupID)]) > 0 { - state.Counts[group] = 0 - } - } - groups := make([]int, 0, len(state.Counts)) - for key := range state.Counts { - group, _ := strconv.Atoi(key) - groups = append(groups, group) - } - sort.Ints(groups) - var response []byte - for _, group := range groups { - for _, content := range s.design.Groups[group] { - row := wire.AppendVarint(nil, 1, uint64(group)) - row = wire.AppendVarint(row, 2, uint64(state.Counts[strconv.Itoa(group)])) - var maxID uint64 - // AchievementPacket.UpdateAchievementCount initializes title MaxClearId - // to 1000; this is the client protocol default, not a granted tier. - if content == 1 { - maxID = 1000 - } - for key := range claimed { - if key.GroupID == uint64(group) && key.ContentsGroup == uint64(content) && key.ID > maxID { - maxID = key.ID - } - } - if maxID > 0 { - row = wire.AppendVarint(row, 3, maxID) - } - if content != 0 { - row = wire.AppendVarint(row, 4, uint64(content)) - } - response = wire.AppendBytes(response, 1, row) - } - } - // Only the mission domain's actual claims advance max_clear_id. - return 166, response, true, nil - } - group, found, err := wire.Varint(request, 2) - if err != nil || !found || group == 0 || group > math.MaxInt32 || len(s.design.Groups[int(group)]) == 0 { - return fail(ErrInvalidRequest) - } - add, found, err := wire.Varint(request, 3) - if err != nil || !found || add == 0 || add > math.MaxInt32 || s.session == "" { - return fail(ErrInvalidRequest) - } - receipt := achievementReceipt{Sequence: seq, Group: int(group), Add: int(add)} - replayKey := s.session + "/" + strconv.FormatUint(seq, 10) - if raw, found, err := s.store.LoadEntry("missions", "achievement_replays", replayKey); err != nil { - return fail(err) - } else if found { - var previous achievementReceipt - if err = json.Unmarshal(raw, &previous); err != nil { - return fail(err) - } - if previous == receipt { - return 167, nil, true, nil - } - return fail(fmt.Errorf("achievement: conflicting request sequence")) - } - if previous, ok := state.Receipts[s.session]; ok && seq <= previous.Sequence { - if previous == receipt { - return 167, nil, true, nil - } - return fail(fmt.Errorf("achievement: stale or conflicting request sequence")) - } - key := strconv.Itoa(int(group)) - current := state.Counts[key] - if current > math.MaxInt64-int64(add) { - return fail(fmt.Errorf("achievement: counter overflow")) - } - state.Counts[key] = current + int64(add) - state.Receipts[s.session] = receipt - raw, err := json.Marshal(state.Counts[key]) - if err != nil { - return fail(err) - } - receiptRaw, err := json.Marshal(receipt) - if err != nil { - return fail(err) - } - changes := []stateio.EntryMutation{{Bucket: "achievement_counts", Key: key, Payload: raw}, {Bucket: "achievement_receipts", Key: s.session, Payload: receiptRaw}, {Bucket: "achievement_replays", Key: replayKey, Payload: receiptRaw}} - // Retain a complete retry window so a committed BatchRequest whose response - // was lost can replay several updates, not just the final update in the batch. - if seq > 256 { - entries, err := s.store.ListEntries("missions", "achievement_replays") - if err != nil { - return fail(err) - } - prefix := s.session + "/" - for replayKey := range entries { - if suffix, found := strings.CutPrefix(replayKey, prefix); found { - old, err := strconv.ParseUint(suffix, 10, 64) - if err != nil { - return fail(err) - } - if old <= seq-256 { - changes = append(changes, stateio.EntryMutation{Bucket: "achievement_replays", Key: replayKey, Delete: true}) - } - } - } - } - if err = s.store.SaveWithEntries("missions", nil, changes); err != nil { - return fail(err) - } - return 167, nil, true, nil -} diff --git a/go/internal/server/world/achievements_test.go b/go/internal/server/world/achievements_test.go deleted file mode 100644 index ae6e4ca..0000000 --- a/go/internal/server/world/achievements_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package world - -import ( - "bytes" - "path/filepath" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func achievementRequest(seq, group, add uint64) []byte { - req := wire.AppendVarint(nil, 1, seq) - req = wire.AppendVarint(req, 2, group) - return wire.AppendVarint(req, 3, add) -} -func achievementTestService(t *testing.T, store stateio.Store) *AchievementService { - t.Helper() - s, err := NewAchievementService(&gamedata.AchievementCounterDesign{Groups: map[int][]int{7: {0, 1}, 9: {0}}}, store) - if err != nil { - t.Fatal(err) - } - s.BeginSession("login-a") - return s -} -func achievementValue(t *testing.T, s *AchievementService) int64 { - t.Helper() - state, err := s.load() - if err != nil { - t.Fatal(err) - } - return state.Counts["7"] -} -func TestAchievementUpdatePersistsAndRetries(t *testing.T) { - store := stateio.NewMemory() - s := achievementTestService(t, store) - req := achievementRequest(10, 7, 1) - for range 2 { - code, body, ok, err := s.Handle("/AchievementUpdate", req) - if err != nil || code != 167 || !ok || len(body) != 0 { - t.Fatalf("update: %d %x %v %v", code, body, ok, err) - } - } - if got := achievementValue(t, s); got != 1 { - t.Fatalf("retry incremented count: %d", got) - } - reopened := achievementTestService(t, store) - if got := achievementValue(t, reopened); got != 1 { - t.Fatalf("reopen: %d", got) - } - if _, _, _, err := reopened.Handle("/AchievementUpdate", achievementRequest(10, 7, 2)); err == nil { - t.Fatal("conflicting sequence accepted") - } - reopened.BeginSession("login-b") - if _, _, _, err := reopened.Handle("/AchievementUpdate", req); err != nil { - t.Fatal(err) - } - code, body, ok, err := reopened.Handle("/AchievementInfo", wire.AppendVarint(nil, 1, 11)) - var expected []byte - for _, content := range []uint64{0, 1} { - row := wire.AppendVarint(nil, 1, 7) - row = wire.AppendVarint(row, 2, 2) - if content != 0 { - row = wire.AppendVarint(row, 3, 1000) - row = wire.AppendVarint(row, 4, content) - } - expected = wire.AppendBytes(expected, 1, row) - } - if err != nil || code != 166 || !ok || !bytes.Equal(body, expected) { - t.Fatalf("info mismatch: %d %x %v %v", code, body, ok, err) - } -} -func TestAchievementInvalidRequestsDoNotMutate(t *testing.T) { - s := achievementTestService(t, stateio.NewMemory()) - for _, req := range [][]byte{achievementRequest(0, 7, 1), achievementRequest(1, 8, 1), achievementRequest(1, 7, 0), achievementRequest(1, 7, 1<<32), {0xff}} { - if _, _, _, err := s.Handle("/AchievementUpdate", req); err == nil { - t.Fatalf("invalid request accepted: %x", req) - } - } - if achievementValue(t, s) != 0 { - t.Fatal("invalid request mutated counter") - } - s.BeginSession("") - if _, _, _, err := s.Handle("/AchievementUpdate", achievementRequest(1, 7, 1)); err == nil { - t.Fatal("missing session accepted") - } -} - -func TestAchievementBatchRetryKeepsEarlierReceipt(t *testing.T) { - s := achievementTestService(t, stateio.NewMemory()) - requests := [][]byte{achievementRequest(10, 7, 1), achievementRequest(11, 7, 2)} - for range 2 { - for _, request := range requests { - if _, _, _, err := s.Handle("/AchievementUpdate", request); err != nil { - t.Fatal(err) - } - } - } - if achievementValue(t, s) != 3 { - t.Fatal("committed batch retry duplicated increments") - } - if _, _, _, err := s.Handle("/AchievementUpdate", achievementRequest(300, 7, 1)); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/AchievementUpdate", requests[0]); err == nil { - t.Fatal("expired replay accepted as a new increment") - } - if achievementValue(t, s) != 4 { - t.Fatal("expired replay changed progress") - } -} - -type achievementClaimsFixture struct{} - -func (achievementClaimsFixture) ClaimedAchievementIDs() map[gamedata.AchievementKey]bool { - return map[gamedata.AchievementKey]bool{{ContentsGroup: 1, GroupID: 7, ID: 1003}: true} -} -func TestAchievementInfoIncludesRealClaimState(t *testing.T) { - s := achievementTestService(t, stateio.NewMemory()) - s.claims = achievementClaimsFixture{} - _, body, _, err := s.Handle("/AchievementInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil { - t.Fatal(err) - } - var rows [][]byte - if err = wire.Walk(body, func(field wire.Field) error { - if field.Number == 1 { - rows = append(rows, field.Value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(rows) != 2 { - t.Fatalf("missing claim group rows: %x", body) - } - clear, found, err := wire.Varint(rows[1], 3) - if err != nil || !found || clear != 1003 { - t.Fatal("max_clear_id did not come from actual claims") - } -} -func TestAchievementTransactionRollbackAndReopen(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s := achievementTestService(t, repo) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(1, 7, 1)); err != nil { - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - s = achievementTestService(t, repo) - if achievementValue(t, s) != 1 { - t.Fatal("SQLite reopen lost count") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(2, 7, 3)); err != nil { - t.Fatal(err) - } - // Repository fences a dirty rollback because other domains cache memory. - // Reopening is the recovery boundary; this service keeps no cached counts. - _ = op.Rollback() - _ = repo.Close() - recovered, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := recovered.Close(); err != nil { - t.Error(err) - } - }() - s = achievementTestService(t, recovered) - if achievementValue(t, s) != 1 { - t.Fatal("rolled-back increment survived") - } - if _, _, _, err = s.Handle("/AchievementUpdate", achievementRequest(2, 7, 3)); err != nil { - t.Fatal(err) - } - if achievementValue(t, s) != 4 { - t.Fatal("rolled-back replay receipt survived") - } -} diff --git a/go/internal/server/world/auto_recovery.go b/go/internal/server/world/auto_recovery.go deleted file mode 100644 index 4a13e22..0000000 --- a/go/internal/server/world/auto_recovery.go +++ /dev/null @@ -1,25 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "fmt" -) - -func (s *Service) AttachAutoRecoveryPolicy(root, version string) error { - p, e := gamedata.LoadPackRecoveryPolicy(root, version) - if e != nil { - return e - } - s.autoRecoveryPolicy = p - return nil -} -func (s *Service) AutoRecoveryAllowed() (bool, error) { - if s.autoRecoveryPolicy == nil { - return false, fmt.Errorf("world: automatic recovery pack policy unavailable") - } - p, e := s.CurrentPackID() - if e != nil { - return false, e - } - return s.autoRecoveryPolicy.Allowed(p, s.packCompleteFor(p)), nil -} diff --git a/go/internal/server/world/event_field_pack.go b/go/internal/server/world/event_field_pack.go deleted file mode 100644 index 482691d..0000000 --- a/go/internal/server/world/event_field_pack.go +++ /dev/null @@ -1,114 +0,0 @@ -package world - -import ( - "fmt" - "slices" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -// EventFieldPackSource binds installed hidden packs to the server's current -// calendars. A purchase receipt alone never authorizes an expired event. -type EventFieldPackSource interface { - ResolveEventFieldPack(int) (gamedata.EventFieldPack, bool, error) - ListEventFieldPacks() ([]gamedata.EventFieldPack, error) -} - -// PackAvailable exposes the same account/calendar authorization to NPC -// services without letting them infer access from the client's pack number. -func (s *Service) PackAvailable(id uint64) bool { - if id == 0 || id > uint64(^uint32(0)>>1) { - return false - } - return s.packUnlocked(int(id)) -} - -func (s *Service) AttachEventFieldPacks(source EventFieldPackSource) error { - if source == nil { - return fmt.Errorf("world: nil event field pack source") - } - s.eventFieldPacks = source - return nil -} - -func (s *Service) resolveEventFieldPack(id int) (gamedata.EventFieldPack, bool, error) { - if s.eventFieldPacks == nil { - return gamedata.EventFieldPack{}, false, nil - } - return s.eventFieldPacks.ResolveEventFieldPack(id) -} - -func (s *Service) eventPackPurchased(id int) bool { - if s.collection == nil { - return false - } - _, found := s.collection.Grant(packPurchaseIdentity(id)) - return found -} - -func (s *Service) eventPackDBInfo(pack gamedata.EventFieldPack) []byte { - row := wire.AppendVarint(nil, 1, uint64(pack.ID)) - if s.eventPackPurchased(pack.ID) { - row = wire.AppendVarint(row, 8, 1) - } - return row -} - -func (s *Service) eventPackInfoRows() ([][]byte, error) { - if s.eventFieldPacks == nil { - return nil, nil - } - packs, err := s.eventFieldPacks.ListEventFieldPacks() - if err != nil { - return nil, err - } - var rows [][]byte - for _, pack := range packs { - // PackManager buys a hidden pack only when PackInfo has no row for it. - if s.eventPackPurchased(pack.ID) { - rows = append(rows, s.eventPackDBInfo(pack)) - } - } - return rows, nil -} - -func (s *Service) enterEventFieldPack(pack gamedata.EventFieldPack) (int, []byte, bool, error) { - if !s.eventPackPurchased(pack.ID) { - return 0, nil, true, fmt.Errorf("%w: event pack %d is not purchased", ErrInvalidRequest, pack.ID) - } - position := pack.InitialPosition - if position == "" { - return 0, nil, true, fmt.Errorf("world: missing event pack initial position") - } - if saved, found := s.state.Position(); found && saved.PackID == pack.ID { - if !slices.Contains(pack.MapIDs, saved.Position.MapID) { - return 0, nil, true, fmt.Errorf("%w: map outside event pack", ErrInvalidRequest) - } - position = saved.RawJSON - } - response := wire.AppendString(nil, 4, position) - buffs, err := s.fieldBuffInfo() - if err != nil { - return 0, nil, true, err - } - response = append(response, buffs...) - // The common callback dereferences HuntingGroundInfo even in hidden packs. - // Use the domain-generated empty/current snapshot; never borrow the outside - // map's monsters or story progress. - var hunting []byte - if s.huntingGround != nil { - var err error - hunting, err = s.huntingGround.EnsureForPack(pack.ID) - if err != nil { - return 0, nil, true, err - } - } - response = wire.AppendBytes(response, 12, hunting) - // Hidden-pack entry must retain the outside field position: the client - // intentionally suppresses SaveUserPosition while playing these packs. The - // persistent active pack also stays outside, so relogin cannot be stranded - // in a hidden scene after its calendar closes. - s.setCurrentPack(pack.ID) - return 5, response, true, nil -} diff --git a/go/internal/server/world/event_field_pack_test.go b/go/internal/server/world/event_field_pack_test.go deleted file mode 100644 index 6b13064..0000000 --- a/go/internal/server/world/event_field_pack_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -type eventPackSource struct { - active bool - pack gamedata.EventFieldPack -} - -func (p *eventPackSource) ResolveEventFieldPack(id int) (gamedata.EventFieldPack, bool, error) { - return p.pack, p.active && p.pack.ID == id, nil -} -func (p *eventPackSource) ListEventFieldPacks() ([]gamedata.EventFieldPack, error) { - if p.active { - return []gamedata.EventFieldPack{p.pack}, nil - } - return nil, nil -} - -// Exercise the actual purchase-to-field request chain rather than a single -// permissive route. A hidden scene must not overwrite relogin's outside map. -func TestHiddenPackPurchaseEntryMonstersAndOutsideRestore(t *testing.T) { - s := testService() - storage := stateio.NewMemory() - var err error - s.state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s.collection, err = player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(storage, s.starter) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{Gold: 100}) - if err != nil { - t.Fatal(err) - } - source := &eventPackSource{active: true, pack: gamedata.EventFieldPack{ID: 912, MapIDs: []int{9121}, InitialPosition: "{}", BuyType: 4, BuyPrice: 20}} - if err = s.AttachEventFieldPacks(source); err != nil { - t.Fatal(err) - } - s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) { - if pack != 912 { - t.Fatalf("wrong pack for monsters %d", pack) - } - return []gamedata.FieldMonsterDesign{{ID: 3, GroupID: 7}}, nil - } - outside := wire.AppendString(wire.AppendVarint(nil, 2, 21), 3, `{"MapId":211,"PlayerPosition":{"x":4}}`) - if err = s.state.SaveUserPosition(outside); err != nil { - t.Fatal(err) - } - if err = s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - r := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 912) - if _, _, _, err = s.Handle("/PackInGameInfo", r); err == nil { - t.Fatal("unbought hidden pack admitted") - } - for range 2 { - code, out, ok, err := s.Handle("/PackBuy", r) - if err != nil || code != 6 || !ok { - t.Fatalf("buy %d %v", code, err) - } - info, found, err := wire.Bytes(out, 1) - if err != nil || !found { - t.Fatal("missing PackInfo") - } - id, _, _ := wire.Varint(info, 1) - owned, _, _ := wire.Varint(info, 8) - if id != 912 || owned != 1 { - t.Fatal("hidden buy response missing purchased identity") - } - } - if s.wallet.Snapshot().Gold != 80 { - t.Fatal("replayed purchase spent twice") - } - if _, _, _, err = s.Handle("/FieldObjectInfo", r); err != nil { - t.Fatal(err) - } - _, out, _, err := s.Handle("/PackInGameInfo", r) - if err != nil { - t.Fatal(err) - } - position, _, _ := wire.Bytes(out, 4) - if string(position) != "{}" { - t.Fatal("outside position leaked into hidden scene") - } - if _, found, _ := wire.Bytes(out, 12); !found { - t.Fatal("client HuntingGroundInfo null") - } - if _, found, _ := wire.Bytes(out, 2); found { - t.Fatal("hidden scene inherited story quests") - } - monster := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 7) - code, out, _, err := s.Handle("/MonsterInfo", monster) - if err != nil || code != 51 { - t.Fatalf("monster info %v", err) - } - m, _, _ := wire.Bytes(out, 1) - id, _, _ := wire.Varint(m, 1) - if id != 3 { - t.Fatal("hidden monster not generated") - } - if pack, err := s.LastPlayedPackID(); err != nil || pack != 21 { - t.Fatalf("hidden scene replaced persistent login %d %v", pack, err) - } - source.active = false - if _, _, _, err = s.Handle("/PackBuy", r); err == nil { - t.Fatal("expired pack repurchased") - } - if _, _, _, err = s.Handle("/PackInGameInfo", r); err == nil { - t.Fatal("expired pack entered") - } - if pack, err := s.LastPlayedPackID(); err != nil || pack != 21 { - t.Fatalf("expired hidden scene locked login %d %v", pack, err) - } -} diff --git a/go/internal/server/world/field_actions.go b/go/internal/server/world/field_actions.go deleted file mode 100644 index d47926e..0000000 --- a/go/internal/server/world/field_actions.go +++ /dev/null @@ -1,99 +0,0 @@ -package world - -import ( - "bd2server/internal/server/progress" - "bd2server/internal/server/wire" - "encoding/binary" - "fmt" - "math" - "sort" -) - -func (s *Service) handleFieldObjectPosition(request []byte) (int, []byte, bool, error) { - pack, err := requestPack(request) - if err != nil { - return 95, nil, true, err - } - group, _, err := wire.Varint(request, 3) - if err != nil || group == 0 || group > 0x7fffffff { - return 95, nil, true, ErrInvalidRequest - } - id, _, err := wire.Varint(request, 4) - if err != nil || id == 0 || id > 0x7fffffff { - return 95, nil, true, ErrInvalidRequest - } - position, found, err := wire.Bytes(request, 5) - if err != nil || !found { - return 95, nil, true, ErrInvalidRequest - } - mapID, _, err := wire.Varint(position, 1) - if err != nil || mapID == 0 || mapID > 0x7fffffff { - return 95, nil, true, ErrInvalidRequest - } - if err = wire.Walk(position, func(f wire.Field) error { - if f.Number >= 2 && f.Number <= 4 { - if f.Type != 5 { - return ErrInvalidRequest - } - value := math.Float32frombits(binary.LittleEndian.Uint32(f.Value)) - if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) { - return ErrInvalidRequest - } - } - return nil - }); err != nil { - return 95, nil, true, err - } - design, err := s.fieldObjectDesign(pack) - if err != nil { - return 95, nil, true, err - } - obj, exists := design.Actions[int(id)] - if !exists || obj.GroupID != int(group) || obj.Type != 1 || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) { - return 95, nil, true, fmt.Errorf("%w: unavailable field action object", ErrInvalidRequest) - } - currentMap, err := s.currentFieldMap(pack) - if err != nil || currentMap != int(mapID) { - return 95, nil, true, fmt.Errorf("%w: field action outside current map", ErrInvalidRequest) - } - cleared := obj.QuestID > 0 && s.state.QuestCleared(obj.QuestID, pack, s.questDifficulty(pack)) - if obj.QuestID > 0 && (obj.QuestEnableType == 1 && !cleared || obj.QuestEnableType == 2 && cleared) { - return 95, nil, true, fmt.Errorf("%w: field action unavailable for quest", ErrInvalidRequest) - } - if err = s.state.SaveFieldActionPosition(pack, int(id), progress.FieldActionPosition{Position: position, QuestCleared: cleared}); err != nil { - return 95, nil, true, err - } - return 95, []byte{}, true, nil -} - -func (s *Service) fieldActionInfo(pack int) ([]byte, error) { - positions, err := s.state.FieldActionPositions(pack) - if err != nil || len(positions) == 0 { - return nil, err - } - design, err := s.fieldObjectDesign(pack) - if err != nil { - return nil, err - } - ids := make([]int, 0, len(positions)) - for id := range positions { - ids = append(ids, id) - } - sort.Ints(ids) - var out []byte - for _, id := range ids { - obj, found := design.Actions[id] - if !found { - return nil, fmt.Errorf("world: saved field action absent from design") - } - stored := positions[id] - // Client resets authored action positions when the related quest ends. - // Omit that stale position on reconnect so the prefab uses its origin. - if obj.QuestID > 0 && stored.QuestCleared != s.state.QuestCleared(obj.QuestID, pack, s.questDifficulty(pack)) { - continue - } - info := wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(id)), 2, stored.Position) - out = wire.AppendBytes(out, 2, info) - } - return out, nil -} diff --git a/go/internal/server/world/field_actions_test.go b/go/internal/server/world/field_actions_test.go deleted file mode 100644 index aea79b9..0000000 --- a/go/internal/server/world/field_actions_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "encoding/binary" - "errors" - "math" - "os" - "path/filepath" - "testing" -) - -func TestCarriedFieldObjectSQLiteReconnectQuestResetAndRollback(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { _ = repo.Close() }() - s := testService() - s.state, err = progress.OpenStore(repo) - if err != nil { - t.Fatal(err) - } - if err = s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - if err = s.state.SaveUserPosition(wire.AppendString(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21), 3, `{"MapId":211,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null}`)); err != nil { - t.Fatal(err) - } - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{}, Actions: map[int]gamedata.FieldActionObject{6011: {ID: 6011, GroupID: 601, MapID: 211, Type: 1, QuestID: 1}}}}) - position := wire.AppendVarint(nil, 1, 211) - position = append(position, 0x15) - position = binary.LittleEndian.AppendUint32(position, math.Float32bits(2.5)) - request := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 21), 3, 601), 4, 6011), 5, position) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - code, _, handled, err := s.Handle("/FieldObjecPositionUpdate", request) - if err != nil || !handled || code != 95 { - t.Fatalf("carry save: %d %v %v", code, handled, err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s.state, err = progress.OpenStore(repo) - if err != nil { - t.Fatal(err) - } - infoRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 21) - _, info, _, err := s.Handle("/FieldObjectInfo", infoRequest) - if err != nil { - t.Fatal(err) - } - object, found, err := wire.Bytes(info, 2) - saved, _, _ := wire.Bytes(object, 2) - x := float32(0) - _ = wire.Walk(saved, func(f wire.Field) error { - if f.Number == 2 { - x = math.Float32frombits(binary.LittleEndian.Uint32(f.Value)) - } - return nil - }) - if err != nil || !found || x != 2.5 { - t.Fatalf("reconnect lost carried position: %x %v", info, err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - changed := append([]byte(nil), request...) - changed[len(changed)-1] ^= 0x80 - if _, _, _, err = s.Handle("/FieldObjecPositionUpdate", changed); err != nil { - t.Fatal(err) - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - s.state, err = progress.OpenStore(repo) - if err != nil { - t.Fatal(err) - } - _, after, _, err := s.Handle("/FieldObjectInfo", infoRequest) - if err != nil || string(info) != string(after) { - t.Fatal("rolled-back position survived") - } - bad := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, 21), 3, 999), 4, 6011), 5, position) - if _, _, _, err = s.Handle("/FieldObjecPositionUpdate", bad); err == nil { - t.Fatal("forged group accepted") - } - if err = s.state.ClearQuest(1, 21, 0); err != nil { - t.Fatal(err) - } - _, after, _, err = s.Handle("/FieldObjectInfo", infoRequest) - _, found, _ = wire.Bytes(after, 2) - if err != nil || found { - t.Fatal("quest transition restored stale carried position") - } -} - -func TestInstalledFieldObjectCountersUseClientIndependentCategories(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA to verify current field summary categories") - } - design, err := gamedata.LoadFieldObjects(root, "20260923193640", 21) - if err != nil { - t.Fatal(err) - } - s := testService() - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: design}) - if len(design.Objects) != 9 { - t.Fatal("authored field regression requires review") - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21) - request = wire.AppendVarint(wire.AppendVarint(request, 3, 2), 3, 3) - _, body, _, err := s.Handle("/PackRewardObjectCount", request) - if err != nil { - t.Fatal(err) - } - totals := map[uint64]uint64{} - if err = wire.Walk(body, func(f wire.Field) error { - if f.Number == 1 { - category, _, e := wire.Varint(f.Value, 1) - total, _, e2 := wire.Varint(f.Value, 4) - if e != nil { - return e - } - if e2 != nil { - return e2 - } - totals[category] = total - } - return nil - }); err != nil { - t.Fatal(err) - } - if totals[2] != 9 || totals[3] != 0 { - t.Fatalf("one-time vs renewable counts: %v", totals) - } -} diff --git a/go/internal/server/world/field_buff_damage_test.go b/go/internal/server/world/field_buff_damage_test.go deleted file mode 100644 index ad50fd3..0000000 --- a/go/internal/server/world/field_buff_damage_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package world - -import ( - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" - "testing" -) - -func TestMonsterFieldDamagePartyFractionAndRequestReplay(t *testing.T) { - s := testService() - store := stateio.NewMemory() - starter := &player.Starter{Version: versionconfig.State(), Characters: []player.Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100}, {InvenIndex: 88, ID: 650, Level: 1, HP: 100}}} - items, e := player.OpenInventory(store, starter) - if e != nil { - t.Fatal(e) - } - chars, e := player.OpenCharacterStore(store, starter.Characters, items, "", "") - if e != nil { - t.Fatal(e) - } - if err := chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err := chars.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := chars.SetCurrentHealth(77, 70); err != nil { - t.Fatal(err) - } - if err := chars.SetCurrentHealth(88, 0); err != nil { - t.Fatal(err) - } - s.characters = chars - s.decks, e = deck.NewStore(deck.Seed{Version: versionconfig.State(), FieldCharControlDeckType: 1, FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}, {Slot: 2, CharacterInvenIndex: 88}}}) - if e != nil { - t.Fatal(e) - } - s.fieldBuffs = map[uint64]gamedata.FieldBuffDesign{4: {ID: 4, Type: 5, TargetType: 1, Value: .25}, 5: {ID: 5, Type: 4, TargetType: 0, Value: 10}} - if err := s.AttachFieldMonsterState(store); err != nil { - t.Fatal(err) - } - s.BeginSession("login") - s.AttachFieldMonsterDamage(s.applyMonsterFieldDamage) - s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { - return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, Type: 3, FieldBuff: 4}}, nil - } - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 9) - _, first, _, e := s.Handle("/FieldMonsterDamage", req) - if e != nil { - t.Fatal(e) - } - hp, _ := chars.CurrentHealth(77) - dead, _ := chars.CurrentHealth(88) - if hp != 45 || dead != 0 { - t.Fatal("wrong party damage", hp, dead) - } - _, again, _, e := s.Handle("/FieldMonsterDamage", req) - if e != nil || string(first) != string(again) { - t.Fatal("damage replay response differs", e) - } - hp, _ = chars.CurrentHealth(77) - if hp != 45 { - t.Fatal("retry damaged again", hp) - } - if err := chars.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - rows, e := s.applyMonsterFieldDamage(21, 5, "test") - if e != nil || len(rows) != 1 { - t.Fatal("dead leader should remain the controlled leader", e) - } - hp, _ = chars.CurrentHealth(88) - if hp != 0 { - t.Fatal("dead leader incorrectly switched to another character") - } -} diff --git a/go/internal/server/world/field_buff_info.go b/go/internal/server/world/field_buff_info.go deleted file mode 100644 index 604fee0..0000000 --- a/go/internal/server/world/field_buff_info.go +++ /dev/null @@ -1,23 +0,0 @@ -package world - -import "bd2server/internal/server/wire" - -func (s *Service) fieldBuffInfo() ([]byte, error) { - rows, err := s.state.FieldBuffs() - if err != nil { - return nil, err - } - now := s.monsterTime().UnixMilli() - var out []byte - for _, row := range rows { - end, _, err := wire.Varint(row, 3) - if err != nil { - return nil, err - } - if end != 0 && (end > 0x7fffffffffffffff || int64(end) <= now) { - continue - } - out = wire.AppendBytes(out, 8, row) - } - return out, nil -} diff --git a/go/internal/server/world/field_chest_open_wrapper_test.go b/go/internal/server/world/field_chest_open_wrapper_test.go deleted file mode 100644 index 8b7c58f..0000000 --- a/go/internal/server/world/field_chest_open_wrapper_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "errors" - "os" - "path/filepath" - "testing" - - "bd2server/internal/server/stateio" -) - -// Player.log's failing map-3 chest returned OPEN box 240001 as ItemDBInfo; -// PlayerController then loaded its intentionally absent ResourceIcon. This -// regression settles that exact selected branch using installed design data. -func TestInstalledFieldChestOpenWrapperProtocolAndSQLiteRollback(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for the chest crash regression") - } - design, err := gamedata.LoadFieldObjects(root, "20260923193640", 1) - if err != nil { - t.Fatal(err) - } - chest := design.Objects[921] - if chest.MapID != 3 || chest.GroupID != 23 { - t.Fatal("crash fixture no longer matches current chest design") - } - var failingBranch gamedata.BattleReward - for _, reward := range chest.Rewards { - if reward.Type == 9 && reward.ID == 240001 { - failingBranch = reward - } - } - if failingBranch.Count != 1 { - t.Fatal("authored OPEN equipment branch missing") - } - // Condition on the real branch selected at 09:15:23; select the first - // authored child deterministically so rollback/restart assertions are exact. - chest.Rewards, chest.Ratios = []gamedata.BattleReward{failingBranch}, []uint64{1} - design.Objects[921] = chest - design.RewardGraph.SetSampler(func(uint64) (uint64, error) { return 0, nil }) - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - store := &fieldRewardFailStore{Repository: repo} - service := func() *Service { - s := sqliteFieldService(t, store).WithFieldObjects(map[int]gamedata.FieldObjectDesign{1: design}) - s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, Quests: map[int]gamedata.QuestDesign{1: {ID: 1}}, MainQuestIDs: []int{1}} - s.packs = map[int]map[int]gamedata.QuestDesign{1: s.storyCatalog.Packs[1].Quests} - if err := s.state.SetActivePackID(1); err != nil { - t.Fatal(err) - } - return s - } - s := service() - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 23), 4, 921) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - store.fail = true - if _, _, _, err := s.handleFieldObjectReward(request); err == nil { - t.Fatal("failed opened marker accepted") - } - if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository, store.fail = repo, false - s = service() - opened, err := s.state.FieldRewardOpened(1, 921, "once") - if err != nil || opened || len(s.equipment.All()) != 0 || len(s.inventory.All()) != 0 { - t.Fatal("rolled back chest left equipment, wrapper, or marker") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - code, response, _, err := s.handleFieldObjectReward(request) - if err != nil || code != 29 { - t.Fatalf("code=%d error=%v", code, err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - bundle, _, _ := wire.Bytes(response, 1) - if fieldWireCount(t, bundle, 1) != 0 || fieldWireCount(t, bundle, 4) != 1 || len(s.inventory.All()) != 0 { - t.Fatal("OPEN wrapper leaked into ItemDBInfo/inventory instead of EquipDBInfo") - } - equipment, found, err := wire.Bytes(bundle, 4) - base, _, _ := wire.Bytes(equipment, 5) - id, _, _ := wire.Varint(base, 1) - index, _, _ := wire.Varint(equipment, 1) - if err != nil || !found || id != 10010 || index == 0 || len(s.equipment.All()) != 1 || s.equipment.All()[0].InvenIndex != index { - t.Fatalf("wrong authored equipment id=%d index=%d", id, index) - } - for _, field := range []int{3, 4, 5} { - if _, found, err := wire.Bytes(base, field); err != nil || !found { - t.Fatalf("equipment missing client metadata field %d", field) - } - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository = repo - s = service() - _, response, _, err = s.handleFieldObjectReward(request) - bundle, _, _ = wire.Bytes(response, 1) - if err != nil || len(bundle) != 0 || len(s.equipment.All()) != 1 || len(s.inventory.All()) != 0 { - t.Fatal("restart/retry repeated equipment or created a wrapper") - } -} diff --git a/go/internal/server/world/field_monster_events.go b/go/internal/server/world/field_monster_events.go deleted file mode 100644 index 920b639..0000000 --- a/go/internal/server/world/field_monster_events.go +++ /dev/null @@ -1,198 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "bytes" - "fmt" -) - -func (s *Service) grantFieldMonster(pack int, m gamedata.FieldMonsterDesign, identity string) ([]byte, error) { - if s.researchEconomy == nil { - return nil, fmt.Errorf("world: monster economy unavailable") - } - var rewards []gamedata.Reward - if m.Reward.Type != 0 && m.Reward.Count > 0 { - rewards = append(rewards, m.Reward) - } else if m.BattleDeck != 0 { - if s.monsterRewards == nil { - return nil, fmt.Errorf("world: monster reward loader unavailable") - } - rows, e := s.monsterRewards(pack, m.BattleDeck) - if e != nil { - return nil, e - } - for _, r := range rows { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - } - } - return s.researchEconomy.Apply(identity, nil, rewards) -} -func monsterBundleFields(bundle []byte, itemField, equipField int) ([]byte, error) { - var b []byte - err := wire.Walk(bundle, func(f wire.Field) error { - if f.Type == 2 && f.Number == 1 { - b = wire.AppendBytes(b, itemField, f.Value) - } else if f.Type == 2 && f.Number == 4 { - b = wire.AppendBytes(b, equipField, f.Value) - } - return nil - }) - return b, err -} -func (s *Service) handleFieldMonsterEvent(path string, request []byte) (int, []byte, bool, error) { - seqCheck, present, seqErr := wire.Varint(request, 1) - if seqErr != nil || !present || seqCheck == 0 || seqCheck > 0x7fffffff { - return 0, nil, true, ErrInvalidRequest - } - code := 83 - if path == "/FieldMonsterDamage" { - code = 85 - } - id, e := requestPack(request) - if e != nil { - return code, nil, true, e - } - pack, e := s.CurrentPackID() - if e != nil || !s.packUnlocked(pack) { - return code, nil, true, ErrInvalidRequest - } - m, found, e := s.findFieldMonster(pack, id) - if e != nil || !found || !s.monsterEligible(pack, m) { - return code, nil, true, ErrInvalidRequest - } - if path == "/FieldMonsterEvent" && m.Type != 2 && m.Type != 3 { - return code, nil, true, ErrInvalidRequest - } - if e = s.authorizeMonsterMap(pack, id); e != nil { - return code, nil, true, e - } - v, e := s.loadMonsterState() - if e != nil { - return code, nil, true, e - } - seq, _, _ := wire.Varint(request, 1) - if s.monsterSession == "" { - return code, nil, true, fmt.Errorf("world: missing monster session") - } - key := fmt.Sprintf("%s:%s:%d", path, s.monsterSession, seq) - if r, ok := v.Requests[key]; ok { - if !bytes.Equal(r.Request, request) { - return code, nil, true, ErrInvalidRequest - } - return code, r.Response, true, nil - } - state, e := s.monsterState(&v, pack, m) - if e != nil { - return code, nil, true, e - } - var b []byte - active := !state.Defeated && state.Respawn <= s.monsterTime().UnixMilli() - instance := fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), id), state.Generation) - if active { - if m.Type == 2 && path == "/FieldMonsterEvent" && !v.Claims[instance] { - bundle, e := s.grantFieldMonster(pack, m, instance) - if e != nil { - return code, nil, true, e - } - b, e = monsterBundleFields(bundle, 3, 4) - if e != nil { - return code, nil, true, e - } - v.Claims[instance] = true - } - dash, _, _ := wire.Varint(request, 3) - if m.FieldBuff > 0 && (path == "/FieldMonsterDamage" || dash == 0) { - if s.monsterDamage == nil { - return code, nil, true, fmt.Errorf("world: monster damage runtime unavailable") - } - rows, e := s.monsterDamage(pack, m.FieldBuff, key) - if e != nil { - return code, nil, true, e - } - for _, r := range rows { - b = wire.AppendBytes(b, 1, r) - } - } - if path == "/FieldMonsterEvent" { - state.Defeated = true - state.Respawn = s.nextMonsterSpawn(m) - v.Monsters[monsterKey(pack, s.questDifficulty(pack), id)] = state - } - } - if path == "/FieldMonsterEvent" { - b = wire.AppendBytes(b, 2, monsterWire(m, state, true)) - } - v.Requests[key] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: b} - if e = s.saveMonsterState(v); e != nil { - return code, nil, true, e - } - return code, b, true, nil -} - -// Current clients receive collision rewards in FieldMonsterEventResponse; -// FieldMonsterReward has no sender in this version and no pending reward queue. -func (s *Service) handleFieldMonsterReward(request []byte) (int, []byte, bool, error) { - seq, present, e := wire.Varint(request, 1) - if e != nil || !present || seq == 0 { - return 84, nil, true, ErrInvalidRequest - } - return 84, []byte{}, true, nil -} -func (s *Service) ApplyTalentMonsterSummon(identity string, c player.Character, r gamedata.TalentUseRule, ids []uint64) ([]byte, error) { - if identity == "" || len(ids) == 0 || r.Class != 20 { - return nil, ErrInvalidRequest - } - pack, e := s.CurrentPackID() - if e != nil { - return nil, e - } - v, e := s.loadMonsterState() - if e != nil { - return nil, e - } - seen := map[uint64]bool{} - var b []byte - for _, id := range ids { - if seen[id] { - return nil, ErrInvalidRequest - } - seen[id] = true - m, found, e := s.findFieldMonster(pack, int(id)) - if e != nil || !found || m.Type != 2 || !s.monsterEligible(pack, m) { - return nil, ErrInvalidRequest - } - if e = s.authorizeMonsterMap(pack, int(id)); e != nil { - return nil, e - } - state, e := s.monsterState(&v, pack, m) - if e != nil { - return nil, e - } - if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() { - return nil, fmt.Errorf("world: summon target is not spawned") - } - instance := fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), m.ID), state.Generation) - if !v.Claims[instance] { - bundle, e := s.grantFieldMonster(pack, m, instance) - if e != nil { - return nil, e - } - extra, e := monsterBundleFields(bundle, 3, 4) - if e != nil { - return nil, e - } - b = append(b, extra...) - v.Claims[instance] = true - } - state.Defeated = true - state.Respawn = s.nextMonsterSpawn(m) - v.Monsters[monsterKey(pack, s.questDifficulty(pack), m.ID)] = state - b = wire.AppendBytes(b, 9, monsterWire(m, state, true)) - } - if e = s.saveMonsterState(v); e != nil { - return nil, e - } - return b, nil -} diff --git a/go/internal/server/world/field_monster_runtime_test.go b/go/internal/server/world/field_monster_runtime_test.go deleted file mode 100644 index 119bca4..0000000 --- a/go/internal/server/world/field_monster_runtime_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "testing" - "time" -) - -func TestPackDetailTracksKillRetryRespawnAndResearchAcrossRestart(t *testing.T) { - s := testService() - store := stateio.NewMemory() - if err := s.AttachFieldMonsterState(store); err != nil { - t.Fatal(err) - } - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - s.monsterNow = func() time.Time { return now } - s.packSummaryTargets = map[int]bool{21: true} - s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) { - return gamedata.PackDetailDesign{RegenMonsterIDs: []int{9}}, nil - } - s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { - return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, BattleDeck: 33, RegenSeconds: 10}}, nil - } - if e := s.state.MarkResearchObject(21, 42, nil); e != nil { - t.Fatal(e) - } - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21) - _, before, _, e := s.Handle("/PackDetailInfo", req) - if e != nil { - t.Fatal(e) - } - research, _, _ := wire.Varint(before, 3) - if research != 42 { - t.Fatal("detail omitted researched object") - } - first, handled, e := s.BeginFieldMonsterBattle(21, 9, 33) - if e != nil || !handled { - t.Fatal(e) - } - dead, e := s.CompleteFieldMonsterBattle(21, 9, first) - if e != nil { - t.Fatal(e) - } - again, e := s.CompleteFieldMonsterBattle(21, 9, first) - if e != nil || !bytes.Equal(dead, again) { - t.Fatal("kill retry changed regeneration time", e) - } - if _, _, e = s.BeginFieldMonsterBattle(21, 9, 33); e == nil { - t.Fatal("defeated monster entered before respawn") - } - _, detail, _, e := s.Handle("/PackDetailInfo", req) - if e != nil { - t.Fatal(e) - } - row, _, _ := wire.Bytes(detail, 1) - respawn, _, _ := wire.Varint(row, 3) - if respawn != uint64(now.Add(10*time.Second).UnixMilli()) { - t.Fatal("wrong respawn deadline") - } - _, summary, _, e := s.Handle("/PackSummaryInfoList", req) - if e != nil { - t.Fatal(e) - } - row, _, _ = wire.Bytes(summary, 1) - count, _, _ := wire.Varint(row, 4) - if count != 1 { - t.Fatal("summary omitted defeated monster") - } - now = now.Add(11 * time.Second) - next, _, e := s.BeginFieldMonsterBattle(21, 9, 33) - if e != nil || next == first { - t.Fatal("new regeneration did not create distinct reward identity", e) - } - if _, e = s.CompleteFieldMonsterBattle(21, 9, first); e != nil { - t.Fatal("delayed receipt retry rejected", e) - } - v, e := s.loadMonsterState() - if e != nil { - t.Fatal(e) - } - if v.Monsters[monsterKey(21, 0, 9)].Defeated { - t.Fatal("old retry killed new generation") - } -} diff --git a/go/internal/server/world/field_object_effects.go b/go/internal/server/world/field_object_effects.go deleted file mode 100644 index 732ffd7..0000000 --- a/go/internal/server/world/field_object_effects.go +++ /dev/null @@ -1,225 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "fmt" - "math" -) - -func (s *Service) openFieldObjectResponse(pack, group, id int) ([]byte, error) { - design, err := s.fieldObjectDesign(pack) - if err != nil { - return nil, err - } - obj, ok := design.Objects[id] - if !ok || obj.GroupID != group || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) { - return nil, ErrInvalidRequest - } - if err := s.validateFieldObjectMap(pack, obj.MapID); err != nil { - return nil, err - } - period, err := s.fieldObjectPeriodFor(pack, obj) - if err != nil { - return nil, err - } - opened, err := s.state.FieldRewardOpened(pack, id, period) - if err != nil { - return nil, err - } - if opened { - return wire.AppendBytes(nil, 1, nil), nil - } - var effects []byte - if obj.BuffID != 0 { - buff, exists := s.fieldBuffs[uint64(obj.BuffID)] - if !exists { - return nil, fmt.Errorf("world: missing field object buff %d", obj.BuffID) - } - row, chars, e := s.applyFieldObjectBuff(pack, buff) - if e != nil { - return nil, e - } - effects = wire.AppendBytes(effects, 2, row) - for _, char := range chars { - effects = wire.AppendBytes(effects, 3, char) - } - } else if obj.MonsterID != 0 && len(obj.Rewards) == 0 { - if s.monsterLoader == nil || s.monsterStore == nil { - return nil, fmt.Errorf("world: dynamic field monster runtime unavailable") - } - monsters, e := s.monsterLoader(pack) - if e != nil { - return nil, e - } - found := false - for _, monster := range monsters { - if monster.ID != obj.MonsterID { - continue - } - if !s.monsterEligible(pack, monster) { - return nil, ErrInvalidRequest - } - snapshot, e := s.loadMonsterState() - if e != nil { - return nil, e - } - state, e := s.monsterState(&snapshot, pack, monster) - if e != nil { - return nil, e - } - if e = s.saveMonsterState(snapshot); e != nil { - return nil, e - } - effects = wire.AppendBytes(effects, 4, monsterWire(monster, state, true)) - found = true - break - } - if !found { - return nil, fmt.Errorf("world: missing field object monster %d", obj.MonsterID) - } - } - if obj.BuffID != 0 { - if err := s.state.MarkFieldRewardOpened(pack, id, period); err != nil { - return nil, err - } - return append(wire.AppendBytes(nil, 1, nil), effects...), nil - } - bundle, err := s.openFieldObject(pack, group, id) - if err != nil { - return nil, err - } - return append(wire.AppendBytes(nil, 1, bundle), effects...), nil -} - -func (s *Service) applyFieldObjectBuff(pack int, buff gamedata.FieldBuffDesign) ([]byte, [][]byte, error) { - if buff.ID == 0 || buff.Type > 5 || buff.TargetType > 2 || math.IsNaN(buff.Value) || math.IsInf(buff.Value, 0) || buff.Value < 0 || math.IsNaN(buff.Time) || math.IsInf(buff.Time, 0) || buff.Time < 0 { - return nil, nil, fmt.Errorf("world: invalid field object buff") - } - row := wire.AppendVarint(nil, 1, buff.ID) - if buff.Type <= 1 { - if buff.Time <= 0 || buff.Time > float64(^uint32(0)>>1) { - return nil, nil, fmt.Errorf("world: invalid persistent field buff duration") - } - if buff.Type == 0 { - row = wire.AppendVarint(row, 2, uint64(buff.Time)) - } else { - row = wire.AppendVarint(row, 3, uint64(s.monsterTime().UnixMilli()+int64(buff.Time*1000))) - } - prior, err := s.state.FieldBuffs() - if err != nil { - return nil, nil, err - } - for _, raw := range prior { - id, _, e := wire.Varint(raw, 1) - if e != nil { - return nil, nil, e - } - old, found := s.fieldBuffs[id] - if !found { - return nil, nil, fmt.Errorf("world: saved field buff absent from design") - } - if old.Type == buff.Type { - if e := s.state.RemoveFieldBuff(id); e != nil { - return nil, nil, e - } - } - } - if err := s.state.SaveFieldBuff(buff.ID, row); err != nil { - return nil, nil, err - } - return row, nil, nil - } - if buff.Type == 4 || buff.Type == 5 { - chars, err := s.applyMonsterFieldDamage(pack, buff.ID, "") - return row, chars, err - } - if s.characters == nil || s.decks == nil { - return nil, nil, fmt.Errorf("world: field healing runtime unavailable") - } - indices, err := s.fieldBuffTargets(pack, buff.TargetType) - if err != nil { - return nil, nil, err - } - var chars [][]byte - for _, index := range indices { - c, found := s.characters.Find(index) - if !found { - return nil, nil, fmt.Errorf("world: field healing character missing") - } - hp, err := s.characters.CurrentHealth(index) - if err != nil { - return nil, nil, err - } - if hp == 0 { - continue - } - max, err := s.characters.MaxHealth(index) - if err != nil { - return nil, nil, err - } - amount := buff.Value - if buff.Type == 3 { - amount *= float64(max) - } - remaining := max - if hp < max && amount < float64(max-hp) { - remaining = hp + uint64(amount) - } - if err = s.characters.SetCurrentHealth(index, remaining); err != nil { - return nil, nil, err - } - c.HP = remaining - chars = append(chars, player.CharacterWire(c)) - } - return row, chars, nil -} - -// ConsumeFieldBattleBuff runs inside the successful BattleEnter transaction. -// A persisted battle identity makes retries and reconnects safe. -func (s *Service) ConsumeFieldBattleBuff(identity string) error { - if identity == "" { - return ErrInvalidRequest - } - rows, err := s.state.FieldBuffs() - if err != nil { - return err - } - var active []byte - for _, row := range rows { - id, _, e := wire.Varint(row, 1) - if e != nil { - return e - } - buff, found := s.fieldBuffs[id] - if !found { - return fmt.Errorf("world: saved battle field buff absent from design") - } - if buff.Type == 0 { - active = row - break - } - } - if active == nil { - return nil - } - claimed, err := s.state.ClaimFieldBuffBattle(identity) - if err != nil || !claimed { - return err - } - id, _, err := wire.Varint(active, 1) - if err != nil { - return err - } - count, _, err := wire.Varint(active, 2) - if err != nil { - return err - } - if count <= 1 { - return s.state.RemoveFieldBuff(id) - } - updated := wire.AppendVarint(nil, 1, id) - updated = wire.AppendVarint(updated, 2, count-1) - return s.state.SaveFieldBuff(id, updated) -} diff --git a/go/internal/server/world/field_object_routes.go b/go/internal/server/world/field_object_routes.go deleted file mode 100644 index 17bd38e..0000000 --- a/go/internal/server/world/field_object_routes.go +++ /dev/null @@ -1,220 +0,0 @@ -package world - -import ( - "bd2server/internal/server/wire" - "fmt" - "sort" -) - -func (s *Service) fieldObjectCurrentPack(pack int) bool { - s.activePackMu.RLock() - current := s.activePack - s.activePackMu.RUnlock() - if current == 0 { - current = s.state.ActivePackID() - } - return current == pack -} - -func (s *Service) validateFieldObjectMap(pack, mapID int) error { - _, saved := s.state.Position() - _, event, err := s.resolveEventFieldPack(pack) - if err != nil { - return err - } - if !saved && !event { - return nil - } - current, err := s.currentFieldMap(pack) - if err != nil || current != mapID { - return fmt.Errorf("%w: field object outside current map", ErrInvalidRequest) - } - return nil -} - -func fieldObjectInt(raw []byte, field int, required bool) (int, error) { - v, found, err := wire.Varint(raw, field) - if err != nil || required && (!found || v == 0) || v > uint64(^uint32(0)>>1) { - return 0, ErrInvalidRequest - } - return int(v), nil -} - -// The client batches LostCoin collections. Validate the complete list before -// drawing or settling any component; the dispatcher commits the whole request. -func (s *Service) handleFieldObjectRewardList(request []byte) (int, []byte, bool, error) { - pack, err := requestPack(request) - if err != nil { - return 260, nil, true, err - } - design, err := s.fieldObjectDesign(pack) - if err != nil { - return 260, nil, true, err - } - type selection struct{ group, id int } - var selections []selection - seen := map[int]bool{} - err = wire.Walk(request, func(f wire.Field) error { - if f.Number != 3 { - return nil - } - if f.Type != 2 || len(selections) >= 100 { - return ErrInvalidRequest - } - group, e := fieldObjectInt(f.Value, 3, true) - if e != nil { - return e - } - id, e := fieldObjectInt(f.Value, 4, true) - if e != nil { - return e - } - obj, ok := design.Objects[id] - if !ok || obj.GroupID != group || obj.BuffID != 0 || obj.MonsterID != 0 || len(obj.Rewards) == 0 && obj.Type != 6 { - return ErrInvalidRequest - } - if !seen[id] { - seen[id] = true - selections = append(selections, selection{group, id}) - } - return nil - }) - if err != nil || len(selections) == 0 || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) { - return 260, nil, true, ErrInvalidRequest - } - var bundle []byte - for _, selection := range selections { - part, e := s.openFieldObject(pack, selection.group, selection.id) - if e != nil { - return 260, nil, true, e - } - bundle = append(bundle, part...) - } - return 260, wire.AppendBytes(nil, 1, bundle), true, nil -} - -func (s *Service) handleFieldObjectPreview(request []byte) (int, []byte, bool, error) { - pack, err := requestPack(request) - if err != nil || !s.packUnlocked(pack) { - return 144, nil, true, ErrInvalidRequest - } - ids, err := s.openedFieldObjects(pack) - if err != nil { - return 144, nil, true, err - } - var response []byte - for _, id := range ids { - response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) - } - research, err := s.state.ResearchObjects(pack) - if err != nil { - return 144, nil, true, err - } - for _, id := range research { - response = wire.AppendVarint(response, 2, uint64(id)) - } - return 144, response, true, nil -} - -// Respawn is a projection of the reset schedule, never an instruction to clear -// receipts. A request before the reset cannot make an object collectible again. -func (s *Service) handleFieldObjectRespawn(request []byte) (int, []byte, bool, error) { - pack, err := requestPack(request) - if err != nil || !s.packUnlocked(pack) { - return 30, nil, true, ErrInvalidRequest - } - group, err := fieldObjectInt(request, 3, true) - if err != nil { - return 30, nil, true, err - } - kind, err := fieldObjectInt(request, 4, false) - if err != nil || kind != 0 && kind != 6 { - return 30, nil, true, ErrInvalidRequest - } - design, err := s.fieldObjectDesign(pack) - if err != nil { - return 30, nil, true, err - } - var ids []int - reset := -1 - for id, obj := range design.Objects { - if obj.GroupID == group { - ids = append(ids, id) - if reset != -1 && reset != obj.ResetType { - return 30, nil, true, fmt.Errorf("world: inconsistent field reset group") - } - reset = obj.ResetType - } - } - if len(ids) == 0 { - return 30, nil, true, ErrInvalidRequest - } - sort.Ints(ids) - var response []byte - for _, id := range ids { - obj := design.Objects[id] - period, e := s.fieldObjectPeriodFor(pack, obj) - if e != nil { - return 30, nil, true, e - } - opened, e := s.state.FieldRewardOpened(pack, id, period) - if e != nil { - return 30, nil, true, e - } - if opened { - response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) - } - } - if reset == 0 || reset == 3 { //nolint:staticcheck // QF1003 - next, e := s.fieldReset.Next(reset, s.monsterTime()) - if e != nil { - return 30, nil, true, e - } - row := wire.AppendVarint(nil, 1, uint64(group)) - row = wire.AppendVarint(row, 2, uint64(next.UnixMilli())) - response = wire.AppendBytes(response, 2, row) - } else if reset == 2 { - resolver, ok := s.eventFieldPacks.(interface { - FieldObjectEventPeriod(int) (string, int64, error) - }) - if !ok { - return 30, nil, true, ErrInvalidRequest - } - _, end, err := resolver.FieldObjectEventPeriod(pack) - if err != nil { - return 30, nil, true, err - } - row := wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(group)), 2, uint64(end)) - response = wire.AppendBytes(response, 2, row) - } - return 30, response, true, nil -} - -func (s *Service) rewardMonsterAvailable(pack, monster int) (bool, error) { - if s.fieldObjectLoader == nil && s.fieldObjects == nil { - return true, nil - } - design, err := s.fieldObjectDesign(pack) - if err != nil { - return false, err - } - linked := false - for _, obj := range design.Objects { - if obj.MonsterID != monster || obj.BuffID != 0 || len(obj.Rewards) != 0 { - continue - } - linked = true - period, e := s.fieldObjectPeriodFor(pack, obj) - if e != nil { - continue - } - opened, e := s.state.FieldRewardOpened(pack, obj.ID, period) - if e != nil { - return false, e - } - if opened { - return true, nil - } - } - return !linked, nil -} diff --git a/go/internal/server/world/field_object_routes_test.go b/go/internal/server/world/field_object_routes_test.go deleted file mode 100644 index d468077..0000000 --- a/go/internal/server/world/field_object_routes_test.go +++ /dev/null @@ -1,392 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "errors" - "os" - "path/filepath" - "testing" - "time" -) - -func sqliteFieldService(t *testing.T, store *fieldRewardFailStore) *Service { - t.Helper() - s := testService() - var err error - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - if err = s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - s.equipment, err = player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - s.monsterStore = store - return s -} - -func fieldBatchRequest(ids ...int) []byte { - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21) - for _, id := range ids { - obj := wire.AppendVarint(wire.AppendVarint(nil, 3, 101), 4, uint64(id)) - request = wire.AppendBytes(request, 3, obj) - } - return request -} - -func TestFieldBatchSQLiteAtomicResetAndRestart(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - store := &fieldRewardFailStore{Repository: repo} - s := sqliteFieldService(t, store) - now := time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) - s.monsterNow = func() time.Time { return now } - objects := map[int]gamedata.FieldRewardObject{} - for _, id := range []int{1001, 1002} { - objects[id] = gamedata.FieldRewardObject{ID: id, GroupID: 101, MapID: 211, Type: 6, ResetType: 0, DropCount: 1, Ratios: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}} - } - designs := map[int]gamedata.FieldObjectDesign{21: {Objects: objects}} - s.WithFieldObjects(designs) - if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 9999)); err == nil { - t.Fatal("invalid later batch object accepted") - } - if s.wallet.Snapshot().FreeJewelry != 0 { - t.Fatal("invalid batch settled earlier object") - } - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - store.fail = true - if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002)); err == nil { - t.Fatal("marker failure accepted") - } - if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository, store.fail = repo, false - s = sqliteFieldService(t, store).WithFieldObjects(designs) - s.monsterNow = func() time.Time { return now } - if s.wallet.Snapshot().FreeJewelry != 0 { - t.Fatal("rollback persisted partial currency") - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - code, reply, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002, 1001)) - if err != nil || code != 260 { - t.Fatalf("batch code=%d err=%v", code, err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - bundle, _, _ := wire.Bytes(reply, 1) - var total uint64 - if err := wire.Walk(bundle, func(f wire.Field) error { - if f.Number == 1 { - count, _, e := wire.Varint(f.Value, 4) - total += count - return e - } - return nil - }); err != nil { - t.Fatal(err) - } - if total != 100 || s.wallet.Snapshot().FreeJewelry != 100 { - t.Fatalf("batch delta=%d wallet=%+v", total, s.wallet.Snapshot()) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository = repo - s = sqliteFieldService(t, store).WithFieldObjects(designs) - s.monsterNow = func() time.Time { return now } - _, reply, _, err = s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002)) - if err != nil { - t.Fatal(err) - } - bundle, _, _ = wire.Bytes(reply, 1) - if len(bundle) != 0 || s.wallet.Snapshot().FreeJewelry != 100 { - t.Fatal("restart duplicate paid again") - } - respawnRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 21), 3, 101) - _, before, _, err := s.handleFieldObjectRespawn(respawnRequest) - if err != nil { - t.Fatal(err) - } - if fieldWireCount(t, before, 1) != 2 { - t.Fatal("premature respawn cleared opened state") - } - now = now.Add(24 * time.Hour) - _, after, _, err := s.handleFieldObjectPreview(wire.AppendVarint(wire.AppendVarint(nil, 1, 3), 2, 21)) - if err != nil || fieldWireCount(t, after, 1) != 0 { - t.Fatalf("reset preview=%x err=%v", after, err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err := s.handleFieldObjectRewardList(fieldBatchRequest(1001, 1002)); err != nil { - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - if s.wallet.Snapshot().FreeJewelry != 200 { - t.Fatal("new reset period did not award") - } -} - -func fieldWireCount(t *testing.T, raw []byte, number int) int { - t.Helper() - count := 0 - if err := wire.Walk(raw, func(f wire.Field) error { - if f.Number == number { - count++ - } - return nil - }); err != nil { - t.Fatal(err) - } - return count -} - -func TestFieldEffectsSQLitePersistenceRepeatAndBattleConsumption(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - store := &fieldRewardFailStore{Repository: repo} - s := sqliteFieldService(t, store) - designs := map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{ - 1001: {ID: 1001, GroupID: 101, MapID: 211, ResetType: 1, BuffID: 1}, - 1002: {ID: 1002, GroupID: 101, MapID: 211, ResetType: 1, BuffID: 2}, - 1003: {ID: 1003, GroupID: 101, MapID: 211, ResetType: 1, MonsterID: 9}, - }}} - buffs := map[uint64]gamedata.FieldBuffDesign{1: {ID: 1, Type: 0, Time: 2}, 2: {ID: 2, Type: 0, Time: 3}} - attach := func(s *Service) { - s.WithFieldObjects(designs) - s.fieldBuffs = buffs - s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { - return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, Type: 3, BattleDeck: 99}}, nil - } - } - attach(s) - for _, id := range []int{1001, 1002, 1003} { - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - response, err := s.openFieldObjectResponse(21, 101, id) - if err != nil { - t.Fatal(err) - } - if id == 1003 { - monster, _, _ := wire.Bytes(response, 4) - mid, _, _ := wire.Varint(monster, 1) - if mid != 9 { - t.Fatal("dynamic monster response missing") - } - } else if fieldWireCount(t, response, 2) != 1 { - t.Fatal("buff response missing") - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository = repo - s = sqliteFieldService(t, store) - attach(s) - rows, err := s.state.FieldBuffs() - if err != nil || len(rows) != 1 { - t.Fatalf("buff rows=%v err=%v", rows, err) - } - id, _, _ := wire.Varint(rows[0], 1) - count, _, _ := wire.Varint(rows[0], 2) - if id != 2 || count != 3 { - t.Fatal("same-type replacement or persisted duration lost") - } - response, err := s.openFieldObjectResponse(21, 101, 1002) - if err != nil || fieldWireCount(t, response, 2) != 0 { - t.Fatal("repeat reapplied buff") - } - for _, identity := range []string{"battle-a", "battle-a", "battle-b", "battle-c"} { - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if err := s.ConsumeFieldBattleBuff(identity); err != nil { - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - } - rows, err = s.state.FieldBuffs() - if err != nil || len(rows) != 0 { - t.Fatal("battle retries decremented incorrectly or buff never expired") - } - snapshot, err := s.loadMonsterState() - if err != nil || len(snapshot.Monsters) != 1 { - t.Fatal("dynamic monster spawn lost on restart") - } -} - -func TestInstalledFieldChestAndHealingObjectSQLiteSettlement(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for installed field settlement regression") - } - version := "20260923193640" - chests, err := gamedata.LoadFieldObjects(root, version, 21) - if err != nil { - t.Fatal(err) - } - traps, err := gamedata.LoadFieldObjects(root, version, 1) - if err != nil { - t.Fatal(err) - } - buffs, err := gamedata.LoadFieldBuffDesign(root, version) - if err != nil { - t.Fatal(err) - } - var chest, healer gamedata.FieldRewardObject - for _, obj := range chests.Objects { - if len(obj.Rewards) > 0 && (chest.ID == 0 || obj.ID < chest.ID) { - chest = obj - } - } - for _, obj := range traps.Objects { - if buff, ok := buffs[uint64(obj.BuffID)]; ok && buff.Type == 3 && (healer.ID == 0 || obj.ID < healer.ID) { - healer = obj - } - } - if chest.ID == 0 || healer.ID == 0 { - t.Fatal("current authored chest/healer graphs missing") - } - repo, err := accountstate.Open(filepath.Join(t.TempDir(), "state.db")) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - store := &fieldRewardFailStore{Repository: repo} - s := sqliteFieldService(t, store).WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: chests, 1: traps}) - s.fieldBuffs = buffs - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - response, err := s.openFieldObjectResponse(21, chest.GroupID, chest.ID) - if err != nil { - t.Fatal(err) - } - bundle, _, _ := wire.Bytes(response, 1) - if len(bundle) == 0 { - t.Fatal("installed chest gave no authored rewards") - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, Quests: map[int]gamedata.QuestDesign{1: {ID: 1}}, MainQuestIDs: []int{1}} - if s.packs == nil { - s.packs = map[int]map[int]gamedata.QuestDesign{} - } - s.packs[1] = s.storyCatalog.Packs[1].Quests - if err := s.state.SetActivePackID(1); err != nil { - t.Fatal(err) - } - s.setCurrentPack(1) - s.characters, err = player.OpenCharacterStore(store, []player.Character{{ID: 350, InvenIndex: 77, Level: 1, HP: 50}}, s.inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := s.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err := s.characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := s.characters.SetCurrentHealth(77, 50); err != nil { - t.Fatal(err) - } - s.decks, err = deck.NewStore(deck.Seed{Version: s.seed.Version, FieldCharControlDeckType: 1, FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}}}) - if err != nil { - t.Fatal(err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - response, err = s.openFieldObjectResponse(1, healer.GroupID, healer.ID) - if err != nil { - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - hp, err := s.characters.CurrentHealth(77) - if err != nil || hp != 80 || fieldWireCount(t, response, 2) != 1 || fieldWireCount(t, response, 3) != 1 { - t.Fatalf("installed 30%% healer hp=%d response=%x err=%v", hp, response, err) - } - if _, err := s.openFieldObjectResponse(1, healer.GroupID, healer.ID); err != nil { - t.Fatal(err) - } - hp, _ = s.characters.CurrentHealth(77) - if hp != 80 { - t.Fatal("healing retry applied twice") - } -} diff --git a/go/internal/server/world/field_objects.go b/go/internal/server/world/field_objects.go deleted file mode 100644 index b07814b..0000000 --- a/go/internal/server/world/field_objects.go +++ /dev/null @@ -1,280 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "fmt" -) - -func (s *Service) WithFieldObjects(designs map[int]gamedata.FieldObjectDesign) *Service { - s.fieldObjects = designs - return s -} -func (s *Service) AttachFieldObjectRuntime(root, version string) error { - schedule, err := gamedata.LoadFieldResetSchedule(root, version) - if err != nil { - return err - } - s.fieldReset = schedule - s.fieldObjectLoader = func(pack int) (gamedata.FieldObjectDesign, error) { - return gamedata.LoadFieldObjects(root, version, pack) - } - s.fieldObjects = map[int]gamedata.FieldObjectDesign{} - return nil -} -func (s *Service) fieldObjectDesign(pack int) (gamedata.FieldObjectDesign, error) { - if design, ok := s.fieldObjects[pack]; ok { - return design, nil - } - if s.fieldObjectLoader == nil { - return gamedata.FieldObjectDesign{}, fmt.Errorf("world: field object design unavailable") - } - if _, story := s.packs[pack]; !story { - if _, field := s.fieldPacks[pack]; !field { - if _, event, err := s.resolveEventFieldPack(pack); err != nil || !event { - return gamedata.FieldObjectDesign{}, fmt.Errorf("%w: unknown field pack", ErrInvalidRequest) - } - } - } - design, err := s.fieldObjectLoader(pack) - if err != nil { - return design, err - } - s.fieldObjects[pack] = design - return design, nil -} -func (s *Service) openedFieldObjects(pack int) ([]int, error) { - ids, err := s.state.OpenedFieldRewards(pack) - if err != nil { - return nil, err - } - if len(ids) == 0 { - return []int{}, nil - } - design, err := s.fieldObjectDesign(pack) - if err != nil { - return nil, err - } - var active []int - for _, id := range ids { - obj, ok := design.Objects[id] - if !ok { - return nil, fmt.Errorf("world: saved field object absent from design") - } - period, e := s.fieldObjectPeriodFor(pack, obj) - if e != nil { - continue - } - opened, e := s.state.FieldRewardOpened(pack, id, period) - if e != nil { - return nil, e - } - if opened { - active = append(active, id) - } - } - return active, nil -} -func (s *Service) WithFieldResetSchedule(schedule gamedata.FieldResetSchedule) *Service { - s.fieldReset = schedule - return s -} -func (s *Service) fieldObjectPeriod(obj gamedata.FieldRewardObject) (string, error) { - return s.fieldReset.Period(obj.ResetType, s.monsterTime()) -} -func (s *Service) fieldObjectPeriodFor(pack int, obj gamedata.FieldRewardObject) (string, error) { - if obj.ResetType == 2 { - resolver, ok := s.eventFieldPacks.(interface { - FieldObjectEventPeriod(int) (string, int64, error) - }) - if !ok { - return "", fmt.Errorf("%w: field event calendar unavailable", ErrInvalidRequest) - } - period, _, err := resolver.FieldObjectEventPeriod(pack) - if err != nil { - return "", err - } - if period == "" { - return "", fmt.Errorf("%w: field event inactive", ErrInvalidRequest) - } - return "event:" + period, nil - } - return s.fieldObjectPeriod(obj) -} -func (s *Service) handleFieldObjectInfo(request []byte) (int, []byte, bool, error) { - pack, err := requestPack(request) - if err != nil { - return 0, nil, true, err - } - if !s.packUnlocked(pack) { - return 0, nil, true, fmt.Errorf("%w: unavailable field pack", ErrInvalidRequest) - } - ids, err := s.openedFieldObjects(pack) - if err != nil { - return 0, nil, true, err - } - var response []byte - for _, id := range ids { - response = wire.AppendBytes(response, 1, wire.AppendVarint(nil, 1, uint64(id))) - } - actions, err := s.fieldActionInfo(pack) - if err != nil { - return 0, nil, true, err - } - response = append(response, actions...) - return 28, response, true, nil -} -func (s *Service) handleFieldObjectReward(request []byte) (int, []byte, bool, error) { - pack, err := requestPack(request) - if err != nil { - return 0, nil, true, err - } - group, _, err := wire.Varint(request, 3) - if err != nil || group == 0 || group > uint64(^uint32(0)>>1) { - return 0, nil, true, ErrInvalidRequest - } - id, _, err := wire.Varint(request, 4) - if err != nil || id == 0 || id > uint64(^uint32(0)>>1) { - return 0, nil, true, ErrInvalidRequest - } - response, err := s.openFieldObjectResponse(pack, int(group), int(id)) - if err != nil { - return 0, nil, true, err - } - return 29, response, true, nil -} -func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) { - design, err := s.fieldObjectDesign(pack) - if err != nil { - return nil, err - } - obj, exists := design.Objects[id] - if !exists || obj.GroupID != group || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) { - return nil, fmt.Errorf("%w: unavailable field object", ErrInvalidRequest) - } - if err := s.validateFieldObjectMap(pack, obj.MapID); err != nil { - return nil, err - } - period, err := s.fieldObjectPeriodFor(pack, obj) - if err != nil { - return nil, err - } - opened, err := s.state.FieldRewardOpened(pack, id, period) - if err != nil { - return nil, err - } - if opened { - return []byte{}, nil - } - // Buff and dynamic-monster objects have no loot group. Their state change - // belongs to the same dispatcher transaction as this consumed-object marker. - if len(obj.Rewards) == 0 { - if obj.BuffID == 0 && obj.MonsterID == 0 && obj.QuestID == 0 && obj.Type != 5 && obj.Type != 6 { - return nil, fmt.Errorf("%w: empty field object", ErrInvalidRequest) - } - return nil, s.state.MarkFieldRewardOpened(pack, id, period) - } - if s.wallet == nil || s.inventory == nil { - return nil, fmt.Errorf("world: field reward stores unavailable") - } - var rewards []gamedata.Reward - var itemRewards []gamedata.BattleReward - var equipmentRewards []player.Equipment - // Validate every branch's type and quantity before drawing or writing a - // receipt. LoadFieldObjects validates every equipment option tree, including - // branches with zero weight, before installing the catalog. - for _, r := range obj.Rewards { - if r.Count == 0 || r.Count > uint64(^uint32(0)>>1) { - return nil, fmt.Errorf("world: invalid field reward count") - } - switch r.Type { - case 2, 3, 4, 12, 20: - case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29: - if r.ID == 0 { - return nil, fmt.Errorf("world: invalid field item") - } - case 10: - if r.ID == 0 || r.Count > 100 || s.equipment == nil || design.Equipment == nil { - return nil, fmt.Errorf("world: invalid field equipment") - } - default: - return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type) - } - } - selected, err := obj.Draw() - if err != nil { - return nil, err - } - if design.RewardGraph != nil { - selected, err = design.RewardGraph.ResolveGranted(selected) - if err != nil { - return nil, err - } - } - for _, r := range selected { - if r.Count == 0 { - return nil, fmt.Errorf("world: empty field reward") - } - switch r.Type { - case 2, 3, 4, 12, 20: - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29: - if r.ID == 0 { - return nil, fmt.Errorf("world: invalid field item") - } - itemRewards = append(itemRewards, r) - case 10: - if s.equipment == nil || design.Equipment == nil || r.Count > 100 || len(equipmentRewards)+int(r.Count) > 100 { - return nil, fmt.Errorf("world: field equipment reward unavailable") - } - for n := uint64(0); n < r.Count; n++ { - main, sub, private, e := design.Equipment.RollOptions(r.ID) - if e != nil { - return nil, e - } - entry := player.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}} - for _, option := range main { - entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) - } - for _, option := range sub { - entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID}) - } - if private != nil { - entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID} - } - equipmentRewards = append(equipmentRewards, entry) - } - default: - return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type) - } - } - identity := fmt.Sprintf("field-reward:%d:%d:%s", pack, id, period) - if _, err = s.wallet.GrantQuestOnce(identity, rewards); err != nil { - return nil, err - } - items, err := s.inventory.GrantOnce(identity, itemRewards) - if err != nil { - return nil, err - } - for i, entry := range equipmentRewards { - equipmentRewards[i], err = s.equipment.GrantGeneratedOnce(fmt.Sprintf("%s:equipment:%d", identity, i), entry) - if err != nil { - return nil, err - } - } - if err = s.state.MarkFieldRewardOpened(pack, id, period); err != nil { - return nil, err - } - var bundle []byte - for _, r := range rewards { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{ID: r.ID, Type: r.Type, Count: r.Count})) - } - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - } - for _, entry := range equipmentRewards { - bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry)) - } - return bundle, nil -} diff --git a/go/internal/server/world/field_objects_test.go b/go/internal/server/world/field_objects_test.go deleted file mode 100644 index 822f50c..0000000 --- a/go/internal/server/world/field_objects_test.go +++ /dev/null @@ -1,401 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "errors" - "path/filepath" - "reflect" - "testing" -) - -func fieldEquipmentCatalog(t *testing.T) *gamedata.EquipmentGachaCatalog { - t.Helper() - options := []gamedata.OptionGroup{{ID: 31, Choices: []gamedata.WeightedOption{{ID: 4, Weight: 1}}}} - d, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 1, GachaSubType: 3, TenTimeGachaID: 2}, 2, 1, - &gamedata.FirstGachaRewardGroup{ID: 3, DropType: 1, DropCount: 1, Entries: []gamedata.FirstGachaRewardEntry{{ItemType: 10, ItemID: 77, Count: 1, Weight: 1}}}, nil, - map[uint64]gamedata.EquipmentDesign{77: {ID: 77, Grade: 3, Main: options, Sub: options, Private: options}}) - if err != nil { - t.Fatal(err) - } - return d.EquipmentCatalog() -} - -func TestFieldEquipmentTransactionRollbackAndRetry(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - store := &fieldRewardFailStore{Repository: repo} - s := testService() - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - if err := s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(store, player.Currency{FreeJewelry: 100}) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - s.equipment, err = player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - if _, err := s.wallet.GrantQuestOnce("baseline", nil); err != nil { - t.Fatal(err) - } - obj := gamedata.FieldRewardObject{ID: 1001, MapID: 211, GroupID: 101, ResetType: 1, DropType: 1, Ratios: []uint64{100, 100, 100}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}, {Type: 9, ID: 88, Count: 1}, {Type: 10, ID: 77, Count: 2}}} - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: obj}, Equipment: fieldEquipmentCatalog(t)}}) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - store.fail = true - if _, err := s.openFieldObject(21, 101, 1001); err == nil { - t.Fatal("failed marker accepted") - } - if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository = repo - store.fail = false - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - s.equipment, err = player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - opened, err := s.state.FieldRewardOpened(21, 1001, "once") - if err != nil || opened || len(s.equipment.All()) != 0 || len(s.inventory.All()) != 0 || s.wallet.Snapshot().FreeJewelry != 100 { - t.Fatalf("partial transaction: opened=%v equipment=%+v items=%+v wallet=%+v err=%v", opened, s.equipment.All(), s.inventory.All(), s.wallet.Snapshot(), err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, err := s.openFieldObject(21, 101, 1001); err != nil { - if rollbackErr := op.Rollback(); rollbackErr != nil { - t.Error(rollbackErr) - } - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - if len(s.equipment.All()) != 2 || len(s.inventory.All()) != 1 || s.wallet.Snapshot().FreeJewelry != 150 { - t.Fatal("retry lost grants or grant receipts") - } -} - -func TestFieldIndependentEquipmentAndBoxPersistAcrossRestart(t *testing.T) { - store := stateio.NewMemory() - s := testService() - var err error - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - if err := s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - s.equipment, err = player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - obj := gamedata.FieldRewardObject{ID: 1001, MapID: 211, GroupID: 101, Type: 8, ResetType: 1, DropType: 1, Ratios: []uint64{100, 100, 100}, Rewards: []gamedata.BattleReward{{Type: 10, ID: 77, Count: 2}, {Type: 9, ID: 88, Count: 1}, {Type: 3, Count: 50}}} - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: obj}, Equipment: fieldEquipmentCatalog(t)}}) - bundle, err := s.openFieldObject(21, 101, 1001) - if err != nil { - t.Fatal(err) - } - entries := s.equipment.All() - if len(entries) != 2 || entries[0].InvenIndex == entries[1].InvenIndex { - t.Fatalf("equipment=%+v", entries) - } - for _, entry := range entries { - if len(entry.MainOption) != 1 || len(entry.SubOption) != 1 || entry.PrivateOption == nil || !reflect.DeepEqual(entry.Rank, []uint64{0, 0, 0}) { - t.Fatalf("missing equipment metadata=%+v", entry) - } - } - var eqCount, boxCount int - if err := wire.Walk(bundle, func(f wire.Field) error { - if f.Number == 4 { - eqCount++ - if !reflect.DeepEqual(f.Value, player.EquipmentWire(entries[eqCount-1])) { - t.Error("equipment wire lost metadata/index") - } - } - if f.Number == 1 { - kind, _, _ := wire.Varint(f.Value, 3) - if kind == 9 { - boxCount++ - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if eqCount != 2 || boxCount != 1 || s.wallet.Snapshot().FreeJewelry != 50 { - t.Fatalf("equipment=%d box=%d wallet=%+v", eqCount, boxCount, s.wallet.Snapshot()) - } - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - s.equipment, err = player.OpenEquipmentInventory(store) - if err != nil { - t.Fatal(err) - } - if err := s.state.SelectQuest(21, progress.QuestSelection{QuestID: 1, Difficulty: 2}); err != nil { - t.Fatal(err) - } - bundle, err = s.openFieldObject(21, 101, 1001) - if err != nil || len(bundle) != 0 || !reflect.DeepEqual(entries, s.equipment.All()) { - t.Fatalf("duplicate after restart: bundle=%x err=%v", bundle, err) - } - obj.ID = 1002 - obj.DropType = 0 - obj.DropCount = 1 - obj.Rewards = []gamedata.BattleReward{{Type: 3, Count: 50}, {Type: 12345, ID: 1, Count: 1}} - obj.Ratios = []uint64{100, 0} - s.fieldObjects[21].Objects[1002] = obj - if _, err := s.openFieldObject(21, 101, 1002); err == nil { - t.Fatal("malformed unselected branch accepted") - } - if s.wallet.Snapshot().FreeJewelry != 50 || len(s.equipment.All()) != 2 { - t.Fatal("malformed branch changed stores") - } -} - -type fieldRewardFailStore struct { - *accountstate.Repository - fail bool -} - -func (s *fieldRewardFailStore) PutEntry(domain, bucket, key string, payload []byte) error { - if s.fail && bucket == "field_rewards" { - return errors.New("forced field reward marker failure") - } - return s.Repository.PutEntry(domain, bucket, key, payload) -} -func TestFieldChestAtomicAwardRollback(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - store := &fieldRewardFailStore{Repository: repo} - s := testService() - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - if err = s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(store, player.Currency{FreeJewelry: 100}) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: {ID: 1001, MapID: 211, GroupID: 101, Type: 2, ResetType: 1, DropCount: 1, Ratios: []uint64{1}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}}}}) - if _, err = s.wallet.GrantQuestOnce("baseline", nil); err != nil { - t.Fatal(err) - } - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - store.fail = true - if _, err = s.openFieldObject(21, 101, 1001); err == nil { - t.Fatal("failed persistence returned success") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository = repo - store.fail = false - s.wallet, err = player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - opened, err := s.state.FieldRewardOpened(21, 1001, "once") - if err != nil || opened || s.wallet.Snapshot().FreeJewelry != 100 || len(s.inventory.All()) != 0 { - t.Fatalf("partial state after rollback opened=%v wallet=%+v err=%v", opened, s.wallet.Snapshot(), err) - } - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, err = s.openFieldObject(21, 101, 1001); err != nil { - if rollbackErr := op.Rollback(); rollbackErr != nil { - t.Error(rollbackErr) - } - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(store, player.Currency{}) - if err != nil { - t.Fatal(err) - } - if s.wallet.Snapshot().FreeJewelry != 150 { - t.Fatal("retry after rollback did not award") - } - obj := s.fieldObjects[21].Objects[1001] - obj.ID = 1002 - obj.Rewards = []gamedata.BattleReward{{Type: 5, ID: 1013, Count: 142}} - s.fieldObjects[21].Objects[1002] = obj - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - store.fail = true - if _, err = s.openFieldObject(21, 101, 1002); err == nil { - t.Fatal("inventory marker failure returned success") - } - if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - store.Repository = repo - store.fail = false - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - opened, err = s.state.FieldRewardOpened(21, 1002, "once") - if err != nil || opened || len(s.inventory.All()) != 0 { - t.Fatalf("inventory partially committed after rollback opened=%v items=%v err=%v", opened, s.inventory.All(), err) - } -} - -func TestFieldChestDeltaAndPersistentRepeat(t *testing.T) { - store := stateio.NewMemory() - s := testService() - var err error - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - if err = s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(store, player.Currency{FreeJewelry: 100}) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(store, s.starter) - if err != nil { - t.Fatal(err) - } - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: {ID: 1001, MapID: 211, GroupID: 101, Type: 2, ResetType: 1, DropCount: 1, Ratios: []uint64{10}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}}}}}}) - req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21), 3, 101), 4, 1001) - code, res, _, err := s.handleFieldObjectReward(req) - if err != nil || code != 29 { - t.Fatalf("code=%d err=%v", code, err) - } - bundle, _, _ := wire.Bytes(res, 1) - item, _, _ := wire.Bytes(bundle, 1) - delta, _, _ := wire.Varint(item, 4) - if delta != 50 || s.wallet.Snapshot().FreeJewelry != 150 { - t.Fatalf("delta=%d wallet=%+v", delta, s.wallet.Snapshot()) - } - s.state, err = progress.OpenStore(store) - if err != nil { - t.Fatal(err) - } - // Quest selection difficulty is independent of the pack's opened chests. - // Switching it must neither hide the chest nor create a second grant. - if err = s.state.SelectQuest(21, progress.QuestSelection{QuestID: 1, Difficulty: 2}); err != nil { - t.Fatal(err) - } - req = wire.AppendVarint(req, 1, 999) - _, res, _, err = s.handleFieldObjectReward(req) - if err != nil { - t.Fatal(err) - } - bundle, _, _ = wire.Bytes(res, 1) - if len(bundle) != 0 || s.wallet.Snapshot().FreeJewelry != 150 { - t.Fatal("repeat awarded") - } - _, res, _, err = s.handleFieldObjectInfo(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)) - if err != nil { - t.Fatal(err) - } - info, _, _ := wire.Bytes(res, 1) - id, _, _ := wire.Varint(info, 1) - if id != 1001 { - t.Fatalf("opened id=%d", id) - } - if _, err = s.openFieldObject(21, 102, 1001); err == nil { - t.Fatal("wrong group accepted") - } -} diff --git a/go/internal/server/world/field_pack_test.go b/go/internal/server/world/field_pack_test.go deleted file mode 100644 index ddcb1fc..0000000 --- a/go/internal/server/world/field_pack_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package world - -import ( - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestArenaPositionRestoresAcrossRestartWithoutStoryQuestOrRepurchase(t *testing.T) { - storage := stateio.NewMemory() - state, err := progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - const raw = `{"MapId":30011,"PlayerPosition":{"x":-2.1,"y":0,"z":6.4}}` - request := wire.AppendVarint(nil, 2, 3001) - request = wire.AppendString(request, 3, raw) - if err := state.SaveUserPosition(request); err != nil { - t.Fatal(err) - } - state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s := testService() - s.state = state - s.fieldPacks = map[int]gamedata.FieldPack{3001: {ID: 3001, Type: 3, BuyPrice: 99, UseSchedule: 1, HasOpenRule: true, TicketID: 13001, SquadLevel: 50, MapIDs: map[int]bool{30011: true}}} - if pack, err := s.LastPlayedPackID(); err != nil || pack != 3001 { - t.Fatalf("restored login pack=%d err=%v", pack, err) - } - s.fieldPacks[3002] = gamedata.FieldPack{ID: 3002, Type: 3, MapIDs: map[int]bool{30021: true}} - accountInfo := s.accountPackInfo() - arenaRows := 0 - if err := wire.Walk(accountInfo, func(field wire.Field) error { - if field.Number == 1 { - id, _, _ := wire.Varint(field.Value, 1) - if id == 3002 { - t.Fatal("unvisited arena was exposed as purchased") - } - if id == 3001 { - arenaRows++ - buy, _, _ := wire.Varint(field.Value, 8) - if buy != 1 { - t.Fatal("saved arena missing purchased marker") - } - if err := wire.Walk(field.Value, func(inner wire.Field) error { - if inner.Number != 1 && inner.Number != 8 { - t.Fatalf("saved arena fabricated field %d", inner.Number) - } - return nil - }); err != nil { - return err - } - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if arenaRows != 1 { - t.Fatalf("saved arena rows=%d", arenaRows) - } - request = wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3001) - code, body, handled, err := s.Handle("/PackInGameInfo", request) - if err != nil || !handled || code != 5 { - t.Fatalf("arena info code=%d handled=%v err=%v", code, handled, err) - } - position, _, err := wire.Bytes(body, 4) - if err != nil || string(position) != raw { - t.Fatalf("restored arena position=%s err=%v", position, err) - } - if _, found, _ := wire.Bytes(body, 2); found { - t.Fatal("arena inherited a story quest") - } - if pack, err := s.CurrentPackID(); err != nil || pack != 3001 { - t.Fatalf("current arena pack=%d err=%v", pack, err) - } - request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 21) - _, body, _, err = s.Handle("/PackInGameInfo", request) - if err != nil { - t.Fatal(err) - } - position, _, _ = wire.Bytes(body, 4) - if string(position) != "{}" { - t.Fatal("arena position leaked into story pack") - } -} - -func TestArenaEntryPreservesTicketPurchaseAndMapRestrictions(t *testing.T) { - s := testService() - s.fieldPacks = map[int]gamedata.FieldPack{9001: {ID: 9001, Type: 3, HasOpenRule: true, TicketID: 555, MapIDs: map[int]bool{90011: true}}} - inventory, err := player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.inventory = inventory - if s.packUnlocked(9001) { - t.Fatal("missing content ticket accepted") - } - if _, err := inventory.GrantOnce("arena-ticket", []gamedata.BattleReward{{Type: 19, ID: 555, Count: 1}}); err != nil { - t.Fatal(err) - } - if !s.packUnlocked(9001) { - t.Fatal("free arena with owned ticket refused") - } - pack := s.fieldPacks[9001] - pack.BuyPrice = 10 - s.fieldPacks[9001] = pack - if s.packUnlocked(9001) { - t.Fatal("paid arena was purchased implicitly") - } - request := wire.AppendVarint(nil, 2, 9001) - request = wire.AppendString(request, 3, `{"MapId":211,"PlayerPosition":{"x":1}}`) - if err := s.state.SaveUserPosition(request); err != nil { - t.Fatal(err) - } - if _, err := s.LastPlayedPackID(); err == nil { - t.Fatal("foreign map restored as arena") - } -} - -func TestLastPlayedStoryPackRestoresItsExactPosition(t *testing.T) { - s := testService() - s.packs = map[int]map[int]gamedata.QuestDesign{21: s.quests, 22: {1: {ID: 1}}} - s.transitions = map[int]gamedata.PackTransition{21: {PackID: 21, NextPackID: 22}} - attachTestStoryCatalog(s) - for quest := range s.quests { - if err := s.state.ClearQuest(quest, 21); err != nil { - t.Fatal(err) - } - } - const raw = `{"MapId":221,"PlayerPosition":{"x":7.5,"y":0,"z":-2.2},"ColleaguePositions":null}` - request := wire.AppendVarint(nil, 2, 22) - request = wire.AppendString(request, 3, raw) - if err := s.state.SaveUserPosition(request); err != nil { - t.Fatal(err) - } - if pack, err := s.LastPlayedPackID(); err != nil || pack != 22 { - t.Fatalf("last story pack=%d err=%v", pack, err) - } - request = wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 22) - _, body, _, err := s.Handle("/PackInGameInfo", request) - if err != nil { - t.Fatal(err) - } - position, _, _ := wire.Bytes(body, 4) - if string(position) != raw { - t.Fatalf("story position=%s", position) - } -} diff --git a/go/internal/server/world/health_test.go b/go/internal/server/world/health_test.go deleted file mode 100644 index 0527b55..0000000 --- a/go/internal/server/world/health_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package world - -import ( - "strconv" - "testing" - - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestTutorialCharInfoRestoresFieldHealthWithoutExposingStoryRoster(t *testing.T) { - for _, hp := range []uint64{0, 17} { - t.Run(strconv.FormatUint(hp, 10), func(t *testing.T) { - storage := stateio.NewMemory() - starter := &player.Starter{Version: "2.35.10", Characters: []player.Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100}}} - inventory, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - all := append(append([]player.Character(nil), starter.Characters...), player.Character{InvenIndex: 88, ID: 650, HP: 100, Level: 1}) - characters, err := player.OpenCharacterStore(storage, all, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err := characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := characters.SetCurrentHealth(77, hp); err != nil { - t.Fatal(err) - } - characters, err = player.OpenCharacterStore(storage, all, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26}, state: progress.NewStore(), starter: starter, characters: characters} - attachTestStoryCatalog(s) - code, response, handled, err := s.Handle("/CharInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || code != 9 || !handled { - t.Fatalf("code=%d handled=%v err=%v", code, handled, err) - } - count := 0 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 { - count++ - index, _, _ := wire.Varint(field.Value, 1) - got, _, _ := wire.Varint(field.Value, 3) - if index != 77 || got != hp { - t.Fatalf("tutorial character=%d hp=%d want=%d", index, got, hp) - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("tutorial exposed %d characters", count) - } - if _, _, handled, err := s.Handle("/CharInfo", nil); !handled || err == nil { - t.Fatal("invalid character request accepted") - } - }) - } -} - -func TestPackInfoWithoutFormationDoesNotEmitSeedStoryRoster(t *testing.T) { - storage := stateio.NewMemory() - starter := &player.Starter{Version: "2.35.10"} - inventory, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - all := []player.Character{{InvenIndex: 77, ID: 350, HP: 100, Level: 1}, {InvenIndex: 88, ID: 650, HP: 100, Level: 1}} - characters, err := player.OpenCharacterStore(storage, all, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - if err := characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - if err := characters.SetCurrentHealth(77, 0); err != nil { - t.Fatal(err) - } - if err := characters.SetCurrentHealth(88, 23); err != nil { - t.Fatal(err) - } - characters, err = player.OpenCharacterStore(storage, all, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil { - t.Fatal(err) - } - state := progress.NewStore() - if err := state.ClearQuest(26, 21); err != nil { - t.Fatal(err) - } - s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: all[0], StoryCharacters: all[1:]}, state: state, starter: starter, characters: characters} - attachTestStoryCatalog(s) - response, err := s.packInfoFor(21) - if err != nil { - t.Fatal(err) - } - count := 0 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 { - count++ - index, _, _ := wire.Varint(field.Value, 1) - hp, _, _ := wire.Varint(field.Value, 3) - want := map[uint64]uint64{77: 0, 88: 23} - if expected, exists := want[index]; !exists || hp != expected { - t.Fatalf("pack character %d hp=%d", index, hp) - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if count != 0 { - t.Fatalf("unconditional seed story roster count=%d", count) - } -} diff --git a/go/internal/server/world/hunting_test.go b/go/internal/server/world/hunting_test.go deleted file mode 100644 index 56a5fae..0000000 --- a/go/internal/server/world/hunting_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package world - -import ( - "testing" - - "bd2server/internal/server/wire" -) - -type fakeHuntingGround struct { - calls []int - row []byte -} - -func (h *fakeHuntingGround) EnsureForPack(pack int) ([]byte, error) { - h.calls = append(h.calls, pack) - return h.row, nil -} - -func TestPackLoadRestoresActualHuntingState(t *testing.T) { - s := testService() - h := &fakeHuntingGround{row: wire.AppendVarint(nil, 2, 91)} - if err := s.AttachHuntingGround(h); err != nil { - t.Fatal(err) - } - out, err := s.packInfoFor(s.seed.PackID) - if err != nil { - t.Fatal(err) - } - b, ok, err := wire.Bytes(out, 12) - if err != nil || !ok { - t.Fatalf("ground missing: %v", err) - } - if id, _, _ := wire.Varint(b, 2); id != 91 { - t.Fatalf("seed placeholder replaced state: %d", id) - } - if len(h.calls) != 1 || h.calls[0] != s.seed.PackID { - t.Fatal("wrong loaded pack") - } -} diff --git a/go/internal/server/world/mission_unlock.go b/go/internal/server/world/mission_unlock.go deleted file mode 100644 index fe6ec3a..0000000 --- a/go/internal/server/world/mission_unlock.go +++ /dev/null @@ -1,18 +0,0 @@ -package world - -import "math" - -// MissionsUnlocked uses the same pack authorization as map entry. A quest -// condition requires a committed normal-difficulty clear in that pack. -func (s *Service) MissionsUnlocked(pack, quest uint64) bool { - if pack == 0 { - return quest == 0 - } - if pack > math.MaxInt32 || quest > math.MaxInt32 { - return false - } - if quest == 0 { - return s.packUnlocked(int(pack)) - } - return s.state.QuestCleared(int(quest), int(pack), 0) -} diff --git a/go/internal/server/world/mission_unlock_test.go b/go/internal/server/world/mission_unlock_test.go deleted file mode 100644 index 14063c9..0000000 --- a/go/internal/server/world/mission_unlock_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "testing" -) - -func TestMissionPackOnlyUnlockUsesTicketAndLevelAuthorization(t *testing.T) { - s := testService() - s.squadLevel = func() (uint64, error) { return 3, nil } - s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, Open: &gamedata.ContentOpenRule{SquadLevel: 4}} - if s.MissionsUnlocked(709, 0) || s.MissionsUnlocked(999, 0) || s.MissionsUnlocked(0, 1) { - t.Fatal("locked pack/invalid condition opened") - } - s.squadLevel = func() (uint64, error) { return 4, nil } - if !s.MissionsUnlocked(709, 0) || !s.MissionsUnlocked(0, 0) { - t.Fatal("authorized pack not opened") - } - s.storyCatalog.Packs[710] = gamedata.StoryPack{ID: 710, Open: &gamedata.ContentOpenRule{TicketID: 901}} - var err error - s.inventory, err = player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - if s.MissionsUnlocked(710, 0) { - t.Fatal("missing ticket opened") - } - if _, err := s.inventory.GrantOnce("ticket", []gamedata.BattleReward{{Type: 19, ID: 901, Count: 1}}); err != nil { - t.Fatal(err) - } - if !s.MissionsUnlocked(710, 0) { - t.Fatal("ticket authorization ignored") - } -} - -func TestCollectionRewardsGrantChangedQuestAndPackOnlyOnNormal(t *testing.T) { - s := testService() - s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, Quests: map[int]gamedata.QuestDesign{49: {ID: 49, CollectionRewards: []gamedata.Reward{{Type: 17, ID: 9901, Count: 1}}}}} - storage := stateio.NewMemory() - var err error - s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - items, _, err := s.grantQuestRewards(709, 49, nil) - if err != nil { - t.Fatal(err) - } - if len(items) != 1 || items[0].ID != 9901 || items[0].Type != 17 { - t.Fatalf("items=%+v", items) - } -} diff --git a/go/internal/server/world/monster_info.go b/go/internal/server/world/monster_info.go deleted file mode 100644 index 1147902..0000000 --- a/go/internal/server/world/monster_info.go +++ /dev/null @@ -1,414 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "encoding/json" - "fmt" - "slices" - "time" -) - -type fieldMonsterState struct { - Generation uint64 - Start, Respawn, LifeEnd int64 - Defeated bool - Period string -} -type fieldMonsterSnapshot struct { - Monsters map[string]fieldMonsterState - Claims map[string]bool - Requests map[string]fieldMonsterReply -} -type fieldMonsterReply struct{ Request, Response []byte } - -func (s *Service) BeginSession(id string) { s.monsterSession = id } -func (s *Service) AttachFieldMonsterDamage(f func(int, uint64, string) ([][]byte, error)) { - s.monsterDamage = f -} - -func (s *Service) AttachFieldMonsterState(store stateio.Store) error { - if store == nil { - return fmt.Errorf("world: missing monster state store") - } - s.monsterStore = store - return nil -} -func (s *Service) monsterTime() time.Time { - if s.monsterNow != nil { - return s.monsterNow() - } - return time.Now() -} -func (s *Service) loadMonsterState() (fieldMonsterSnapshot, error) { - v := fieldMonsterSnapshot{Monsters: map[string]fieldMonsterState{}, Claims: map[string]bool{}, Requests: map[string]fieldMonsterReply{}} - if s.monsterStore == nil { - return v, nil - } - b, e := s.monsterStore.Load("field_monster_runtime") - if e != nil || b == nil { - return v, e - } - if e = stateio.RequireExactJSONObject(b, "Monsters", "Claims", "Requests"); e != nil { - return v, e - } - if e = json.Unmarshal(b, &v); e != nil || v.Monsters == nil || v.Claims == nil || v.Requests == nil { - return v, fmt.Errorf("world: invalid field monster state") - } - for _, m := range v.Monsters { - if m.Generation == 0 || m.Start <= 0 || m.Respawn < 0 || m.LifeEnd < 0 { - return v, fmt.Errorf("world: invalid monster timestamps") - } - } - return v, nil -} -func (s *Service) saveMonsterState(v fieldMonsterSnapshot) error { - if s.monsterStore == nil { - return fmt.Errorf("world: monster persistence unavailable") - } - b, e := json.Marshal(v) - if e != nil { - return e - } - return s.monsterStore.Save("field_monster_runtime", b) -} -func monsterKey(pack, difficulty, id int) string { - return fmt.Sprintf("%d/%d/%d", pack, difficulty, id) -} -func (s *Service) monsterPeriod(m gamedata.FieldMonsterDesign) (string, error) { - if m.ResetType == 0 { - return "own", nil - } - reset := 0 - if m.ResetType == 2 { - reset = 3 - } - return s.fieldReset.Period(reset, s.monsterTime()) -} -func (s *Service) monsterState(v *fieldMonsterSnapshot, pack int, m gamedata.FieldMonsterDesign) (fieldMonsterState, error) { - key := monsterKey(pack, s.questDifficulty(pack), m.ID) - old, exists := v.Monsters[key] - period, e := s.monsterPeriod(m) - if e != nil { - return old, e - } - now := s.monsterTime().UnixMilli() - fresh := func(generation uint64, start int64) fieldMonsterState { - r := fieldMonsterState{Generation: generation, Start: start, Period: period} - if m.LifeSeconds > 0 { - r.LifeEnd = start + int64(m.LifeSeconds)*1000 - } - return r - } - if !exists { - old = fresh(1, now) - } else if old.Period != period { - old = fresh(old.Generation+1, now) - } else if old.Respawn > 0 && now >= old.Respawn { - old = fresh(old.Generation+1, old.Respawn) - } else if old.LifeEnd > 0 && now >= old.LifeEnd && !old.Defeated { - old.Defeated = true - old.Respawn = old.LifeEnd + int64(m.RegenSeconds)*1000 - if old.Respawn <= now { - old = fresh(old.Generation+1, now) - } - } - v.Monsters[key] = old - return old, nil -} -func (s *Service) monsterEligible(pack int, m gamedata.FieldMonsterDesign) bool { - return m.QuestID == 0 || s.state.QuestCleared(m.QuestID, pack, s.questDifficulty(pack)) -} -func monsterWire(m gamedata.FieldMonsterDesign, state fieldMonsterState, eligible bool) []byte { - b := wire.AppendVarint(nil, 1, uint64(m.ID)) - if m.BattleDeck != 0 { - b = wire.AppendVarint(b, 2, m.BattleDeck) - } - if state.Respawn > 0 { - b = wire.AppendVarint(b, 3, uint64(state.Respawn)) - } - if state.LifeEnd > 0 { - b = wire.AppendVarint(b, 4, uint64(state.LifeEnd)) - } - b = wire.AppendVarint(b, 5, uint64(m.GroupID)) - if eligible { - b = wire.AppendVarint(b, 6, 1) - } - return b -} -func (s *Service) monsterRows(pack int, filter map[int]bool) ([][]byte, error) { - if s.monsterLoader == nil { - return nil, fmt.Errorf("%w: missing field monster design", ErrInvalidRequest) - } - design, e := s.monsterLoader(pack) - if e != nil { - return nil, e - } - v, e := s.loadMonsterState() - if e != nil { - return nil, e - } - var rows [][]byte - changed := false - for _, m := range design { - if m.GroupID == 0 { - continue - } - if filter != nil && !filter[m.ID] { - continue - } - eligible := s.monsterEligible(pack, m) - if eligible { - available, err := s.rewardMonsterAvailable(pack, m.ID) - if err != nil { - return nil, err - } - eligible = available - } - state := fieldMonsterState{} - if eligible { - state, e = s.monsterState(&v, pack, m) - if e != nil { - return nil, e - } - changed = true - } - rows = append(rows, monsterWire(m, state, eligible)) - } - if changed && s.monsterStore != nil { - if e = s.saveMonsterState(v); e != nil { - return nil, e - } - } - return rows, nil -} -func (s *Service) handleMonsterInfo(request []byte) (int, []byte, bool, error) { - seq, present, e := wire.Varint(request, 1) - if e != nil || !present || seq == 0 || seq > 0x7fffffff { - return 0, nil, true, ErrInvalidRequest - } - groups, e := intsRequest(request, 2) - if e != nil { - return 0, nil, true, e - } - wanted := map[int]bool{} - for _, g := range groups { - if g == 0 || g > 0x7fffffff { - return 0, nil, true, ErrInvalidRequest - } - wanted[int(g)] = true - } - pack, e := s.CurrentPackID() - if e != nil || !s.packUnlocked(pack) || s.monsterLoader == nil { - return 0, nil, true, ErrInvalidRequest - } - design, e := s.monsterLoader(pack) - if e != nil { - return 0, nil, true, e - } - filter := map[int]bool{} - for _, m := range design { - if wanted[m.GroupID] { - filter[m.ID] = true - } - } - rows, e := s.monsterRows(pack, filter) - if e != nil { - return 0, nil, true, e - } - var b []byte - for _, r := range rows { - b = wire.AppendBytes(b, 1, r) - } - return 51, b, true, nil -} -func (s *Service) attachFieldMonsterDesign(root, version string) { - s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) { - return gamedata.LoadFieldMonsters(root, version, pack) - } - s.monsterRewards = func(pack int, deck uint64) ([]gamedata.BattleReward, error) { - return gamedata.BattleDeckRewards(root, version, pack, deck) - } - s.monsterMaps = func(pack int) (map[int][]int, error) { return gamedata.LoadFieldMonsterMaps(root, version, pack) } -} -func (s *Service) authorizeMonsterMap(pack, id int) error { - available, err := s.rewardMonsterAvailable(pack, id) - if err != nil { - return err - } - if !available { - return fmt.Errorf("world: reward monster not summoned") - } - if s.monsterMaps == nil { - return nil - } - maps, e := s.monsterMaps(pack) - if e != nil { - return e - } - current, e := s.currentFieldMap(pack) - if e != nil { - return e - } - if slices.Contains(maps[id], current) { - return nil - } - return fmt.Errorf("world: monster outside current map") -} -func (s *Service) findFieldMonster(pack, id int) (gamedata.FieldMonsterDesign, bool, error) { - if s.monsterLoader == nil { - return gamedata.FieldMonsterDesign{}, false, nil - } - d, e := s.monsterLoader(pack) - if e != nil { - return gamedata.FieldMonsterDesign{}, false, e - } - for _, m := range d { - if m.ID == id { - return m, true, nil - } - } - return gamedata.FieldMonsterDesign{}, false, nil -} - -// BeginFieldMonsterBattle locks the regenerated instance for retry-safe rewards. -// Scripted monsters without regeneration return handled=false. -func (s *Service) BeginFieldMonsterBattle(pack int, id, deck uint64) (string, bool, error) { - m, found, e := s.findFieldMonster(pack, int(id)) - if e == nil && found { - available, err := s.rewardMonsterAvailable(pack, int(id)) - if err != nil { - return "", true, err - } - if !available { - return "", true, fmt.Errorf("world: reward monster not summoned") - } - } - if found && m.GroupID == 0 { - return "", false, nil - } - if e != nil || !found { - return "", found, e - } - if !s.packUnlocked(pack) || !s.monsterEligible(pack, m) { - return "", true, ErrInvalidRequest - } - deckOK := deck == m.BattleDeck - for _, allowed := range m.BattleDecks { - if deck == allowed { - deckOK = true - } - } - if m.BattleDeck != 0 && !deckOK { - return "", true, fmt.Errorf("world: field monster deck mismatch") - } - if e = s.authorizeMonsterMap(pack, int(id)); e != nil { - return "", true, e - } - v, e := s.loadMonsterState() - if e != nil { - return "", true, e - } - state, e := s.monsterState(&v, pack, m) - if e != nil { - return "", true, e - } - if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() { - return "", true, fmt.Errorf("world: monster is not spawned") - } - if e = s.saveMonsterState(v); e != nil { - return "", true, e - } - return fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), m.ID), state.Generation), true, nil -} -func (s *Service) CompleteFieldMonsterBattle(pack int, id uint64, instance string) ([]byte, error) { - m, found, e := s.findFieldMonster(pack, int(id)) - if e != nil { - return nil, e - } - if !found { - return nil, ErrInvalidRequest - } - v, e := s.loadMonsterState() - if e != nil { - return nil, e - } - key := monsterKey(pack, s.questDifficulty(pack), m.ID) - state, exists := v.Monsters[key] - if !exists { - return nil, ErrInvalidRequest - } - if !v.Claims[instance] { - if instance != fmt.Sprintf("fieldmonster:%s:%d", key, state.Generation) { - return nil, fmt.Errorf("world: stale monster battle") - } - state.Defeated = true - state.Respawn = s.nextMonsterSpawn(m) - v.Monsters[key] = state - v.Claims[instance] = true - if e = s.saveMonsterState(v); e != nil { - return nil, e - } - } - return monsterWire(m, state, s.monsterEligible(pack, m)), nil -} -func (s *Service) nextMonsterSpawn(m gamedata.FieldMonsterDesign) int64 { - now := s.monsterTime() - if m.ResetType == 0 { - return now.UnixMilli() + int64(m.RegenSeconds)*1000 - } - shift := 9*time.Hour - s.fieldReset.DailyReset - t := now.UTC().Add(shift) - day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1) - if m.ResetType == 2 { - delta := (int(s.fieldReset.WeeklyDay) - int(day.Weekday()) + 7) % 7 - day = day.AddDate(0, 0, delta) - } - return day.Add(-shift).UnixMilli() -} -func (s *Service) handleFieldMonsterRegen(request []byte) (int, []byte, bool, error) { - id, e := requestPack(request) - if e != nil { - return 0, nil, true, e - } - pack, e := s.CurrentPackID() - if e != nil || !s.packUnlocked(pack) { - return 0, nil, true, ErrInvalidRequest - } - m, found, e := s.findFieldMonster(pack, id) - if e != nil || !found { - return 0, nil, true, ErrInvalidRequest - } - v, e := s.loadMonsterState() - if e != nil { - return 0, nil, true, e - } - seq, _, _ := wire.Varint(request, 1) - identity := fmt.Sprintf("regen:%s:%d", s.monsterSession, seq) - if s.monsterSession == "" { - return 0, nil, true, fmt.Errorf("world: missing monster session") - } - if reply, ok := v.Requests[identity]; ok { - if !bytes.Equal(request, reply.Request) { - return 0, nil, true, ErrInvalidRequest - } - return 139, reply.Response, true, nil - } - state, e := s.monsterState(&v, pack, m) - if e != nil { - return 0, nil, true, e - } - if !state.Defeated { - state.Defeated = true - state.Respawn = s.nextMonsterSpawn(m) - v.Monsters[monsterKey(pack, s.questDifficulty(pack), m.ID)] = state - } - response := wire.AppendBytes(nil, 1, monsterWire(m, state, s.monsterEligible(pack, m))) - v.Requests[identity] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: response} - if e = s.saveMonsterState(v); e != nil { - return 0, nil, true, e - } - return 139, response, true, nil -} diff --git a/go/internal/server/world/monster_info_test.go b/go/internal/server/world/monster_info_test.go deleted file mode 100644 index d3304de..0000000 --- a/go/internal/server/world/monster_info_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "testing" - "time" -) - -// This regression exercises the missing field entry route with duplicate packed -// regeneration IDs, and verifies that other groups cannot leak into the map. -func TestMonsterInfoRegenerationGroupsAndQuestGate(t *testing.T) { - s := testService() - s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) { - if pack != 21 { - t.Fatalf("loaded wrong pack %d", pack) - } - return []gamedata.FieldMonsterDesign{{ID: 1001, GroupID: 7}, {ID: 1002, GroupID: 7, QuestID: 2, BattleDeck: 77}, {ID: 1003, GroupID: 8}}, nil - } - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendBytes(request, 2, []byte{7, 7}) - code, body, handled, err := s.Handle("/MonsterInfo", request) - if err != nil || !handled || code != 51 { - t.Fatalf("MonsterInfo: %d %v %v", code, handled, err) - } - var ids []uint64 - var active []uint64 - err = wire.Walk(body, func(f wire.Field) error { - id, _, e := wire.Varint(f.Value, 1) - if e != nil { - return e - } - a, _, e := wire.Varint(f.Value, 6) - ids = append(ids, id) - active = append(active, a) - return e - }) - if err != nil || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 || active[0] != 1 || active[1] != 0 { - t.Fatalf("wrong field monsters %v active %v: %v", ids, active, err) - } - _, retry, _, err := s.Handle("/MonsterInfo", request) - if err != nil || !bytes.Equal(body, retry) { - t.Fatal("reading stable field monsters changed the response") - } - unpacked := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 7) - _, alternate, _, err := s.Handle("/MonsterInfo", unpacked) - if err != nil || !bytes.Equal(body, alternate) { - t.Fatal("unpacked protobuf group IDs changed results") - } - if _, _, _, err = s.Handle("/MonsterInfo", wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, []byte{128})); err == nil { - t.Fatal("accepted truncated group ID") - } -} - -func TestMonsterLifetimeSurvivesReaderRestartWithoutRenewal(t *testing.T) { - s := testService() - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - s.monsterNow = func() time.Time { return now } - store := stateio.NewMemory() - if err := s.AttachFieldMonsterState(store); err != nil { - t.Fatal(err) - } - s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { - return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, LifeSeconds: 30, RegenSeconds: 10}}, nil - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 7) - _, first, _, err := s.Handle("/MonsterInfo", request) - if err != nil { - t.Fatal(err) - } - s2 := testService() - s2.monsterLoader = s.monsterLoader - s2.monsterNow = s.monsterNow - if err := s2.AttachFieldMonsterState(store); err != nil { - t.Fatal(err) - } - _, again, _, err := s2.Handle("/MonsterInfo", request) - if err != nil || !bytes.Equal(first, again) { - t.Fatal("restart renewed finite monster lifetime") - } - now = now.Add(31 * time.Second) - _, expired, _, err := s2.Handle("/MonsterInfo", request) - if err != nil { - t.Fatal(err) - } - row, _, err := wire.Bytes(expired, 1) - if err != nil { - t.Fatal(err) - } - respawn, _, _ := wire.Varint(row, 3) - if respawn <= uint64(now.UnixMilli()) { - t.Fatal("expired monster did not wait for regeneration") - } - now = now.Add(10 * time.Second) - _, revived, _, err := s2.Handle("/MonsterInfo", request) - if err != nil { - t.Fatal(err) - } - row, _, _ = wire.Bytes(revived, 1) - respawn, _, _ = wire.Varint(row, 3) - if respawn != 0 { - t.Fatal("regeneration time did not create the next instance") - } -} diff --git a/go/internal/server/world/npc_dialog.go b/go/internal/server/world/npc_dialog.go deleted file mode 100644 index cc02bdf..0000000 --- a/go/internal/server/world/npc_dialog.go +++ /dev/null @@ -1,27 +0,0 @@ -package world - -import "bd2server/internal/server/wire" - -// handleQuestUpdate accepts the existing task update packet used when an NPC -// conversation finishes. Ordinary Talk is local and sends AchievementUpdate, -// not a separate NPC dialog packet. QuestUpdate has no NPC identity to validate. -func (s *Service) handleQuestUpdate(request []byte) (int, []byte, bool, error) { - quest, pack, err := requestQuest(request) - if err != nil { - return 0, nil, true, err - } - current, err := s.CurrentPackID() - if err != nil || current != pack || !s.canClear(pack, quest) { - return 0, nil, true, ErrInvalidRequest - } - if s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest)) { - // A delayed replay must not reinsert a cleared quest into active progress. - return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil - } - if _, err := s.state.UpdateQuest(request); err != nil { - return 0, nil, true, err - } - // field 2 is RewardDBInfoBundle, not QuestDBInfo. Task completion/claims - // remain in QuestClear; an update alone must not invent or duplicate rewards. - return 19, wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(quest)), 2, nil), true, nil -} diff --git a/go/internal/server/world/npc_dialog_test.go b/go/internal/server/world/npc_dialog_test.go deleted file mode 100644 index 594dd7d..0000000 --- a/go/internal/server/world/npc_dialog_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package world - -import ( - "bd2server/internal/server/wire" - "bytes" - "testing" -) - -func TestNpcTaskQuestUpdateRejectsUnavailableAndReplaysClear(t *testing.T) { - s := testService() - req := func(quest, pack, value uint64) []byte { - out := wire.AppendVarint(nil, 1, 1) - out = wire.AppendVarint(out, 2, quest) - out = wire.AppendVarint(out, 3, pack) - return wire.AppendVarint(out, 4, value) - } - for _, bad := range [][]byte{req(99, 21, 1), req(2, 21, 1), req(1, 22, 1)} { - if _, _, _, err := s.handleQuestUpdate(bad); err == nil { - t.Fatalf("unavailable update accepted: %x", bad) - } - } - code, body, ok, err := s.handleQuestUpdate(req(1, 21, 1)) - expected := wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, nil) - if err != nil || code != 19 || !ok || !bytes.Equal(body, expected) { - t.Fatalf("update: %d %x %v %v", code, body, ok, err) - } - current, found := s.state.QuestInPack(1, 21) - if !found || len(current.Values) != 1 || current.Values[0] != 1 { - t.Fatal("task progress not persisted") - } - if err = s.state.ClearQuest(1, 21); err != nil { - t.Fatal(err) - } - if _, _, _, err = s.handleQuestUpdate(req(1, 21, 2)); err != nil { - t.Fatal(err) - } - current, found = s.state.QuestInPack(1, 21) - if !s.state.QuestCleared(1, 21) || !found || len(current.Values) != 1 || current.Values[0] != 1 { - t.Fatal("cleared quest replay changed committed progress") - } -} diff --git a/go/internal/server/world/npc_reputation_test.go b/go/internal/server/world/npc_reputation_test.go deleted file mode 100644 index c888ae8..0000000 --- a/go/internal/server/world/npc_reputation_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" - "time" -) - -func TestNPCReputationCompletionRetriesDoNotExtendDiscount(t *testing.T) { - s := testService() - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - store := stateio.NewMemory() - d := gamedata.NPCReputationDesign{Groups: map[uint64]gamedata.NPCReputationRule{9: {ID: 9, DownHours: 2, GoodInn: 10, GoodPrice: 20}}, MapGroups: map[int]uint64{211: 9}} - s.npcReputation = &npcReputationRuntime{store: store, now: func() time.Time { return now }, load: func(int) (gamedata.NPCReputationDesign, error) { return d, nil }} - b, err := s.CompleteNPCReputation("quest-1", 21, 9) - if err != nil { - t.Fatal(err) - } - if v, _, _ := wire.Varint(b, 2); v != 2 { - t.Fatal("completion did not grant good reputation") - } - now = now.Add(time.Hour) - b, err = s.CompleteNPCReputation("quest-1", 21, 9) - if err != nil { - t.Fatal(err) - } - if elapsed, _, _ := wire.Varint(b, 3); elapsed != 3600 { - t.Fatal("retry reset good reputation expiry") - } - now = now.Add(time.Hour) - state, _, err := s.reputationState(21, d.Groups[9]) - if err != nil || state != 1 { - t.Fatal("good reputation did not expire after GameData hours") - } - if _, err = s.CompleteNPCReputation("quest-2", 21, 99); err == nil { - t.Fatal("unknown reputation group accepted") - } -} - -func TestInnContextUsesCurrentMapAndShopCanProjectOtherUnlockedPacks(t *testing.T) { - s := testService() - s.storyCatalog.Packs[77] = gamedata.StoryPack{ID: 77} - position := wire.AppendString(wire.AppendVarint(nil, 2, 21), 3, `{"mapId":211}`) - if err := s.state.SaveUserPosition(position); err != nil { - t.Fatal(err) - } - d := gamedata.NPCReputationDesign{Groups: map[uint64]gamedata.NPCReputationRule{9: {ID: 9, DownHours: 2, GoodInn: 10, GoodPrice: 20}}, MapGroups: map[int]uint64{211: 9}} - s.npcReputation = &npcReputationRuntime{store: stateio.NewMemory(), now: time.Now, load: func(int) (gamedata.NPCReputationDesign, error) { return d, nil }, inns: func(int) ([]gamedata.InnRule, error) { - return []gamedata.InnRule{{NPCID: 7, MapID: 211, MapGroup: 9}}, nil - }} - if _, state, err := s.InnContext(21, 7); err != nil || state != 1 { - t.Fatal("valid motel context rejected") - } - if _, _, err := s.InnContext(21, 8); err == nil { - t.Fatal("unrelated NPC treated as motel") - } - if _, _, err := s.InnContext(77, 7); err == nil { - t.Fatal("motel from another pack accepted") - } - if _, err := s.CompleteNPCReputation("other-pack-delegation", 77, 9); err != nil { - t.Fatal(err) - } - state, discount, err := s.NPCShopReputation(77) - if err != nil || state != 2 || discount != 20 { - t.Fatal("ShopOpen cannot project reputation for another unlocked pack") - } -} diff --git a/go/internal/server/world/overwhelm.go b/go/internal/server/world/overwhelm.go deleted file mode 100644 index c9ea532..0000000 --- a/go/internal/server/world/overwhelm.go +++ /dev/null @@ -1,331 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "bytes" - "fmt" -) - -func (s *Service) AttachOverwhelmAuthorization(f func(string, uint64) error) { - s.overwhelmAuthorize = f -} -func (s *Service) AttachOverwhelmHunting(h interface { - ValidateBattle(int, uint64, uint64, uint64) error - CompleteBattle(int, uint64, uint64, uint64, string) ([]byte, [][]byte, error) -}) { - s.overwhelmHunting = h -} -func nestedMessages(b []byte, number int) ([][]byte, error) { - var out [][]byte - e := wire.Walk(b, func(f wire.Field) error { - if f.Number == number { - if f.Type != 2 { - return ErrInvalidRequest - } - out = append(out, append([]byte(nil), f.Value...)) - } - return nil - }) - return out, e -} - -type overwhelmedMonster struct { - ID, Group, Deck, Mode uint64 - Definition gamedata.FieldMonsterDesign - Instance string - Costs []gamedata.Reward -} - -func (s *Service) AttachOverwhelmDesign(root, version string) error { - rows, e := gamedata.LoadSkyWayOverwhelm(root, version) - if e != nil { - return e - } - s.overwhelmSky = rows - s.overwhelmQuest = func(pack, quest int) (gamedata.OverwhelmQuestRule, error) { - return gamedata.LoadOverwhelmQuest(root, version, pack, quest) - } - return nil -} - -func (s *Service) handleOverwhelm(request []byte) (int, []byte, bool, error) { - seq, present, e := wire.Varint(request, 1) - if e != nil || !present || seq == 0 || seq > 0x7fffffff || s.monsterSession == "" { - return 275, nil, true, ErrInvalidRequest - } - pack, e := s.CurrentPackID() - if e != nil { - return 275, nil, true, e - } - v, e := s.loadMonsterState() - if e != nil { - return 275, nil, true, e - } - identity := fmt.Sprintf("overwhelm:%s:%d", s.monsterSession, seq) - if prior, ok := v.Requests[identity]; ok { - if !bytes.Equal(prior.Request, request) { - return 275, nil, true, ErrInvalidRequest - } - return 275, prior.Response, true, nil - } - raw, e := nestedMessages(request, 2) - if e != nil || len(raw) == 0 || len(raw) > 4096 { - return 275, nil, true, ErrInvalidRequest - } - var targets []overwhelmedMonster - seen := map[uint64]bool{} - for _, b := range raw { - var m overwhelmedMonster - for f, dst := range map[int]*uint64{1: &m.Group, 2: &m.ID, 3: &m.Deck, 4: &m.Mode} { - *dst, _, e = wire.Varint(b, f) - if e != nil || *dst > 0x7fffffff { - return 275, nil, true, ErrInvalidRequest - } - } - if m.ID == 0 || seen[m.ID] { - return 275, nil, true, ErrInvalidRequest - } - seen[m.ID] = true - if m.Mode == 5 { - definition, found, x := s.findFieldMonster(pack, int(m.ID)) - if x != nil || !found || definition.UseBattleSkip != 1 || definition.Type >= 2 { - return 275, nil, true, fmt.Errorf("world: hunting monster cannot be overwhelmed") - } - if s.overwhelmHunting == nil { - return 275, nil, true, fmt.Errorf("world: hunting overwhelm runtime missing") - } - if e = s.overwhelmHunting.ValidateBattle(pack, 5, m.ID, m.Deck); e != nil { - return 275, nil, true, e - } - } else { - if m.Mode != 1 && m.Mode != 2 && m.Mode != 4 && (m.Mode < 9 || m.Mode > 15) { - return 275, nil, true, fmt.Errorf("world: unavailable overwhelm battle mode %d", m.Mode) - } - definition, found, e := s.findFieldMonster(pack, int(m.ID)) - if e != nil || !found || (definition.UseBattleSkip != 1 && definition.Type != 3) { - return 275, nil, true, ErrInvalidRequest - } - m.Definition = definition - if definition.Type == 2 || definition.Type == 4 { - return 275, nil, true, fmt.Errorf("world: private field monster cannot be overwhelmed") - } - if e = s.authorizeMonsterMap(pack, int(m.ID)); e != nil { - return 275, nil, true, e - } - validDeck := definition.Type == 3 && m.Deck == 0 - for _, d := range definition.BattleDecks { - if d == m.Deck { - validDeck = true - } - } - if definition.BattleDeck == m.Deck { - validDeck = true - } - if !validDeck { - return 275, nil, true, fmt.Errorf("world: overwhelm deck mismatch") - } - if m.Mode >= 9 && m.Mode <= 15 { - mapID, e := s.currentFieldMap(pack) - if e != nil { - return 275, nil, true, e - } - found := false - for _, rule := range s.overwhelmSky { - if rule.Map != uint64(mapID) || rule.Group+8 != m.Mode { - continue - } - cost := uint64(0) - if rule.Boss == m.ID { - found = true - cost = rule.BossAP - } else { - for i, id := range rule.Monsters { - if id == m.ID { - found = true - cost = rule.AP[i] - } - } - } - if found { - typ := uint64(21) - if rule.APType == 2 { - typ = 23 - } - if cost > 0 { - m.Costs = []gamedata.Reward{{Type: typ, Count: cost}} - } - break - } - } - if !found { - return 275, nil, true, fmt.Errorf("world: skyway monster does not match current dungeon") - } - m.Instance = fmt.Sprintf("skyway:%s:%d", identity, m.ID) - } else if m.Mode == 2 { - if int(m.Group) != definition.GroupID || definition.GroupID == 0 || !s.monsterEligible(pack, definition) { - return 275, nil, true, ErrInvalidRequest - } - state, e := s.monsterState(&v, pack, definition) - if e != nil { - return 275, nil, true, e - } - if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() { - return 275, nil, true, fmt.Errorf("world: overwhelm monster not spawned") - } - m.Instance = fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), definition.ID), state.Generation) - } else { - if m.Group != 0 { - return 275, nil, true, ErrInvalidRequest - } - m.Instance = fmt.Sprintf("questmonster:%d:%d:%d", pack, s.questDifficulty(pack), m.ID) - } - } - targets = append(targets, m) - } - quests, e := nestedMessages(request, 3) - if e != nil { - return 275, nil, true, e - } - var updates [][]byte - var updated []uint64 - for _, b := range quests { - quest, _, e := wire.Varint(b, 1) - if e != nil { - return 275, nil, true, e - } - qp, _, e := wire.Varint(b, 2) - if e != nil || int(qp) != pack || quest == 0 || !s.canClear(pack, int(quest)) { - return 275, nil, true, ErrInvalidRequest - } - values, e := intsRequest(b, 3) - if e != nil || len(values) == 0 { - return 275, nil, true, ErrInvalidRequest - } - if e = s.validateOverwhelmQuest(pack, int(quest), values, targets); e != nil { - return 275, nil, true, e - } - req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, quest), 3, qp) - for _, value := range values { - if value > 0x7fffffff { - return 275, nil, true, ErrInvalidRequest - } - req = wire.AppendVarint(req, 4, value) - } - updates = append(updates, req) - updated = append(updated, quest) - } - if s.overwhelmAuthorize == nil { - return 275, nil, true, fmt.Errorf("world: overwhelm requires successful talent use") - } - if e = s.overwhelmAuthorize(identity, uint64(len(targets))); e != nil { - return 275, nil, true, e - } - var response, bundle []byte - for _, m := range targets { - if m.Mode == 5 { - reward, monsters, e := s.overwhelmHunting.CompleteBattle(pack, 5, m.ID, m.Deck, fmt.Sprintf("%s:%d", identity, m.ID)) - if e != nil { - return 275, nil, true, e - } - bundle = append(bundle, reward...) - for _, row := range monsters { - response = wire.AppendBytes(response, 1, row) - } - } else { - if !v.Claims[m.Instance] && m.Definition.Type != 3 { - definition := m.Definition - definition.BattleDeck = m.Deck - var reward []byte - var e error - if len(m.Costs) > 0 { - if s.monsterRewards == nil || s.researchEconomy == nil { - return 275, nil, true, ErrInvalidRequest - } - rs, x := s.monsterRewards(pack, m.Deck) - if x != nil { - return 275, nil, true, x - } - rewards := make([]gamedata.Reward, 0, len(rs)) - for _, r := range rs { - rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016 - } - reward, e = s.researchEconomy.Apply(m.Instance, m.Costs, rewards) - } else { - reward, e = s.grantFieldMonster(pack, definition, m.Instance) - } - if e != nil { - return 275, nil, true, e - } - bundle = append(bundle, reward...) - } - v.Claims[m.Instance] = true - if m.Definition.GroupID > 0 { - state, e := s.monsterState(&v, pack, m.Definition) - if e != nil { - return 275, nil, true, e - } - state.Defeated = true - state.Respawn = s.nextMonsterSpawn(m.Definition) - v.Monsters[monsterKey(pack, s.questDifficulty(pack), int(m.ID))] = state - response = wire.AppendBytes(response, 1, monsterWire(m.Definition, state, true)) - } - } - } - for i, req := range updates { - if _, _, _, e = s.handleQuestUpdate(req); e != nil { - return 275, nil, true, e - } - response = wire.AppendVarint(response, 2, updated[i]) - } - response = wire.AppendBytes(response, 3, bundle) - v.Requests[identity] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: response} - if e = s.saveMonsterState(v); e != nil { - return 275, nil, true, e - } - return 275, response, true, nil -} -func (s *Service) validateOverwhelmQuest(pack, quest int, values []uint64, targets []overwhelmedMonster) error { - if s.overwhelmQuest == nil { - return fmt.Errorf("world: overwhelm quest rules missing") - } - r, e := s.overwhelmQuest(pack, quest) - if e != nil { - return e - } - mapID, e := s.currentFieldMap(pack) - if e != nil { - return e - } - gain := uint64(0) - for _, m := range targets { - switch r.Type { - case 3: - for _, id := range r.Targets { - if id == m.ID { - gain++ - } - } - case 1: - for _, enemy := range r.Enemies[m.Deck] { - for _, id := range r.Targets { - if enemy == id { - gain++ - } - } - } - case 8: - if len(r.Targets) > 0 && r.Targets[0] == uint64(mapID) { - gain += uint64(len(r.Enemies[m.Deck])) - } - } - } - old := uint64(0) - if p, ok := s.state.QuestInPack(quest, pack, s.questDifficultyFor(pack, quest)); ok && len(p.Values) > 0 { - old = uint64(p.Values[0]) - } - if gain == 0 || len(values) != 1 || values[0] != old+gain { - return fmt.Errorf("world: quest does not match overwhelmed monsters") - } - return nil -} diff --git a/go/internal/server/world/overwhelm_test.go b/go/internal/server/world/overwhelm_test.go deleted file mode 100644 index 1de3c98..0000000 --- a/go/internal/server/world/overwhelm_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "bytes" - "errors" - "fmt" - "path/filepath" - "testing" - "time" -) - -type overwhelmFailedState struct{ stateio.Store } - -func (s overwhelmFailedState) Save(name string, b []byte) error { - if name == "field_monster_runtime" { - return fmt.Errorf("failed receipt") - } - return s.Store.Save(name, b) -} - -type overwhelmPersistedEconomy struct{ stateio.Store } - -func (s overwhelmPersistedEconomy) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) { - if e := s.Save("overwhelm_test_reward", []byte{1}); e != nil { - return nil, e - } - return nil, nil -} -func TestOverwhelmRewardAndReceiptRollbackTogether(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, e := accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - s := testService() - if err := s.AttachFieldMonsterState(overwhelmFailedState{repo}); err != nil { - t.Fatal(err) - } - s.BeginSession("login") - s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { - return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, BattleDeck: 33, UseBattleSkip: 1, RegenSeconds: 10, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}}}, nil - } - s.researchEconomy = overwhelmPersistedEconomy{repo} - s.AttachOverwhelmAuthorization(func(string, uint64) error { return repo.Save("overwhelm_test_authorization", []byte{1}) }) - op, e := repo.BeginOperation() - if e != nil { - t.Fatal(e) - } - if _, _, _, e = s.Handle("/Overwhelm", overwhelmRequest(1, 9)); e == nil { - t.Fatal("expected failed final receipt") - } - if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) { - t.Fatal(e) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, e = accountstate.Open(path) - if e != nil { - t.Fatal(e) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - for _, name := range []string{"overwhelm_test_authorization", "overwhelm_test_reward", "field_monster_runtime"} { - b, e := repo.Load(name) - if e != nil || b != nil { - t.Fatal("partial batch survived rollback", name, e) - } - } -} - -func overwhelmRequest(seq uint64, ids ...uint64) []byte { - b := wire.AppendVarint(nil, 1, seq) - for _, id := range ids { - m := wire.AppendVarint(nil, 1, 7) - m = wire.AppendVarint(m, 2, id) - m = wire.AppendVarint(m, 3, 33) - m = wire.AppendVarint(m, 4, 2) - b = wire.AppendBytes(b, 2, m) - } - return b -} -func TestOverwhelmBatchPrevalidationAuthorizationAndGenerationReplay(t *testing.T) { - s := testService() - store := stateio.NewMemory() - if err := s.AttachFieldMonsterState(store); err != nil { - t.Fatal(err) - } - s.BeginSession("login") - now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) - s.monsterNow = func() time.Time { return now } - s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) { - return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, BattleDeck: 33, UseBattleSkip: 1, RegenSeconds: 10, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}}, {ID: 10, GroupID: 7, BattleDeck: 33, UseBattleSkip: 1, RegenSeconds: 10, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}}}, nil - } - eco := &researchEconomySpy{} - s.researchEconomy = eco - auth := 0 - s.AttachOverwhelmAuthorization(func(identity string, n uint64) error { - if n != 2 { - return fmt.Errorf("wrong batch size") - } - auth++ - return nil - }) - if _, _, _, e := s.Handle("/Overwhelm", overwhelmRequest(1, 9, 999)); e == nil || eco.calls != 0 || auth != 0 { - t.Fatal("invalid tail partially settled batch", e) - } - req := overwhelmRequest(2, 9, 10) - code, first, _, e := s.Handle("/Overwhelm", req) - if e != nil || code != 275 || eco.calls != 2 || auth != 1 { - t.Fatal("valid batch not settled", code, eco.calls, auth, e) - } - now = now.Add(11 * time.Second) - _, retry, _, e := s.Handle("/Overwhelm", req) - if e != nil || !bytes.Equal(first, retry) || eco.calls != 2 || auth != 1 { - t.Fatal("retry awarded or authorized twice", e) - } - if _, _, _, e = s.Handle("/Overwhelm", overwhelmRequest(2, 10, 9)); e == nil { - t.Fatal("sequence reuse accepted") - } - if _, _, _, e = s.Handle("/Overwhelm", overwhelmRequest(3, 9, 10)); e != nil || eco.calls != 4 { - t.Fatal("new generation did not award", eco.calls, e) - } -} diff --git a/go/internal/server/world/pack_detail.go b/go/internal/server/world/pack_detail.go deleted file mode 100644 index d66f8a7..0000000 --- a/go/internal/server/world/pack_detail.go +++ /dev/null @@ -1,63 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "fmt" -) - -func (s *Service) handlePackDetail(request []byte) (int, []byte, bool, error) { - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > 0x7fffffff { - return 0, nil, true, fmt.Errorf("%w: PackDetailInfo invalid sequence", ErrInvalidRequest) - } - packID, err := requestPack(request) - if err != nil { - return 0, nil, true, err - } - if !s.packUnlocked(packID) || !s.packSummaryTargets[packID] { - return 0, nil, true, fmt.Errorf("%w: PackDetailInfo unavailable pack %d", ErrInvalidRequest, packID) - } - if s.packDetailDesign == nil { - return 0, nil, true, fmt.Errorf("world: missing pack detail design loader") - } - design, err := s.packDetailDesign(packID) - if err != nil { - return 0, nil, true, err - } - filter := map[int]bool{} - for _, id := range design.RegenMonsterIDs { - filter[id] = true - } - ids, err := s.openedFieldObjects(packID) - if err != nil { - return 0, nil, true, err - } - response := []byte{} - if len(filter) > 0 { - rows, e := s.monsterRows(packID, filter) - if e != nil { - return 0, nil, true, e - } - for _, row := range rows { - response = wire.AppendBytes(response, 1, row) - } - } - for _, id := range ids { - response = wire.AppendBytes(response, 2, wire.AppendVarint(nil, 1, uint64(id))) - } - research, e := s.state.ResearchObjects(packID) - if e != nil { - return 0, nil, true, e - } - for _, id := range research { - response = wire.AppendVarint(response, 3, uint64(id)) - } - return 627, response, true, nil -} - -func (s *Service) attachPackDetailDesign(root, version string) { - s.packDetailDesign = func(packID int) (gamedata.PackDetailDesign, error) { - return gamedata.LoadPackDetailDesign(root, version, packID) - } -} diff --git a/go/internal/server/world/pack_detail_test.go b/go/internal/server/world/pack_detail_test.go deleted file mode 100644 index 7e649f7..0000000 --- a/go/internal/server/world/pack_detail_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "errors" - "testing" -) - -func TestCollectedFieldObjectsAppearInDetailAndSummary(t *testing.T) { - s := testService() - s.packSummaryTargets = map[int]bool{21: true} - s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) { return gamedata.PackDetailDesign{}, nil } - s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{ - 1001: {ID: 1001, GroupID: 101, Type: 2, ResetType: 1}, - 1002: {ID: 1002, GroupID: 102, Type: 1, ResetType: 1}, - }}}) - for _, id := range []int{1001, 1002} { - if err := s.state.MarkFieldRewardOpened(21, id, "once"); err != nil { - t.Fatal(err) - } - } - req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21) - _, detail, _, err := s.Handle("/PackDetailInfo", req) - if err != nil { - t.Fatal(err) - } - rows := 0 - err = wire.Walk(detail, func(field wire.Field) error { - if field.Number == 2 { - rows++ - } - return nil - }) - if err != nil || rows != 2 { - t.Fatalf("opened detail %x: %v", detail, err) - } - _, summary, _, err := s.Handle("/PackSummaryInfoList", req) - if err != nil { - t.Fatal(err) - } - row, _, _ := wire.Bytes(summary, 1) - once, _, _ := wire.Varint(row, 2) - regen, _, _ := wire.Varint(row, 3) - if once != 1 || regen != 1 { - t.Fatalf("collected counts %x", row) - } -} - -func TestPackDetailUsesVerifiedDesignAndRejectsUnavailableState(t *testing.T) { - s := testService() - s.packSummaryTargets = map[int]bool{21: true, 22: true} - called := 0 - s.packDetailDesign = func(id int) (gamedata.PackDetailDesign, error) { - called++ - if id != 21 { - t.Fatalf("wrong pack %d", id) - } - return gamedata.PackDetailDesign{}, nil - } - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 10), 2, 21) - if err := s.state.ClearQuest(1, 21); err != nil { - t.Fatal(err) - } - code, response, handled, err := s.Handle("/PackDetailInfo", request) - if err != nil || !handled || code != 627 || response == nil || len(response) != 0 || called != 1 { - t.Fatalf("detail code=%d body=%x handled=%v err=%v calls=%d", code, response, handled, err, called) - } - s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) { - return gamedata.PackDetailDesign{RegenMonsterIDs: []int{101}}, nil - } - _, _, handled, err = s.Handle("/PackDetailInfo", request) - if !handled || !errors.Is(err, ErrInvalidRequest) { - t.Fatalf("unsupported regeneration handled=%v err=%v", handled, err) - } - s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) { - return gamedata.PackDetailDesign{}, errors.New("missing design") - } - if _, _, _, err := s.Handle("/PackDetailInfo", request); err == nil { - t.Fatal("design failure swallowed") - } -} - -func TestPackDetailRejectsInvalidAndLockedRequestsBeforeReadingDesign(t *testing.T) { - s := testService() - s.packSummaryTargets = map[int]bool{21: true, 22: true} - s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) { - t.Fatal("invalid request read GameData") - return gamedata.PackDetailDesign{}, nil - } - for _, request := range [][]byte{nil, {8}, wire.AppendVarint(nil, 1, 1), wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 22), wire.AppendVarint(wire.AppendVarint(nil, 1, 0x80000000), 2, 21)} { - if _, _, handled, err := s.Handle("/PackDetailInfo", request); !handled || err == nil { - t.Fatalf("request %x handled=%v err=%v", request, handled, err) - } - } - delete(s.packSummaryTargets, 21) - if _, _, _, err := s.Handle("/PackDetailInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)); err == nil { - t.Fatal("non-target pack accepted") - } -} diff --git a/go/internal/server/world/pack_jam.go b/go/internal/server/world/pack_jam.go deleted file mode 100644 index 63bd070..0000000 --- a/go/internal/server/world/pack_jam.go +++ /dev/null @@ -1,53 +0,0 @@ -package world - -import ( - "fmt" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" -) - -func packJamIdentity(packID int) string { return fmt.Sprintf("pack-jam:%d", packID) } - -func (s *Service) handlePackDocking(path string, request []byte) (int, []byte, bool, error) { - packID, err := requestPack(request) - if err != nil { - return 0, nil, true, err - } - if _, known := s.questsFor(packID); !known || !s.packUnlocked(packID) { - return 0, nil, true, fmt.Errorf("%w: unavailable docking pack %d", ErrInvalidRequest, packID) - } - if s.wallet == nil || s.packJamDesign == nil { - return 0, nil, true, fmt.Errorf("world: pack jam design or wallet unavailable") - } - identity := packJamIdentity(packID) - if path == "/PackPreviewInfo" { - var response []byte - if active := s.firstUnclearedQuestFor(packID); active != 0 { - quest := wire.AppendVarint(nil, 1, uint64(active)) - quest = wire.AppendVarint(quest, 6, uint64(packID)) - response = wire.AppendBytes(response, 1, quest) - } - if s.wallet.WasGranted(identity) { - response = wire.AppendVarint(response, 3, 1) - } - return 104, response, true, nil - } - // Serialize the claim check and grant so concurrent domain callers cannot - // return the animation reward twice. The wallet commits balance and ledger together. - s.packJamMu.Lock() - defer s.packJamMu.Unlock() - if s.wallet.WasGranted(identity) { - return 72, nil, true, nil - } - reward := s.packJamDesign.Reward - if s.packJamDesign.ValidateReward() != nil { - return 0, nil, true, fmt.Errorf("world: unsupported pack jam reward") - } - if _, err := s.wallet.GrantQuestOnce(identity, []gamedata.Reward{reward}); err != nil { - return 0, nil, true, fmt.Errorf("world: grant pack jam reward: %w", err) - } - item := wire.AppendVarint(nil, 3, reward.Type) - item = wire.AppendVarint(item, 4, reward.Count) - return 72, wire.AppendBytes(nil, 1, item), true, nil -} diff --git a/go/internal/server/world/pack_jam_test.go b/go/internal/server/world/pack_jam_test.go deleted file mode 100644 index 05e5e9c..0000000 --- a/go/internal/server/world/pack_jam_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "errors" - "testing" -) - -type jamFailStore struct { - *stateio.Memory - fail bool -} - -func (s *jamFailStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { - if s.fail { - return errors.New("write failed") - } - return s.Memory.SaveWithEntries(domain, core, changes) -} - -func TestPackJamClaimPersistsAndRepeatedDockingSucceeds(t *testing.T) { - storage := &jamFailStore{Memory: stateio.NewMemory()} - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s := testService() - s.wallet = wallet - s.packJamDesign = &gamedata.PackJamDesign{InsertMin: 5, InsertMax: 5, Reward: gamedata.Reward{Type: 3, Count: 150}} - s.fieldPacks = map[int]gamedata.FieldPack{3001: {ID: 3001, Type: 3, MapIDs: map[int]bool{30011: true}}} - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3001) - _, preview, _, err := s.Handle("/PackPreviewInfo", request) - if err != nil { - t.Fatal(err) - } - if _, found, _ := wire.Bytes(preview, 1); found { - t.Fatal("arena fabricated story quest") - } - if claimed, _, _ := wire.Varint(preview, 3); claimed != 0 { - t.Fatal("unclaimed reward marked claimed") - } - storage.fail = true - if _, body, _, err := s.Handle("/PackJamEvent", request); err == nil || body != nil { - t.Fatal("failed write returned success reward") - } - if wallet.WasGranted(packJamIdentity(3001)) || wallet.Snapshot().FreeJewelry != 0 { - t.Fatal("failed write changed wallet") - } - _, preview, _, err = s.Handle("/PackPreviewInfo", request) - if claimed, _, _ := wire.Varint(preview, 3); err != nil || claimed != 0 { - t.Fatal("failed grant preview marked claimed") - } - storage.fail = false - code, body, handled, err := s.Handle("/PackJamEvent", request) - if err != nil || !handled || code != 72 { - t.Fatalf("claim: %d %v %v", code, handled, err) - } - item, found, _ := wire.Bytes(body, 1) - typ, _, _ := wire.Varint(item, 3) - count, _, _ := wire.Varint(item, 4) - if !found || typ != 3 || count != 150 || wallet.Snapshot().FreeJewelry != 150 { - t.Fatal("wrong currency reward") - } - s.wallet, err = player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - _, preview, _, err = s.Handle("/PackPreviewInfo", request) - claimed, _, _ := wire.Varint(preview, 3) - if err != nil || claimed != 1 { - t.Fatal("reopened preview lost claim") - } - code, body, _, err = s.Handle("/PackJamEvent", request) - if err != nil || code != 72 || len(body) != 0 || s.wallet.Snapshot().FreeJewelry != 150 { - t.Fatal("repeated docking replayed reward") - } - story := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21) - _, preview, _, err = s.Handle("/PackPreviewInfo", story) - quest, found, _ := wire.Bytes(preview, 1) - id, _, _ := wire.Varint(quest, 1) - pack, _, _ := wire.Varint(quest, 6) - if err != nil || !found || id != 1 || pack != 21 { - t.Fatal("preview lost real active quest") - } - for _, invalid := range [][]byte{nil, wire.AppendVarint(nil, 2, 3001), wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 9999), wire.AppendVarint(wire.AppendVarint(nil, 1, 0), 2, 3001)} { - for _, path := range []string{"/PackPreviewInfo", "/PackJamEvent"} { - if _, _, handled, err := s.Handle(path, invalid); !handled || !errors.Is(err, ErrInvalidRequest) { - t.Fatalf("invalid %s: %v", path, err) - } - } - } - if s.wallet.Snapshot().FreeJewelry != 150 || s.wallet.WasGranted(packJamIdentity(9999)) { - t.Fatal("invalid pack changed wallet") - } - s.packJamDesign.Reward.Type = 8 - if _, _, _, err := s.Handle("/PackJamEvent", story); err == nil { - t.Fatal("unsupported reward accepted") - } - if s.wallet.WasGranted(packJamIdentity(21)) { - t.Fatal("unsupported reward marked claimed") - } - s.packJamDesign.Reward.Type = 4 - s.packJamDesign.Reward.Count = 73 - code, body, _, err = s.Handle("/PackJamEvent", story) - item, _, _ = wire.Bytes(body, 1) - typ, _, _ = wire.Varint(item, 3) - count, _, _ = wire.Varint(item, 4) - if err != nil || code != 72 || typ != 4 || count != 73 || s.wallet.Snapshot().Gold != 73 { - t.Fatal("changed reward currency/count ignored") - } -} diff --git a/go/internal/server/world/pack_purchase.go b/go/internal/server/world/pack_purchase.go deleted file mode 100644 index 4c4e73c..0000000 --- a/go/internal/server/world/pack_purchase.go +++ /dev/null @@ -1,186 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "fmt" -) - -func packPurchaseIdentity(id int) string { return fmt.Sprintf("pack-purchase:%d", id) } - -// EnsureInitialPackPurchase is called only by new-account initialization. It -// awards the bootstrap pack's real purchase rewards, not a recovery inference -// from a saved position or from the former seed-only unlock chain. -func (s *Service) EnsureInitialPackPurchase() error { - _, err := s.purchaseStoryPack(s.startingPack(), true) - return err -} - -func (s *Service) handlePackBuy(request []byte) (int, []byte, bool, error) { - id, err := requestPack(request) - if err != nil { - return 0, nil, true, err - } - if pack, found, err := s.resolveEventFieldPack(id); err != nil { - return 0, nil, true, err - } else if found { - bundle, err := s.purchasePack(id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, false) - if err != nil { - return 0, nil, true, err - } - response := wire.AppendBytes(nil, 1, s.eventPackDBInfo(pack)) - return 6, wire.AppendBytes(response, 2, bundle), true, nil - } - if !s.packUnlocked(id) { - return 0, nil, true, fmt.Errorf("%w: unavailable purchase pack %d", ErrInvalidRequest, id) - } - bundle, err := s.purchaseStoryPack(id, false) - if err != nil { - return 0, nil, true, err - } - var info []byte - for _, row := range s.packDBInfoRows() { - value, _, _ := wire.Varint(row, 1) - if value == uint64(id) { - info = row - break - } - } - if info == nil { - return 0, nil, true, fmt.Errorf("world: purchased pack absent from account") - } - response := wire.AppendBytes(nil, 1, info) - return 6, wire.AppendBytes(response, 2, bundle), true, nil -} - -func (s *Service) grantPurchaseRewards(identity string, rewards []gamedata.Reward) ([]player.Item, error) { - var itemRewards []gamedata.BattleReward - for _, reward := range rewards { - if reward.Count == 0 && reward.Type != 11 { - return nil, fmt.Errorf("world: empty pack purchase reward") - } - if reward.Type == 19 { - if reward.ID == 0 { - return nil, fmt.Errorf("world: invalid pack ticket") - } - itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 - } - } - var costumeIDs []uint64 - for _, reward := range rewards { - if reward.Type == 11 { - if s.questCostumes == nil { - return nil, fmt.Errorf("world: missing purchase costume design") - } - if _, found := s.questCostumes.Character(reward.ID); !found { - return nil, fmt.Errorf("world: missing purchase costume %d", reward.ID) - } - costumeIDs = append(costumeIDs, reward.ID) - } - } - if len(costumeIDs) > 0 { - grant, err := s.collection.GrantCostumes(identity+":costumes", costumeIDs, s.questCostumes) - if err != nil { - return nil, err - } - var exchanges []gamedata.Reward - for _, x := range grant.Exchanges { - if !purchaseCurrency(x.ExchangeItemType) || x.ExchangeItemID != 0 { - return nil, fmt.Errorf("world: unsupported purchase costume exchange") - } - exchanges = append(exchanges, gamedata.Reward{Type: x.ExchangeItemType, ID: x.ExchangeItemID, Count: x.ExchangeCount}) - } - if len(exchanges) > 0 { - if _, err := s.wallet.GrantQuestOnce(identity+":costumes:exchange", exchanges); err != nil { - return nil, err - } - } - } - if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil { - return nil, err - } - if len(itemRewards) == 0 { - return nil, nil - } - items, err := s.inventory.GrantOnce(identity+":items", itemRewards) - if err == nil && len(items) == 0 { - items = s.inventory.GrantedItems(identity + ":items") - } - return items, err -} - -func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) { - if s.storyCatalog == nil { - return nil, fmt.Errorf("world: purchase services unavailable") - } - pack, exists := s.storyCatalog.Packs[id] - if !exists { - return nil, fmt.Errorf("%w: unknown purchase pack %d", ErrInvalidRequest, id) - } - return s.purchasePack(id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, initial) -} - -// purchasePack shares the same durable receipt and enclosing account transaction -// across story and calendar-authorized hidden event packs. -func (s *Service) purchasePack(id int, buyType, buyPrice uint64, buyRewards []gamedata.Reward, initial bool) ([]byte, error) { - if s.collection == nil || s.wallet == nil || s.inventory == nil { - return nil, fmt.Errorf("world: purchase services unavailable") - } - identity := packPurchaseIdentity(id) - if _, owned := s.collection.Grant(identity); owned { - return []byte{}, nil - } - for _, reward := range buyRewards { - if !purchaseCurrency(reward.Type) && reward.Type != 19 && reward.Type != 11 { - return nil, fmt.Errorf("world: unsupported pack purchase reward type %d", reward.Type) - } - } - if !initial && buyPrice != 0 { - var err error - switch buyType { - case 2: - _, err = s.wallet.SpendJewelryOnce(identity+":price", buyPrice) - case 3: - _, err = s.wallet.SpendFreeJewelryOnce(identity+":price", buyPrice) - case 4: - _, err = s.wallet.SpendGoldOnce(identity+":price", buyPrice) - case 12: - _, err = s.wallet.SpendCatalystOnce(identity+":price", buyPrice) - default: - return nil, fmt.Errorf("%w: unsupported pack purchase currency%d", ErrInvalidRequest, buyType) - } - if err != nil { - return nil, err - } - } - items, err := s.grantPurchaseRewards(identity, buyRewards) - if err != nil { - return nil, err - } - if err := s.collection.RecordGrantMarker(identity); err != nil { - return nil, err - } - var bundle []byte - if grant, found := s.collection.Grant(identity + ":costumes"); found { - bundle = append(bundle, player.CollectionRewardBundle(s.collection, grant)...) - } - for _, reward := range buyRewards { - if purchaseCurrency(reward.Type) { - currency := wire.AppendVarint(wire.AppendVarint(nil, 3, reward.Type), 4, reward.Count) - bundle = wire.AppendBytes(bundle, 1, currency) - } - } - for _, item := range items { - bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item)) - } - return bundle, nil -} - -func purchaseCurrency(typ uint64) bool { - switch typ { - case 2, 3, 4, 12, 20: - return true - } - return false -} diff --git a/go/internal/server/world/pack_purchase_test.go b/go/internal/server/world/pack_purchase_test.go deleted file mode 100644 index 407b283..0000000 --- a/go/internal/server/world/pack_purchase_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "errors" - "path/filepath" - "testing" -) - -func TestPaidPackUsesDesignCostAndCurrenciesOnce(t *testing.T) { - storage := stateio.NewMemory() - s := testService() - var err error - s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.collection, err = player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{FreeJewelry: 101}) - if err != nil { - t.Fatal(err) - } - s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, BuyType: 3, BuyPrice: 37, BuyRewards: []gamedata.Reward{{Type: 2, Count: 8}, {Type: 20, Count: 11}}} - for range 2 { - if _, err := s.purchaseStoryPack(709, false); err != nil { - t.Fatal(err) - } - } - got := s.wallet.Snapshot() - if got.FreeJewelry != 64 || got.Jewelry != 8 || got.Mileage != 11 { - t.Fatalf("currency=%+v", got) - } - s.storyCatalog.Packs[710] = gamedata.StoryPack{ID: 710, BuyType: 3, BuyPrice: 37, BuyRewards: []gamedata.Reward{{Type: 8, ID: 4, Count: 1}}} - if _, err := s.purchaseStoryPack(710, false); err == nil { - t.Fatal("invalid reward accepted") - } - if s.wallet.Snapshot().FreeJewelry != 64 { - t.Fatal("invalid reward charged") - } -} - -type purchaseFailStore struct { - *accountstate.Repository - fail bool -} - -func (s *purchaseFailStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error { - if s.fail && domain == "collection" { - return errors.New("forced purchase marker failure") - } - return s.Repository.SaveWithEntries(domain, core, changes) -} - -func TestPackPurchaseFailureRollsBackSQLiteCurrencyTicketAndMarker(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := repo.Close(); err != nil { - t.Error(err) - } - }() - storage := &purchaseFailStore{Repository: repo} - s := testService() - s.seed.PackID = 709 - s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{Catalyst: 5}) - if err != nil { - t.Fatal(err) - } - s.collection, err = player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if _, err := s.wallet.GrantQuestOnce("test:baseline", nil); err != nil { - t.Fatal(err) - } - s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{709: {ID: 709, BuyRewards: []gamedata.Reward{{Type: 12, Count: 17}, {Type: 19, ID: 88, Count: 1}}}}} - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - storage.fail = true - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 709) - if _, _, _, err := s.Handle("/PackBuy", request); err == nil { - t.Fatal("forced collection failure returned success") - } - if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatal(err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, err = accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - storage.Repository = repo - storage.fail = false - // Requests reconstruct domains following a rollback; in-memory snapshots - // that participated in the aborted operation must not be reused. - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - collection, err := player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - if wallet.CatalystBalance() != 5 || wallet.WasGranted("pack-purchase:709:currency") || len(inventory.All()) != 0 { - t.Fatal("aborted purchase persisted partial awards") - } - if _, found := collection.Grant("pack-purchase:709"); found { - t.Fatal("aborted purchase persisted marker") - } - s.wallet, s.inventory, s.collection = wallet, inventory, collection - op, err = repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/PackBuy", request); err != nil { - if rollbackErr := op.Rollback(); rollbackErr != nil { - t.Error(rollbackErr) - } - t.Fatal(err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - if wallet.CatalystBalance() != 22 || len(inventory.All()) != 1 { - t.Fatal("retried purchase did not grant exactly once") - } -} diff --git a/go/internal/server/world/pack_reward_counts.go b/go/internal/server/world/pack_reward_counts.go deleted file mode 100644 index af3fbcd..0000000 --- a/go/internal/server/world/pack_reward_counts.go +++ /dev/null @@ -1,270 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "encoding/binary" - "fmt" -) - -func (s *Service) AttachResearchRuntime(root, version string, economy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) -}) error { - if economy == nil { - return fmt.Errorf("world: nil research economy") - } - chars, err := gamedata.LoadResearchCharacters(root, version) - if err != nil { - return err - } - s.researchCharacters = chars - s.researchEconomy = economy - s.researchDesigns = map[int]gamedata.FieldResearchDesign{} - s.researchLoader = func(pack int) (gamedata.FieldResearchDesign, error) { - return gamedata.LoadFieldResearch(root, version, pack) - } - return nil -} -func (s *Service) researchDesign(pack int) (gamedata.FieldResearchDesign, error) { - if d, ok := s.researchDesigns[pack]; ok { - return d, nil - } - if s.researchLoader == nil { - return gamedata.FieldResearchDesign{}, fmt.Errorf("world: research design unavailable") - } - d, err := s.researchLoader(pack) - if err != nil { - return d, err - } - s.researchDesigns[pack] = d - return d, nil -} -func intsRequest(raw []byte, number int) ([]uint64, error) { - var out []uint64 - err := wire.Walk(raw, func(f wire.Field) error { - if f.Number != number { - return nil - } - if f.Type == 0 { - v, n := binary.Uvarint(f.Value) - if n <= 0 { - return wire.ErrMalformed - } - out = append(out, v) - return nil - } - if f.Type != 2 { - return ErrInvalidRequest - } - for raw := f.Value; len(raw) > 0; { - v, n := binary.Uvarint(raw) - if n <= 0 { - return wire.ErrMalformed - } - out = append(out, v) - raw = raw[n:] - } - return nil - }) - return out, err -} -func (s *Service) handleFieldResearch(request []byte) (int, []byte, bool, error) { - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 || seq > 0x7fffffff { - return 59, nil, true, ErrInvalidRequest - } - pack, err := requestPack(request) - if err != nil { - return 59, nil, true, err - } - id, _, err := wire.Varint(request, 3) - if err != nil || id == 0 || id > 0x7fffffff || !s.packUnlocked(pack) || !s.fieldObjectCurrentPack(pack) { - return 59, nil, true, ErrInvalidRequest - } - design, err := s.researchDesign(pack) - if err != nil { - return 59, nil, true, err - } - obj, ok := design.Objects[int(id)] - if !ok || obj.CollectionID == 0 && obj.Reward.Type == 0 { - return 59, nil, true, ErrInvalidRequest - } - position, saved := s.state.Position() - mapOK := false - for _, mapID := range obj.Maps { - if saved && position.PackID == pack && position.Position.MapID == mapID { - mapOK = true - } - } - if !mapOK { - return 59, nil, true, fmt.Errorf("%w: research outside current map", ErrInvalidRequest) - } - // Quest interactions use QuestUpdate, while FieldObjectResearch grants the - // object's collection/reward after its quest interaction is no longer active. - for _, quest := range obj.InteractionQuests { - if _, active := s.state.QuestInPack(quest, pack, s.questDifficulty(pack)); active && !s.state.QuestCleared(quest, pack, s.questDifficulty(pack)) { - return 59, nil, true, fmt.Errorf("%w: research belongs to active quest", ErrInvalidRequest) - } - } - if obj.Type == 1 { - eligible := false - // This server validates a learned research talent. Client animation and - // temporary highlight flags are presentation state rather than authority. - if s.characters != nil { - for _, c := range s.characters.All() { - if c.TalentLevel > 0 && s.researchCharacters[c.ID] { - eligible = true - break - } - } - } - if !eligible { - return 59, nil, true, fmt.Errorf("%w: research talent unavailable", ErrInvalidRequest) - } - } - prior, err := s.state.ResearchObjects(pack) - if err != nil { - return 59, nil, true, err - } - for _, v := range prior { - if v == int(id) { - _, found, e := s.state.ResearchObjectReply(pack, int(id)) - if e != nil { - return 59, nil, true, e - } - if !found { - return 59, nil, true, fmt.Errorf("world: researched object reward receipt missing") - } - // RewardItem is a delta. A fresh request for an already researched - // object must not credit that delta again on the client. - return 59, wire.AppendVarint(nil, 1, seq), true, nil - } - } - if s.researchEconomy == nil { - return 59, nil, true, fmt.Errorf("world: research economy unavailable") - } - var rewards []gamedata.Reward - if obj.Reward.Type != 0 && obj.Reward.Count > 0 { - rewards = append(rewards, obj.Reward) - } - if obj.CollectionID > 0 { - rewards = append(rewards, gamedata.Reward{Type: 17, ID: uint64(obj.CollectionID), Count: 1}) - } - bundle, err := s.researchEconomy.Apply(fmt.Sprintf("research:%d:%d", pack, id), nil, rewards) - if err != nil { - return 59, nil, true, err - } - var items []byte - err = wire.Walk(bundle, func(f wire.Field) error { - if f.Number == 1 && f.Type == 2 { - items = wire.AppendBytes(items, 2, f.Value) - } - return nil - }) - if err != nil { - return 59, nil, true, err - } - if err = s.state.MarkResearchObject(pack, int(id), items); err != nil { - return 59, nil, true, err - } - return 59, append(wire.AppendVarint(nil, 1, seq), items...), true, nil -} -func matchesFieldCount(obj gamedata.FieldRewardObject, category uint64) bool { - // PackMapRewardInfo uses independent predicates; a normal or hidden box - // with a one-time reset belongs in both once and acquisition totals. - switch category { - case 2: - return obj.ResetType == 1 && obj.Type != 5 - case 3: - return obj.Type == 1 || obj.Type == 3 - case 4: - return obj.Type == 6 && obj.ResetType == 2 - } - return false -} -func (s *Service) handlePackRewardCounts(request []byte) (int, []byte, bool, error) { - seq, present, err := wire.Varint(request, 1) - if err != nil || !present || seq == 0 { - return 226, nil, true, ErrInvalidRequest - } - packs, err := intsRequest(request, 2) - if err != nil || len(packs) == 0 { - return 226, nil, true, ErrInvalidRequest - } - types, err := intsRequest(request, 3) - if err != nil { - return 226, nil, true, err - } - if len(types) == 0 { - types = []uint64{1, 2, 3, 4} - } - var out []byte - seen := map[[2]uint64]bool{} - for _, p := range packs { - pack := int(p) - if pack <= 0 || !s.packUnlocked(pack) { - return 226, nil, true, ErrInvalidRequest - } - for _, t := range types { - if t < 1 || t > 4 { - return 226, nil, true, ErrInvalidRequest - } - key := [2]uint64{p, t} - if seen[key] { - continue - } - seen[key] = true - var count, max uint64 - if t == 1 { - d, e := s.researchDesign(pack) - if e != nil { - return 226, nil, true, e - } - ids, e := s.state.ResearchObjects(pack) - if e != nil { - return 226, nil, true, e - } - for _, o := range d.Objects { - if o.CollectionID > 0 || o.Reward.Type > 0 { - max++ - } - } - for _, id := range ids { - if o, ok := d.Objects[id]; ok && (o.CollectionID > 0 || o.Reward.Type > 0) { - count++ - } - } - } else { - d, e := s.fieldObjectDesign(pack) - if e != nil { - return 226, nil, true, e - } - for _, o := range d.Objects { - if !matchesFieldCount(o, t) { - continue - } - period, e := s.fieldObjectPeriodFor(pack, o) - if t != 4 || e == nil { - max++ - } - if e != nil { - continue - } - opened, e := s.state.FieldRewardOpened(pack, o.ID, period) - if e != nil { - return 226, nil, true, e - } - if opened { - count++ - } - } - } - info := wire.AppendVarint(nil, 1, t) - info = wire.AppendVarint(info, 2, p) - info = wire.AppendVarint(info, 3, count) - info = wire.AppendVarint(info, 4, max) - out = wire.AppendBytes(out, 1, info) - } - } - return 226, out, true, nil -} diff --git a/go/internal/server/world/pack_reward_counts_test.go b/go/internal/server/world/pack_reward_counts_test.go deleted file mode 100644 index d91c12c..0000000 --- a/go/internal/server/world/pack_reward_counts_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -type researchEconomySpy struct { - calls int - rewards []gamedata.Reward -} - -func (e *researchEconomySpy) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) { - e.calls++ - e.rewards = rewards - item := wire.AppendVarint(nil, 1, 17) - item = wire.AppendVarint(item, 2, 103) - item = wire.AppendVarint(item, 3, 1) - return wire.AppendBytes(nil, 1, item), nil -} -func TestResearchRequiresCurrentMapPersistsRewardAndCounts(t *testing.T) { - s := testService() - storage := stateio.NewMemory() - var err error - s.state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - if err = s.state.SetActivePackID(21); err != nil { - t.Fatal(err) - } - save := wire.AppendVarint(nil, 1, 1) - save = wire.AppendVarint(save, 2, 21) - save = wire.AppendString(save, 3, `{"MapId":211,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":null}`) - if err = s.state.SaveUserPosition(save); err != nil { - t.Fatal(err) - } - s.researchDesigns = map[int]gamedata.FieldResearchDesign{21: {Objects: map[int]gamedata.FieldResearchObject{401: {ID: 401, Maps: []int{211}, CollectionID: 103}, 402: {ID: 402, Maps: []int{212}, CollectionID: 104}}}} - spy := &researchEconomySpy{} - s.researchEconomy = spy - req := wire.AppendVarint(nil, 1, 10) - req = wire.AppendVarint(req, 2, 21) - bad := wire.AppendVarint(append([]byte(nil), req...), 3, 402) - if _, _, _, err = s.Handle("/FieldObjectResearch", bad); err == nil { - t.Fatal("accepted different map") - } - req = wire.AppendVarint(req, 3, 401) - code, first, handled, err := s.Handle("/FieldObjectResearch", req) - if err != nil || !handled || code != 59 { - t.Fatalf("research: %d %v %v", code, handled, err) - } - if len(spy.rewards) != 1 || spy.rewards[0].Type != 17 || spy.rewards[0].ID != 103 { - t.Fatalf("collection reward must use type17: %+v", spy.rewards) - } - s.state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - _, again, _, err := s.Handle("/FieldObjectResearch", req) - _, firstReward, _ := wire.Bytes(first, 2) - _, repeatedReward, _ := wire.Bytes(again, 2) - if err != nil || !firstReward || repeatedReward || spy.calls != 1 { - t.Fatalf("replay duplicated reward or lost response: calls%d err%v", spy.calls, err) - } - counts := wire.AppendVarint(nil, 1, 11) - counts = wire.AppendVarint(counts, 2, 21) - counts = wire.AppendVarint(counts, 3, 1) - code, body, _, err := s.Handle("/PackRewardObjectCount", counts) - if err != nil || code != 226 { - t.Fatal(err) - } - info, _, err := wire.Bytes(body, 1) - if err != nil { - t.Fatal(err) - } - count, _, _ := wire.Varint(info, 3) - max, _, _ := wire.Varint(info, 4) - if count != 1 || max != 2 { - t.Fatalf("count%d max%d", count, max) - } -} diff --git a/go/internal/server/world/pack_summary.go b/go/internal/server/world/pack_summary.go deleted file mode 100644 index eb75faa..0000000 --- a/go/internal/server/world/pack_summary.go +++ /dev/null @@ -1,77 +0,0 @@ -package world - -import ( - "bd2server/internal/server/wire" - "fmt" -) - -func (s *Service) handlePackSummary(request []byte) (int, []byte, bool, error) { - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 || seq > 0x7fffffff { - return 0, nil, true, fmt.Errorf("%w: PackSummaryInfoList missing or invalid sequence", ErrInvalidRequest) - } - var response []byte - for _, row := range s.packDBInfoRows() { - id, found, err := wire.Varint(row, 1) - if err != nil || !found { - return 0, nil, true, fmt.Errorf("world: invalid account pack row") - } - if !s.packSummaryTargets[int(id)] { - continue - } - ids, err := s.openedFieldObjects(int(id)) - if err != nil { - return 0, nil, true, err - } - var once, regen uint64 - for _, objectID := range ids { - object := s.fieldObjects[int(id)].Objects[objectID] - if object.Type == 2 { //nolint:staticcheck // QF1003 - once++ - } else if object.Type == 1 || object.Type == 3 { - regen++ - } - } - row := wire.AppendVarint(nil, 1, id) - if once > 0 { - row = wire.AppendVarint(row, 2, once) - } - if regen > 0 { - row = wire.AppendVarint(row, 3, regen) - } - if s.packDetailDesign != nil && s.monsterLoader != nil { - design, e := s.packDetailDesign(int(id)) - if e != nil { - return 0, nil, true, e - } - filter := map[int]bool{} - for _, n := range design.RegenMonsterIDs { - filter[n] = true - } - rows, e := s.monsterRows(int(id), filter) - if e != nil { - return 0, nil, true, e - } - var defeated uint64 - for _, m := range rows { - active, _, _ := wire.Varint(m, 6) - respawn, _, _ := wire.Varint(m, 3) - if active == 0 || respawn > uint64(s.monsterTime().UnixMilli()) { - defeated++ - } - } - if defeated > 0 { - row = wire.AppendVarint(row, 4, defeated) - } - } - research, e := s.state.ResearchObjects(int(id)) - if e != nil { - return 0, nil, true, e - } - if len(research) > 0 { - row = wire.AppendVarint(row, 5, uint64(len(research))) - } - response = wire.AppendBytes(response, 1, row) - } - return 625, response, true, nil -} diff --git a/go/internal/server/world/pack_summary_test.go b/go/internal/server/world/pack_summary_test.go deleted file mode 100644 index a95f7ef..0000000 --- a/go/internal/server/world/pack_summary_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package world - -import ( - "bytes" - "errors" - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -func TestPackSummaryUsesUnlockedPackAndCollectedCounts(t *testing.T) { - s := testService() - s.packSummaryTargets = map[int]bool{21: true, 22: true} - s.packs = map[int]map[int]gamedata.QuestDesign{21: s.quests, 22: {1: {ID: 1}}} - s.transitions = map[int]gamedata.PackTransition{21: {PackID: 21, NextPackID: 22}} - attachTestStoryCatalog(s) - pack22 := s.storyCatalog.Packs[22] - pack22.Open = &gamedata.ContentOpenRule{TicketID: 122} - s.storyCatalog.Packs[22] = pack22 - s.inventory, _ = player.OpenInventory(stateio.NewMemory(), s.starter) - request := wire.AppendVarint(nil, 1, 7) - code, got, handled, err := s.Handle("/PackSummaryInfoList", request) - want := wire.AppendBytes(nil, 1, wire.AppendVarint(nil, 1, 21)) - if err != nil || !handled || code != 625 || !bytes.Equal(got, want) { - t.Fatalf("initial summary code=%d handled=%v body=%x err=%v", code, handled, got, err) - } - if err := s.state.ClearQuest(1, 21); err != nil { - t.Fatal(err) - } - _, got, _, err = s.Handle("/PackSummaryInfoList", request) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("quest completion changed collected rewards: %x %v", got, err) - } - for _, id := range []int{2, 3} { - if err := s.state.ClearQuest(id, 21); err != nil { - t.Fatal(err) - } - } - if _, err := s.inventory.GrantOnce("test-terminal-ticket", []gamedata.BattleReward{{Type: 19, ID: 122, Count: 1}}); err != nil { - t.Fatal(err) - } - _, got, _, err = s.Handle("/PackSummaryInfoList", request) - want = wire.AppendBytes(want, 1, wire.AppendVarint(nil, 1, 22)) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("unlocked summary=%x want=%x err=%v", got, want, err) - } - // A non-target pack must never enter the reward summary even if it is - // represented in the account's PackDBInfo rows. - delete(s.packSummaryTargets, 21) - _, got, _, err = s.Handle("/PackSummaryInfoList", request) - want = wire.AppendBytes(nil, 1, wire.AppendVarint(nil, 1, 22)) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("filtered summary=%x want=%x err=%v", got, want, err) - } -} - -func TestPackSummaryRejectsInvalidSequence(t *testing.T) { - s := testService() - for _, request := range [][]byte{nil, {8}, wire.AppendVarint(nil, 1, 0), wire.AppendVarint(nil, 1, 0x80000000)} { - _, _, handled, err := s.Handle("/PackSummaryInfoList", request) - if !handled || !errors.Is(err, ErrInvalidRequest) { - t.Fatalf("request=%x handled=%v err=%v", request, handled, err) - } - } -} diff --git a/go/internal/server/world/quest_selection_test.go b/go/internal/server/world/quest_selection_test.go deleted file mode 100644 index 1603c23..0000000 --- a/go/internal/server/world/quest_selection_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package world - -import ( - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "reflect" - "testing" -) - -func selectionRequest(quest, pack, level uint64) []byte { - out := wire.AppendVarint(nil, 1, 1) - out = wire.AppendVarint(out, 2, quest) - out = wire.AppendVarint(out, 3, pack) - return wire.AppendVarint(out, 4, level) -} -func TestDifficultySelectionPersistsAndRewardsRemainIndependent(t *testing.T) { - s := testService() - storage := stateio.NewMemory() - var err error - s.state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s.questDifficulties = map[int]map[int]bool{21: {1: true, 2: true}} - for id, design := range s.storyCatalog.Packs[21].Quests { - design.Rewards[0] = []gamedata.Reward{{Type: 2, Count: 10}} - design.Rewards[1] = []gamedata.Reward{{Type: 2, Count: 20}} - s.storyCatalog.Packs[21].Quests[id] = design - } - if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(1, 21, 1)); err == nil { - t.Fatal("hard accepted before normal completion") - } - for id := uint64(1); id <= 3; id++ { - if _, _, _, err := s.Handle("/QuestClear", selectionRequest(id, 21, 0)); err != nil { - t.Fatal(err) - } - } - if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(2, 21, 1)); err == nil { - t.Fatal("arbitrary hard quest accepted") - } - if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(1, 21, 1)); err != nil { - t.Fatal(err) - } - for range 2 { - if _, _, _, err := s.Handle("/QuestClear", selectionRequest(1, 21, 0)); err != nil { - t.Fatal(err) - } - } - if s.wallet.Snapshot().Jewelry != 50 { - t.Fatalf("reward isolation/replay balance %+v", s.wallet.Snapshot()) - } - reopened, err := progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s.state = reopened - if selection, ok := reopened.Selection(21); !ok || selection.Difficulty != 1 || selection.QuestID != 2 { - t.Fatalf("selection lost %+v", selection) - } - _, response, _, err := s.Handle("/PackInGameInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)) - if err != nil { - t.Fatal(err) - } - quest, _, _ := wire.Bytes(response, 2) - id, _, _ := wire.Varint(quest, 1) - level, _, _ := wire.Varint(quest, 4) - if id != 2 || level != 1 { - t.Fatalf("restored id%d level%d", id, level) - } - if _, _, _, err := s.Handle("/QuestGiveUp", selectionRequest(2, 21, 0)); err != nil { - t.Fatal(err) - } - if !s.state.QuestCleared(1, 21, 1) { - t.Fatal("abandon erased hard clear") - } - if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(2, 21, 1)); err != nil { - t.Fatal(err) - } - if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(1, 21, 4)); err == nil { - t.Fatal("unsupported table difficulty accepted") - } -} -func TestSideQuestRemainsIndependentFromMainDifficulty(t *testing.T) { - s := testService() - var err error - s.decks, err = deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 905}}}) - if err != nil { - t.Fatal(err) - } - saved := []deck.DeckEntry{ - {CharacterInvenIndex: 905, CostumeInvenIndex: 0, Slot: 1}, - {CharacterInvenIndex: 904, CostumeInvenIndex: 8, Slot: 2}, - {CharacterInvenIndex: 903, CostumeInvenIndex: 11, Slot: 3}, - {CharacterInvenIndex: 902, CostumeInvenIndex: 3, Slot: 4}, - {CharacterInvenIndex: 901, CostumeInvenIndex: ^uint64(0), Slot: 5}, - } - saveStoryTestDeck(t, s.decks, saved) - call := func(path string, deckField int) { - t.Helper() - _, response, handled, err := s.Handle(path, selectionRequest(50, 21, 0)) - if err != nil || !handled { - t.Fatalf("%s handled=%v err=%v", path, handled, err) - } - var entries [][]byte - if err := wire.Walk(response, func(f wire.Field) error { - if f.Number == deckField { - entries = append(entries, f.Value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if got := decodeStoryTestDeck(t, entries); !reflect.DeepEqual(got, saved) || !reflect.DeepEqual(s.decks.CurrentDeck(), saved) { - t.Fatalf("%s changed saved battle deck: response=%+v saved=%+v", path, got, s.decks.CurrentDeck()) - } - } - pack := s.storyCatalog.Packs[21] - pack.Quests[50] = gamedata.QuestDesign{ID: 50, Type: 1} - s.storyCatalog.Packs[21] = pack - call("/QuestAccept", 3) - call("/QuestGiveUp", 2) - call("/QuestAccept", 3) - if first := s.firstUnclearedQuestFor(21); first != 1 { - t.Fatalf("side replaced main%d", first) - } - call("/QuestClear", 4) - if !s.state.QuestCleared(50, 21) || s.state.QuestCleared(1, 21) { - t.Fatal("side clear affected main") - } -} - -func TestSelectionCannotSwitchWhileBattleIsActive(t *testing.T) { - s := testService() - if err := s.AttachBattleActive(func() bool { return true }); err != nil { - t.Fatal(err) - } - for _, path := range []string{"/QuestAccept", "/QuestGiveUp"} { - if _, _, _, err := s.Handle(path, selectionRequest(1, 21, 0)); err == nil { - t.Fatalf("%s switched active battle", path) - } - } - if _, _, _, err := s.Handle("/PackInGameInfo", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)); err == nil { - t.Fatal("pack switch allowed during battle") - } -} diff --git a/go/internal/server/world/recruit_npc_test.go b/go/internal/server/world/recruit_npc_test.go deleted file mode 100644 index ce30c9f..0000000 --- a/go/internal/server/world/recruit_npc_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/wire" - "strconv" - "testing" -) - -func recruitNPCPosition(t *testing.T, s *Service, pack, mapID int) { - t.Helper() - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, uint64(pack)) - request = wire.AppendString(request, 3, `{"MapId":`+strconv.Itoa(mapID)+`,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":[]}`) - if err := s.state.SaveUserPosition(request); err != nil { - t.Fatal(err) - } -} - -func TestResolveRecruitNPCRequiresMapStoryCompletionAndOrdinaryType(t *testing.T) { - s := testService() - d := &gamedata.RecruitDesign{Rules: map[uint64]gamedata.RecruitRule{40: {ID: 40, Type: 0, PackID: 21}}} - npc := gamedata.RecruitNPC{ID: 7, MapID: 9, ScoutID: 40} - if _, err := s.resolveRecruitNPC(npc, d); err == nil { - t.Fatal("accepted NPC without saved map") - } - recruitNPCPosition(t, s, 21, 9) - if _, err := s.resolveRecruitNPC(npc, d); err == nil { - t.Fatal("accepted incomplete story") - } - for _, id := range []int{1, 2, 3} { - if err := s.state.ClearQuest(id, 21); err != nil { - t.Fatal(err) - } - } - if id, err := s.resolveRecruitNPC(npc, d); err != nil || id != 40 { - t.Fatalf("resolved=%d err=%v", id, err) - } - recruitNPCPosition(t, s, 21, 8) - if _, err := s.resolveRecruitNPC(npc, d); err == nil { - t.Fatal("accepted NPC in other map") - } - recruitNPCPosition(t, s, 21, 9) - d.Rules[40] = gamedata.RecruitRule{ID: 40, Type: 1, PackID: 21} - if _, err := s.resolveRecruitNPC(npc, d); err == nil { - t.Fatal("accepted special NPC through ordinary endpoint") - } -} - -func TestResolveRecruitNPCBlocksActiveQuestRangesAfterEarlierRecruitChapter(t *testing.T) { - s := testService() - s.activePack = 22 - s.packs = map[int]map[int]gamedata.QuestDesign{21: s.quests, 22: {1: {ID: 1}, 2: {ID: 2}}} - s.transitions = map[int]gamedata.PackTransition{21: {PackID: 21, NextPackID: 22}} - attachTestStoryCatalog(s) - for _, id := range []int{1, 2, 3} { - if err := s.state.ClearQuest(id, 21); err != nil { - t.Fatal(err) - } - } - recruitNPCPosition(t, s, 22, 9) - d := &gamedata.RecruitDesign{Rules: map[uint64]gamedata.RecruitRule{40: {ID: 40, Type: 0, PackID: 21}}} - npc := gamedata.RecruitNPC{ID: 7, MapID: 9, ScoutID: 40, QuestEnableTypes: []uint64{0}, QuestRanges: []uint64{1}, QuestTypes: map[uint64]uint64{1: 0, 2: 0}} - if _, err := s.resolveRecruitNPC(npc, d); err == nil { - t.Fatal("accepted NPC used by active main quest") - } - npc.QuestRanges[0] = 2 - if id, err := s.resolveRecruitNPC(npc, d); err != nil || id != 40 { - t.Fatalf("future quest blocked NPC=%d err=%v", id, err) - } - npc.QuestTypes[1] = 1 - npc.QuestRanges[0] = 1 - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1), 3, 22) - if _, err := s.state.UpdateQuest(request); err != nil { - t.Fatal(err) - } - if _, err := s.resolveRecruitNPC(npc, d); err == nil { - t.Fatal("accepted NPC used by active subquest") - } - if err := s.state.ClearQuest(1, 22); err != nil { - t.Fatal(err) - } - if id, err := s.resolveRecruitNPC(npc, d); err != nil || id != 40 { - t.Fatalf("cleared subquest still blocked NPC=%d err=%v", id, err) - } -} diff --git a/go/internal/server/world/start_pack_test.go b/go/internal/server/world/start_pack_test.go deleted file mode 100644 index 0680a14..0000000 --- a/go/internal/server/world/start_pack_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package world - -import ( - "testing" - - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" -) - -func TestStartingChapterPolicyPersistsWithoutReplacingTutorialSeed(t *testing.T) { - storage := stateio.NewMemory() - state, err := progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s := testService() - s.state = state - s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, MainQuestIDs: []int{8}, Quests: map[int]gamedata.QuestDesign{8: {ID: 8}}, Open: &gamedata.ContentOpenRule{TicketID: 71}} - if err := s.ConfigureStartPack(1, true); err != nil { - t.Fatal(err) - } - if s.seed.PackID != 21 || !s.packUnlocked(1) || s.tutorialRosterRestricted() { - t.Fatal("entry policy overwrote tutorial seed or blocked entry") - } - if id, err := s.LastPlayedPackID(); err != nil || id != 1 { - t.Fatalf("destination %d, %v", id, err) - } - selection, ok := s.state.Selection(1) - if !ok || selection.QuestID != 8 || selection.Difficulty != 0 { - t.Fatalf("initial quest %+v", selection) - } - reopened, err := progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s.state = reopened - if err := s.ConfigureStartPack(21, false); err == nil { - t.Fatal("accepted account inconsistent with server entry policy") - } - if err := s.ConfigureStartPack(1, false); err != nil { - t.Fatal(err) - } - if s.startingPack() != 1 { - t.Fatal("changed server policy moved existing account") - } -} - -func TestInitialPurchaseUsesSelectedEntryChapter(t *testing.T) { - s := testService() - storage := stateio.NewMemory() - var err error - s.state, err = progress.OpenStore(storage) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s.collection, err = player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - s.storyCatalog.Packs[1] = gamedata.StoryPack{ID: 1, MainQuestIDs: []int{8}, Quests: map[int]gamedata.QuestDesign{8: {ID: 8}}, BuyRewards: []gamedata.Reward{{Type: 12, Count: 17}}} - if err := s.ConfigureStartPack(1, true); err != nil { - t.Fatal(err) - } - if err := s.EnsureInitialPackPurchase(); err != nil { - t.Fatal(err) - } - if _, owned := s.collection.Grant("pack-purchase:1"); !owned || s.wallet.CatalystBalance() != 17 { - t.Fatal("entry purchase not committed") - } - if _, owned := s.collection.Grant("pack-purchase:21"); owned { - t.Fatal("tutorial purchase incorrectly granted") - } -} diff --git a/go/internal/server/world/story_catalog.go b/go/internal/server/world/story_catalog.go deleted file mode 100644 index e84606b..0000000 --- a/go/internal/server/world/story_catalog.go +++ /dev/null @@ -1,126 +0,0 @@ -package world - -import ( - "bd2server/internal/server/wire" - "fmt" - "sort" -) - -// orderMainQuests follows the actual QuestTable links. Subquests never become -// a predecessor merely because their numeric ID lies between two main quests. -func (s *Service) orderMainQuests() error { - for id, pack := range s.storyCatalog.Packs { - start := 0 - for _, qid := range pack.MainQuestIDs { - if pack.Quests[qid].PriorQuestID == 0 { - if start != 0 { - return fmt.Errorf("world: pack %d has multiple main quest roots", id) - } - start = qid - } - } - var ordered []int - seen := map[int]bool{} - for qid := start; qid != 0; { - q, exists := pack.Quests[qid] - if !exists || q.Type != 0 || seen[qid] { - return fmt.Errorf("world: invalid main quest chain pack %d quest %d", id, qid) - } - seen[qid] = true - ordered = append(ordered, qid) - qid = q.NextQuestID - } - if len(ordered) != len(pack.MainQuestIDs) { - return fmt.Errorf("world: disconnected main quest chain pack %d", id) - } - pack.MainQuestIDs = ordered - s.storyCatalog.Packs[id] = pack - } - return nil -} - -func (s *Service) storyPackUnlocked(id int) bool { - pack, exists := s.storyCatalog.Packs[id] - if !exists { - return false - } - // A server-selected entry chapter is explicitly available to this account. - // This does not invent tickets for the remaining chapter catalog. - if id == s.startingPack() { - return true - } - // ContentOpen.TutorialID triggers a tutorial; the client does not use it - // as an authorization requirement. Story NextPackID is navigation only. - if pack.Open == nil { - return true - } - if pack.Open.SquadLevel != 0 { - if s.squadLevel == nil { - return false - } - level, err := s.squadLevel() - if err != nil || level < pack.Open.SquadLevel { - return false - } - } - if pack.Open.TicketID != 0 { - if s.inventory == nil { - return false - } - for _, item := range s.inventory.All() { - if item.Type == 19 && item.ID == pack.Open.TicketID && item.Count > 0 { - return true - } - } - return false - } - return true -} - -func (s *Service) storyPackDBInfoRows() [][]byte { - ids := make([]int, 0, len(s.storyCatalog.Packs)) - for id := range s.storyCatalog.Packs { - if s.storyPackUnlocked(id) { - ids = append(ids, id) - } - } - sort.Ints(ids) - var rows [][]byte - for _, id := range ids { - row := wire.AppendVarint(nil, 1, uint64(id)) - selection, _ := s.state.Selection(id) - if selection.Difficulty != 0 { - row = wire.AppendVarint(row, 4, uint64(selection.Difficulty)) - } - if selection.Option != 0 { - row = wire.AppendVarint(row, 5, uint64(selection.Option)) - } - mainCleared := 0 - for _, qid := range s.storyCatalog.Packs[id].MainQuestIDs { - if s.state.QuestCleared(qid, id, s.questDifficulty(id)) { - mainCleared++ - } - } - if mainCleared > 0 { - row = wire.AppendVarint(row, 2, uint64(mainCleared)) - } - if s.packCompleteFor(id) { - row = wire.AppendVarint(row, 3, 1) - } - purchased := false - if s.collection != nil { - _, owned := s.collection.Grant(packPurchaseIdentity(id)) - purchased = purchased || owned - } - if purchased { - row = wire.AppendVarint(row, 8, 1) - } - rows = append(rows, row) - } - if saved, found := s.state.Position(); found { - if pack, arena := s.fieldPacks[saved.PackID]; arena && pack.MapIDs[saved.Position.MapID] { - rows = append(rows, wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(saved.PackID)), 8, 1)) - } - } - return rows -} diff --git a/go/internal/server/world/story_catalog_test.go b/go/internal/server/world/story_catalog_test.go deleted file mode 100644 index bf60d37..0000000 --- a/go/internal/server/world/story_catalog_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "testing" -) - -type purchaseTestCostumes map[uint64]gamedata.CharacterDesign - -func (c purchaseTestCostumes) Character(id uint64) (gamedata.CharacterDesign, bool) { - d, ok := c[id] - return d, ok -} - -func TestStoryCatalogUsesTicketsAndQuestLinksAcrossIndependentPacks(t *testing.T) { - s := testService() - s.seed.PackID = 701 - storage := stateio.NewMemory() - var err error - s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s.collection, err = player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - quests := map[int]gamedata.QuestDesign{90: {ID: 90, NextQuestID: 7}, 7: {ID: 7, PriorQuestID: 90, Rewards: [5][]gamedata.Reward{{{Type: 19, ID: 888, Count: 1}}}}, 50: {ID: 50, Type: 1}} - s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{ - 701: {ID: 701, Quests: quests, MainQuestIDs: []int{7, 90}}, - 405: {ID: 405, Open: &gamedata.ContentOpenRule{TicketID: 888}, Quests: map[int]gamedata.QuestDesign{33: {ID: 33}}, MainQuestIDs: []int{33}}, - }} - s.packs = map[int]map[int]gamedata.QuestDesign{701: quests, 405: s.storyCatalog.Packs[405].Quests} - if err := s.orderMainQuests(); err != nil { - t.Fatal(err) - } - if s.firstUnclearedQuestFor(701) != 90 || s.canClear(701, 7) || s.canClear(701, 50) || s.packUnlocked(405) { - t.Fatal("numeric ordering or ticket gate bypassed") - } - if err := s.state.ClearQuest(90, 701); err != nil { - t.Fatal(err) - } - if !s.canClear(701, 7) || s.nextQuestFor(701, 90) != 7 { - t.Fatal("design next/prior ignored") - } - request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 7), 3, 701) - _, response, _, err := s.Handle("/QuestClear", request) - if err != nil { - t.Fatal(err) - } - if !s.packCompleteFor(701) || !s.packUnlocked(405) { - t.Fatal("main terminal ticket did not unlock independent story") - } - if s.state.QuestCleared(50, 701) { - t.Fatal("side quest fabricated") - } - var ids []uint64 - if err := wire.Walk(response, func(f wire.Field) error { - if f.Number == 11 { - id, _, _ := wire.Varint(f.Value, 1) - ids = append(ids, id) - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(ids) != 2 || ids[0] != 405 || ids[1] != 701 { - t.Fatalf("terminal updates %v", ids) - } - if err := s.state.ClearQuest(33, 405); err != nil { - t.Fatal(err) - } - if err := s.state.ClearQuest(50, 701); err != nil { - t.Fatal(err) - } - var counts = map[uint64]uint64{} - if err := wire.Walk(s.accountPackInfo(), func(f wire.Field) error { - if f.Number == 2 { - id, _, _ := wire.Varint(f.Value, 1) - count, _, _ := wire.Varint(f.Value, 3) - counts[id] = count - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(counts) != 2 || counts[405] != 33 || counts[701] != 7 { - t.Fatalf("completed normal levels do not retain linked terminal quest IDs: %v", counts) - } -} - -func TestInitialPackPurchaseUsesDesignAndDurableIdentity(t *testing.T) { - s := testService() - s.seed.PackID = 707 - storage := stateio.NewMemory() - var err error - s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s.wallet, err = player.OpenWallet(storage, player.Currency{Catalyst: 9}) - if err != nil { - t.Fatal(err) - } - s.collection, err = player.OpenCollectionStore(storage, nil) - if err != nil { - t.Fatal(err) - } - s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{707: {ID: 707, BuyRewards: []gamedata.Reward{{Type: 12, Count: 17}, {Type: 4, Count: 31}, {Type: 19, ID: 88, Count: 1}, {Type: 11, ID: 101, Count: 0}}}}} - s.questCostumes = purchaseTestCostumes{101: {ID: 10, HP: 100, CostumeMaxLevel: 5}} - for range 2 { - if err := s.EnsureInitialPackPurchase(); err != nil { - t.Fatal(err) - } - } - if s.wallet.CatalystBalance() != 26 || len(s.inventory.All()) != 1 { - t.Fatal("purchase reward repeated or hardcoded") - } - if s.wallet.Snapshot().Gold != 31 || len(s.collection.Costumes()) != 1 { - t.Fatal("purchase gold or non-stackable level-zero costume handled incorrectly") - } - if _, owned := s.collection.Grant("pack-purchase:707"); !owned { - t.Fatal("missing purchase marker") - } - if !s.wallet.WasGranted("pack-purchase:707:currency") { - t.Fatal("missing currency marker") - } -} diff --git a/go/internal/server/world/story_characters_test.go b/go/internal/server/world/story_characters_test.go deleted file mode 100644 index a20adbc..0000000 --- a/go/internal/server/world/story_characters_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" - "os" - "path/filepath" - "reflect" - "testing" -) - -// Source: GameData 20260923193640 CharTable10140 is the temporary blacksmith -// joining pack1 quest11, and Proto/Net/CharDBInfo supplies talent_level field8. -// Its TalentSkillTable group901 has row1 only; omitted field8 defaults to zero -// in the client and causes a lookup of the nonexistent group901/row0. -func TestInstalledStoryCharactersInitialTalentPersistsAndEncodes(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - catalog, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{1}, 996000) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(t.TempDir(), "state.db") - open := func() (*accountstate.Repository, *Service) { - t.Helper() - storage, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = storage.Close() }) - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - s := testService() - s.storyRoster = catalog - s.characters, err = player.OpenCharacterStore(storage, nil, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := s.characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - return storage, s - } - storage, s := open() - party, err := s.resolveStoryCharacters(1, 11) - if err != nil { - t.Fatal(err) - } - var joined player.Character - for _, c := range party { - if c.ID == 10140 { - joined = c - } - } - if joined.InvenIndex == 0 || joined.TalentLevel != 1 { - t.Fatalf("temporary blacksmith has invalid initial talent: %+v", joined) - } - before := s.characters.RawAll() - repeated, err := s.resolveStoryCharacters(1, 11) - if err != nil || !reflect.DeepEqual(repeated, party) || !reflect.DeepEqual(s.characters.RawAll(), before) { - t.Fatalf("repeated temporary resolution changed identity or character state: err=%v", err) - } - if err = s.characters.SetCurrentHealth(joined.InvenIndex, 7); err != nil { - t.Fatal(err) - } - if err = storage.Close(); err != nil { - t.Fatal(err) - } - _, s = open() - characters, decks, err := s.resolveActivePartyWires(1, 11) - if err != nil { - t.Fatal(err) - } - if len(decks) != 0 { - t.Fatal("temporary talent character fabricated an unsaved battle deck") - } - for _, data := range characters { - id, _, err := wire.Varint(data, 2) - if err != nil { - t.Fatal(err) - } - if id != joined.ID { - continue - } - level, present, err := wire.Varint(data, 8) - if err != nil || !present || level != 1 { - t.Fatalf("CharDBInfo talent_level=%d present=%v err=%v", level, present, err) - } - hp, _, err := wire.Varint(data, 3) - if err != nil || hp != 7 { - t.Fatalf("reconnect changed temporary HP=%d err=%v", hp, err) - } - return - } - t.Fatal("temporary blacksmith omitted from party CharDBInfo") -} - -// Source: GameData 20260923193640 QuestTable1 quests 13..16 use CharGroup102 -// while their cosmetic StoryCharGroup changes. The saved five-player party -// and positions below implement the user's policy for ordinary story battles. -func TestInstalledQuest13ClearKeepsSavedBattlePartyAcrossReopen(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{1}, 996000) - if err != nil { - t.Fatal(err) - } - catalog, err := gamedata.LoadStoryCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{ - {InvenIndex: 101, ID: 10, HP: 71, Level: 30, CostumeID: 101, UseCostume: 1001, TalentLevel: 1, TalentExp: 13, Exp: 123}, - {InvenIndex: 102, ID: 20, HP: 72, Level: 30, CostumeID: 202, UseCostume: 1002, TalentLevel: 1, TalentExp: 14, Exp: 124}, - {InvenIndex: 103, ID: 130, HP: 73, Level: 30, CostumeID: 1301, UseCostume: 1003, TalentLevel: 1, TalentExp: 15, Exp: 125}, - {InvenIndex: 104, ID: 140, HP: 74, Level: 30, CostumeID: 1401, UseCostume: 1004, TalentLevel: 1}, - {InvenIndex: 105, ID: 350, HP: 75, Level: 30, CostumeID: 3501, UseCostume: 1005, TalentLevel: 1}, - }, Costumes: []player.Costume{ - {InvenIndex: 1001, ID: 101, UseChar: 101}, {InvenIndex: 1002, ID: 202, UseChar: 102}, - {InvenIndex: 1003, ID: 1301, UseChar: 103}, {InvenIndex: 1004, ID: 1401, UseChar: 104}, - {InvenIndex: 1005, ID: 3501, UseChar: 105}, - }} - // Inventory order, wire order, slot sequence, and grid position are distinct. - saved := []deck.DeckEntry{ - {CharacterInvenIndex: 105, CostumeInvenIndex: 0, Slot: 3}, - {CharacterInvenIndex: 103, CostumeInvenIndex: 11, Slot: 1}, - {CharacterInvenIndex: 101, CostumeInvenIndex: 3, Slot: 5}, - {CharacterInvenIndex: 104, CostumeInvenIndex: ^uint64(0), Slot: 2}, - {CharacterInvenIndex: 102, CostumeInvenIndex: 8, Slot: 4}, - } - path := filepath.Join(t.TempDir(), "state.db") - open := func() (*accountstate.Repository, *Service) { - t.Helper() - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - s := testService() - s.seed.PackID, s.seed.StartQuestID, s.startingPackID = 1, 1, 1 - s.storyRoster, s.storyCatalog, s.starter = roster, catalog, seed - s.quests = catalog.Packs[1].Quests - s.state, err = progress.OpenStore(repo) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(repo, seed) - if err != nil { - t.Fatal(err) - } - s.characters, err = player.OpenCharacterStore(repo, seed.Characters, s.inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err = s.characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 104}}}) - if err != nil { - t.Fatal(err) - } - return repo, s - } - repo, s := open() - saveStoryTestDeck(t, s.decks, saved) - for id := 1; id < 13; id++ { - if err := s.state.ClearQuest(id, 1); err != nil { - t.Fatal(err) - } - } - if _, err := s.state.UpdateQuest(wire.AppendVarint(selectionRequest(13, 1, 0), 4, 1)); err != nil { - t.Fatal(err) - } - beforeResolve := s.characters.RawAll() - assertStoryTestBattleParty(t, s, saved) - if !reflect.DeepEqual(s.characters.RawAll(), beforeResolve) { - t.Fatal("battle party resolution materialized temporary characters") - } - temporary, err := s.resolveStoryCharacters(1, 13) - if err != nil || len(temporary) == 0 { - t.Fatalf("quest13 temporary talent characters=%+v err=%v", temporary, err) - } - for i, c := range temporary { - if err := s.characters.SetCurrentHealth(c.InvenIndex, uint64(7+i)); err != nil { - t.Fatal(err) - } - } - before := s.characters.RawAll() - for id := 13; id <= 15; id++ { - chars, decks, err := s.resolveActivePartyWires(1, id) - if err != nil { - t.Fatal(err) - } - if len(chars) != len(temporary) || !reflect.DeepEqual(decodeStoryTestDeck(t, decks), saved) { - t.Fatalf("quest%d displaced a player or moved positions", id) - } - assertStoryTestBattleParty(t, s, saved) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, response, handled, err := s.Handle("/QuestClear", selectionRequest(uint64(id), 1, 0)) - if err != nil || !handled { - _ = op.Rollback() - t.Fatalf("quest%d clear handled=%v err=%v", id, handled, err) - } - if err := op.Commit(); err != nil { - t.Fatal(err) - } - echoed, joins := storyTestClearParty(t, response) - if !reflect.DeepEqual(echoed, saved) || len(joins) != 0 { - t.Fatalf("quest%d changed choices/positions or fabricated join UI: deck=%+v want=%+v joins=%v", id, echoed, saved, joins) - } - assertStoryTestBattleParty(t, s, saved) - if !reflect.DeepEqual(s.characters.RawAll(), before) { - t.Fatalf("quest%d transition changed character stats, talents, health, or identities", id) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, s = open() - if !s.state.QuestCleared(id, 1) || s.firstUnclearedQuestFor(1) != id+1 { - t.Fatalf("quest%d clear did not survive SQLite reopen", id) - } - assertStoryTestBattleParty(t, s, saved) - if !reflect.DeepEqual(s.characters.RawAll(), before) { - t.Fatalf("quest%d reopen changed character state", id) - } - } -} - -// Source: installed GameData CharGroup rows choose an adjacent unchanged -// temporary cast; the server's instance policy preserves its identity and HP. -func TestInstalledAdjacentStoryCharactersRetainHealth(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("BD2_REAL_GAMEDATA not configured") - } - catalog, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{21}) - if err != nil { - t.Fatal(err) - } - first, second := 0, 0 - for quest := 1; quest < 38; quest++ { - a, err := catalog.Characters(21, quest) - if err != nil { - t.Fatal(err) - } - b, err := catalog.Characters(21, quest+1) - if err != nil { - t.Fatal(err) - } - if len(a) > 0 && reflect.DeepEqual(a, b) { - first, second = quest, quest+1 - break - } - } - if first == 0 { - t.Fatal("no adjacent authored same party fixture") - } - storage := stateio.NewMemory() - inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - designs, err := catalog.Characters(21, first) - if err != nil { - t.Fatal(err) - } - legacy := player.Character{InvenIndex: 123, ID: designs[0].CharacterID, HP: designs[0].HP, Level: designs[0].Level} - characters, err := player.OpenCharacterStore(storage, []player.Character{legacy}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err := characters.AttachMaxHealth(func(c player.Character) (uint64, error) { return c.HP, nil }); err != nil { - t.Fatal(err) - } - s := testService() - s.characters = characters - s.storyRoster = catalog - party, err := s.resolveStoryCharacters(21, first) - if err != nil { - t.Fatal(err) - } - if party[0].InvenIndex != legacy.InvenIndex { - t.Fatal("existing seeded temporary identity was not reused") - } - if err := characters.SetCurrentHealth(party[0].InvenIndex, 7); err != nil { - t.Fatal(err) - } - next, err := s.resolveStoryCharacters(21, second) - if err != nil { - t.Fatal(err) - } - if next[0].InvenIndex != party[0].InvenIndex || next[0].HP != 7 { - t.Fatalf("quest%d→%d reset party hp or index: %+v→%+v", first, second, party[0], next[0]) - } - if len(characters.RawAll()) != len(party) { - t.Fatal("adjacent quest duplicated temporary instances") - } -} diff --git a/go/internal/server/world/story_party_changes.go b/go/internal/server/world/story_party_changes.go deleted file mode 100644 index 698c264..0000000 --- a/go/internal/server/world/story_party_changes.go +++ /dev/null @@ -1,35 +0,0 @@ -package world - -import ( - "bd2server/internal/server/player" - "bd2server/internal/server/wire" -) - -// QuestClear.CharInfo is processed by AddCharDBInfoReward and the join UI, -// not as a complete roster snapshot. DeckInfo still carries the full formation; -// only joining characters or authored level/costume changes belong in CharInfo. -func storyPartyChanges(previous []player.Character, next [][]byte) ([][]byte, error) { - type appearance struct{ id, level, costume, useCostume uint64 } - known := make(map[uint64]appearance, len(previous)) - for _, c := range previous { - known[c.InvenIndex] = appearance{c.ID, c.Level, c.CostumeID, c.UseCostume} - } - var changed [][]byte - for _, body := range next { - var index uint64 - var value appearance - for field, destination := range map[int]*uint64{1: &index, 2: &value.id, 4: &value.level, 5: &value.costume, 7: &value.useCostume} { - var err error - *destination, _, err = wire.Varint(body, field) - if err != nil { - return nil, err - } - } - if old, exists := known[index]; exists && old == value { - continue - } - changed = append(changed, body) - known[index] = value - } - return changed, nil -} diff --git a/go/internal/server/world/story_party_changes_test.go b/go/internal/server/world/story_party_changes_test.go deleted file mode 100644 index 62996ee..0000000 --- a/go/internal/server/world/story_party_changes_test.go +++ /dev/null @@ -1,316 +0,0 @@ -package world - -import ( - "fmt" - "os" - "path/filepath" - "reflect" - "sort" - "testing" - - "bd2server/internal/server/accountstate" - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/wire" -) - -// Source: GameData 20260923193640 supplies all main-quest edges and temporary -// CharGroup designs; client QuestPacket feeds QuestClear.CharInfo into its join -// UI. User policy keeps the saved player battle deck on every ordinary edge. -func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) { - root := os.Getenv("BD2_REAL_GAMEDATA") - if root == "" { - t.Skip("set BD2_REAL_GAMEDATA for authored party transition regression") - } - catalog, err := gamedata.LoadStoryCatalog(root, "20260923193640") - if err != nil { - t.Fatal(err) - } - var packs []int - for id := range catalog.Packs { - packs = append(packs, id) - } - sort.Ints(packs) - roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", packs, 996000) - if err != nil { - t.Fatal(err) - } - edges, changedEdges := 0, 0 - for _, pack := range packs { - t.Run(fmt.Sprintf("pack%d", pack), func(t *testing.T) { - design := catalog.Packs[pack] - ids := append([]int(nil), design.MainQuestIDs...) - path := filepath.Join(t.TempDir(), "state.db") - seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{ - {InvenIndex: 77, ID: 10, Level: 1, HP: 7, CostumeID: 101, TalentLevel: 1}, - {InvenIndex: 78, ID: 20, Level: 1, HP: 7, CostumeID: 202, TalentLevel: 1}, - {InvenIndex: 79, ID: 130, Level: 1, HP: 7, CostumeID: 1301, TalentLevel: 1}, - {InvenIndex: 80, ID: 140, Level: 1, HP: 7, CostumeID: 1401, TalentLevel: 1}, - {InvenIndex: 81, ID: 350, Level: 1, HP: 7, CostumeID: 3501, TalentLevel: 1}, - }} - chosen := []deck.DeckEntry{ - {CharacterInvenIndex: 81, CostumeInvenIndex: 0, Slot: 3}, - {CharacterInvenIndex: 79, CostumeInvenIndex: 11, Slot: 1}, - {CharacterInvenIndex: 77, CostumeInvenIndex: 3, Slot: 5}, - {CharacterInvenIndex: 80, CostumeInvenIndex: ^uint64(0), Slot: 2}, - {CharacterInvenIndex: 78, CostumeInvenIndex: 8, Slot: 4}, - } - open := func() (*accountstate.Repository, *Service) { - t.Helper() - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - s := testService() - s.seed.PackID, s.startingPackID = pack, pack - s.storyRoster, s.starter = roster, seed - s.state, err = progress.OpenStore(repo) - if err != nil { - t.Fatal(err) - } - s.inventory, err = player.OpenInventory(repo, seed) - if err != nil { - t.Fatal(err) - } - s.characters, err = player.OpenCharacterStore(repo, seed.Characters, s.inventory, "", "") - if err != nil { - t.Fatal(err) - } - if err = s.characters.EnsurePersisted(); err != nil { - t.Fatal(err) - } - s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}}}) - if err != nil { - t.Fatal(err) - } - if len(s.decks.CurrentDeck()) == 0 { - saveStoryTestDeck(t, s.decks, chosen) - } - s.quests = map[int]gamedata.QuestDesign{} - for _, id := range ids { - q := design.Quests[id] - s.quests[id] = gamedata.QuestDesign{ID: id, Type: q.Type, NextQuestID: q.NextQuestID, PriorQuestID: q.PriorQuestID} - } - s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{pack: {ID: pack, Quests: s.quests, MainQuestIDs: ids}}} - s.packs = map[int]map[int]gamedata.QuestDesign{pack: s.quests} - return repo, s - } - repo, s := open() - unchanged := false - for _, id := range ids { - next := design.Quests[id].NextQuestID - if next == 0 { - continue - } - edges++ - assertStoryTestBattleParty(t, s, chosen) - before, err := s.resolveStoryCharacters(pack, id) - if err != nil { - t.Fatal(err) - } - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - _, body, _, err := s.Handle("/QuestClear", selectionRequest(uint64(id), uint64(pack), 0)) - if err != nil { - _ = op.Rollback() - t.Fatal(err) - } - if err = op.Commit(); err != nil { - t.Fatal(err) - } - after, err := s.resolveStoryCharacters(pack, next) - if err != nil { - t.Fatal(err) - } - known := map[uint64]player.Character{} - for _, c := range before { - known[c.InvenIndex] = c - } - var want []uint64 - for _, c := range after { - old, exists := known[c.InvenIndex] - if !exists || old.ID != c.ID || old.Level != c.Level || old.CostumeID != c.CostumeID || old.UseCostume != c.UseCostume { - want = append(want, c.InvenIndex) - } - } - echoed, got := storyTestClearParty(t, body) - if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(echoed, chosen) { - t.Fatalf("pack%d quest%d→%d joins=%v want=%v deck=%+v want=%+v", pack, id, next, got, want, echoed, chosen) - } - for _, index := range got { - c, found := s.characters.Find(index) - if !found || !player.IsStoryCharacter(c) { - t.Fatalf("pack%d quest%d→%d fabricated owned join %+v", pack, id, next, c) - } - } - assertStoryTestBattleParty(t, s, chosen) - for _, c := range seed.Characters { - current, found := s.characters.Find(c.InvenIndex) - if !found || !reflect.DeepEqual(current, c) { - t.Fatalf("pack%d quest%d→%d changed player state: got=%+v want=%+v", pack, id, next, current, c) - } - } - unchanged = unchanged || len(want) == 0 - if len(want) != 0 { - changedEdges++ - } - if pack == 1 && id == 2 { - if len(got) != 0 { - t.Fatal("pack-1 gate quest repeated character acquisition") - } - if err = repo.Close(); err != nil { - t.Fatal(err) - } - repo, s = open() - assertStoryTestBattleParty(t, s, chosen) - c, ok := s.characters.Find(77) - if !ok || c.HP != 7 { - t.Fatal("quest transition healed or replaced existing character") - } - } - } - if !unchanged { - t.Fatal("authored regression did not exercise stable party transitions") - } - beforeReopen := s.characters.RawAll() - if err := repo.Close(); err != nil { - t.Fatal(err) - } - _, s = open() - assertStoryTestBattleParty(t, s, chosen) - if !reflect.DeepEqual(s.characters.RawAll(), beforeReopen) { - t.Fatal("SQLite reopen changed player or temporary character state") - } - }) - } - if edges == 0 || changedEdges == 0 { - t.Fatalf("main-quest regression omitted edges or temporary joins: edges=%d changes=%d", edges, changedEdges) - } - t.Logf("validated %d packs, %d main-quest edges, and %d temporary join changes", len(packs), edges, changedEdges) -} - -// Source: seed reward handling is server policy. Inventory acquisition must -// preserve the user's five saved battle choices, including cell 0 and -1. -func TestQuestUnlockRewardKeepsSavedBattleParty(t *testing.T) { - s := testService() - s.seed.BattleUnlockQuestID = 1 - s.seed.RewardCharacter = player.Character{InvenIndex: 900, ID: 90, Level: 1, HP: 100} - s.seed.RewardCostume = player.Costume{InvenIndex: 901, ID: 901} - s.seed.StoryCharacters = []player.Character{{InvenIndex: 902, ID: 10090, Level: 1, HP: 100}} - var err error - s.decks, err = deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 105}}}) - if err != nil { - t.Fatal(err) - } - saved := []deck.DeckEntry{ - {CharacterInvenIndex: 105, CostumeInvenIndex: 0, Slot: 1}, - {CharacterInvenIndex: 104, CostumeInvenIndex: 8, Slot: 2}, - {CharacterInvenIndex: 103, CostumeInvenIndex: 11, Slot: 3}, - {CharacterInvenIndex: 102, CostumeInvenIndex: 3, Slot: 4}, - {CharacterInvenIndex: 101, CostumeInvenIndex: ^uint64(0), Slot: 5}, - } - saveStoryTestDeck(t, s.decks, saved) - _, response, handled, err := s.Handle("/QuestClear", selectionRequest(1, 21, 0)) - if err != nil || !handled { - t.Fatalf("unlock clear handled=%v err=%v", handled, err) - } - echoed, joins := storyTestClearParty(t, response) - if !reflect.DeepEqual(echoed, saved) || !reflect.DeepEqual(s.decks.CurrentDeck(), saved) || len(joins) != 0 { - t.Fatalf("unlock reward replaced deck or fabricated joins: got=%+v want=%+v joins=%v", echoed, saved, joins) - } - rewardFound := false - if err := wire.Walk(response, func(f wire.Field) error { - if f.Number != 1 { - return nil - } - return wire.Walk(f.Value, func(reward wire.Field) error { - if reward.Number == 2 { - index, _, err := wire.Varint(reward.Value, 1) - rewardFound = rewardFound || index == s.seed.RewardCharacter.InvenIndex - return err - } - return nil - }) - }); err != nil || !rewardFound { - t.Fatalf("saved party policy lost unlock reward: found=%v err=%v", rewardFound, err) - } -} - -func saveStoryTestDeck(t *testing.T, store *deck.Store, entries []deck.DeckEntry) { - t.Helper() - request := wire.AppendVarint(nil, 1, 1) - for _, entry := range entries { - data := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex) - data = wire.AppendVarint(data, 2, entry.CostumeInvenIndex) - data = wire.AppendVarint(data, 3, entry.Slot) - request = wire.AppendBytes(request, 2, data) - } - if _, _, handled, err := store.Handle("/DeckSave", request); err != nil || !handled { - t.Fatalf("save test player deck handled=%v err=%v", handled, err) - } -} - -func decodeStoryTestDeck(t *testing.T, wires [][]byte) []deck.DeckEntry { - t.Helper() - var out []deck.DeckEntry - for _, data := range wires { - index, present, err := wire.Varint(data, 1) - if err != nil || !present { - t.Fatalf("deck character absent: %v", err) - } - // Proto/Net/DeckDBInfo.position_ defaults to 0 when field 2 is omitted. - position, _, err := wire.Varint(data, 2) - if err != nil { - t.Fatalf("deck position: %v", err) - } - slot, present, err := wire.Varint(data, 3) - if err != nil || !present { - t.Fatalf("deck slot absent: %v", err) - } - out = append(out, deck.DeckEntry{CharacterInvenIndex: index, CostumeInvenIndex: position, Slot: slot}) - } - return out -} - -func storyTestClearParty(t *testing.T, response []byte) ([]deck.DeckEntry, []uint64) { - t.Helper() - var decks [][]byte - var joins []uint64 - if err := wire.Walk(response, func(f wire.Field) error { - if f.Number == 4 { - decks = append(decks, f.Value) - } - if f.Number == 5 { - index, present, err := wire.Varint(f.Value, 1) - if err != nil || !present { - t.Fatalf("join character absent: %v", err) - } - joins = append(joins, index) - } - return nil - }); err != nil { - t.Fatal(err) - } - return decodeStoryTestDeck(t, decks), joins -} - -func assertStoryTestBattleParty(t *testing.T, s *Service, saved []deck.DeckEntry) { - t.Helper() - party, err := s.currentBattleParty() - if err != nil || len(party) != len(saved) { - t.Fatalf("player battle party=%+v want=%+v err=%v", party, saved, err) - } - for i, c := range party { - if c.InvenIndex != saved[i].CharacterInvenIndex || player.IsStoryCharacter(c) { - t.Fatalf("displaced player %d: %+v want=%+v", i, c, saved[i]) - } - } - if !reflect.DeepEqual(s.decks.CurrentDeck(), saved) { - t.Fatalf("rewrote saved positions or order: got=%+v want=%+v", s.decks.CurrentDeck(), saved) - } -} diff --git a/go/internal/server/world/talent_effects.go b/go/internal/server/world/talent_effects.go deleted file mode 100644 index 409ad92..0000000 --- a/go/internal/server/world/talent_effects.go +++ /dev/null @@ -1,80 +0,0 @@ -package world - -import ( - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/wire" - "fmt" -) - -func (s *Service) AttachTalentPackInfo(source func(int) ([]byte, error)) { s.talentPackInfo = source } - -func (s *Service) TalentFieldContext() (int, uint64, bool, error) { - pack, err := s.CurrentPackID() - if err != nil { - return 0, 0, false, err - } - mapID, err := s.currentFieldMap(pack) - if err != nil { - return 0, 0, false, err - } - battle := s.battleActive != nil && s.battleActive() - return pack, uint64(mapID), battle, nil -} - -// Hidden packs deliberately suppress SaveUserPosition, so a calendar-authored -// initial map is authoritative until the client supplies a valid saved map. -// Ordinary pack starts live in GateSpotData assets, not QuestTable.MapId; -// guessing a quest target map would authorize interactions in another scene. -func (s *Service) currentFieldMap(pack int) (int, error) { - if !s.packUnlocked(pack) { - return 0, ErrInvalidRequest - } - if saved, ok := s.state.Position(); ok && saved.PackID == pack && saved.Difficulty == s.questDifficulty(pack) && saved.Position.MapID > 0 { - return saved.Position.MapID, nil - } - if event, found, e := s.resolveEventFieldPack(pack); e != nil { - return 0, e - } else if found && event.InitialMapID > 0 { - return int(event.InitialMapID), nil - } - return 0, fmt.Errorf("world: current scene map is not yet known") -} - -// Absorb consumes the exact acquisition objects requested by the client using -// the same period receipts and reward graph as an ordinary field interaction. -func (s *Service) ApplyTalentFieldAbsorb(_ string, _ player.Character, rule gamedata.TalentUseRule, ids []uint64) ([]byte, error) { - pack, mapID, _, err := s.TalentFieldContext() - if err != nil { - return nil, err - } - if rule.Class != 4 || len(ids) == 0 { - return nil, ErrInvalidRequest - } - design, err := s.fieldObjectDesign(pack) - if err != nil { - return nil, err - } - var out []byte - for _, id := range ids { - obj, ok := design.Objects[int(id)] - if !ok || obj.MapID != int(mapID) || (obj.Type != 1 && obj.Type != 3) { - return nil, fmt.Errorf("world: absorption target is not an acquisition object") - } - bundle, e := s.openFieldObject(pack, obj.GroupID, int(id)) - if e != nil { - return nil, e - } - e = wire.Walk(bundle, func(f wire.Field) error { - to := map[int]int{1: 3, 2: 5, 3: 6, 4: 4}[f.Number] - if to > 0 { - out = wire.AppendBytes(out, to, f.Value) - } - return nil - }) - if e != nil { - return nil, e - } - } - return out, nil -} diff --git a/go/internal/server/world/today_quests.go b/go/internal/server/world/today_quests.go deleted file mode 100644 index 22cc79f..0000000 --- a/go/internal/server/world/today_quests.go +++ /dev/null @@ -1,81 +0,0 @@ -package world - -import ( - "bd2server/internal/server/todayquest" - "bd2server/internal/server/wire" - "fmt" -) - -func (s *Service) AttachTodayQuests(service *todayquest.Service) error { - if service == nil { - return fmt.Errorf("world: nil commission service") - } - s.todayQuests = service - return nil -} - -// QuestPacket always passes the repeated DeckInfo to CommonPacket.RefreshDeck, -// which replaces the entire battle deck even when that list is empty. Keep -// this live snapshot outside the commission reward receipt: retrying a clear -// after the player changes formation must return the current saved deck. -func (s *Service) commissionResponseDeck(code int, response []byte) []byte { - field := 0 - switch code { - case 17: // QuestAcceptResponse.deck_info - field = 3 - case 18: // QuestClearResponse.deck_info - field = 4 - case 20: // QuestGiveUpResponse.deck_info - field = 2 - } - if field != 0 { - return s.appendCurrentBattleDeck(response, field) - } - return response -} - -func (s *Service) currentBattleDeckWires() [][]byte { - if s.decks == nil { - return nil - } - var entries [][]byte - for _, current := range s.decks.CurrentDeck() { - entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex) - // DeckDBInfo field 2 is the battle-grid position, including zero and -1. - entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex) - entry = wire.AppendVarint(entry, 3, current.Slot) - entries = append(entries, entry) - } - return entries -} - -func (s *Service) appendCurrentBattleDeck(response []byte, field int) []byte { - for _, entry := range s.currentBattleDeckWires() { - response = wire.AppendBytes(response, field, entry) - } - return response -} - -// NPCController checks whether any owned pack has completed its main story, -// rather than requiring completion of the board's own pack. -func (s *Service) CommissionPackUnlocked(pack int) bool { - if !s.packUnlocked(pack) || s.storyCatalog == nil { - return false - } - for id, design := range s.storyCatalog.Packs { - if !s.packUnlocked(id) || len(design.MainQuestIDs) == 0 { - continue - } - complete := true - for _, quest := range design.MainQuestIDs { - if !s.state.QuestCleared(quest, id, 0) { - complete = false - break - } - } - if complete { - return true - } - } - return false -} diff --git a/go/internal/server/world/today_quests_test.go b/go/internal/server/world/today_quests_test.go deleted file mode 100644 index d96b60a..0000000 --- a/go/internal/server/world/today_quests_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package world - -import ( - "bd2server/internal/server/accountstate" - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/todayquest" - "bd2server/internal/server/wire" - "encoding/binary" - "errors" - "path/filepath" - "reflect" - "testing" - "time" -) - -type commissionEconomy struct{ wallet *player.Wallet } - -func (e commissionEconomy) Apply(id string, costs, rewards []gamedata.Reward) ([]byte, error) { - _, err := e.wallet.GrantQuestOnce(id, rewards) - return nil, err -} - -// Synthetic IDs describe a two-node collection commission and an independent -// pack. CommonPacket parses PackInGameInfo, requests TodayQuestInfo, then calls -// PackManager.Enter from the waypoint callback. Those last two callbacks can -// arrive in either order; only TodayQuestInfo restores commissions. -func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.db") - design := &gamedata.TodayQuestCatalog{Limit: 3, PostCount: 3, AchievementScore: 7, - Reset: gamedata.FieldResetSchedule{WeeklyDay: time.Monday, DailyReset: 9 * time.Hour}, - Quests: map[int]gamedata.TodayQuest{ - 101: {ID: 101, PackID: 21, NextID: 102, ConditionType: 19, ConditionCount: 1}, - 102: {ID: 102, PackID: 21, PriorID: 101, ConditionType: 2, ConditionCount: 2, MagicValues: []uint64{71, 72}, GiveItemIDs: []uint64{100001}, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}}, - 201: {ID: 201, PackID: 22, ConditionType: 19, ConditionCount: 1}, - 301: {ID: 301, PackID: 21, ConditionType: 19, ConditionCount: 1}, - }} - open := func() (*accountstate.Repository, *Service, *player.Wallet, *player.Inventory) { - t.Helper() - repo, err := accountstate.Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = repo.Close() }) - s := testService() - s.storyCatalog.Packs[22] = gamedata.StoryPack{ID: 22, Quests: map[int]gamedata.QuestDesign{}} - s.state, err = progress.OpenStore(repo) - if err != nil { - t.Fatal(err) - } - s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 901}}}) - if err != nil { - t.Fatal(err) - } - wallet, err := player.OpenWallet(repo, player.Currency{}) - if err != nil { - t.Fatal(err) - } - inv, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) - if err != nil { - t.Fatal(err) - } - store := stateio.EntrySnapshotStore{Entries: repo, Domain: "missions", Bucket: "gameplay"} - commissions, err := todayquest.Open(store, design, commissionEconomy{wallet}, inv, func(int) bool { return true }) - if err != nil { - t.Fatal(err) - } - commissions.CompleteAchievement = func(id string) error { return store.Save("commission_test_achievement", []byte(id)) } - if err := s.AttachTodayQuests(commissions); err != nil { - t.Fatal(err) - } - return repo, s, wallet, inv - } - call := func(s *Service, path string, id, pack int, values ...uint64) []byte { - t.Helper() - req := selectionRequest(uint64(id), uint64(pack), 0) - if len(values) > 0 { - // QuestUpdate uses field 4 as repeated int32, not difficulty. - req = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, uint64(id)), 3, uint64(pack)) - for _, value := range values { - req = wire.AppendVarint(req, 4, value) - } - } - _, b, handled, err := s.Handle(path, req) - if !handled || err != nil { - t.Fatalf("%s: handled=%v %v", path, handled, err) - } - // QuestAccept, QuestClear and QuestGiveUp all call client RefreshDeck - // with a non-null repeated list: the response must contain the complete - // current battle deck, even for a replayed commission reward receipt. - deckField := map[string]int{"/QuestAccept": 3, "/QuestClear": 4, "/QuestGiveUp": 2}[path] - if deckField != 0 { - var entries [][]byte - if err := wire.Walk(b, func(f wire.Field) error { - if f.Number == deckField { - entries = append(entries, f.Value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if got := decodeStoryTestDeck(t, entries); !reflect.DeepEqual(got, s.decks.CurrentDeck()) { - t.Fatalf("%s replaced saved formation: got=%+v want=%+v", path, got, s.decks.CurrentDeck()) - } - } - return b - } - assertEntry := func(s *Service, pack int, active map[int][]uint64, cleared []int) { - t.Helper() - request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, uint64(pack)) - _, body, handled, err := s.Handle("/PackInGameInfo", request) - if !handled || err != nil { - t.Fatalf("pack entry: handled=%v %v", handled, err) - } - var packQuests []int - var packClears []int - // The pack response is parsed before TodayQuestInfo is requested. Its - // regular task list is applied later, when the waypoint request finishes. - if err := wire.Walk(body, func(f wire.Field) error { - if f.Number == 2 { - id, _, _ := wire.Varint(f.Value, 1) - if _, exists := design.Quests[int(id)]; exists { - t.Fatalf("commission %d duplicated into PackInGameInfo", id) - } - packQuests = append(packQuests, int(id)) - } - if f.Number == 3 { - for b := f.Value; len(b) > 0; { - id, n := binary.Uvarint(b) - if n <= 0 { - t.Fatal("invalid cleared IDs") - } - if _, exists := design.Quests[int(id)]; exists { - t.Fatalf("commission %d restored through pack completion list", id) - } - packClears = append(packClears, int(id)) - b = b[n:] - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if pack == 21 && !reflect.DeepEqual(packQuests, []int{1}) { - t.Fatalf("pack entry lost ordinary main quest: %v", packQuests) - } - today := call(s, "/TodayQuestInfo", 0, 0) - got := map[int][]uint64{} - var clears []int - var currentPackToday []int - if err := wire.Walk(today, func(f wire.Field) error { - if f.Number == 1 { - id, _, _ := wire.Varint(f.Value, 1) - qpack, _, _ := wire.Varint(f.Value, 6) - if int(qpack) == pack { - if _, duplicate := got[int(id)]; duplicate { - t.Fatalf("TodayQuestInfo repeated commission %d", id) - } - got[int(id)] = nil - currentPackToday = append(currentPackToday, int(id)) - return wire.Walk(f.Value, func(q wire.Field) error { - if q.Number == 3 { - n, _ := binary.Uvarint(q.Value) - got[int(id)] = append(got[int(id)], n) - } - return nil - }) - } - } - if f.Number == 2 { - id, _ := binary.Uvarint(f.Value) - if design.Quests[int(id)].PackID == pack { - clears = append(clears, int(id)) - } - } - return nil - }); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, active) || !reflect.DeepEqual(clears, cleared) { - t.Fatalf("pack %d TodayQuestInfo active=%v cleared=%v", pack, got, clears) - } - for _, todayFirst := range []bool{true, false} { - var clientQuests []int - // The pack completion snapshot was assigned before either of - // these callbacks; TodayQuestInfo subsequently merges its IDs. - clientClears := append([]int(nil), packClears...) - addToday := func() { - var ordinary []int - for _, id := range clientQuests { - if _, today := design.Quests[id]; !today { - ordinary = append(ordinary, id) - } - } - clientQuests = append(ordinary, currentPackToday...) - for _, id := range clears { - present := false - for _, existing := range clientClears { - present = present || existing == id - } - if !present { - clientClears = append(clientClears, id) - } - } - } - if todayFirst { - addToday() - clientQuests = append(clientQuests, packQuests...) - } else { - clientQuests = append(clientQuests, packQuests...) - addToday() - } - seen := map[int]bool{} - for _, id := range clientQuests { - if seen[id] { - t.Fatalf("HUD duplicate quest %d when todayFirst=%v", id, todayFirst) - } - seen[id] = true - } - if len(seen) != len(packQuests)+len(active) { - t.Fatalf("entry callbacks lost tasks when todayFirst=%v: %v", todayFirst, clientQuests) - } - var completedToday []int - for _, id := range clientClears { - if _, today := design.Quests[id]; today { - completedToday = append(completedToday, id) - } - } - if !reflect.DeepEqual(completedToday, cleared) { - t.Fatalf("entry callbacks lost completed commissions when todayFirst=%v: %v", todayFirst, completedToday) - } - } - } - var wallet *player.Wallet - repo, s, _, inv := open() - savedDeck := []deck.DeckEntry{ - {CharacterInvenIndex: 901, CostumeInvenIndex: 0, Slot: 1}, - {CharacterInvenIndex: 905, CostumeInvenIndex: 8, Slot: 2}, - {CharacterInvenIndex: 903, CostumeInvenIndex: 11, Slot: 3}, - {CharacterInvenIndex: 904, CostumeInvenIndex: 3, Slot: 4}, - {CharacterInvenIndex: 902, CostumeInvenIndex: ^uint64(0), Slot: 5}, - } - saveStoryTestDeck(t, s.decks, savedDeck) - call(s, "/QuestAccept", 101, 21) - call(s, "/QuestAccept", 201, 22) - call(s, "/QuestAccept", 301, 21) - call(s, "/QuestAccept", 101, 21) - call(s, "/QuestGiveUp", 301, 21) - if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) { - t.Fatal("commission accepts or abandonment changed saved formation") - } - call(s, "/QuestUpdate", 101, 21, 1) - call(s, "/QuestClear", 101, 21) - call(s, "/QuestUpdate", 102, 21, 71) - if len(inv.All()) != 1 { - t.Fatal("continuation item missing") - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, s, _, _ = open() - if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) { - t.Fatal("SQLite reopen lost selected formation") - } - assertEntry(s, 21, map[int][]uint64{102: {71}}, []int{101}) - assertEntry(s, 22, map[int][]uint64{201: nil}, nil) - op, err := repo.BeginOperation() - if err != nil { - t.Fatal(err) - } - call(s, "/QuestUpdate", 102, 21, 72) - call(s, "/QuestClear", 102, 21) - if err := op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) { - t.Fatalf("dirty rollback must require domain reopen: %v", err) - } - if err := repo.Close(); err != nil { - t.Fatal(err) - } - repo, s, wallet, inv = open() - assertEntry(s, 21, map[int][]uint64{102: {71}}, []int{101}) - if wallet.Snapshot().Gold != 0 { - t.Fatal("rolled-back reward persisted") - } - if len(inv.All()) != 1 { - t.Fatal("continuation item duplicated on restart") - } - call(s, "/QuestUpdate", 102, 21, 72) - call(s, "/QuestClear", 102, 21) - // A retry is an idempotent reward operation, but its deck projection is a - // live snapshot. Persist a different formation and reopen before replay. - savedDeck[0], savedDeck[4] = savedDeck[4], savedDeck[0] - savedDeck[0].Slot, savedDeck[4].Slot = 1, 5 - saveStoryTestDeck(t, s.decks, savedDeck) - if err := repo.Close(); err != nil { - t.Fatal(err) - } - _, s, wallet, _ = open() - call(s, "/QuestClear", 102, 21) - assertEntry(s, 21, map[int][]uint64{}, []int{101, 102}) - if wallet.Snapshot().Gold != 10 { - t.Fatal("reward replay changed balance") - } - if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) { - t.Fatal("replayed commission clear restored stale formation") - } - call(s, "/QuestGiveUp", 201, 22) - assertEntry(s, 22, map[int][]uint64{}, nil) -} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go deleted file mode 100644 index 65028e6..0000000 --- a/go/internal/server/world/world.go +++ /dev/null @@ -1,1017 +0,0 @@ -// Package world implements the local map/quest state that is not part of a -// player's inventory. It deliberately stores semantic seed values, never a -// captured response payload. -package world - -import ( - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "log/slog" - "os" - "sync" - "time" - - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/todayquest" - "bd2server/internal/server/versionconfig" - "bd2server/internal/server/wire" -) - -var ErrInvalidRequest = errors.New("world: invalid request") - -type InitialReputation struct { - GroupID uint64 `json:"group_id"` - State uint64 `json:"state"` - ElapsedSeconds uint64 `json:"elapsed_seconds,omitempty"` -} -type InitialRankStatue struct { - ID uint64 `json:"id"` - Season uint64 `json:"season"` - Error bool `json:"error"` -} - -type Seed struct { - SquareSceneID uint64 `json:"square_scene_id,omitempty"` - InitialReputations []InitialReputation `json:"initial_reputations,omitempty"` - InitialRankStatues []InitialRankStatue `json:"initial_rank_statues,omitempty"` - // Versioned story slot placeholder; confirmed current CostumeTable row, slot semantics await current capture. - PlaceholderCostumeID uint64 `json:"placeholder_costume_id,omitempty"` - Version string `json:"version"` - PackID int `json:"pack_id"` - StartQuestID int `json:"start_quest_id"` - BattleUnlockQuestID int `json:"battle_unlock_quest_id"` - RewardCharacter player.Character `json:"reward_character"` - RewardCostume player.Costume `json:"reward_costume"` - StoryCharacters []player.Character `json:"story_characters"` -} - -// Load reads the small, versioned world seed. Quest IDs are then verified -// against the authoritative shared QuestTable GameData database. -func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store, state *progress.Store, starter *player.Starter, equipment *player.EquipmentInventory, inventory *player.Inventory, wallet *player.Wallet) (*Service, error) { - if state == nil || starter == nil || equipment == nil || inventory == nil || wallet == nil { - return nil, errors.New("world: nil player state") - } - b, err := os.ReadFile(seedPath) - if err != nil { - return nil, fmt.Errorf("world: read seed: %w", err) - } - var seed Seed - if err := json.Unmarshal(b, &seed); err != nil { - return nil, fmt.Errorf("world: decode seed: %w", err) - } - if seed.Version != versionconfig.State() || seed.PackID <= 0 || seed.StartQuestID <= 0 || seed.BattleUnlockQuestID <= 0 || seed.RewardCharacter.ID == 0 || seed.RewardCostume.ID == 0 || len(seed.StoryCharacters) == 0 { - return nil, errors.New("world: invalid seed") - } - ownedCharacters := append([]player.Character(nil), starter.Characters...) - ownedCharacters = append(ownedCharacters, seed.RewardCharacter) - ownedCharacters = append(ownedCharacters, seed.StoryCharacters...) - characters, err := player.OpenCharacterStore(storage, ownedCharacters, inventory, gameDataRoot, gameDataVersion) - if err != nil { - return nil, fmt.Errorf("world: open character state: %w", err) - } - storyCatalog, err := gamedata.LoadStoryCatalog(gameDataRoot, gameDataVersion) - if err != nil { - return nil, err - } - packs := make(map[int]map[int]gamedata.QuestDesign) - transitions := make(map[int]gamedata.PackTransition) - for id, pack := range storyCatalog.Packs { - packs[id] = pack.Quests - transitions[id] = gamedata.PackTransition{PackID: id, NextPackID: pack.NextPackID} - } - questCostumes, err := gamedata.LoadQuestCostumes(gameDataRoot, gameDataVersion, packs, storyCatalog) - if err != nil { - return nil, err - } - quests := packs[seed.PackID] - fieldPacks, err := gamedata.LoadFieldPacks(gameDataRoot, gameDataVersion) - if err != nil { - return nil, err - } - packSummaryTargets, err := gamedata.LoadPackSummaryTargets(gameDataRoot, gameDataVersion) - if err != nil { - return nil, err - } - packJamDesign, err := gamedata.LoadPackJamDesign(gameDataRoot, gameDataVersion) - if err != nil { - return nil, err - } - if _, ok := quests[seed.StartQuestID]; !ok { - return nil, fmt.Errorf("world: start quest %d is absent from QuestTable%d", seed.StartQuestID, seed.PackID) - } - transition := transitions[seed.PackID] - activePack := seed.PackID - if id := state.ActivePackID(); id != 0 { - activePack = id - } - if saved, found := state.Position(); found && state.ActivePackID() == 0 { - _, storyKnown := packs[saved.PackID] - _, fieldKnown := fieldPacks[saved.PackID] - if storyKnown || fieldKnown { - activePack = saved.PackID - } - } - service := &Service{seed: seed, state: state, starter: starter, equipment: equipment, inventory: inventory, wallet: wallet, characters: characters, quests: quests, transition: transition, packs: packs, transitions: transitions, activePack: activePack, fieldPacks: fieldPacks} - service.questDifficulties, err = gamedata.LoadQuestDifficulties(gameDataRoot, gameDataVersion) - if err != nil { - return nil, err - } - service.packJamDesign = packJamDesign - service.packSummaryTargets = packSummaryTargets - service.storyCatalog = storyCatalog - if err := service.orderMainQuests(); err != nil { - return nil, err - } - service.questCostumes = questCostumes - var storyPackIDs []int - for id := range packs { - storyPackIDs = append(storyPackIDs, id) - } - service.storyRoster, err = gamedata.LoadStoryCharacterCatalog(gameDataRoot, gameDataVersion, storyPackIDs, seed.PlaceholderCostumeID) - if err != nil { - return nil, err - } - service.attachPackDetailDesign(gameDataRoot, gameDataVersion) - service.attachFieldMonsterDesign(gameDataRoot, gameDataVersion) - return service, nil -} - -func (s *Service) CharacterService() *player.CharacterStore { return s.characters } - -func (s *Service) EarnedQuestCostume() (player.Costume, bool) { - return s.seed.RewardCostume, s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) -} - -// CurrentPackID returns the story pack selected by the latest successful -// PackInGameInfo request. BattleEnter does not carry a pack field, so battle -// sessions lock this value when they begin. On restart, Load seeds it from the -// persisted position and finally falls back to the versioned starter pack. -func (s *Service) CurrentPackID() (int, error) { - s.activePackMu.RLock() - packID := s.activePack - s.activePackMu.RUnlock() - if packID == 0 { - packID = s.seed.PackID - } - if _, known := s.questsFor(packID); !known || !s.packUnlocked(packID) { - return 0, fmt.Errorf("world: current pack %d is unavailable", packID) - } - return packID, nil -} - -func (s *Service) setCurrentPack(packID int) { - s.activePackMu.Lock() - s.activePack = packID - s.activePackMu.Unlock() -} - -type Service struct { - autoRecoveryPolicy *gamedata.PackRecoveryPolicy - todayQuests *todayquest.Service - huntingGround interface{ EnsureForPack(int) ([]byte, error) } - battleActive func() bool - questDifficulties map[int]map[int]bool - startingPackID int - storyRoster *gamedata.StoryCharacterCatalog - storyCatalog *gamedata.StoryCatalog - questCostumes player.CostumeDesignSource - packDetailDesign func(int) (gamedata.PackDetailDesign, error) - packSummaryTargets map[int]bool - packJamDesign *gamedata.PackJamDesign - packJamMu sync.Mutex - fieldPacks map[int]gamedata.FieldPack - eventFieldPacks EventFieldPackSource - fieldObjects map[int]gamedata.FieldObjectDesign - fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error) - monsterLoader func(int) ([]gamedata.FieldMonsterDesign, error) - monsterStore stateio.Store - monsterNow func() time.Time - monsterSession string - monsterRewards func(int, uint64) ([]gamedata.BattleReward, error) - monsterMaps func(int) (map[int][]int, error) - monsterDamage func(int, uint64, string) ([][]byte, error) - fieldBuffs map[uint64]gamedata.FieldBuffDesign - talentPackInfo func(int) ([]byte, error) - overwhelmAuthorize func(string, uint64) error - overwhelmSky []gamedata.SkyWayOverwhelmRule - overwhelmQuest func(int, int) (gamedata.OverwhelmQuestRule, error) - overwhelmHunting interface { - ValidateBattle(int, uint64, uint64, uint64) error - CompleteBattle(int, uint64, uint64, uint64, string) ([]byte, [][]byte, error) - } - npcReputation *npcReputationRuntime - fieldReset gamedata.FieldResetSchedule - researchDesigns map[int]gamedata.FieldResearchDesign - researchLoader func(int) (gamedata.FieldResearchDesign, error) - researchCharacters map[uint64]bool - researchEconomy interface { - Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) - } - squadLevel func() (uint64, error) - seed Seed - state *progress.Store - starter *player.Starter - equipment *player.EquipmentInventory - inventory *player.Inventory - wallet *player.Wallet - characters *player.CharacterStore - collection *player.CollectionStore - decks *deck.Store - quests map[int]gamedata.QuestDesign - transition gamedata.PackTransition - packs map[int]map[int]gamedata.QuestDesign - transitions map[int]gamedata.PackTransition - activePackMu sync.RWMutex - activePack int - prestigeSelections func() (map[uint64]uint64, error) -} - -func (s *Service) AttachCollection(collection *player.CollectionStore) error { - if collection == nil { - return errors.New("world: nil collection store") - } - s.collection = collection - return s.characters.AttachCollection(collection) -} - -// AttachPrestigeSelections projects the durable skin choice into CostumeInfo -// responses without changing the frozen collection/deck schemas. -func (s *Service) AttachPrestigeSelections(provider func() (map[uint64]uint64, error)) error { - if provider == nil { - return errors.New("world: nil prestige selection provider") - } - s.prestigeSelections = provider - return nil -} - -func (s *Service) AttachDecks(decks *deck.Store) error { - if decks == nil { - return errors.New("world: nil deck store") - } - s.decks = decks - return nil -} - -func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { - if s.todayQuests != nil { - if code, body, handled, err := s.todayQuests.Handle(path, request); handled { - if err == nil { - body = s.commissionResponseDeck(code, body) - } - return code, body, handled, err - } - } - switch path { - case "/MonsterInfo": - return s.handleMonsterInfo(request) - case "/FieldMonsterRegen": - return s.handleFieldMonsterRegen(request) - case "/FieldMonsterEvent", "/FieldMonsterDamage": - return s.handleFieldMonsterEvent(path, request) - case "/FieldMonsterReward": - return s.handleFieldMonsterReward(request) - case "/Overwhelm": - return s.handleOverwhelm(request) - case "/QuestUpdate": - return s.handleQuestUpdate(request) - case "/FieldObjectInfo": - return s.handleFieldObjectInfo(request) - case "/FieldObjectReward": - return s.handleFieldObjectReward(request) - case "/FieldObjectRewardList": - return s.handleFieldObjectRewardList(request) - case "/FieldObjectPreview": - return s.handleFieldObjectPreview(request) - case "/FieldObjectRespawn": - return s.handleFieldObjectRespawn(request) - case "/FieldObjecPositionUpdate", "/FieldObjectPositionUpdate": - return s.handleFieldObjectPosition(request) - case "/FieldObjectResearch": - return s.handleFieldResearch(request) - case "/PackRewardObjectCount": - return s.handlePackRewardCounts(request) - case "/QuestInfo", "/QuestAccept", "/QuestGiveUp": - return s.handleQuestSelection(path, request) - case "/PackBuy": - return s.handlePackBuy(request) - case "/PackDetailInfo": - return s.handlePackDetail(request) - case "/PackSummaryInfoList": - return s.handlePackSummary(request) - case "/PackPreviewInfo", "/PackJamEvent": - return s.handlePackDocking(path, request) - case "/PackInfo": - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 0, nil, true, errors.New("world: PackInfo missing sequence") - } - response := s.accountPackInfo() - rows, err := s.eventPackInfoRows() - if err != nil { - return 0, nil, true, err - } - for _, row := range rows { - response = wire.AppendBytes(response, 1, row) - } - return 4, response, true, nil - case "/CharInfo": - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 0, nil, true, errors.New("world: CharInfo missing sequence") - } - var response []byte - characters := s.visibleOwnedCharacters(s.characters.All()) - if s.tutorialRosterRestricted() { - // The tutorial roster still contains only starter identities, but - // their field HP must come from persisted state rather than falling - // through to Starter.Handle's immutable new-account HP. - characters = make([]player.Character, 0, len(s.starter.Characters)) - for _, seeded := range s.starter.Characters { - character, exists := s.characters.Find(seeded.InvenIndex) - if !exists { - return 0, nil, true, fmt.Errorf("world: missing starter character %d", seeded.InvenIndex) - } - characters = append(characters, character) - } - } - if s.decks != nil { - seen := map[uint64]bool{} - for _, char := range characters { - seen[char.InvenIndex] = true - } - deckCharacters := s.decks.CurrentDeck() - for _, entry := range s.decks.CurrentFieldDeck() { - deckCharacters = append(deckCharacters, deck.DeckEntry{CharacterInvenIndex: entry.CharacterInvenIndex}) - } - for _, entry := range deckCharacters { - if seen[entry.CharacterInvenIndex] { - continue - } - if char, ok := s.characters.Find(entry.CharacterInvenIndex); ok && player.IsStoryCharacter(char) { - characters = append(characters, char) - seen[char.InvenIndex] = true - } - } - } - for _, character := range characters { - response = wire.AppendBytes(response, 1, encodeCharacter(character)) - } - control := s.starter.FieldCharControlDeckType - if s.decks != nil { - control = s.decks.FieldControlType() - } - response = wire.AppendVarint(response, 2, control) - return 9, response, true, nil - case "/CostumeInfo": - if s.tutorialRosterRestricted() { - return 0, nil, false, nil - } - var response []byte - costumes := s.starter.Costumes - if s.collection != nil { - costumes = s.collection.Costumes() - } - var selections map[uint64]uint64 - if s.prestigeSelections != nil { - var err error - selections, err = s.prestigeSelections() - if err != nil { - return 0, nil, true, err - } - } - for _, costume := range costumes { - if design := selections[costume.ID]; design != 0 { - costume.DesignID = design - } - response = wire.AppendBytes(response, 1, encodeCostume(costume)) - } - if s.collection == nil { - costume := s.seed.RewardCostume - if design := selections[costume.ID]; design != 0 { - costume.DesignID = design - } - response = wire.AppendBytes(response, 1, encodeCostume(costume)) - } - return 40, response, true, nil - case "/PackInGameInfo": - if s.battleActive != nil && s.battleActive() { - return 0, nil, true, fmt.Errorf("%w: active battle", ErrInvalidRequest) - } - pack, err := requestPack(request) - if err != nil { - return 0, nil, true, err - } - if eventPack, found, err := s.resolveEventFieldPack(pack); err != nil { - return 0, nil, true, err - } else if found { - code, body, handled, e := s.enterEventFieldPack(eventPack) - if e == nil && s.talentPackInfo != nil { - extra, x := s.talentPackInfo(pack) - if x != nil { - return 0, nil, true, x - } - body = append(body, extra...) - } - return code, body, handled, e - } - if !s.packUnlocked(pack) { - return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack) - } - if active := s.firstUnclearedQuestFor(pack); active != 0 { - if _, err := s.ensureQuestItems(pack, active); err != nil { - return 0, nil, true, err - } - } - response, err := s.packInfoFor(pack) - if err != nil { - return 0, nil, true, err - } - if s.talentPackInfo != nil { - extra, e := s.talentPackInfo(pack) - if e != nil { - return 0, nil, true, e - } - response = append(response, extra...) - } - if err := s.state.SetActivePackID(pack); err != nil { - return 0, nil, true, err - } - s.setCurrentPack(pack) - return 5, response, true, nil - case "/QuestClear": - quest, pack, err := requestQuest(request) - if err != nil { - return 0, nil, true, err - } - quests, unlocked := s.questsFor(pack) - design, exists := quests[quest] - if !unlocked || !s.packUnlocked(pack) || !exists { - return 0, nil, true, fmt.Errorf("%w: quest %d pack %d", ErrInvalidRequest, quest, pack) - } - if !s.canClear(pack, quest) { - return 0, nil, true, fmt.Errorf("%w: quest %d is not active", ErrInvalidRequest, quest) - } - wasCleared := s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest)) - var previousParty []player.Character - if design.Type == 0 && s.storyRoster != nil { - previousParty, err = s.resolveStoryCharacters(pack, quest) - if err != nil { - return 0, nil, true, err - } - } - items, questEquipment, err := s.grantQuestRewards(pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)]) - if err != nil { - return 0, nil, true, err - } - if err := s.state.ClearQuest(quest, pack, s.questDifficultyFor(pack, quest)); err != nil { - return 0, nil, true, fmt.Errorf("world: clear quest: %w", err) - } - if s.collection != nil && quest == s.seed.BattleUnlockQuestID && pack == s.seed.PackID && s.questDifficulty(pack) == 0 && !wasCleared { - if err := s.collection.AttachRewardCostume(s.seed.RewardCostume); err != nil { - return 0, nil, true, fmt.Errorf("world: attach cleared quest costume: %w", err) - } - } - if selection, selected := s.state.Selection(pack); selected && design.Type == 0 && !wasCleared { - selection.QuestID = s.nextQuestFor(pack, quest) - if err := s.state.SelectQuest(pack, selection); err != nil { - return 0, nil, true, err - } - } - var nextItems []player.Item - var nextChars [][]byte - if design.Type == 0 { - if next := s.nextQuestFor(pack, quest); next != 0 { - var err error - nextChars, _, err = s.resolveActivePartyWires(pack, next) - if err != nil { - return 0, nil, true, err - } - nextChars, err = storyPartyChanges(previousParty, nextChars) - if err != nil { - return 0, nil, true, err - } - nextItems, err = s.ensureQuestItems(pack, next) - if err != nil { - return 0, nil, true, err - } - } - } - return 18, s.clearResponse(pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)], items, questEquipment, nextItems, nextChars), true, nil - default: - return 0, nil, false, nil - } -} - -func requestPack(request []byte) (int, error) { - seq, found, err := wire.Varint(request, 1) - if err != nil || !found || seq == 0 { - return 0, ErrInvalidRequest - } - pack, found, err := wire.Varint(request, 2) - if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) { - return 0, ErrInvalidRequest - } - return int(pack), nil -} - -func requestQuest(request []byte) (int, int, error) { - quest, err := requestPack(request) // fields 1 and 2 have the same validation. - if err != nil { - return 0, 0, err - } - pack, found, err := wire.Varint(request, 3) - if err != nil || !found || pack == 0 || pack > uint64(^uint32(0)>>1) { - return 0, 0, ErrInvalidRequest - } - return quest, int(pack), nil -} - -func (s *Service) questsFor(packID int) (map[int]gamedata.QuestDesign, bool) { - if _, found, err := s.resolveEventFieldPack(packID); err == nil && found { - return map[int]gamedata.QuestDesign{}, true - } - if _, exists := s.fieldPacks[packID]; exists { - return map[int]gamedata.QuestDesign{}, true - } - if s.storyCatalog != nil { - pack, found := s.storyCatalog.Packs[packID] - return pack.Quests, found - } - return nil, false -} - -// packUnlocked uses installed ContentOpen rules and real account tickets. -func (s *Service) packUnlocked(packID int) bool { - if pack, found, err := s.resolveEventFieldPack(packID); err != nil { - return false - } else if found { - return s.eventPackPurchased(pack.ID) - } - if pack, exists := s.fieldPacks[packID]; exists { - return s.fieldPackUnlocked(pack) - } - return s.storyCatalog != nil && s.storyPackUnlocked(packID) -} - -func (s *Service) canClear(packID, quest int) bool { - if s.state.QuestCleared(quest, packID, s.questDifficultyFor(packID, quest)) { - return true - } - quests, found := s.questsFor(packID) - if !found || s.storyCatalog == nil { - return false - } - design, found := quests[quest] - if !found { - return false - } - if design.PriorQuestID != 0 && !s.state.QuestCleared(design.PriorQuestID, packID, s.questDifficultyFor(packID, quest)) { - return false - } - if design.Type == 0 { - return quest == s.firstUnclearedQuestFor(packID) - } - _, active := s.state.QuestInPack(quest, packID, s.questDifficultyFor(packID, quest)) - return active -} - -func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.Reward) ([]player.Item, *player.Equipment, error) { - identity := questRewardIdentity(packID, quest, s.questDifficultyFor(packID, quest)) - if s.wallet != nil { - if _, err := s.wallet.GrantQuestOnce(identity, designRewards); err != nil { - return nil, nil, fmt.Errorf("world: grant quest currency: %w", err) - } - } - var itemRewards []gamedata.BattleReward - var equipmentReward *gamedata.Reward - for i := range designRewards { - reward := designRewards[i] - switch reward.Type { - case 2, 3, 4, 12, 20: - if reward.Count == 0 { - return nil, nil, errors.New("world: zero currency reward") - } - case 10: - if reward.ID == 0 || equipmentReward != nil { - return nil, nil, errors.New("world: invalid equipment reward") - } - equipmentReward = &reward - case 11: - // Quest 26's character/costume instances come from the versioned - // story seed and are encoded below; they are not stackable items. - if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID { - continue - } - if s.collection == nil || s.questCostumes == nil { - return nil, nil, fmt.Errorf("world: costume reward service unavailable") - } - if _, ok := s.questCostumes.Character(reward.ID); !ok { - return nil, nil, fmt.Errorf("world: missing quest costume %d", reward.ID) - } - default: - if reward.ID == 0 || reward.Count == 0 { - return nil, nil, fmt.Errorf("world: invalid item reward type=%d id=%d count=%d", reward.Type, reward.ID, reward.Count) - } - itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 - } - } - var costumeIDs []uint64 - for _, reward := range designRewards { - if reward.Type == 11 && !(packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 && reward.ID == s.seed.RewardCostume.ID) { //nolint:staticcheck // QF1001 - costumeIDs = append(costumeIDs, reward.ID) - } - } - if len(costumeIDs) != 0 { - grant, err := s.collection.GrantCostumes(identity+":costumes", costumeIDs, s.questCostumes) - if err != nil { - return nil, nil, fmt.Errorf("world: grant quest costume: %w", err) - } - var exchanges []gamedata.Reward - for _, exchange := range grant.Exchanges { - if exchange.ExchangeItemType != 20 { - return nil, nil, fmt.Errorf("world: unsupported quest costume exchange type %d", exchange.ExchangeItemType) - } - exchanges = append(exchanges, gamedata.Reward{Type: exchange.ExchangeItemType, ID: exchange.ExchangeItemID, Count: exchange.ExchangeCount}) - } - if len(exchanges) > 0 { - if s.wallet == nil { - return nil, nil, fmt.Errorf("world: quest exchange wallet unavailable") - } - if _, err := s.wallet.GrantQuestOnce(identity+":costumes:exchange", exchanges); err != nil { - return nil, nil, err - } - } - } - if s.questDifficultyFor(packID, quest) == 0 { - quests, known := s.questsFor(packID) - if !known { - return nil, nil, fmt.Errorf("world: unknown collection reward pack%d", packID) - } - for _, reward := range quests[quest].CollectionRewards { - itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count}) //nolint:staticcheck // S1016 - } - } - var items []player.Item - if len(itemRewards) != 0 { - if s.inventory == nil { - return nil, nil, errors.New("world: inventory unavailable") - } - var err error - items, err = s.inventory.GrantOnce(identity+":items", itemRewards) - if err != nil { - return nil, nil, fmt.Errorf("world: grant quest items: %w", err) - } - if len(items) == 0 { - items = s.inventory.GrantedItems(identity + ":items") - } - } - var equipment *player.Equipment - if equipmentReward != nil { - if s.equipment == nil { - return nil, nil, errors.New("world: equipment inventory unavailable") - } - entry, err := s.equipment.GrantOnce(fmt.Sprintf("%s:equip%d", identity, equipmentReward.ID), equipmentReward.ID) - if err != nil { - return nil, nil, fmt.Errorf("world: grant quest equipment: %w", err) - } - equipment = &entry - } - return items, equipment, nil -} - -// packInfo is the canonical protobuf encoding of the semantic new-account -// starter-pack state. Its response is generated from the authoritative local progress state. -func (s *Service) packInfo() ([]byte, error) { - return s.packInfoFor(s.seed.PackID) -} - -func (s *Service) packInfoFor(packID int) ([]byte, error) { - out, err := s.basePackInfoFor(packID) - if err != nil { - return out, err - } - buffs, err := s.fieldBuffInfo() - if err != nil { - return nil, err - } - out = append(out, buffs...) - if s.huntingGround == nil { - return out, nil - } - ground, err := s.huntingGround.EnsureForPack(packID) - if err != nil { - return nil, err - } - if len(ground) == 0 { - return out, nil - } - out, _, err = wire.ReplaceBytes(out, 12, ground) - return out, err -} - -func (s *Service) basePackInfoFor(packID int) ([]byte, error) { - var out []byte - if active := s.firstUnclearedQuestFor(packID); active != 0 { - quest := s.questInfoWire(packID, active) - chars, _, err := s.resolveActivePartyWires(packID, active) - if err != nil { - return nil, err - } - for _, char := range chars { - out = wire.AppendBytes(out, 1, char) - } - out = wire.AppendBytes(out, 2, quest) - } - for _, quest := range s.activeSideQuestWires(packID) { - out = wire.AppendBytes(out, 2, quest) - } - cleared := s.state.ClearedQuests(packID, s.questDifficulty(packID)) - // CommonPacket requests TodayQuestInfo after parsing this response, before - // the waypoint callback calls PackManager.Enter. That separate response owns - // commission restoration; including commissions here lets Enter append them - // a second time when TodayQuestInfo arrives first, crashing the quest HUD. - if len(cleared) != 0 { - var packed []byte - for _, id := range cleared { - packed = binary.AppendUvarint(packed, uint64(id)) - } - out = wire.AppendBytes(out, 3, packed) - } - position := "{}" - mapID := 0 - restored := false - if saved, found := s.state.Position(); found && saved.PackID == packID && saved.Difficulty == s.questDifficulty(packID) && saved.RawJSON != "" { - if pack, arena := s.fieldPacks[packID]; arena && !pack.MapIDs[saved.Position.MapID] { - return nil, fmt.Errorf("world: saved map %d does not belong to arena pack %d", saved.Position.MapID, packID) - } - position = saved.RawJSON - mapID = saved.Position.MapID - restored = true - } - slog.Info("world: deliver field position", "pack", packID, "map", mapID, "restored", restored) - out = wire.AppendString(out, 4, position) - if s.npcReputation != nil { - rows, err := s.npcReputationRows(packID) - if err != nil { - return nil, err - } - for _, row := range rows { - out = wire.AppendBytes(out, 9, row) - } - } - // The remaining starter-only records were observed in the official - // starter-pack response. They represent reputation, hunting-ground, statue, - // and reward state, not generic defaults, so a newly entered later pack must - // not inherit them. - if packID != s.seed.PackID { - visit := wire.AppendVarint(nil, 5, uint64(packID)) - return wire.AppendBytes(out, 12, visit), nil - } - for _, state := range s.seed.InitialReputations { - if s.npcReputation != nil { - break - } - row := wire.AppendVarint(nil, 1, state.GroupID) - row = wire.AppendVarint(row, 2, state.State) - if state.ElapsedSeconds != 0 { - row = wire.AppendVarint(row, 3, state.ElapsedSeconds) - } - out = wire.AppendBytes(out, 9, row) - } - visit := wire.AppendVarint(nil, 5, uint64(packID)) - out = wire.AppendBytes(out, 12, visit) - for _, state := range s.seed.InitialRankStatues { - row := wire.AppendVarint(nil, 1, state.ID) - row = wire.AppendVarint(row, 2, state.Season) - if state.Error { - row = wire.AppendVarint(row, 3, 1) - } - out = wire.AppendBytes(out, 14, row) - } - if s.packJamDesign == nil { - return out, nil - } - if err := s.packJamDesign.ValidateReward(); err != nil { - return nil, err - } - reward := wire.AppendVarint(nil, 3, s.packJamDesign.Reward.Type) - reward = wire.AppendVarint(reward, 4, s.packJamDesign.Reward.Count) - group := wire.AppendBytes(nil, 1, reward) - group = wire.AppendBytes(group, 6, reward) - return wire.AppendBytes(out, 16, group), nil -} - -func (s *Service) firstUnclearedQuestFor(packID int) int { - if s.storyCatalog == nil { - return 0 - } - for _, id := range s.storyCatalog.Packs[packID].MainQuestIDs { - if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) { - return id - } - } - return 0 -} - -func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Reward, items []player.Item, questEquipment *player.Equipment, nextItems []player.Item, nextChars [][]byte) []byte { - var rewards []byte - if s.collection != nil { - if grant, found := s.collection.Grant(questRewardIdentity(packID, quest, s.questDifficultyFor(packID, quest)) + ":costumes"); found { - rewards = append(rewards, player.CollectionRewardBundle(s.collection, grant)...) - } - } - for _, reward := range designRewards { - if reward.Type != 2 && reward.Type != 3 && reward.Type != 4 && reward.Type != 12 && reward.Type != 20 { - continue - } - currency := wire.AppendVarint(nil, 3, reward.Type) - currency = wire.AppendVarint(currency, 4, reward.Count) - rewards = wire.AppendBytes(rewards, 1, currency) - } - for _, item := range items { - entry := player.ItemWire(item) - if item.Type == 17 { - pictorial := wire.AppendVarint(nil, 1, 5) - pictorial = wire.AppendVarint(pictorial, 2, item.ID) - entry = wire.AppendBytes(entry, 7, pictorial) - } - rewards = wire.AppendBytes(rewards, 1, entry) - view := wire.AppendVarint(nil, 2, item.ID) - view = wire.AppendVarint(view, 3, item.Type) - view = wire.AppendVarint(view, 4, item.Count) - rewards = wire.AppendBytes(rewards, 6, view) - } - if questEquipment != nil { - // RewardDBInfoBundle field 4 is EquipDBInfo. Equipment is an instance, - // not an ItemDBInfo with a fabricated stack count. - rewards = wire.AppendBytes(rewards, 4, player.EquipmentWire(*questEquipment)) - } - if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 { - rewardCharacter := encodeCharacter(s.seed.RewardCharacter) - rewards = wire.AppendBytes(rewards, 2, rewardCharacter) - costume := encodeCostume(s.seed.RewardCostume) - rewards = wire.AppendBytes(rewards, 3, costume) - for _, character := range s.seed.StoryCharacters { - view := wire.AppendVarint(nil, 2, character.ID) - view = wire.AppendVarint(view, 3, 6) - rewards = wire.AppendBytes(rewards, 6, view) - } - viewCostume := wire.AppendVarint(nil, 2, s.seed.RewardCostume.ID) - viewCostume = wire.AppendVarint(viewCostume, 3, 11) - rewards = wire.AppendBytes(rewards, 6, viewCostume) - viewCharacter := wire.AppendVarint(nil, 2, s.seed.RewardCharacter.ID) - viewCharacter = wire.AppendVarint(viewCharacter, 3, 6) - viewCharacter = wire.AppendVarint(viewCharacter, 4, 1) - rewards = wire.AppendBytes(rewards, 6, viewCharacter) - } - var out []byte - out = wire.AppendBytes(out, 1, rewards) - next := s.nextQuestFor(packID, quest) - if s.storyCatalog.Packs[packID].Quests[quest].Type != 0 { - next = 0 - } - if next != 0 { - out = wire.AppendBytes(out, 2, s.questInfoWire(packID, next)) - } else { - // QuestClearResponse.QuestInfo is dereferenced by the client - // even when this is the final quest of a pack. An explicitly present, - // empty QuestDBInfo gives that generated protobuf property a non-null - // object whose Id is the client-recognized zero sentinel. Omitting the - // field parses as null and makes the completion coroutine throw before - // it can mark the pack complete. A final-pack official capture has not - // yet been obtained, so this exact wire choice remains marked for parity - // verification even though its client behavior is deterministic. - out = wire.AppendBytes(out, 2, nil) - } - out = wire.AppendVarint(out, 3, uint64(quest)) - for _, item := range nextItems { - out = wire.AppendBytes(out, 6, player.ItemWire(item)) - } - if s.storyCatalog.Packs[packID].Quests[quest].Type == 0 && next == 0 && s.packCompleteFor(packID) { - // The final normal quest unlocks PackTable.NextPackId. Without these - // PackDBInfo updates the client cannot find the next story pack and - // falls back to presenting the hard-difficulty objective. - for _, info := range s.packDBInfoRows() { - out = wire.AppendBytes(out, 11, info) - } - } - // Receiving a character is not a request to change the saved formation. - out = s.appendCurrentBattleDeck(out, 4) - for _, char := range nextChars { - out = wire.AppendBytes(out, 5, char) - } - out = wire.AppendBytes(out, 9, s.questLevelInfoWire(packID, s.questDifficultyFor(packID, quest))) - out = wire.AppendBytes(out, 12, nil) - return wire.AppendBytes(out, 13, nil) -} - -func (s *Service) packCompleteFor(packID int) bool { - if s.storyCatalog == nil { - return false - } - ids := s.storyCatalog.Packs[packID].MainQuestIDs - if len(ids) == 0 { - return false - } - for _, id := range ids { - if !s.state.QuestCleared(id, packID, s.questDifficulty(packID)) { - return false - } - } - return true -} - -func (s *Service) packDBInfoRows() [][]byte { - if s.storyCatalog == nil { - return nil - } - return s.storyPackDBInfoRows() -} - -func (s *Service) accountPackInfo() []byte { - var out []byte - for _, info := range s.packDBInfoRows() { - out = wire.AppendBytes(out, 1, info) - } - for _, info := range s.packDBInfoRows() { - id, _, _ := wire.Varint(info, 1) - for level := 0; level <= 4; level++ { - if len(s.state.ClearedQuests(int(id), level)) > 0 || s.questDifficulty(int(id)) == level { - out = wire.AppendBytes(out, 2, s.questLevelInfoWire(int(id), level)) - } - } - } - if s.seed.SquareSceneID != 0 { - out = wire.AppendVarint(out, 5, s.seed.SquareSceneID) - } - return out -} - -// PictorialCharacters hides quest-26 rewards until they are earned, even -// though their instances already exist in the versioned world seed. -func (s *Service) PictorialCharacters() []player.Character { - if !s.tutorialRosterRestricted() && s.characters != nil { - var permanent []player.Character - for _, c := range s.visibleOwnedCharacters(s.characters.RawAll()) { - if !player.IsCharmCharacter(c) { - permanent = append(permanent, c) - } - } - return permanent - } - return append([]player.Character(nil), s.starter.Characters...) -} - -func (s *Service) PictorialCostumes() []player.Costume { - result := append([]player.Costume(nil), s.starter.Costumes...) - if s.collection != nil { - result = s.collection.Costumes() - } - if s.collection == nil && !s.tutorialRosterRestricted() && s.startingPack() == s.seed.PackID { - result = append(result, s.seed.RewardCostume) - } - return result -} - -func (s *Service) PictorialItems() []player.Item { - var result []player.Item - if s.inventory != nil { - result = s.inventory.All() - } else { - result = append(result, s.starter.Items...) - } - return result -} - -func (s *Service) PictorialEquipment() []player.Equipment { - if s.equipment == nil { - return nil - } - return s.equipment.All() -} - -func (s *Service) PictorialDiscovered() []player.Pictorial { - return append([]player.Pictorial(nil), s.starter.Pictorialbook...) -} - -func encodeCostume(c player.Costume) []byte { - return player.CostumeWire(c) -} - -func (s *Service) nextQuestFor(packID, current int) int { - quests, found := s.questsFor(packID) - if !found { - return 0 - } - return quests[current].NextQuestID -} - -func encodeCharacter(c player.Character) []byte { - return player.CharacterWire(c) -} diff --git a/go/internal/server/world/world_test.go b/go/internal/server/world/world_test.go deleted file mode 100644 index 6de21b1..0000000 --- a/go/internal/server/world/world_test.go +++ /dev/null @@ -1,465 +0,0 @@ -package world - -import ( - "bytes" - "encoding/binary" - "sort" - "testing" - - "bd2server/internal/server/deck" - "bd2server/internal/server/gamedata" - "bd2server/internal/server/player" - "bd2server/internal/server/progress" - "bd2server/internal/server/stateio" - "bd2server/internal/server/wire" -) - -// Legacy regression fixtures define their own linear IDs. Production uses -// GameData links; this helper gives those small test fixtures explicit links. -func attachTestStoryCatalog(s *Service) { - packs := s.packs - if packs == nil { - packs = map[int]map[int]gamedata.QuestDesign{s.seed.PackID: s.quests} - } - s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{}} - for id, quests := range packs { - var ids []int - for qid := range quests { - ids = append(ids, qid) - } - sort.Ints(ids) - for i, qid := range ids { - q := quests[qid] - q.Type = 0 - if i > 0 { - q.PriorQuestID = ids[i-1] - } - if i+1 < len(ids) { - q.NextQuestID = ids[i+1] - } - quests[qid] = q - } - s.storyCatalog.Packs[id] = gamedata.StoryPack{ID: id, Quests: quests, MainQuestIDs: ids} - } - if s.transition.NextPackID != 0 { - if _, exists := s.storyCatalog.Packs[s.transition.NextPackID]; !exists { - s.storyCatalog.Packs[s.transition.NextPackID] = gamedata.StoryPack{ID: s.transition.NextPackID} - } - } -} - -func testService() *Service { - s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}} - s.seed.SquareSceneID = 3 - s.seed.InitialReputations = []InitialReputation{{GroupID: 1, State: 1}} - s.seed.InitialRankStatues = []InitialRankStatue{{ID: 3, Season: 77, Error: true}} - s.packJamDesign = &gamedata.PackJamDesign{Reward: gamedata.Reward{Type: 3, Count: 150}} - attachTestStoryCatalog(s) - return s -} - -func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { - state := progress.NewStore() - quests := make(map[int]gamedata.QuestDesign) - for id := 1; id <= 30; id++ { - quests[id] = gamedata.QuestDesign{ID: id} - if id < 29 { - if err := state.ClearQuest(id, 21); err != nil { - t.Fatal(err) - } - } - } - decks, err := deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{ - {Slot: 1, CharacterInvenIndex: 535607162}, - }}) - if err != nil { - t.Fatal(err) - } - save := wire.AppendVarint(nil, 1, 1) - want := []uint64{535607162, 535607159, 535607160, 535607161, 535604120} - for i, index := range want { - entry := wire.AppendVarint(nil, 1, index) - entry = wire.AppendVarint(entry, 2, uint64(i+1)) - entry = wire.AppendVarint(entry, 3, uint64(i+1)) - save = wire.AppendBytes(save, 2, entry) - } - if _, _, ok, err := decks.Handle("/DeckSave", save); err != nil || !ok { - t.Fatalf("deck save ok=%v err=%v", ok, err) - } - s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state, - starter: &player.Starter{Version: "2.35.10"}, quests: quests, decks: decks} - attachTestStoryCatalog(s) - request := wire.AppendVarint(nil, 1, 2) - request = wire.AppendVarint(request, 2, 29) - request = wire.AppendVarint(request, 3, 21) - _, response, ok, err := s.Handle("/QuestClear", request) - if err != nil || !ok { - t.Fatalf("quest clear ok=%v err=%v", ok, err) - } - var got []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 4 { - index, _, _ := wire.Varint(field.Value, 1) - got = append(got, index) - } - return nil - }); err != nil { - t.Fatal(err) - } - if len(got) != len(want) { - t.Fatalf("deck rows=%v want=%v", got, want) - } - for i := range want { - if got[i] != want[i] { - t.Fatalf("deck=%v want=%v", got, want) - } - } -} - -func TestInitialPackInfoMatchesVersionedProtocol(t *testing.T) { - s := testService() - request := wire.AppendVarint(nil, 1, 89) - request = wire.AppendVarint(request, 2, 21) - code, got, ok, err := s.Handle("/PackInGameInfo", request) - if err != nil || !ok || code != 5 { - t.Fatalf("Handle: code=%d ok=%v err=%v", code, ok, err) - } - want := []byte{0x12, 4, 8, 1, 0x30, 0x15, 0x22, 2, '{', '}', 0x4a, 4, 8, 1, 0x10, 1, 0x62, 2, 0x28, 0x15, 0x72, 6, 8, 3, 0x10, 0x4d, 0x18, 1, 0x82, 1, 0x0e, 0x0a, 5, 0x18, 3, 0x20, 0x96, 1, 0x32, 5, 0x18, 3, 0x20, 0x96, 1} - if !bytes.Equal(got, want) { - t.Fatalf("pack proto mismatch\ngot %x\nwant %x", got, want) - } -} - -func TestQuestClearAdvancesAndPersists(t *testing.T) { - s := testService() - request := wire.AppendVarint(nil, 1, 107) - request = wire.AppendVarint(request, 2, 1) - request = wire.AppendVarint(request, 3, 21) - code, response, ok, err := s.Handle("/QuestClear", request) - if err != nil || !ok || code != 18 { - t.Fatalf("first clear: code=%d ok=%v err=%v", code, ok, err) - } - cleared, found, err := wire.Varint(response, 3) - if err != nil || !found || cleared != 1 { - t.Fatalf("clear echo: %d %v %v", cleared, found, err) - } - next, found, err := wire.Bytes(response, 2) - if err != nil || !found { - t.Fatalf("next: %v", err) - } - id, found, err := wire.Varint(next, 1) - if err != nil || !found || id != 2 { - t.Fatalf("next id=%d found=%v err=%v", id, found, err) - } - if !s.state.QuestCleared(1, 21) { - t.Fatal("clear was not stored") - } - var deckRows int - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 4 { - deckRows++ - } - return nil - }); err != nil { - t.Fatal(err) - } - if deckRows != 0 { - t.Fatalf("ordinary quest clear unexpectedly replaced deck with %d rows", deckRows) - } - packRequest := wire.AppendVarint(nil, 1, 200) - packRequest = wire.AppendVarint(packRequest, 2, 21) - _, restored, _, err := s.Handle("/PackInGameInfo", packRequest) - if err != nil { - t.Fatal(err) - } - active, found, err := wire.Bytes(restored, 2) - if err != nil || !found { - t.Fatalf("restored active quest missing: %v", err) - } - activeID, found, err := wire.Varint(active, 1) - if err != nil || !found || activeID != 2 { - t.Fatalf("restored active quest=%d found=%v err=%v", activeID, found, err) - } - clearedIDs, found, err := wire.Bytes(restored, 3) - clearedID, count := binary.Uvarint(clearedIDs) - if err != nil || !found || count <= 0 || clearedID != 1 { - t.Fatalf("restored cleared quests=%x found=%v err=%v", clearedIDs, found, err) - } - request = wire.AppendVarint(nil, 1, 108) - request = wire.AppendVarint(request, 2, 3) - request = wire.AppendVarint(request, 3, 21) - if _, _, _, err := s.Handle("/QuestClear", request); err == nil { - t.Fatal("out-of-order clear succeeded") - } -} - -func TestFinalQuestClearIncludesEmptyNextQuestInfo(t *testing.T) { - // The client completion coroutine unconditionally evaluates - // QuestClearResponse.QuestInfo.Id. An absent protobuf field becomes a - // null C# reference; an explicitly present zero-length message becomes a - // non-null QuestDBInfo with ID 0, satisfying the client's final-quest - // sentinel path. Exact official final-pack wire parity is still unverified. - s := &Service{ - seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 38}, - state: progress.NewStore(), - starter: &player.Starter{Version: "2.35.10"}, - quests: map[int]gamedata.QuestDesign{38: {ID: 38}}, - transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}, - } - attachTestStoryCatalog(s) - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 38) - request = wire.AppendVarint(request, 3, 21) - _, response, ok, err := s.Handle("/QuestClear", request) - if err != nil || !ok { - t.Fatalf("final clear ok=%v err=%v", ok, err) - } - var nextCount int - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number != 2 { - return nil - } - nextCount++ - if len(field.Value) != 0 { - t.Fatalf("final next QuestInfo=%x, want explicitly empty message", field.Value) - } - return nil - }); err != nil { - t.Fatal(err) - } - if nextCount != 1 { - t.Fatalf("final clear has %d QuestInfo fields, want one empty message", nextCount) - } - var packs []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 11 { - id, _, _ := wire.Varint(field.Value, 1) - packs = append(packs, id) - } - return nil - }); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 { - t.Fatalf("final pack transition=%v err=%v", packs, err) - } -} - -func TestPackInfoRestoresCompletedPackAndUnlockedNextPack(t *testing.T) { - state := progress.NewStore() - for _, id := range []int{1, 2} { - if err := state.ClearQuest(id, 21); err != nil { - t.Fatal(err) - } - } - s := &Service{seed: Seed{Version: "2.35.10", PackID: 21}, state: state, - quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}} - attachTestStoryCatalog(s) - code, response, handled, err := s.Handle("/PackInfo", wire.AppendVarint(nil, 1, 1)) - if err != nil || !handled || code != 4 { - t.Fatalf("PackInfo code=%d handled=%v err=%v", code, handled, err) - } - var packs []uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 { - id, _, _ := wire.Varint(field.Value, 1) - packs = append(packs, id) - if id == 22 { - bought, _, _ := wire.Varint(field.Value, 8) - if bought != 0 { - t.Fatalf("newly unlocked pack22 is already marked bought") - } - } - } - return nil - }); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 { - t.Fatalf("PackInfo packs=%v err=%v", packs, err) - } -} - -func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) { - state := progress.NewStore() - if err := state.ClearQuest(1, 21); err != nil { - t.Fatal(err) - } - pack21 := map[int]gamedata.QuestDesign{1: {ID: 1}} - pack22 := map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}} - s := &Service{ - seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, - state: state, - starter: &player.Starter{Version: "2.35.10"}, - quests: pack21, - packs: map[int]map[int]gamedata.QuestDesign{21: pack21, 22: pack22}, - transitions: map[int]gamedata.PackTransition{ - 21: {PackID: 21, NextPackID: 22}, - 22: {PackID: 22}, - }, - } - attachTestStoryCatalog(s) - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 22) - code, response, handled, err := s.Handle("/PackInGameInfo", request) - if err != nil || !handled || code != 5 { - t.Fatalf("pack22 init code=%d handled=%v err=%v", code, handled, err) - } - currentPack, err := s.CurrentPackID() - if err != nil || currentPack != 22 { - t.Fatalf("current pack after pack22 init=%d err=%v", currentPack, err) - } - active, found, err := wire.Bytes(response, 2) - if err != nil || !found { - t.Fatalf("pack22 active quest missing: %v", err) - } - id, _, _ := wire.Varint(active, 1) - packID, _, _ := wire.Varint(active, 6) - if id != 1 || packID != 22 { - t.Fatalf("pack22 active quest id=%d pack=%d", id, packID) - } - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number == 1 || field.Number == 9 || field.Number == 14 || field.Number == 16 { - t.Fatalf("pack22 inherited pack21-only field %d", field.Number) - } - return nil - }); err != nil { - t.Fatal(err) - } - clear := wire.AppendVarint(nil, 1, 2) - clear = wire.AppendVarint(clear, 2, 1) - clear = wire.AppendVarint(clear, 3, 22) - if _, _, _, err := s.Handle("/QuestClear", clear); err != nil { - t.Fatal(err) - } - if !state.QuestCleared(1, 21) || !state.QuestCleared(1, 22) { - t.Fatal("quest 1 did not remain independently cleared in pack21 and pack22") - } -} - -func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) { - state := progress.NewStore() - quests := make(map[int]gamedata.QuestDesign) - for id := 1; id <= 29; id++ { - quests[id] = gamedata.QuestDesign{ID: id} - if id < 28 { - if err := state.ClearQuest(id, 21); err != nil { - t.Fatal(err) - } - } - } - storage := stateio.NewMemory() - equipment, err := player.OpenEquipmentInventory(storage) - if err != nil { - t.Fatal(err) - } - starter := &player.Starter{Version: "2.35.10"} - inventory, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - entry := quests[28] - entry.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}, {Type: 10, ID: 10010}} - quests[28] = entry - s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state, - starter: starter, equipment: equipment, inventory: inventory, quests: quests} - attachTestStoryCatalog(s) - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 28) - request = wire.AppendVarint(request, 3, 21) - code, response, ok, err := s.Handle("/QuestClear", request) - if err != nil || !ok || code != 18 { - t.Fatalf("clear: code=%d ok=%v err=%v", code, ok, err) - } - bundle, found, err := wire.Bytes(response, 1) - if err != nil || !found { - t.Fatalf("reward bundle: %v", err) - } - encoded, found, err := wire.Bytes(bundle, 4) - if err != nil || !found { - t.Fatalf("equipment reward: %v", err) - } - base, found, err := wire.Bytes(encoded, 5) - if err != nil || !found { - t.Fatalf("equipment base: %v", err) - } - id, found, err := wire.Varint(base, 1) - if err != nil || !found || id != 10010 { - t.Fatalf("equipment id=%d found=%v err=%v", id, found, err) - } -} - -func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) { - storage := stateio.NewMemory() - state := progress.NewStore() - quests := make(map[int]gamedata.QuestDesign) - for id := 1; id <= 27; id++ { - quests[id] = gamedata.QuestDesign{ID: id} - if id < 27 { - if err := state.ClearQuest(id, 21); err != nil { - t.Fatal(err) - } - } - } - design := quests[27] - design.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}} - quests[27] = design - wallet, err := player.OpenWallet(storage, player.Currency{}) - if err != nil { - t.Fatal(err) - } - s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state, - starter: &player.Starter{Version: "2.35.10"}, wallet: wallet, quests: quests} - attachTestStoryCatalog(s) - request := wire.AppendVarint(nil, 1, 1) - request = wire.AppendVarint(request, 2, 27) - request = wire.AppendVarint(request, 3, 21) - _, response, _, err := s.Handle("/QuestClear", request) - if err != nil { - t.Fatal(err) - } - if got := wallet.Snapshot().FreeJewelry; got != 70 { - t.Fatalf("free jewelry=%d", got) - } - bundle, _, _ := wire.Bytes(response, 1) - item, _, _ := wire.Bytes(bundle, 1) - typ, _, _ := wire.Varint(item, 3) - count, _, _ := wire.Varint(item, 4) - if typ != 3 || count != 70 { - t.Fatalf("currency type=%d count=%d", typ, count) - } -} - -func TestPackInfoDoesNotEmitFixedRewardRosterWithoutActiveFormation(t *testing.T) { - storage := stateio.NewMemory() - starter := &player.Starter{Version: "2.35.10"} - inventory, err := player.OpenInventory(storage, starter) - if err != nil { - t.Fatal(err) - } - characters, err := player.OpenCharacterStore(storage, - []player.Character{{InvenIndex: 77, ID: 350, Level: 20}}, inventory, "", "") - if err != nil { - t.Fatal(err) - } - state := progress.NewStore() - if err := state.ClearQuest(26, 21); err != nil { - t.Fatal(err) - } - s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: player.Character{InvenIndex: 77, ID: 350, Level: 1}}, state: state, starter: starter, characters: characters} - attachTestStoryCatalog(s) - response, err := s.packInfo() - if err != nil { - t.Fatal(err) - } - var level uint64 - if err := wire.Walk(response, func(field wire.Field) error { - if field.Number != 1 { - return nil - } - index, _, _ := wire.Varint(field.Value, 1) - if index == 77 { - level, _, _ = wire.Varint(field.Value, 4) - } - return nil - }); err != nil { - t.Fatal(err) - } - if level != 0 { - t.Fatalf("fixed reward character leaked without an authored active formation: level=%d", level) - } -} diff --git a/plugins/CaptureEnvironment/Plugin.cs b/plugins/CaptureEnvironment/Plugin.cs index c9f704f..f97a28c 100644 --- a/plugins/CaptureEnvironment/Plugin.cs +++ b/plugins/CaptureEnvironment/Plugin.cs @@ -1,7 +1,6 @@ using System; using System.IO; using System.Text; -using BD2.GameNames; using BepInEx; using HarmonyLib; using UnityEngine; @@ -20,7 +19,6 @@ public sealed class Plugin : BaseUnityPlugin { try { - Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message)); CaptureStorageIsolation.Initialize(Paths.GameRootPath, Logger); CaptureWriter.Initialize(Paths.GameRootPath, Logger); diff --git a/plugins/CashShop/Plugin.cs b/plugins/CashShop/Plugin.cs index 73ac346..906572b 100644 --- a/plugins/CashShop/Plugin.cs +++ b/plugins/CashShop/Plugin.cs @@ -56,7 +56,6 @@ public sealed class Plugin : BaseUnityPlugin try { Log = Logger; - Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message)); harmony = new Harmony("bd2.cashshop"); Patch(typeof(PlatformManager), "Purchase", nameof(Purchase), false, 3); Patch(typeof(PlatformManager), "FinishPurchase", nameof(Suppress), false, 2); diff --git a/plugins/GameNames/Game.cs b/plugins/GameNames/Game.cs index 5609ab8..7a0c174 100644 --- a/plugins/GameNames/Game.cs +++ b/plugins/GameNames/Game.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.IO.Compression; using System.Linq; @@ -28,13 +29,13 @@ public static class Game if (Table.schema_version != 1) throw new InvalidDataException("BD2.GameNames: unsupported table schema"); Types = Table.types.ToDictionary(t => t.readable, StringComparer.Ordinal); Tokens = Table.members.ToDictionary(m => m.token); + ValidateGame(Table); } } private static readonly Lazy Names = new(() => new Index()); private static readonly ConcurrentDictionary TypeCache = new(); private static readonly ConcurrentDictionary MemberCache = new(); - private static readonly object ValidationLock = new(); - private static string ValidatedStamp; + static Game() => _ = Names.Value; /// Gets the game version embedded in this package. public static string GameVersion => Names.Value.Table.game_version; @@ -185,59 +186,32 @@ public static class Game public static EventInfo GetGameEvent(this Type type, string readable, BindingFlags flags) => Cached(type, "event", readable, flags, null, () => Single(type.GetEvents(flags).Where(m => Matches(m, "event", readable)), type, readable)); - /// Validate plugin/table version, binary fingerprint and known metadata entries before installing patches. - public static void Validate(Assembly plugin, Action log = null) => Validate(plugin, GameVersion, log); - - /// Validates only the game binary against the embedded table for plugins that do not use the compiler SDK. - public static void ValidateGame(Action log = null) => ValidateCore(null, GameVersion, log); - - /// Validates a compiler SDK plugin, its expected game version, the table and the game binary. Throws on mismatch. - public static void Validate(Assembly plugin, string expectedVersion, Action log) + internal static void InitializePlugin(string expectedStamp) { - if (plugin == null) throw new ArgumentNullException(nameof(plugin)); - if (expectedVersion == null) throw new ArgumentNullException(nameof(expectedVersion)); - ValidateCore(plugin, expectedVersion, log); + NameTable table = Names.Value.Table; + string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256; + if (expectedStamp != stamp) + throw new InvalidDataException("BD2.GameNames: plugin/table mismatch; rebuild the plugin and deploy the matching BD2.GameNames.dll"); } - private static void ValidateCore(Assembly plugin, string expectedVersion, Action log) + private static void ValidateGame(NameTable table) { - try - { - NameTable table = Names.Value.Table; - string stamp = table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256; - AssemblyMetadataAttribute metadata = plugin?.GetCustomAttributes().SingleOrDefault(a => a.Key == "BD2.GameNames"); - if (expectedVersion != table.game_version || plugin != null && metadata?.Value != stamp) - throw new InvalidDataException("plugin/table mismatch; rebuild the plugin and BD2.GameNames together"); - lock (ValidationLock) - { - if (ValidatedStamp != stamp) - { - Assembly assembly = GameAssembly(); - if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid) - throw new InvalidDataException("Assembly-CSharp MVID mismatch"); - using (var sha = SHA256.Create()) - using (FileStream stream = File.OpenRead(assembly.Location)) - { - string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); - if (actual != table.assembly_sha256) throw new InvalidDataException("Assembly-CSharp SHA-256 mismatch"); - } - const BindingFlags all = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; - Type app = FindType("AppManager") ?? throw new TypeLoadException("AppManager"); - Type intro = FindType("IntroUI") ?? throw new TypeLoadException("IntroUI"); - Type network = FindType("BDNetwork.NetworkManager") ?? throw new TypeLoadException("BDNetwork.NetworkManager"); - if (app.GetGameProperty("IsPlatformLogin", all)?.PropertyType != typeof(bool) || - intro.GetGameMethod("SendMaintenanceInfo", all, null, [typeof(bool)], null) == null || - network.GetGameMethod("GetPachedGameDataPath", all, null, Type.EmptyTypes, null)?.ReturnType != typeof(string)) - throw new MissingMemberException("known game-name probes failed"); - ValidatedStamp = stamp; - } - } - log?.Invoke("BD2.GameNames self-check passed: game=" + table.game_version + ", MVID=" + table.assembly_mvid); - } - catch (Exception ex) - { - log?.Invoke("BD2.GameNames SELF-CHECK FAILED: expected game=" + expectedVersion + "; " + ex.Message + "; game patches will not be installed"); - throw; - } + Assembly assembly = GameAssembly(); + if (assembly.ManifestModule.ModuleVersionId.ToString() != table.assembly_mvid) + throw new InvalidDataException("BD2.GameNames: Assembly-CSharp MVID mismatch; deploy the SDK and plugin for this game version"); + using var sha = SHA256.Create(); + using FileStream stream = File.OpenRead(assembly.Location); + string actual = BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", "").ToLowerInvariant(); + if (actual != table.assembly_sha256) + throw new InvalidDataException("BD2.GameNames: Assembly-CSharp SHA-256 mismatch; deploy the SDK and plugin for this game version"); } } + +/// Runtime entry point used by the module initializer inserted by the SDK. +[EditorBrowsable(EditorBrowsableState.Never)] +public static class RuntimeCompatibility +{ + /// Checks the plugin's build fingerprint against the installed runtime and game. + [EditorBrowsable(EditorBrowsableState.Never)] + public static void InitializePlugin(string expectedStamp) => Game.InitializePlugin(expectedStamp); +} diff --git a/plugins/GameNames/README.md b/plugins/GameNames/README.md index 77bc0fc..f1fc222 100644 --- a/plugins/GameNames/README.md +++ b/plugins/GameNames/README.md @@ -12,7 +12,6 @@ Brown Dust II 插件的共享名字解析运行时,目标框架为 `.NET Stand using BD2.GameNames; using System.Reflection; -Game.Validate(typeof(MyPlugin).Assembly, message => Logger.LogInfo(message)); var intro = Game.FindType("IntroUI"); var enter = intro?.GetGameMethod("Enter", BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null); @@ -20,7 +19,7 @@ var enter = intro?.GetGameMethod("Enter", BindingFlags.Instance | BindingFlags.N 未知名字保留字面名;查不到成员返回 null,歧义抛出 `AmbiguousMatchException`。表中私有/编译器生成成员可通过字符串通道解析,公开调用可使用 `BD2.GameSdk` 生成的壳接受编译器检查。`nameof` 和协程字符串需显式经过名字表,普通字符串不会被 reobf 改写。 -单独引用本包的插件需使用 `Game.ValidateGame` 校验游戏和内嵌表,`Game.Validate` 还会校验 SDK 在插件中写入的编译指纹。运行环境只需要本插件的 DLL。同一客户端只安装一份 `BD2.GameNames.dll`,不同表指纹的插件会在启动时明确报错。 +首次调用 `Game` API 时自动核对内嵌表与实际游戏 DLL 的 MVID 和 SHA-256;每个进程只核对一次。配套 SDK 还会在 reobf 时自动写入模块初始化器,在插件代码执行前核对插件编译指纹与运行时名字表,无需编写启动校验代码。同一客户端只安装一份 `BD2.GameNames.dll`,指纹不匹配会抛出明确异常并阻止插件继续初始化。 单独引用本包的 SDK-style 类库在 `.csproj` 中设置 `true`,让运行时 DLL 复制到 `bin`;`BD2.GameSdk` 会自动设置此默认值。 diff --git a/plugins/GameSdk/GameSdk.csproj b/plugins/GameSdk/GameSdk.csproj index 1dafcb7..84fda02 100644 --- a/plugins/GameSdk/GameSdk.csproj +++ b/plugins/GameSdk/GameSdk.csproj @@ -7,12 +7,6 @@ enable disable false - - false - - false @@ -20,5 +14,10 @@ + + + + diff --git a/plugins/GameSdk/Generation/AssemblyNames.cs b/plugins/GameSdk/Generation/AssemblyNames.cs new file mode 100644 index 0000000..c30d877 --- /dev/null +++ b/plugins/GameSdk/Generation/AssemblyNames.cs @@ -0,0 +1,179 @@ +using BD2.GameNames.Internal; +using Mono.Cecil; +using Mono.Cecil.Cil; +using static BD2.GameSdk.Generation.ShellGenerator; + +namespace BD2.GameSdk.Generation; + +internal static class AssemblyNames +{ + internal static IEnumerable Types(ModuleDefinition module) => module.GetTypes(); + internal static string TypeKey(TypeReference type) => type switch + { + GenericParameter p => (p.Type == GenericParameterType.Method ? "!!" : "!") + p.Position, + GenericInstanceType g => TypeKey(g.ElementType) + "<" + string.Join(",", g.GenericArguments.Select(TypeKey)) + ">", + ArrayType a => TypeKey(a.ElementType) + "[" + new string(',', a.Rank - 1) + "]", + ByReferenceType b => TypeKey(b.ElementType) + "&", + PointerType p => TypeKey(p.ElementType) + "*", + OptionalModifierType m => TypeKey(m.ElementType) + " modopt(" + TypeKey(m.ModifierType) + ")", + RequiredModifierType m => TypeKey(m.ElementType) + " modreq(" + TypeKey(m.ModifierType) + ")", + FunctionPointerType f => "fnptr(" + TypeKey(f.ReturnType) + ":" + string.Join(",", f.Parameters.Select(p => TypeKey(p.ParameterType))) + ")", + _ => type.FullName.Replace('/', '+') + }; + internal static string Signature(MemberReference member) => member switch + { + MethodReference m => $"{m.GenericParameters.Count}:{TypeKey(m.ReturnType)}({string.Join(",", m.Parameters.Select(p => TypeKey(p.ParameterType)))})", + FieldReference f => TypeKey(f.FieldType), + _ => throw new ArgumentException("Unsupported member " + member) + }; + + internal static ModuleDefinition ReadModule(string path, params string[] searchDirectories) + { + var resolver = new DefaultAssemblyResolver(); + resolver.AddSearchDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); + foreach (string directory in searchDirectories) resolver.AddSearchDirectory(directory); + return ModuleDefinition.ReadModule(path, new ReaderParameters { InMemory = true, AssemblyResolver = resolver }); + } + + internal static void SetTypeName(TypeReference type, string full) + { + if (type.DeclaringType != null) { type.Name = full[(full.LastIndexOf('+') + 1)..]; return; } + int dot = full.LastIndexOf('.'); + type.Namespace = dot < 0 ? "" : full[..dot]; + type.Name = full[(dot + 1)..]; + } + + internal static void Rewrite(ModuleDefinition module, NameTable table, bool toReadable) + { + var types = table.types.ToDictionary(t => toReadable ? t.original : t.readable); + var readableByOriginal = table.types.ToDictionary(t => t.original, t => t.readable); + string TranslateSignature(string sig) + { + // Type references in signatures are tokenized, so substring collisions are avoided. + return System.Text.RegularExpressions.Regex.Replace(sig, @"[^,:()\[\]&*<>\s]+", m => readableByOriginal.TryGetValue(m.Value, out string v) ? v : m.Value); + } + var members = table.members.Where(m => m.kind is "method" or "field").ToDictionary( + m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable) + "|" + (toReadable ? m.signature : TranslateSignature(m.signature))); + var namedMembers = table.members.Where(m => m.original != m.readable && m.kind is "method" or "field") + .Select(m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable)).ToHashSet(StringComparer.Ordinal); + bool IsGame(TypeReference type) + { + while (type is TypeSpecification s) type = s.ElementType; + while (type.DeclaringType != null) type = type.DeclaringType; + return type.Scope is AssemblyNameReference a ? a.Name == (toReadable ? table.assembly_name : ShellName) : toReadable && type.Scope == module; + } + var memberRefs = module.GetMemberReferences().ToList(); + var attributes = new List(); + void Attributes(ICustomAttributeProvider provider) + { + if (provider.HasCustomAttributes) attributes.AddRange(provider.CustomAttributes); + } + Attributes(module); Attributes(module.Assembly); + foreach (TypeDefinition t in Types(module)) + { + Attributes(t); + foreach (GenericParameter gp in t.GenericParameters) Attributes(gp); + foreach (InterfaceImplementation i in t.Interfaces) Attributes(i); + foreach (FieldDefinition f in t.Fields) Attributes(f); + foreach (PropertyDefinition p in t.Properties) Attributes(p); + foreach (EventDefinition e in t.Events) Attributes(e); + foreach (MethodDefinition m in t.Methods) + { + Attributes(m); Attributes(m.MethodReturnType); + foreach (ParameterDefinition p in m.Parameters) Attributes(p); + foreach (GenericParameter gp in m.GenericParameters) Attributes(gp); + } + } + foreach (CustomAttribute attribute in attributes) memberRefs.Add(attribute.Constructor); + foreach (TypeDefinition type in Types(module)) + foreach (MethodDefinition method in type.Methods) + { + foreach (MethodReference ov in method.Overrides) memberRefs.Add(ov); + if (method.HasBody) foreach (Instruction instruction in method.Body.Instructions) + if (instruction.Operand is MemberReference mr) memberRefs.Add(mr); + } + foreach (MemberReference reference in memberRefs.Distinct()) + { + MemberReference member = reference is GenericInstanceMethod gm ? gm.ElementMethod : reference; + if (member is not MethodReference && member is not FieldReference || member is IMemberDefinition || !IsGame(member.DeclaringType)) continue; + TypeReference declaring = member.DeclaringType is GenericInstanceType gi ? gi.ElementType : member.DeclaringType; + // CLR array Get/Set/Address pseudo-methods have no metadata definitions. + if (declaring is ArrayType) continue; + string key = TypeKey(declaring) + "|" + (member is MethodReference ? "method" : "field") + "|" + member.Name + "|" + Signature(member); + if (members.TryGetValue(key, out MemberName entry)) member.Name = toReadable ? entry.readable : entry.original; + else if (namedMembers.Contains(key[..key.LastIndexOf('|')])) throw new InvalidDataException("Game member signature does not match the names table: " + key); + } + // Snapshot names before renaming a parent of a nested type. + var renames = new Dictionary(); + void Visit(TypeReference type) + { + if (type == null || type is GenericParameter) return; + if (type is FunctionPointerType fp) { Visit(fp.ReturnType); foreach (ParameterDefinition p in fp.Parameters) Visit(p.ParameterType); return; } + if (type is TypeSpecification spec) + { + Visit(spec.ElementType); + if (type is GenericInstanceType gi) foreach (TypeReference a in gi.GenericArguments) Visit(a); + if (type is IModifierType modifier) Visit(modifier.ModifierType); + return; + } + if (!renames.ContainsKey(type) && IsGame(type)) + { + if (!types.TryGetValue(TypeKey(type), out TypeName entry)) throw new InvalidDataException("Unmapped game type: " + TypeKey(type)); + renames[type] = toReadable ? entry.readable : entry.original; + } + Visit(type.DeclaringType); + } + void VisitArgument(CustomAttributeArgument argument) + { + Visit(argument.Type); + if (argument.Value is TypeReference type) Visit(type); + if (argument.Value is CustomAttributeArgument boxed) VisitArgument(boxed); + if (argument.Value is CustomAttributeArgument[] array) foreach (CustomAttributeArgument item in array) VisitArgument(item); + } + foreach (CustomAttribute attribute in attributes) + { + // Decode blobs before changing assembly scopes. System.Type arguments are typed + // metadata even though the ECMA-335 blob stores assembly-qualified text. + foreach (CustomAttributeArgument argument in attribute.ConstructorArguments) VisitArgument(argument); + void NamedArguments(Mono.Collections.Generic.Collection arguments, string kind) + { + for (int i = 0; i < arguments.Count; i++) + { + CustomAttributeNamedArgument argument = arguments[i]; VisitArgument(argument.Argument); + if (!IsGame(attribute.AttributeType)) continue; + string declaring = TypeKey(attribute.AttributeType); + MemberName entry = table.members.SingleOrDefault(m => m.kind == kind && + (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) == declaring && + (toReadable ? m.original : m.readable) == argument.Name); + if (entry != null) arguments[i] = new CustomAttributeNamedArgument(toReadable ? entry.readable : entry.original, argument.Argument); + } + } + NamedArguments(attribute.Fields, "field"); NamedArguments(attribute.Properties, "property"); + } + foreach (TypeReference t in module.GetTypeReferences()) Visit(t); + foreach (MemberReference member in memberRefs) + { + Visit(member.DeclaringType); + if (member is MethodReference m) { Visit(m.ReturnType); foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (TypeReference a in gm.GenericArguments) Visit(a); } + if (member is FieldReference f) Visit(f.FieldType); + } + foreach (TypeDefinition type in Types(module)) + { + Visit(type.BaseType); + foreach (InterfaceImplementation i in type.Interfaces) Visit(i.InterfaceType); + foreach (FieldDefinition f in type.Fields) Visit(f.FieldType); + foreach (PropertyDefinition p in type.Properties) { Visit(p.PropertyType); foreach (ParameterDefinition a in p.Parameters) Visit(a.ParameterType); } + foreach (EventDefinition e in type.Events) Visit(e.EventType); + foreach (GenericParameter gp in type.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType); + foreach (MethodDefinition m in type.Methods) + { + Visit(m.ReturnType); + foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType); + foreach (GenericParameter gp in m.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType); + if (m.HasBody) { foreach (VariableDefinition v in m.Body.Variables) Visit(v.VariableType); foreach (ExceptionHandler h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (Instruction i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); } + } + } + foreach (KeyValuePair pair in renames) SetTypeName(pair.Key, pair.Value); + } + +} diff --git a/plugins/GameSdk/Generation/GenerationInputs.cs b/plugins/GameSdk/Generation/GenerationInputs.cs new file mode 100644 index 0000000..90cfc7f --- /dev/null +++ b/plugins/GameSdk/Generation/GenerationInputs.cs @@ -0,0 +1,38 @@ +using System.Security.Cryptography; +using System.Text; +using ICSharpCode.Decompiler.CSharp; +using Mono.Cecil; + +namespace BD2.GameSdk.Generation; + +internal static class GenerationInputs +{ + internal static string Hash(string path) + { + using FileStream stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + internal static string Stamp() => SourceStamp("BD2.GameSdk.Generation.") + "|" + + Hash(typeof(CSharpDecompiler).Assembly.Location) + "|" + Hash(typeof(ModuleDefinition).Assembly.Location); + + internal static string NamesStamp() => Convert.ToHexString(SHA256.HashData( + Encoding.UTF8.GetBytes(Stamp() + "|" + SourceStamp("BD2.GameSdk.Names.")))).ToLowerInvariant(); + + private static string SourceStamp(string prefix) + { + System.Reflection.Assembly assembly = typeof(GenerationInputs).Assembly; + string[] resources = [.. assembly.GetManifestResourceNames() + .Where(name => name.StartsWith(prefix, StringComparison.Ordinal)) + .OrderBy(name => name, StringComparer.Ordinal)]; + if (resources.Length == 0) throw new InvalidDataException("SDK generation inputs are missing; rebuild the SDK"); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (string name in resources) + { + hash.AppendData(Encoding.UTF8.GetBytes(name + "\0")); + using Stream stream = assembly.GetManifestResourceStream(name); + hash.AppendData(SHA256.HashData(stream)); + } + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } +} diff --git a/plugins/GameSdk/Generation/NameGenerator.cs b/plugins/GameSdk/Generation/NameGenerator.cs new file mode 100644 index 0000000..078bdc6 --- /dev/null +++ b/plugins/GameSdk/Generation/NameGenerator.cs @@ -0,0 +1,105 @@ +using System.IO.Compression; +using System.Text.Json; +using BD2.GameNames.Internal; +using Mono.Cecil; +using static BD2.GameSdk.Generation.AssemblyNames; +using static BD2.GameSdk.Generation.GenerationInputs; +using static BD2.GameSdk.Generation.ShellGenerator; + +namespace BD2.GameSdk.Generation; + +internal static class NameGenerator +{ + internal static void Generate(string assembly, string mapping, string versions, string tablePath) + { + using var config = JsonDocument.Parse(File.ReadAllText(versions)); + string version = config.RootElement.GetProperty("game_version").GetString(); + if (string.IsNullOrWhiteSpace(version) || !Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal)) + throw new InvalidDataException("Mapping filename must identify game_version " + version); + var map = new Dictionary(StringComparer.Ordinal); + foreach (string raw in File.ReadLines(mapping)) + { + string line = raw.Trim().TrimStart('\ufeff'); + if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal)) continue; + string[] parts = line.Split('⇨'); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) + throw new InvalidDataException("Invalid mapping row: " + line); + string key = parts[0].Trim(), value = parts[1].Trim(); + if (map.TryGetValue(key, out string old) && old != value) throw new InvalidDataException("Ambiguous mapping: " + key); + map[key] = value; + } + string Translate(string name) + { + // Dots in explicit interface members are part of the CLR name, not a scope prefix. + if (map.TryGetValue(name, out string value)) return value.Split('/').Last(); + // Accessors can retain their CLR prefix while the property/event is renamed. + foreach (string prefix in new[] { "get_", "set_", "add_", "remove_" }) + if (name.StartsWith(prefix, StringComparison.Ordinal) && map.TryGetValue(name[prefix.Length..], out value)) + { + string property = value.Split('/').Last(); + int dot = property.LastIndexOf('.'); + return property[..(dot + 1)] + prefix + property[(dot + 1)..]; + } + return name; + } + using ModuleDefinition module = ReadModule(assembly); + if (module.Assembly.Name.Name != "Assembly-CSharp") throw new InvalidDataException("Expected Assembly-CSharp"); + var table = new NameTable + { + game_version = version, + assembly_name = module.Assembly.Name.Name, + assembly_mvid = module.Mvid.ToString(), + assembly_sha256 = Hash(assembly), + mapping_sha256 = Hash(mapping), + generator_sha256 = GenerationInputs.NamesStamp() + }; + TypeDefinition[] definitions = Types(module).ToArray(); + var originalTypes = definitions.ToDictionary(t => t, TypeKey); + string ReadableType(TypeDefinition t) + { + if (t.DeclaringType != null) return ReadableType(t.DeclaringType) + "+" + Translate(t.Name); + if (map.TryGetValue(t.Name, out string full)) return full.Replace('/', '+'); + return string.IsNullOrEmpty(t.Namespace) ? Translate(t.Name) : t.Namespace + "." + Translate(t.Name); + } + foreach (TypeDefinition type in definitions) + table.types.Add(new TypeName { token = type.MetadataToken.ToInt32(), original = originalTypes[type], readable = ReadableType(type) }); + if (table.types.GroupBy(t => t.readable).Any(g => g.Count() > 1)) throw new InvalidDataException("Readable type collision"); + var memberKeys = new HashSet(StringComparer.Ordinal); + foreach (TypeDefinition type in definitions) + { + void Add(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null) + { + var entry = new MemberName + { + token = member.MetadataToken.ToInt32(), + kind = kind, + declaring_type = originalTypes[type], + original = member.Name, + readable = Translate(member.Name), + signature = signature + }; + if (!memberKeys.Add(originalTypes[type] + "|" + kind + "|" + entry.readable + "|" + signature)) + throw new InvalidDataException("Readable member collision: " + originalTypes[type] + "." + entry.readable + " " + signature); + if (method != null) + foreach (ParameterDefinition p in method.Parameters) + if (Translate(p.Name) != p.Name) entry.parameters.Add(new ParameterName { position = p.Index, original = p.Name, readable = Translate(p.Name) }); + table.members.Add(entry); + } + foreach (MethodDefinition m in type.Methods) Add(m, "method", Signature(m), m); + foreach (FieldDefinition f in type.Fields) Add(f, "field", Signature(f)); + foreach (PropertyDefinition p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")"); + foreach (EventDefinition e in type.Events) Add(e, "event", TypeKey(e.EventType)); + } + // Keep unrenamed overloads that share a readable name with a renamed member. + // Otherwise runtime lookup would accidentally omit the literal overload. + var renamedGroups = table.members.Where(m => m.readable != m.original) + .Select(m => m.declaring_type + "|" + m.kind + "|" + m.readable).ToHashSet(StringComparer.Ordinal); + table.members = table.members.Where(m => m.readable != m.original || m.parameters.Count != 0 || renamedGroups.Contains(m.declaring_type + "|" + m.kind + "|" + m.readable)).ToList(); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath))); + File.WriteAllText(tablePath, JsonSerializer.Serialize(table, Json)); + using (var gz = new GZipStream(File.Create(tablePath + ".gz"), CompressionLevel.SmallestSize)) + gz.Write(File.ReadAllBytes(tablePath)); + Console.WriteLine($"Game names {version}: {table.types.Count} types, {table.members.Count} member deltas => {tablePath}"); + } + +} diff --git a/plugins/GameSdk/Generation/ShellGenerator.cs b/plugins/GameSdk/Generation/ShellGenerator.cs new file mode 100644 index 0000000..2eb752e --- /dev/null +++ b/plugins/GameSdk/Generation/ShellGenerator.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using BD2.GameNames.Internal; +using Mono.Cecil; +using static BD2.GameSdk.Generation.AssemblyNames; +using static BD2.GameSdk.Generation.GenerationInputs; + +namespace BD2.GameSdk.Generation; + +internal static class ShellGenerator +{ + internal const string ShellName = "Assembly-CSharp.Readable"; + internal static readonly JsonSerializerOptions Json = new() { IncludeFields = true }; + internal static NameTable ReadTable(string path) => JsonSerializer.Deserialize(File.ReadAllText(path), Json); + + internal static void Generate(string tablePath, string assembly, string shellPath) + { + NameTable table = ReadTable(tablePath); + if (Hash(assembly) != table.assembly_sha256) throw new InvalidDataException($"Assembly-CSharp does not match SDK names for game {table.game_version}; install the matching client or update the SDK names table"); + using ModuleDefinition module = ReadModule(assembly); + TypeDefinition[] definitions = Types(module).ToArray(); + // Rename references while their declaring types still have original names. + Rewrite(module, table, toReadable: true); + var membersByToken = table.members.ToDictionary(m => m.token); + var typesByToken = table.types.ToDictionary(t => t.token); + foreach (TypeDefinition type in definitions) + { + foreach (IMemberDefinition member in type.Methods.Cast().Concat(type.Fields).Concat(type.Properties).Concat(type.Events)) + { + membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out MemberName entry); + if (entry != null) member.Name = entry.readable; + if (member is MethodDefinition m) + { + if (entry != null) foreach (ParameterName p in entry.parameters) m.Parameters[p.position].Name = p.readable; + // Retain IL only for offline source navigation/decompilation. + // ReferenceAssemblyAttribute prevents this assembly from executing. + } + } + SetTypeName(type, typesByToken[type.MetadataToken.ToInt32()].readable); + } + module.Assembly.Name.Name = ShellName; + module.Name = ShellName + ".dll"; + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath))); + // Visual Studio resolves reference-source navigation via the standard ref -> lib + // layout. The companion is never a compiler/runtime dependency; it is only for IDEs. + string root = Path.GetDirectoryName(Path.GetFullPath(shellPath)); + string implementation = Path.Combine(root, "lib", ShellName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(implementation)); + module.Write(implementation); + SourceNavigation.Generate(implementation, Path.GetDirectoryName(Path.GetFullPath(assembly))); + // The CLR refuses to execute a reference assembly even if it is copied accidentally. + var marker = new TypeReference("System.Runtime.CompilerServices", "ReferenceAssemblyAttribute", module, module.TypeSystem.CoreLibrary); + module.Assembly.CustomAttributes.Add(new CustomAttribute(new MethodReference(".ctor", module.TypeSystem.Void, marker) { HasThis = true })); + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath))); + module.Write(shellPath); + string reference = Path.Combine(root, "ref", ShellName + ".dll"); + Directory.CreateDirectory(Path.GetDirectoryName(reference)); + File.Copy(shellPath, reference, overwrite: true); + File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(reference, ".xml"), overwrite: true); + File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(shellPath, ".xml"), overwrite: true); + File.Copy(Path.Combine(root, "lib", "navigation.json"), Path.Combine(root, "navigation.json"), overwrite: true); + Console.WriteLine($"Readable reference assembly {table.game_version} => {shellPath}"); + } + +} diff --git a/plugins/GameSdk/Generation/SourceNavigation.cs b/plugins/GameSdk/Generation/SourceNavigation.cs new file mode 100644 index 0000000..fb2f41e --- /dev/null +++ b/plugins/GameSdk/Generation/SourceNavigation.cs @@ -0,0 +1,395 @@ +using System.Globalization; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using ICSharpCode.Decompiler; +using ICSharpCode.Decompiler.CSharp; +using ICSharpCode.Decompiler.CSharp.OutputVisitor; +using ICSharpCode.Decompiler.CSharp.Syntax; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; +using Mono.Cecil; +using Mono.Cecil.Cil; +using EntityHandle = System.Reflection.Metadata.EntityHandle; +using ModuleDefinition = Mono.Cecil.ModuleDefinition; +using MethodDefinition = Mono.Cecil.MethodDefinition; +using MethodDebugInformation = Mono.Cecil.Cil.MethodDebugInformation; + +namespace BD2.GameSdk.Generation; + +/// Build local, offline external-source navigation from the readable implementation. +internal static class SourceNavigation +{ + internal static readonly Guid EmbeddedSource = new("0E8A571B-6926-466E-B4AD-8AB04611F5FE"); + internal static readonly Guid TypeDocuments = new("932E74BC-DBA9-4478-8D46-0F32A7BAB3D3"); + private static readonly Guid CompilationOptions = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8"); + private static readonly Guid CompilationReferences = new("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D"); + internal static readonly Guid CSharpLanguage = new("3F5162F8-07C6-11D3-9053-00C04FA302A1"); + + internal sealed record Declaration(int Token, string Name, string Kind, string File, int Line, int Column, int EndLine, int EndColumn, bool GeneratedView); + internal sealed record Point(int Offset, int Line, int Column, int EndLine, int EndColumn); + private sealed record Source(string Path, byte[] Content, Dictionary Declarations, Dictionary> Methods); + internal sealed record Manifest(int SchemaVersion, string AssemblySha256, string PdbSha256, string DecompilerVersion, string SourceRoot, + int Documents, int Declarations, int MethodsWithBodies, int MethodsWithSymbols, List Diagnostics, List Symbols); + + internal static void Generate(string shellPath, string dependencyDirectory) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(shellPath)); + string sourceRoot = Path.Combine(directory, "sources"); + Directory.CreateDirectory(sourceRoot); + using ModuleDefinition module = AssemblyNames.ReadModule(shellPath, dependencyDirectory); + using var pe = new PEFile(shellPath, new MemoryStream(File.ReadAllBytes(shellPath)), PEStreamOptions.PrefetchEntireImage); + MetadataReader reader = pe.Metadata; + TypeDefinitionHandle[] types = reader.TypeDefinitions.Where(h => reader.GetTypeDefinition(h).GetDeclaringType().IsNil && reader.GetString(reader.GetTypeDefinition(h).Name) != "").ToArray(); + var sources = new ConcurrentBag(); + var timer = Stopwatch.StartNew(); + int completed = 0; + var expected = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32(), t => + t.Methods.Cast().Concat(t.Fields).Concat(t.Properties).Concat(t.Events).Select(m => m.MetadataToken.ToInt32()).Append(t.MetadataToken.ToInt32()).ToHashSet()); + var definitionsByToken = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32()); + // Each worker owns its decompiler and resolver. PE metadata is immutable; + // mutable decompiler state is never shared between workers. + int workers = Math.Clamp(Environment.ProcessorCount / 2, 1, 4); + Parallel.ForEach(types, new ParallelOptions { MaxDegreeOfParallelism = workers }, + () => new Worker(shellPath, dependencyDirectory), (handle, _, worker) => + { + int token = MetadataTokens.GetToken(handle); + string relative = PathFor(reader, handle); + Source primary = worker.Decompile(handle, Path.Combine(sourceRoot, relative), raw: false); + ApplyImplicitDeclarations(primary.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]); + sources.Add(primary); + int[] typeTokens = Descendants(reader, handle).Select(h => MetadataTokens.GetToken(h)).ToArray(); + var wanted = typeTokens.SelectMany(t => expected[t]).ToHashSet(); + var covered = primary.Declarations.Keys.ToHashSet(); + if (wanted.Except(covered).Any()) + { + // Keep idiomatic async/iterator/lambda code in the primary view, and + // expose the compiler-generated implementation in a companion view. + // Roslyn materializes embedded documents by basename, so primary + // and generated views must have distinct filenames as well as paths. + string generatedPath = Path.Combine(Path.GetDirectoryName(relative), Path.GetFileNameWithoutExtension(relative) + ".generated.cs"); + Source raw = worker.Decompile(handle, Path.Combine(sourceRoot, "generated", generatedPath), raw: true); + ApplyImplicitDeclarations(raw.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]); + sources.Add(raw); + covered.UnionWith(raw.Declarations.Keys); + } + foreach (int missing in wanted.Except(covered)) + { + // ILSpy hides some runtime-only definitions even with transformations + // disabled. Decompile them explicitly, retaining exact token identity. + Source supplemental = worker.DecompileMember(MetadataTokens.EntityHandle(missing), + Path.Combine(sourceRoot, "metadata", missing.ToString("X8", CultureInfo.InvariantCulture) + ".cs")); + sources.Add(supplemental); + if (!supplemental.Declarations.ContainsKey(missing)) + throw new InvalidDataException($"Decompiler omitted declaration 0x{missing:X8}; complete navigation cannot be published"); + } + int done = Interlocked.Increment(ref completed); + if (done % 500 == 0 || done == types.Length) + Console.WriteLine($"Game source navigation: {done}/{types.Length} top-level types ({timer.Elapsed.TotalSeconds:F0}s)"); + return worker; + }, worker => worker.Dispose()); + + Source[] ordered = [.. sources.OrderBy(s => s.Path, StringComparer.Ordinal)]; + var sourceByPath = ordered.ToDictionary(s => s.Path, StringComparer.Ordinal); + var declarations = new Dictionary(); + var methodPoints = new Dictionary Points)>(); + foreach (Source source in ordered.OrderBy(s => s.Declarations.Values.FirstOrDefault()?.GeneratedView == true)) + { + foreach (KeyValuePair declaration in source.Declarations) declarations.TryAdd(declaration.Key, declaration.Value); + foreach (KeyValuePair> method in source.Methods) methodPoints.TryAdd(method.Key, (source, method.Value)); + } + // Accessors may be folded into properties/events. Their declaration navigation + // maps to the exact accessor when present, otherwise its owning declaration. + foreach (Mono.Cecil.TypeDefinition type in module.GetTypes()) + { + foreach (Mono.Cecil.PropertyDefinition property in type.Properties) + Alias(property, property.GetMethod, property.SetMethod); + foreach (Mono.Cecil.EventDefinition @event in type.Events) + Alias(@event, @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod); + } + void Alias(IMemberDefinition owner, params MethodDefinition[] accessors) + { + if (!declarations.TryGetValue(owner.MetadataToken.ToInt32(), out Declaration location)) return; + foreach (MethodDefinition method in accessors.Where(m => m != null)) + declarations.TryAdd(method.MetadataToken.ToInt32(), location with { Token = method.MetadataToken.ToInt32(), Name = method.FullName, Kind = "Method" }); + } + foreach (MethodDefinition method in module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody)) + { + int token = method.MetadataToken.ToInt32(); + if (!methodPoints.ContainsKey(token) && declarations.TryGetValue(token, out Declaration declaration)) + { + Source source = sourceByPath[declaration.File]; + methodPoints[token] = (source, new List { new(0, declaration.Line, declaration.Column, declaration.EndLine, declaration.EndColumn) }); + } + } + int bodies = module.GetTypes().Sum(t => t.Methods.Count(m => m.HasBody)); + if (methodPoints.Count < bodies) throw new InvalidDataException($"Navigation coverage incomplete: {methodPoints.Count} symbols for {bodies} method bodies"); + string pdbPath = Path.ChangeExtension(shellPath, ".pdb"); + (byte[] Bytes, BlobContentId Id) = BuildPdb(pe, module, ordered, declarations, methodPoints, dependencyDirectory); + File.WriteAllBytes(pdbPath, Bytes); + // Attach exactly the PDB we generated to this PE. No implementation is executed. + module.Write(shellPath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new NavigationSymbolWriterProvider(Id, pdbPath, Bytes) }); + // Store the source root once instead of repeating a long machine-specific + // cache path for hundreds of thousands of declarations. + var manifest = new Manifest(2, GenerationInputs.Hash(shellPath), GenerationInputs.Hash(pdbPath), typeof(CSharpDecompiler).Assembly.GetName().Version.ToString(), sourceRoot, + ordered.Length, declarations.Count, bodies, methodPoints.Count, [], + declarations.Values.OrderBy(d => d.Token).Select(d => d with { File = Path.GetRelativePath(sourceRoot, d.File) }).ToList()); + File.WriteAllText(Path.Combine(directory, "navigation.json"), JsonSerializer.Serialize(manifest)); + WriteXmlDocumentation(shellPath, pe, declarations); + Console.WriteLine($"Game source navigation ready: {ordered.Length} source files, {declarations.Count} declarations, {methodPoints.Count} method symbols"); + } + + private static void ApplyImplicitDeclarations(Dictionary declarations, Mono.Cecil.TypeDefinition type) + { + foreach (Mono.Cecil.PropertyDefinition property in type.Properties) + if (declarations.TryGetValue(property.MetadataToken.ToInt32(), out Declaration location)) + foreach (MethodDefinition accessor in new[] { property.GetMethod, property.SetMethod }.Where(m => m != null)) + declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" }); + foreach (Mono.Cecil.EventDefinition @event in type.Events) + if (declarations.TryGetValue(@event.MetadataToken.ToInt32(), out Declaration location)) + foreach (MethodDefinition accessor in new[] { @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod }.Where(m => m != null)) + declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" }); + if (declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration declaration)) + { + foreach (MethodDefinition constructor in type.Methods.Where(m => m.IsConstructor && !declarations.ContainsKey(m.MetadataToken.ToInt32()))) + declarations.TryAdd(constructor.MetadataToken.ToInt32(), declaration with { Token = constructor.MetadataToken.ToInt32(), Name = constructor.FullName, Kind = "Method" }); + foreach (Mono.Cecil.FieldDefinition field in type.Fields.Where(f => f.IsRuntimeSpecialName)) + declarations.TryAdd(field.MetadataToken.ToInt32(), declaration with { Token = field.MetadataToken.ToInt32(), Name = field.FullName, Kind = "Field" }); + } + foreach (Mono.Cecil.TypeDefinition nested in type.NestedTypes) ApplyImplicitDeclarations(declarations, nested); + } + + private static IEnumerable Descendants(MetadataReader reader, TypeDefinitionHandle handle) + { + yield return handle; + foreach (TypeDefinitionHandle child in reader.GetTypeDefinition(handle).GetNestedTypes()) + foreach (TypeDefinitionHandle nested in Descendants(reader, child)) yield return nested; + } + private static string PathFor(MetadataReader reader, TypeDefinitionHandle handle) + { + System.Reflection.Metadata.TypeDefinition type = reader.GetTypeDefinition(handle); + static string Safe(string name) => string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' ? c : '_')); + string ns = reader.GetString(type.Namespace), name = reader.GetString(type.Name); + // Token suffix prevents collisions from generic arity, case-insensitive filesystems, + // and names whose invalid filename characters normalize to the same spelling. + return Path.Combine(Safe(ns), Safe(name) + "." + MetadataTokens.GetToken(handle).ToString("X8", CultureInfo.InvariantCulture) + ".cs"); + } + + private sealed class Worker : IDisposable + { + private readonly PEFile file; + private readonly UniversalAssemblyResolver resolver; + private readonly CSharpDecompiler primary; + private readonly CSharpDecompiler raw; + private readonly DecompilerSettings primarySettings; + private readonly DecompilerSettings rawSettings; + internal Worker(string path, string dependencies) + { + file = new PEFile(path, new MemoryStream(File.ReadAllBytes(path)), PEStreamOptions.PrefetchEntireImage); + resolver = new UniversalAssemblyResolver(path, true, file.DetectTargetFrameworkId()); + resolver.AddSearchDirectory(dependencies); + primarySettings = Settings(false); rawSettings = Settings(true); + primary = new CSharpDecompiler(file, resolver, primarySettings); + raw = new CSharpDecompiler((DecompilerTypeSystem)primary.TypeSystem, rawSettings); + } + private static DecompilerSettings Settings(bool raw) => new() + { + ThrowOnAssemblyResolveErrors = true, + UseDebugSymbols = false, + ShowXmlDocumentation = false, + UseNestedDirectoriesForNamespaces = true, + AnonymousMethods = !raw, + AnonymousTypes = !raw, + AsyncAwait = !raw, + YieldReturn = !raw, + AutomaticProperties = !raw, + AutomaticEvents = !raw, + GetterOnlyAutomaticProperties = !raw, + UseExpressionBodyForCalculatedGetterOnlyProperties = !raw + }; + internal Source Decompile(TypeDefinitionHandle type, string path, bool raw) + { + CSharpDecompiler decompiler = raw ? this.raw : primary; + return Render(decompiler, decompiler.DecompileTypes((TypeDefinitionHandle[])[type]), path, raw); + } + internal Source DecompileMember(EntityHandle member, string path) + { + Source source = Render(raw, raw.Decompile(member), path, true); + int token = MetadataTokens.GetToken(member); + if (!source.Declarations.ContainsKey(token)) + { + string text = Encoding.UTF8.GetString(source.Content); + source.Declarations[token] = new Declaration(token, "Metadata declaration 0x" + token.ToString("X8", CultureInfo.InvariantCulture), member.Kind.ToString(), path, 2, 1, text.Count(c => c == '\n') + 1, 1, true); + } + return source; + } + private Source Render(CSharpDecompiler decompiler, SyntaxTree tree, string path, bool generated) + { + // CLR compiler-generated names such as d__0 are not legal C#. + // Keep token identity in the index/PDB while spelling those identifiers + // legally in the generated view, so one state machine cannot break the + // semantic parser and navigation for all ordinary members in its file. + foreach (Identifier identifier in tree.DescendantsAndSelf.OfType()) + { + string name = identifier.Name; + if (name.Any(c => !(char.IsLetterOrDigit(c) || c == '_'))) + identifier.Name = "__generated_" + string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c.ToString() : "u" + ((int)c).ToString("X4", CultureInfo.InvariantCulture))); + } + tree.InsertChildAfter(null, new Comment(" Decompiled from the matching game DLL using the shared BD2 names table. For navigation; not compiled or executed."), Roles.Comment); + using var text = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); + var writer = TokenWriter.WrapInWriterThatSetsLocationsInAST(new TextWriterTokenWriter(text)); + tree.AcceptVisitor(new CSharpOutputVisitor(writer, (generated ? rawSettings : primarySettings).CSharpFormattingOptions)); + byte[] content = new UTF8Encoding(false).GetBytes(text.ToString()); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, content); + var declarations = new Dictionary(); + foreach (AstNode node in tree.DescendantsAndSelf) + { + if (node is not EntityDeclaration && node is not VariableInitializer && node is not Accessor) continue; + if (node.GetSymbol() is not IEntity entity || entity.MetadataToken.IsNil) continue; + int token = MetadataTokens.GetToken(entity.MetadataToken); + TextLocation start = node is EntityDeclaration declaration && !declaration.NameToken.IsNull ? declaration.NameToken.StartLocation : node.StartLocation; + TextLocation end = node.EndLocation; + if (start.Line <= 0 || end.Line < start.Line) continue; + declarations.TryAdd(token, new Declaration(token, entity.ReflectionName, entity.SymbolKind.ToString(), path, start.Line, start.Column, end.Line, end.Column, generated)); + } + var points = new Dictionary>(); + foreach (KeyValuePair> function in decompiler.CreateSequencePoints(tree)) + { + IMethod method = function.Key.MoveNextMethod ?? function.Key.Method; + if (method == null || method.MetadataToken.IsNil) continue; + int token = MetadataTokens.GetToken(method.MetadataToken); + var sequence = function.Value.Where(p => !p.IsHidden).Select(p => new Point(p.Offset, p.StartLine, p.StartColumn, p.EndLine, p.EndColumn)).ToList(); + if (sequence.Count > 0) points.TryAdd(token, sequence); + } + return new Source(path, content, declarations, points); + } + public void Dispose() => file.Dispose(); + } + + private static (byte[] Bytes, BlobContentId Id) BuildPdb(PEFile pe, ModuleDefinition module, Source[] sources, + Dictionary declarations, Dictionary Points)> methods, string dependencies) + { + var metadata = new MetadataBuilder(); + var documents = new Dictionary(StringComparer.Ordinal); + var custom = new List<(EntityHandle Parent, Guid Kind, byte[] Bytes)>(); + GuidHandle csharp = metadata.GetOrAddGuid(CSharpLanguage); + GuidHandle sha256 = metadata.GetOrAddGuid(new Guid("8829D00F-11B8-4213-878B-770E8597AC16")); + foreach (Source source in sources) + { + DocumentHandle document = metadata.AddDocument(metadata.GetOrAddDocumentName(source.Path), sha256, metadata.GetOrAddBlob(SHA256.HashData(source.Content)), csharp); + documents.Add(source.Path, document); + using var stream = new MemoryStream(); + using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) writer.Write(source.Content.Length); + using (var deflate = new System.IO.Compression.DeflateStream(stream, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true)) deflate.Write(source.Content); + custom.Add((document, EmbeddedSource, stream.ToArray())); + } + var methodDefs = module.GetTypes().SelectMany(t => t.Methods).ToDictionary(m => m.MetadataToken.ToInt32()); + foreach (MethodDefinitionHandle handle in pe.Metadata.MethodDefinitions) + { + int token = MetadataTokens.GetToken(handle); + if (!methods.TryGetValue(token, out (Source Source, List Points) mapped)) { metadata.AddMethodDebugInformation(default, default); continue; } + Point[] points = [.. mapped.Points.Where(p => p.Offset >= 0 && p.Offset < methodDefs[token].Body.CodeSize && p.Line > 0 && p.EndLine >= p.Line) + .GroupBy(p => p.Offset).Select(g => g.First()).OrderBy(p => p.Offset)]; + var blob = new BlobBuilder(); blob.WriteCompressedInteger(0); // no local signature is needed for navigation + int previousOffset = 0, previousLine = 0, previousColumn = 0; + for (int i = 0; i < points.Length; i++) + { + Point p = points[i]; blob.WriteCompressedInteger(i == 0 ? p.Offset : p.Offset - previousOffset); + int lines = p.EndLine - p.Line; blob.WriteCompressedInteger(lines); + int columns = p.EndColumn - p.Column; + if (lines == 0) blob.WriteCompressedInteger(Math.Max(1, columns)); else blob.WriteCompressedSignedInteger(columns); + if (i == 0) { blob.WriteCompressedInteger(p.Line); blob.WriteCompressedInteger(p.Column); } + else { blob.WriteCompressedSignedInteger(p.Line - previousLine); blob.WriteCompressedSignedInteger(p.Column - previousColumn); } + previousOffset = p.Offset; previousLine = p.Line; previousColumn = p.Column; + } + metadata.AddMethodDebugInformation(documents[mapped.Source.Path], metadata.GetOrAddBlob(blob)); + } + foreach (Mono.Cecil.TypeDefinition type in module.GetTypes()) + { + if (!declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration d)) continue; + var docs = new BlobBuilder(); docs.WriteCompressedInteger(MetadataTokens.GetRowNumber(documents[d.File])); + custom.Add((MetadataTokens.EntityHandle(type.MetadataToken.ToInt32()), TypeDocuments, docs.ToArray())); + } + byte[] options = Encoding.UTF8.GetBytes("language\0C#\0language-version\0" + "12.0\0compiler-version\0BD2.GameSdk\0output-kind\0DynamicallyLinkedLibrary\0optimization\0debug\0"); + custom.Add((MetadataTokens.EntityHandle(1), CompilationOptions, options)); + var references = new BlobBuilder(); + foreach (Mono.Cecil.AssemblyNameReference reference in module.AssemblyReferences) + { + string path = Path.Combine(dependencies, reference.Name + ".dll"); + if (!File.Exists(path)) continue; + using FileStream stream = File.OpenRead(path); using var dependency = new PEReader(stream); + MetadataReader reader = dependency.GetMetadataReader(); + references.WriteBytes(Encoding.UTF8.GetBytes(Path.GetFileName(path))); references.WriteByte(0); + references.WriteByte(0); // global alias + references.WriteByte(1); // combined embedInteropTypes=false, image kind=assembly + references.WriteUInt32((uint)dependency.PEHeaders.CoffHeader.TimeDateStamp); + references.WriteUInt32((uint)dependency.PEHeaders.PEHeader.SizeOfImage); + references.WriteBytes(reader.GetGuid(reader.GetModuleDefinition().Mvid).ToByteArray()); + } + custom.Add((MetadataTokens.EntityHandle(1), CompilationReferences, references.ToArray())); + foreach ((EntityHandle Parent, Guid Kind, byte[] Bytes) in custom.OrderBy(c => CodedIndex.HasCustomDebugInformation(c.Parent))) + metadata.AddCustomDebugInformation(Parent, metadata.GetOrAddGuid(Kind), metadata.GetOrAddBlob(Bytes)); + var counts = Enumerable.Range(0, 64).Select(i => pe.Metadata.GetTableRowCount((TableIndex)i)).ToImmutableArray(); + var builder = new PortablePdbBuilder(metadata, counts, default, blobs => + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + foreach (Blob blob in blobs) hash.AppendData(blob.GetBytes()); + return BlobContentId.FromHash(hash.GetHashAndReset()); + }); + var output = new BlobBuilder(); BlobContentId id = builder.Serialize(output); + return (output.ToArray(), id); + } + + private static void WriteXmlDocumentation(string assembly, PEFile pe, Dictionary declarations) + { + var settings = new DecompilerSettings(); + var resolver = new UniversalAssemblyResolver(assembly, false, pe.DetectTargetFrameworkId()); + var decompiler = new CSharpDecompiler(pe, resolver, settings); + using var writer = System.Xml.XmlWriter.Create(Path.ChangeExtension(assembly, ".xml"), new System.Xml.XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false) }); + writer.WriteStartElement("doc"); writer.WriteStartElement("assembly"); writer.WriteElementString("name", ShellGenerator.ShellName); writer.WriteEndElement(); writer.WriteStartElement("members"); + // Method signatures already provide IntelliSense, and PDB supplies exact + // navigation. Repeating the same boilerplate for every member inflated + // each XML file to hundreds of MB without adding API documentation. + foreach (IEntity entity in decompiler.TypeSystem.MainModule.TypeDefinitions) + { + if (entity.MetadataToken.IsNil || !declarations.TryGetValue(MetadataTokens.GetToken(entity.MetadataToken), out Declaration location)) continue; + string id = ICSharpCode.Decompiler.Documentation.IdStringProvider.GetIdString(entity); + writer.WriteStartElement("member"); writer.WriteAttributeString("name", id); + writer.WriteElementString("summary", "Readable game type. Decompiled source: " + Path.GetRelativePath(Path.Combine(Path.GetDirectoryName(assembly), "sources"), location.File) + ":" + location.Line); + writer.WriteElementString("remarks", "Navigation-only reference; the runtime implementation is in the matching Assembly-CSharp."); + writer.WriteEndElement(); + } + writer.WriteEndElement(); writer.WriteEndElement(); + } + + private sealed class NavigationSymbolWriterProvider(BlobContentId id, string pdbPath, byte[] pdb) : ISymbolWriterProvider + { + public ISymbolWriter GetSymbolWriter(ModuleDefinition module, string fileName) => new Writer(id, pdbPath, pdb); + public ISymbolWriter GetSymbolWriter(ModuleDefinition module, Stream symbolStream) => throw new NotSupportedException(); + private sealed class Writer(BlobContentId id, string path, byte[] pdb) : ISymbolWriter + { + public ISymbolReaderProvider GetReaderProvider() => new PortablePdbReaderProvider(); + public void Write(MethodDebugInformation info) { } + public void Write(ICustomDebugInformationProvider provider) { } + public void Write() { } + public void Dispose() { } + public ImageDebugHeader GetDebugHeader() + { + using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream); + writer.Write(0x53445352); writer.Write(id.Guid.ToByteArray()); writer.Write(1); writer.Write(Encoding.UTF8.GetBytes(path)); writer.Write((byte)0); + byte[] codeview = stream.ToArray(); + return new ImageDebugHeader([ + new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.CodeView, MajorVersion = 0x100, MinorVersion = 0x504d, TimeDateStamp = (int)id.Stamp, SizeOfData = codeview.Length }, codeview), + new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.PdbChecksum, SizeOfData = 39 }, [.. Encoding.UTF8.GetBytes("SHA256\0"), .. SHA256.HashData(pdb)]) + ]); + } + } + } +} diff --git a/plugins/GameSdk/Program.cs b/plugins/GameSdk/Program.cs index 1ba594c..b4b1ada 100644 --- a/plugins/GameSdk/Program.cs +++ b/plugins/GameSdk/Program.cs @@ -1,17 +1,18 @@ using System.IO.Compression; +using BD2.GameSdk.Generation; using System.Security.Cryptography; using System.Text.Json; using BD2.GameNames.Internal; using Mono.Cecil; -using Mono.Cecil.Cil; + +using static BD2.GameSdk.Generation.AssemblyNames; +using static BD2.GameSdk.Generation.GenerationInputs; +using static BD2.GameSdk.Generation.ShellGenerator; namespace BD2.GameSdk; internal static class Program { - internal const string ShellName = "Assembly-CSharp.Readable"; - internal static readonly JsonSerializerOptions Json = new() { IncludeFields = true }; - private static int Main(string[] args) { try @@ -23,28 +24,23 @@ internal static class Program if (args.Length == 5 && args[3] != "--game-version") throw new ArgumentException("Invalid prepare-embedded options"); PrepareEmbedded(args[1], args[2], args.Length == 5 ? args[4] : null); break; case "export-names" when args.Length == 2: ExportNames(args[1]); break; - case "names" when args.Length == 5: GenerateNames(args[1], args[2], args[3], args[4]); break; - case "shell" when args.Length == 4: GenerateShell(args[1], args[2], args[3]); break; + case "names" when args.Length == 5: NameGenerator.Generate(args[1], args[2], args[3], args[4]); break; + case "shell" when args.Length == 4: ShellGenerator.Generate(args[1], args[2], args[3]); break; case "source-navigation" when args.Length == 3: SourceNavigation.Generate(args[1], args[2]); break; - case "verify-navigation" when args.Length == 2: SourceNavigation.Verify(args[1]); break; + case "verify-navigation" when args.Length == 2: SourceNavigationVerifier.Verify(args[1]); break; case "reobf" when args.Length is 4 or 5 or 6: Reobfuscate(args[1], args[2], args[3], args.Length >= 5 ? args[4] : null, args.Length == 6 ? args[5] : null); break; case "verify" when args.Length is 3 or 4: Verify(args[1], args[2], args.Length == 4 ? args[3] : null); break; - case "verify-runtime" when args.Length == 3: SelfTest.VerifyRuntime(args[1], args[2]); break; - case "self-test": SelfTest.Run(); break; - default: throw new ArgumentException("Usage: prepare-embedded [--game-version version] | export-names | names | shell | source-navigation | verify-navigation | reobf [Assembly-CSharp.dll] [dependency-directory] | verify [Assembly-CSharp.dll] | verify-runtime | self-test"); + + default: throw new ArgumentException("Usage: prepare-embedded [--game-version version] | export-names | names | shell | source-navigation | verify-navigation | reobf [Assembly-CSharp.dll] [dependency-directory] | verify [Assembly-CSharp.dll]"); } return 0; } catch (Exception ex) { Console.Error.WriteLine("BD2 GameSdk: " + ex); return 1; } } - internal static string Hash(string path) => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); - private static string GeneratorStamp() => Hash(typeof(Program).Assembly.Location) + "|" + - Hash(typeof(ICSharpCode.Decompiler.CSharp.CSharpDecompiler).Assembly.Location) + "|" + Hash(typeof(ModuleDefinition).Assembly.Location); private static string DependencyStamp(string assembly) => string.Join("|", Directory.EnumerateFiles(Path.GetDirectoryName(Path.GetFullPath(assembly)), "*.dll").OrderBy(p => p, StringComparer.Ordinal) .Select(p => Path.GetFileName(p) + ":" + Hash(p))); - internal static NameTable ReadTable(string path) => JsonSerializer.Deserialize(File.ReadAllText(path), Json); internal static string Stamp(NameTable table) => table.game_version + "|" + table.assembly_sha256 + "|" + table.mapping_sha256; internal static byte[] EmbeddedNames() @@ -96,7 +92,7 @@ internal static class Program string cacheRoot = Environment.GetEnvironmentVariable("BD2_GAME_SDK_CACHE"); if (string.IsNullOrEmpty(cacheRoot)) cacheRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "BD2", "GameSdk", "navigation"); string tableHash = Convert.ToHexString(SHA256.HashData(compressed)).ToLowerInvariant(); - string inputs = tableHash + "|" + GeneratorStamp() + "|" + DependencyStamp(assembly); + string inputs = tableHash + "|" + GenerationInputs.Stamp() + "|" + DependencyStamp(assembly); // Game version is the public grouping; the fingerprint prevents incompatible // tools or binaries within one game version from overwriting active references. string key = Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(inputs))).ToLowerInvariant(); @@ -115,12 +111,12 @@ internal static class Program File.WriteAllBytes(tablePath + ".gz", compressed); using (var gzip = new GZipStream(new MemoryStream(compressed), CompressionMode.Decompress)) using (FileStream destination = File.Create(tablePath)) gzip.CopyTo(destination); - GenerateShell(tablePath, assembly, Path.Combine(shared, ShellName + ".dll")); + ShellGenerator.Generate(tablePath, assembly, Path.Combine(shared, ShellName + ".dll")); File.WriteAllText(ready, StampFiles()); } + else Console.WriteLine($"Game source navigation cache hit: game={gameVersion}"); WriteNavigationItems(shared); - WriteGeneratedFile(Path.Combine(output, "GameSdkIdentity.g.cs"), System.Text.Encoding.UTF8.GetBytes( - "// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(table)) + ")]\n")); + WriteIdentity(Path.Combine(output, "GameSdkIdentity.g.cs"), table); WriteGeneratedFile(Path.Combine(output, "shared-sdk.txt"), System.Text.Encoding.UTF8.GetBytes(shared)); var properties = new System.Xml.Linq.XElement("PropertyGroup", new System.Xml.Linq.XElement("BD2SharedSdkDir", shared)); @@ -163,7 +159,7 @@ internal static class Program { SourceNavigation.Manifest manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(output, "navigation.json"))); if (!Directory.Exists(manifest.SourceRoot) || Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).Count() != manifest.Documents) - SourceNavigation.RestoreSources(output); + SourceNavigationVerifier.RestoreSources(output); var items = new System.Xml.Linq.XElement("ItemGroup"); foreach (string path in Directory.EnumerateFiles(manifest.SourceRoot, "*.cs", SearchOption.AllDirectories).OrderBy(p => p, StringComparer.Ordinal)) items.Add(new System.Xml.Linq.XElement("None", new System.Xml.Linq.XAttribute("Include", path), @@ -196,339 +192,16 @@ internal static class Program finally { if (File.Exists(temporary)) File.Delete(temporary); } } - private static void WriteIdentity(string tablePath, string identityPath) => - File.WriteAllText(identityPath, "// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(ReadTable(tablePath))) + ")]\n"); - internal static IEnumerable Types(ModuleDefinition module) => module.GetTypes(); - internal static string TypeKey(TypeReference type) => type switch - { - GenericParameter p => (p.Type == GenericParameterType.Method ? "!!" : "!") + p.Position, - GenericInstanceType g => TypeKey(g.ElementType) + "<" + string.Join(",", g.GenericArguments.Select(TypeKey)) + ">", - ArrayType a => TypeKey(a.ElementType) + "[" + new string(',', a.Rank - 1) + "]", - ByReferenceType b => TypeKey(b.ElementType) + "&", - PointerType p => TypeKey(p.ElementType) + "*", - OptionalModifierType m => TypeKey(m.ElementType) + " modopt(" + TypeKey(m.ModifierType) + ")", - RequiredModifierType m => TypeKey(m.ElementType) + " modreq(" + TypeKey(m.ModifierType) + ")", - FunctionPointerType f => "fnptr(" + TypeKey(f.ReturnType) + ":" + string.Join(",", f.Parameters.Select(p => TypeKey(p.ParameterType))) + ")", - _ => type.FullName.Replace('/', '+') - }; - internal static string Signature(MemberReference member) => member switch - { - MethodReference m => $"{m.GenericParameters.Count}:{TypeKey(m.ReturnType)}({string.Join(",", m.Parameters.Select(p => TypeKey(p.ParameterType)))})", - FieldReference f => TypeKey(f.FieldType), - _ => throw new ArgumentException("Unsupported member " + member) - }; + private static void WriteIdentity(string path, NameTable table) => + WriteGeneratedFile(path, System.Text.Encoding.UTF8.GetBytes( + "// Generated from the shared names table.\n[assembly: System.Reflection.AssemblyMetadataAttribute(\"BD2.GameNames\", " + JsonSerializer.Serialize(Stamp(table)) + ")]\n")); internal static void Prepare(string assembly, string mapping, string versions, string output) { - string version = JsonDocument.Parse(File.ReadAllText(versions)).RootElement.GetProperty("game_version").GetString(); - if (!Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal)) - throw new InvalidDataException("Mapping filename must identify game_version " + version); Directory.CreateDirectory(output); string tablePath = Path.Combine(output, "names.json"); - string shellPath = Path.Combine(output, ShellName + ".dll"); - string assemblyHash = Hash(assembly), mappingHash = Hash(mapping), generatorHash = Hash(typeof(Program).Assembly.Location); - string identityPath = Path.Combine(output, "GameSdkIdentity.g.cs"), readyPath = Path.Combine(output, "ready.txt"); - string inputStamp = version + "|" + assemblyHash + "|" + mappingHash + "|" + generatorHash; - if (File.Exists(readyPath) && File.Exists(tablePath) && File.Exists(shellPath) && File.Exists(tablePath + ".gz") && File.Exists(identityPath)) - { - string completeStamp = inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath); - if (File.ReadAllText(readyPath) == completeStamp) return; - } - File.Delete(readyPath); - GenerateNames(assembly, mapping, versions, tablePath); - GenerateShell(tablePath, assembly, shellPath); - WriteIdentity(tablePath, identityPath); - File.WriteAllText(readyPath, inputStamp + "|" + Hash(shellPath) + "|" + Hash(tablePath) + "|" + Hash(tablePath + ".gz") + "|" + Hash(identityPath)); - } - - internal static void GenerateNames(string assembly, string mapping, string versions, string tablePath) - { - using var config = JsonDocument.Parse(File.ReadAllText(versions)); - string version = config.RootElement.GetProperty("game_version").GetString(); - if (string.IsNullOrWhiteSpace(version) || !Path.GetFileName(mapping).Contains(version, StringComparison.Ordinal)) - throw new InvalidDataException("Mapping filename must identify game_version " + version); - var map = new Dictionary(StringComparer.Ordinal); - foreach (string raw in File.ReadLines(mapping)) - { - string line = raw.Trim().TrimStart('\ufeff'); - if (line.Length == 0 || line.StartsWith('#') || line.StartsWith("//", StringComparison.Ordinal)) continue; - string[] parts = line.Split('⇨'); - if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]) || string.IsNullOrWhiteSpace(parts[1])) - throw new InvalidDataException("Invalid mapping row: " + line); - string key = parts[0].Trim(), value = parts[1].Trim(); - if (map.TryGetValue(key, out string old) && old != value) throw new InvalidDataException("Ambiguous mapping: " + key); - map[key] = value; - } - string Translate(string name) - { - // Dots in explicit interface members are part of the CLR name, not a scope prefix. - if (map.TryGetValue(name, out string value)) return value.Split('/').Last(); - // Accessors can retain their CLR prefix while the property/event is renamed. - foreach (string prefix in new[] { "get_", "set_", "add_", "remove_" }) - if (name.StartsWith(prefix, StringComparison.Ordinal) && map.TryGetValue(name[prefix.Length..], out value)) - { - string property = value.Split('/').Last(); - int dot = property.LastIndexOf('.'); - return property[..(dot + 1)] + prefix + property[(dot + 1)..]; - } - return name; - } - using ModuleDefinition module = ReadModule(assembly); - if (module.Assembly.Name.Name != "Assembly-CSharp") throw new InvalidDataException("Expected Assembly-CSharp"); - var table = new NameTable - { - game_version = version, - assembly_name = module.Assembly.Name.Name, - assembly_mvid = module.Mvid.ToString(), - assembly_sha256 = Hash(assembly), - mapping_sha256 = Hash(mapping), - generator_sha256 = Hash(typeof(Program).Assembly.Location) - }; - TypeDefinition[] definitions = Types(module).ToArray(); - var originalTypes = definitions.ToDictionary(t => t, TypeKey); - string ReadableType(TypeDefinition t) - { - if (t.DeclaringType != null) return ReadableType(t.DeclaringType) + "+" + Translate(t.Name); - if (map.TryGetValue(t.Name, out string full)) return full.Replace('/', '+'); - return string.IsNullOrEmpty(t.Namespace) ? Translate(t.Name) : t.Namespace + "." + Translate(t.Name); - } - foreach (TypeDefinition type in definitions) - table.types.Add(new TypeName { token = type.MetadataToken.ToInt32(), original = originalTypes[type], readable = ReadableType(type) }); - if (table.types.GroupBy(t => t.readable).Any(g => g.Count() > 1)) throw new InvalidDataException("Readable type collision"); - var memberKeys = new HashSet(StringComparer.Ordinal); - foreach (TypeDefinition type in definitions) - { - void Add(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null) - { - var entry = new MemberName - { - token = member.MetadataToken.ToInt32(), - kind = kind, - declaring_type = originalTypes[type], - original = member.Name, - readable = Translate(member.Name), - signature = signature - }; - if (!memberKeys.Add(originalTypes[type] + "|" + kind + "|" + entry.readable + "|" + signature)) - throw new InvalidDataException("Readable member collision: " + originalTypes[type] + "." + entry.readable + " " + signature); - if (method != null) - foreach (ParameterDefinition p in method.Parameters) - if (Translate(p.Name) != p.Name) entry.parameters.Add(new ParameterName { position = p.Index, original = p.Name, readable = Translate(p.Name) }); - table.members.Add(entry); - } - foreach (MethodDefinition m in type.Methods) Add(m, "method", Signature(m), m); - foreach (FieldDefinition f in type.Fields) Add(f, "field", Signature(f)); - foreach (PropertyDefinition p in type.Properties) Add(p, "property", TypeKey(p.PropertyType) + "(" + string.Join(",", p.Parameters.Select(a => TypeKey(a.ParameterType))) + ")"); - foreach (EventDefinition e in type.Events) Add(e, "event", TypeKey(e.EventType)); - } - // Keep unrenamed overloads that share a readable name with a renamed member. - // Otherwise runtime lookup would accidentally omit the literal overload. - var renamedGroups = table.members.Where(m => m.readable != m.original) - .Select(m => m.declaring_type + "|" + m.kind + "|" + m.readable).ToHashSet(StringComparer.Ordinal); - table.members = table.members.Where(m => m.readable != m.original || m.parameters.Count != 0 || renamedGroups.Contains(m.declaring_type + "|" + m.kind + "|" + m.readable)).ToList(); - Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(tablePath))); - File.WriteAllText(tablePath, JsonSerializer.Serialize(table, Json)); - using (var gz = new GZipStream(File.Create(tablePath + ".gz"), CompressionLevel.SmallestSize)) - gz.Write(File.ReadAllBytes(tablePath)); - Console.WriteLine($"Game names {version}: {table.types.Count} types, {table.members.Count} member deltas => {tablePath}"); - } - - internal static void GenerateShell(string tablePath, string assembly, string shellPath) - { - NameTable table = ReadTable(tablePath); - if (Hash(assembly) != table.assembly_sha256) throw new InvalidDataException($"Assembly-CSharp does not match SDK names for game {table.game_version}; install the matching client or update the SDK names table"); - using ModuleDefinition module = ReadModule(assembly); - TypeDefinition[] definitions = Types(module).ToArray(); - // Rename references while their declaring types still have original names. - Rewrite(module, table, toReadable: true); - var membersByToken = table.members.ToDictionary(m => m.token); - var typesByToken = table.types.ToDictionary(t => t.token); - foreach (TypeDefinition type in definitions) - { - foreach (IMemberDefinition member in type.Methods.Cast().Concat(type.Fields).Concat(type.Properties).Concat(type.Events)) - { - membersByToken.TryGetValue(member.MetadataToken.ToInt32(), out MemberName entry); - if (entry != null) member.Name = entry.readable; - if (member is MethodDefinition m) - { - if (entry != null) foreach (ParameterName p in entry.parameters) m.Parameters[p.position].Name = p.readable; - // Retain IL only for offline source navigation/decompilation. - // ReferenceAssemblyAttribute prevents this assembly from executing. - } - } - SetTypeName(type, typesByToken[type.MetadataToken.ToInt32()].readable); - } - module.Assembly.Name.Name = ShellName; - module.Name = ShellName + ".dll"; - Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath))); - // Visual Studio resolves reference-source navigation via the standard ref -> lib - // layout. The companion is never a compiler/runtime dependency; it is only for IDEs. - string root = Path.GetDirectoryName(Path.GetFullPath(shellPath)); - string implementation = Path.Combine(root, "lib", ShellName + ".dll"); - Directory.CreateDirectory(Path.GetDirectoryName(implementation)); - module.Write(implementation); - SourceNavigation.Generate(implementation, Path.GetDirectoryName(Path.GetFullPath(assembly))); - // The CLR refuses to execute a reference assembly even if it is copied accidentally. - var marker = new TypeReference("System.Runtime.CompilerServices", "ReferenceAssemblyAttribute", module, module.TypeSystem.CoreLibrary); - module.Assembly.CustomAttributes.Add(new CustomAttribute(new MethodReference(".ctor", module.TypeSystem.Void, marker) { HasThis = true })); - Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(shellPath))); - module.Write(shellPath); - string reference = Path.Combine(root, "ref", ShellName + ".dll"); - Directory.CreateDirectory(Path.GetDirectoryName(reference)); - File.Copy(shellPath, reference, overwrite: true); - File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(reference, ".xml"), overwrite: true); - File.Copy(Path.ChangeExtension(implementation, ".xml"), Path.ChangeExtension(shellPath, ".xml"), overwrite: true); - File.Copy(Path.Combine(root, "lib", "navigation.json"), Path.Combine(root, "navigation.json"), overwrite: true); - Console.WriteLine($"Readable reference assembly {table.game_version} => {shellPath}"); - } - - internal static ModuleDefinition ReadModule(string path, params string[] searchDirectories) - { - var resolver = new DefaultAssemblyResolver(); - resolver.AddSearchDirectory(Path.GetDirectoryName(Path.GetFullPath(path))); - foreach (string directory in searchDirectories) resolver.AddSearchDirectory(directory); - return ModuleDefinition.ReadModule(path, new ReaderParameters { InMemory = true, AssemblyResolver = resolver }); - } - - internal static void SetTypeName(TypeReference type, string full) - { - if (type.DeclaringType != null) { type.Name = full[(full.LastIndexOf('+') + 1)..]; return; } - int dot = full.LastIndexOf('.'); - type.Namespace = dot < 0 ? "" : full[..dot]; - type.Name = full[(dot + 1)..]; - } - - internal static void Rewrite(ModuleDefinition module, NameTable table, bool toReadable) - { - var types = table.types.ToDictionary(t => toReadable ? t.original : t.readable); - var readableByOriginal = table.types.ToDictionary(t => t.original, t => t.readable); - string TranslateSignature(string sig) - { - // Type references in signatures are tokenized, so substring collisions are avoided. - return System.Text.RegularExpressions.Regex.Replace(sig, @"[^,:()\[\]&*<>\s]+", m => readableByOriginal.TryGetValue(m.Value, out string v) ? v : m.Value); - } - var members = table.members.Where(m => m.kind is "method" or "field").ToDictionary( - m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable) + "|" + (toReadable ? m.signature : TranslateSignature(m.signature))); - var namedMembers = table.members.Where(m => m.original != m.readable && m.kind is "method" or "field") - .Select(m => (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) + "|" + m.kind + "|" + (toReadable ? m.original : m.readable)).ToHashSet(StringComparer.Ordinal); - bool IsGame(TypeReference type) - { - while (type is TypeSpecification s) type = s.ElementType; - while (type.DeclaringType != null) type = type.DeclaringType; - return type.Scope is AssemblyNameReference a ? a.Name == (toReadable ? table.assembly_name : ShellName) : toReadable && type.Scope == module; - } - var memberRefs = module.GetMemberReferences().ToList(); - var attributes = new List(); - void Attributes(ICustomAttributeProvider provider) - { - if (provider.HasCustomAttributes) attributes.AddRange(provider.CustomAttributes); - } - Attributes(module); Attributes(module.Assembly); - foreach (TypeDefinition t in Types(module)) - { - Attributes(t); - foreach (GenericParameter gp in t.GenericParameters) Attributes(gp); - foreach (InterfaceImplementation i in t.Interfaces) Attributes(i); - foreach (FieldDefinition f in t.Fields) Attributes(f); - foreach (PropertyDefinition p in t.Properties) Attributes(p); - foreach (EventDefinition e in t.Events) Attributes(e); - foreach (MethodDefinition m in t.Methods) - { - Attributes(m); Attributes(m.MethodReturnType); - foreach (ParameterDefinition p in m.Parameters) Attributes(p); - foreach (GenericParameter gp in m.GenericParameters) Attributes(gp); - } - } - foreach (CustomAttribute attribute in attributes) memberRefs.Add(attribute.Constructor); - foreach (TypeDefinition type in Types(module)) - foreach (MethodDefinition method in type.Methods) - { - foreach (MethodReference ov in method.Overrides) memberRefs.Add(ov); - if (method.HasBody) foreach (Instruction instruction in method.Body.Instructions) - if (instruction.Operand is MemberReference mr) memberRefs.Add(mr); - } - foreach (MemberReference reference in memberRefs.Distinct()) - { - MemberReference member = reference is GenericInstanceMethod gm ? gm.ElementMethod : reference; - if (member is not MethodReference && member is not FieldReference || member is IMemberDefinition || !IsGame(member.DeclaringType)) continue; - TypeReference declaring = member.DeclaringType is GenericInstanceType gi ? gi.ElementType : member.DeclaringType; - // CLR array Get/Set/Address pseudo-methods have no metadata definitions. - if (declaring is ArrayType) continue; - string key = TypeKey(declaring) + "|" + (member is MethodReference ? "method" : "field") + "|" + member.Name + "|" + Signature(member); - if (members.TryGetValue(key, out MemberName entry)) member.Name = toReadable ? entry.readable : entry.original; - else if (namedMembers.Contains(key[..key.LastIndexOf('|')])) throw new InvalidDataException("Game member signature does not match the names table: " + key); - } - // Snapshot names before renaming a parent of a nested type. - var renames = new Dictionary(); - void Visit(TypeReference type) - { - if (type == null || type is GenericParameter) return; - if (type is FunctionPointerType fp) { Visit(fp.ReturnType); foreach (ParameterDefinition p in fp.Parameters) Visit(p.ParameterType); return; } - if (type is TypeSpecification spec) - { - Visit(spec.ElementType); - if (type is GenericInstanceType gi) foreach (TypeReference a in gi.GenericArguments) Visit(a); - if (type is IModifierType modifier) Visit(modifier.ModifierType); - return; - } - if (!renames.ContainsKey(type) && IsGame(type)) - { - if (!types.TryGetValue(TypeKey(type), out TypeName entry)) throw new InvalidDataException("Unmapped game type: " + TypeKey(type)); - renames[type] = toReadable ? entry.readable : entry.original; - } - Visit(type.DeclaringType); - } - void VisitArgument(CustomAttributeArgument argument) - { - Visit(argument.Type); - if (argument.Value is TypeReference type) Visit(type); - if (argument.Value is CustomAttributeArgument boxed) VisitArgument(boxed); - if (argument.Value is CustomAttributeArgument[] array) foreach (CustomAttributeArgument item in array) VisitArgument(item); - } - foreach (CustomAttribute attribute in attributes) - { - // Decode blobs before changing assembly scopes. System.Type arguments are typed - // metadata even though the ECMA-335 blob stores assembly-qualified text. - foreach (CustomAttributeArgument argument in attribute.ConstructorArguments) VisitArgument(argument); - void NamedArguments(Mono.Collections.Generic.Collection arguments, string kind) - { - for (int i = 0; i < arguments.Count; i++) - { - CustomAttributeNamedArgument argument = arguments[i]; VisitArgument(argument.Argument); - if (!IsGame(attribute.AttributeType)) continue; - string declaring = TypeKey(attribute.AttributeType); - MemberName entry = table.members.SingleOrDefault(m => m.kind == kind && - (toReadable ? m.declaring_type : readableByOriginal[m.declaring_type]) == declaring && - (toReadable ? m.original : m.readable) == argument.Name); - if (entry != null) arguments[i] = new CustomAttributeNamedArgument(toReadable ? entry.readable : entry.original, argument.Argument); - } - } - NamedArguments(attribute.Fields, "field"); NamedArguments(attribute.Properties, "property"); - } - foreach (TypeReference t in module.GetTypeReferences()) Visit(t); - foreach (MemberReference member in memberRefs) - { - Visit(member.DeclaringType); - if (member is MethodReference m) { Visit(m.ReturnType); foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType); if (m is GenericInstanceMethod gm) foreach (TypeReference a in gm.GenericArguments) Visit(a); } - if (member is FieldReference f) Visit(f.FieldType); - } - foreach (TypeDefinition type in Types(module)) - { - Visit(type.BaseType); - foreach (InterfaceImplementation i in type.Interfaces) Visit(i.InterfaceType); - foreach (FieldDefinition f in type.Fields) Visit(f.FieldType); - foreach (PropertyDefinition p in type.Properties) { Visit(p.PropertyType); foreach (ParameterDefinition a in p.Parameters) Visit(a.ParameterType); } - foreach (EventDefinition e in type.Events) Visit(e.EventType); - foreach (GenericParameter gp in type.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType); - foreach (MethodDefinition m in type.Methods) - { - Visit(m.ReturnType); - foreach (ParameterDefinition p in m.Parameters) Visit(p.ParameterType); - foreach (GenericParameter gp in m.GenericParameters) foreach (GenericParameterConstraint c in gp.Constraints) Visit(c.ConstraintType); - if (m.HasBody) { foreach (VariableDefinition v in m.Body.Variables) Visit(v.VariableType); foreach (ExceptionHandler h in m.Body.ExceptionHandlers) Visit(h.CatchType); foreach (Instruction i in m.Body.Instructions) if (i.Operand is TypeReference t) Visit(t); } - } - } - foreach (KeyValuePair pair in renames) SetTypeName(pair.Key, pair.Value); + NameGenerator.Generate(assembly, mapping, versions, tablePath); + PreparePackage(tablePath + ".gz", assembly, output); } internal static void Reobfuscate(string tablePath, string input, string output, string originalAssembly = null, string dependencyDirectory = null) @@ -542,6 +215,7 @@ internal static class Program VerifyStamp(module, table); Rewrite(module, table, false); foreach (AssemblyNameReference a in module.AssemblyReferences.Where(a => a.Name == ShellName)) a.Name = table.assembly_name; + RuntimeInitialization.Inject(module, Stamp(table)); // Preserve compiler PDBs in obj; rewritten runtime DLL deliberately has no stale symbols. string absoluteOutput = Path.GetFullPath(output); Directory.CreateDirectory(Path.GetDirectoryName(absoluteOutput)); @@ -562,6 +236,7 @@ internal static class Program using ModuleDefinition module = originalAssembly == null ? ReadModule(plugin) : ReadModule(plugin, Path.GetDirectoryName(Path.GetFullPath(originalAssembly))); if (module.AssemblyReferences.Any(a => a.Name == ShellName)) throw new InvalidDataException("Runtime DLL still references the readable shell"); VerifyStamp(module, table); + RuntimeInitialization.Verify(module, Stamp(table)); if (originalAssembly != null) { if (Hash(originalAssembly) != table.assembly_sha256) throw new InvalidDataException("Verification game binary does not match the names table"); diff --git a/plugins/GameSdk/README.md b/plugins/GameSdk/README.md index 6fea087..03d9275 100644 --- a/plugins/GameSdk/README.md +++ b/plugins/GameSdk/README.md @@ -25,7 +25,7 @@ ```powershell dotnet nuget add source 'D:\NuGet\BD2' --name BD2 -dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.2-game.2.35.10 +dotnet add MyPlugin.csproj package BD2.GameSdk --version 0.2.3-game.2.35.10 ``` Visual Studio / Rider 也可在 NuGet 包管理界面添加该源。 @@ -41,7 +41,7 @@ Visual Studio / Rider 也可在 NuGet 包管理界面添加该源。 2.35.10 - + ``` @@ -82,7 +82,7 @@ IDE 项目加载时构建会准备可读引用,CLI 首次构建同样会自动 构建核对声明值、SDK 内嵌表、真实游戏 DLL 指纹。缺少声明或版本不匹配会报错;不会自动选用其他版本。NuGet 项目仍需安装带对应游戏版本的包。 -仓库默认缓存根为 `.build/game-sdk`,与源码位于同一盘。第三方 NuGet 项目默认使用 `%LOCALAPPDATA%\BD2\GameSdk\navigation`;可在本机 `Directory.Build.props` 设置 `BD2GameSdkCache`,或设置环境变量 `BD2_GAME_SDK_CACHE`。 `obj` 不影响共享缓存。缺失的共享源码可在下次准备时从 PDB 恢复,只有删除共享缓存才会触发重新生成。 +仓库默认缓存根为 `.build/game-sdk`,与源码位于同一盘。第三方 NuGet 项目默认使用 `%LOCALAPPDATA%\BD2\GameSdk\navigation`;可在本机 `Directory.Build.props` 设置 `BD2GameSdkCache`,或设置环境变量 `BD2_GAME_SDK_CACHE`。 `obj` 不影响共享缓存。缺失的共享源码可在下次准备时从 PDB 恢复。真实游戏 DLL、映射表、游戏依赖、生成逻辑或反编译依赖改变时会生成新的缓存。 `lib` 中的程序集仅供开发导航,**不要部署或执行**。这里展示的是当前 DLL 的反编译源码,局部变量名和语法可能与原始工程不同。导航 PDB 对应可读程序集,不能用于真实混淆游戏 DLL 的逐行调试。 @@ -109,7 +109,7 @@ owner.StartCoroutine(Game.MemberName(owner.GetType(), "ReadableCoroutineName")); `GetGameMethod/GetGameField/GetGameProperty/GetGameEvent` 采用 .NET 反射约定:查不到返回 null,重载歧义抛出 `AmbiguousMatchException`,null 名字参数抛出 `ArgumentNullException`。`MemberName` 的成员种类使用 `GameMemberKind` 枚举。未知映射保留字面名,普通 UI 文案、`nameof` 字符串和协程常量都不会被 reobf 自动替换。 -`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,启动时用 `Game.ValidateGame(...)`。这种方式没有可读游戏类型引用,也没有编译指纹,使用 SDK 的项目必须用 `Game.Validate(typeof(MyPlugin).Assembly, ...)`。 +`Game.FindType` 保留给运行时才知道类型名称的查询。只做字符串反射的项目可单独安装 `BD2.GameNames`,首次调用 `Game` API 时自动核对游戏 DLL 与名字表。SDK 项目在 reobf 时自动写入模块初始化器,在插件代码执行前核对插件编译指纹、运行时名字表和游戏 DLL;即使插件只使用可读类型、没有调用 `Game` API,也会自动检查,无需编写启动校验代码。 ## 构建与部署 diff --git a/plugins/GameSdk/RuntimeInitialization.cs b/plugins/GameSdk/RuntimeInitialization.cs new file mode 100644 index 0000000..2ce733a --- /dev/null +++ b/plugins/GameSdk/RuntimeInitialization.cs @@ -0,0 +1,55 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace BD2.GameSdk; + +internal static class RuntimeInitialization +{ + private const string RuntimeAssembly = "BD2.GameNames"; + private const string RuntimeType = "BD2.GameNames.RuntimeCompatibility"; + private const string RuntimeMethod = "InitializePlugin"; + + internal static void Inject(ModuleDefinition module, string stamp) + { + TypeDefinition moduleType = module.Types.Single(t => t.Name == ""); + MethodDefinition initializer = moduleType.Methods.SingleOrDefault(m => m.Name == ".cctor"); + if (initializer == null) + { + initializer = new MethodDefinition(".cctor", MethodAttributes.Private | MethodAttributes.Static | + MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, module.TypeSystem.Void); + moduleType.Methods.Add(initializer); + initializer.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + } + if (initializer.HasBody && initializer.Body.Instructions.Count >= 2 && IsRuntimeCall(initializer.Body.Instructions[1])) + { + Verify(module, stamp); + return; + } + AssemblyNameReference runtime = module.AssemblyReferences.SingleOrDefault(a => a.Name == RuntimeAssembly); + if (runtime == null) + { + runtime = new AssemblyNameReference(RuntimeAssembly, new Version(1, 0, 0, 0)); + module.AssemblyReferences.Add(runtime); + } + var type = new TypeReference("BD2.GameNames", "RuntimeCompatibility", module, runtime); + var method = new MethodReference(RuntimeMethod, module.TypeSystem.Void, type); + method.Parameters.Add(new ParameterDefinition(module.TypeSystem.String)); + initializer.Body.Instructions.Insert(0, Instruction.Create(OpCodes.Ldstr, stamp)); + initializer.Body.Instructions.Insert(1, Instruction.Create(OpCodes.Call, method)); + } + + internal static void Verify(ModuleDefinition module, string stamp) + { + MethodDefinition initializer = module.Types.Single(t => t.Name == "").Methods.SingleOrDefault(m => m.Name == ".cctor"); + if (initializer?.HasBody != true || initializer.Body.Instructions.Count < 3 || + initializer.Body.Instructions[0].OpCode != OpCodes.Ldstr || initializer.Body.Instructions[0].Operand as string != stamp || + !IsRuntimeCall(initializer.Body.Instructions[1])) + throw new InvalidDataException("Missing/mismatched SDK runtime initializer: rebuild this plugin with BD2.GameSdk"); + } + + private static bool IsRuntimeCall(Instruction instruction) => instruction.OpCode == OpCodes.Call && + instruction.Operand is MethodReference method && method.Name == RuntimeMethod && !method.HasThis && + method.DeclaringType.FullName == RuntimeType && method.DeclaringType.Scope is AssemblyNameReference assembly && + assembly.Name == RuntimeAssembly && method.ReturnType.MetadataType == MetadataType.Void && + method.Parameters.Count == 1 && method.Parameters[0].ParameterType.MetadataType == MetadataType.String; +} diff --git a/plugins/GameSdk/SelfTest.cs b/plugins/GameSdk/SelfTest.cs deleted file mode 100644 index 1183ad8..0000000 --- a/plugins/GameSdk/SelfTest.cs +++ /dev/null @@ -1,275 +0,0 @@ -using System.Globalization; -using System.Diagnostics; -using System.Runtime.Loader; -using Mono.Cecil; -using Mono.Cecil.Cil; - -namespace BD2.GameSdk; - -/// End-to-end tests with synthetic binaries. Never execute the production game or readable shell. -internal static class SelfTest -{ - private static readonly string[] PublicRuntimeTypes = ["BD2.GameNames.Game", "BD2.GameNames.GameMemberKind"]; - private static void Assert(bool condition, string message) - { - if (!condition) throw new InvalidOperationException("Self-test: " + message); - } - private static void Build(string project, params string[] properties) - { - var start = new ProcessStartInfo("dotnet") { RedirectStandardOutput = true, RedirectStandardError = true }; - foreach (string arg in new[] { "build", project, "--nologo", "-c", "Release" }.Concat(properties)) start.ArgumentList.Add(arg); - using var process = Process.Start(start); - Task stdout = process.StandardOutput.ReadToEndAsync(); Task stderr = process.StandardError.ReadToEndAsync(); - process.WaitForExit(); - string output = stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult(); - if (process.ExitCode != 0) throw new InvalidOperationException(output); - } - private static string Project(string name, string references = "") => $""" - - netstandard2.1latest{name} - {references} - - """; - internal static void Run() - { - string repository = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../")); - Assert(File.Exists(Path.Combine(repository, "versions.json")), "run from a built GameSdk project"); - string root = Path.Combine(repository, ".build", "game-sdk-tests", Guid.NewGuid().ToString("N")); - string gameDir = Path.Combine(root, "game"), sdkDir = Path.Combine(root, "sdk"), probeDir = Path.Combine(root, "probe"); - Directory.CreateDirectory(gameDir); Directory.CreateDirectory(probeDir); - string exported = Path.Combine(root, "embedded", "names.json"); - Program.ExportNames(exported); - Assert(File.ReadAllBytes(exported + ".gz").SequenceEqual(Program.EmbeddedNames()), "SDK exports the exact embedded table"); - Assert(Program.ReadTable(exported).schema_version == 1, "embedded names have supported schema"); - string gameProject = Path.Combine(gameDir, "Game.csproj"); - File.WriteAllText(gameProject, Project("Assembly-CSharp")); - File.WriteAllText(Path.Combine(gameDir, "Game.cs"), """ - using System; - public class AppManager { public bool IsPlatformLogin { get { return true; } } } - public class IntroUI { public void SendMaintenanceInfo(bool force) {} } - namespace BDNetwork { public class NetworkManager { public string GetPachedGameDataPath() { return "path"; } } } - namespace Readable { - public interface IThing { string Name { get; } } - public class Agent : IThing { - string IThing.Name { get { return "agent"; } } - public event EventHandler Changed; - public int Value; - public int Ping(int value) { return value; } - public string Ping(string value) { return value; } - public void Act() {} - public void Awake() {} - private void PauseRoutine() {} - private int secret = 3; - public class State {} - public T Echo(T value) { return value; } - public Agent[,] Grid(Agent[,] value) { return value; } - public void Ref(ref Agent value) {} - } - public class Box { public T Item; public T Echo(T value) { return value; } public class Nested { public U Item; } } - public class Derived : Box {} - } - """); - Build(gameProject); - string gamePath = Path.Combine(gameDir, "bin/Release/netstandard2.1/Assembly-CSharp.dll"); - string mappingPath = Path.Combine(root, "ObfuscationTranslation_9.8.7.obfuscate"), versionPath = Path.Combine(root, "versions.json"); - File.WriteAllText(versionPath, "{\"game_version\":\"9.8.7\"}"); - var rows = new List { "#ReverseOrder", "#Classes", "#Methods", "#Fields", "#Properties", "#Events", "#Parameters" }; - using (ModuleDefinition game = Program.ReadModule(gamePath)) - { - var table = new BD2.GameNames.Internal.NameTable { assembly_name = "Assembly-CSharp" }; - TypeDefinition[] defs = game.GetTypes().ToArray(); int counter = 0; - string Obfuscated() => "ὠ" + string.Concat((counter++).ToString(CultureInfo.InvariantCulture).Select(c => (char)('ὠ' + c - '0'))); - var newNames = new Dictionary(); - string Full(TypeDefinition t) => t.DeclaringType != null ? Full(t.DeclaringType) + "+" + newNames[t] : newNames.TryGetValue(t, out string n) ? n : Program.TypeKey(t); - foreach (TypeDefinition t in defs) - if (t.Namespace == "Readable" || t.DeclaringType?.Namespace == "Readable") - { - newNames[t] = Obfuscated(); - string meaning = t.DeclaringType == null ? Program.TypeKey(t) : newNames[t.DeclaringType] + "/" + (t.Name == "State" ? "d__0" : t.Name); - rows.Add(newNames[t] + "⇨" + meaning); - } - foreach (TypeDefinition t in defs) table.types.Add(new BD2.GameNames.Internal.TypeName { token = t.MetadataToken.ToInt32(), original = Program.TypeKey(t), readable = Full(t) }); - foreach (TypeDefinition t in defs) - { - void Rename(IMemberDefinition member, string kind, string signature = null, MethodDefinition method = null) - { - bool renamed = member.Name != ".ctor" && member.Name != "Awake" && t.Name != ""; - string original = member.Name, target = renamed ? Obfuscated() : original; - if (renamed) rows.Add(target + "⇨" + original); - var entry = new BD2.GameNames.Internal.MemberName { token = member.MetadataToken.ToInt32(), declaring_type = Program.TypeKey(t), original = original, readable = target, kind = kind, signature = signature }; - if (method != null) foreach (ParameterDefinition p in method.Parameters) - { - if (string.IsNullOrEmpty(p.Name)) continue; - string name = Obfuscated(); rows.Add(name + "⇨" + p.Name); - entry.parameters.Add(new BD2.GameNames.Internal.ParameterName { position = p.Index, original = p.Name, readable = name }); - } - table.members.Add(entry); - } - foreach (MethodDefinition m in t.Methods) Rename(m, "method", Program.Signature(m), m); - foreach (FieldDefinition f in t.Fields) Rename(f, "field", Program.Signature(f)); - foreach (PropertyDefinition p in t.Properties) Rename(p, "property"); - foreach (EventDefinition e in t.Events) Rename(e, "event", Program.TypeKey(e.EventType)); - } - Program.Rewrite(game, table, true); - var entries = table.members.ToDictionary(m => m.token); - foreach (TypeDefinition t in defs) - { - foreach (MethodDefinition m in t.Methods) - { - GameNames.Internal.MemberName entry = entries[m.MetadataToken.ToInt32()]; m.Name = entry.readable; - foreach (GameNames.Internal.ParameterName p in entry.parameters) m.Parameters[p.position].Name = p.readable; - } - foreach (FieldDefinition f in t.Fields) f.Name = entries[f.MetadataToken.ToInt32()].readable; - foreach (PropertyDefinition p in t.Properties) p.Name = entries[p.MetadataToken.ToInt32()].readable; - foreach (EventDefinition e in t.Events) e.Name = entries[e.MetadataToken.ToInt32()].readable; - Program.SetTypeName(t, table.types.Single(e => e.token == t.MetadataToken.ToInt32()).readable); - } - game.Write(gamePath); - } - File.WriteAllLines(mappingPath, rows); - bool embeddedMismatchRejected = false; - try { Program.PrepareEmbedded(gamePath, Path.Combine(root, "mismatched-embedded")); } - catch (InvalidDataException ex) { embeddedMismatchRejected = ex.Message.Contains("does not match SDK names for game"); } - Assert(embeddedMismatchRejected, "embedded table rejects another game binary without seeking an external mapping"); - Program.Prepare(gamePath, mappingPath, versionPath, sdkDir); - SourceNavigation.Verify(sdkDir); - string tablePath = Path.Combine(sdkDir, "names.json"); - GameNames.Internal.NameTable generated = Program.ReadTable(tablePath); - Assert(generated.types.Any(t => t.readable == "Readable.Agent+d__0"), "compiler-generated scoped type"); - using (ModuleDefinition shell = Program.ReadModule(Path.Combine(sdkDir, Program.ShellName + ".dll"))) - { - Assert(shell.Assembly.CustomAttributes.Any(a => a.AttributeType.Name == "ReferenceAssemblyAttribute"), "reference assembly marker"); - Assert(shell.GetType("Readable.Agent").Methods.First(m => m.Name == "Ping").Body.Instructions.Any(i => i.OpCode == OpCodes.Ret), "readable IL retained for offline decompiler fallback"); - Assert(shell.GetType("Readable.Agent").Methods.Count(m => m.Name == "Ping") == 2, "overload names restored"); - } - string runtimeProject = Path.Combine(repository, "plugins/GameNames/GameNames.csproj"), runtimeBin = Path.Combine(root, "runtime-bin") + Path.DirectorySeparatorChar; - Build(runtimeProject, "-p:GameNamesTable=" + tablePath, "-p:BaseIntermediateOutputPath=" + Path.Combine(root, "runtime-obj") + Path.DirectorySeparatorChar, "-p:OutputPath=" + runtimeBin); - string runtimePath = Path.Combine(runtimeBin, "BD2.GameNames.dll"); - string references = $"{System.Security.SecurityElement.Escape(Path.Combine(sdkDir, "ref", Program.ShellName + ".dll"))}false{System.Security.SecurityElement.Escape(runtimePath)}"; - string probeProject = Path.Combine(probeDir, "Probe.csproj"); - File.WriteAllText(probeProject, Project("Probe", references)); - File.WriteAllText(Path.Combine(probeDir, "Probe.cs"), """ - using System; - using System.Collections.Generic; - using System.Reflection; - using BD2.GameNames; - using Readable; - public sealed class MarkerAttribute : Attribute { public Type Target; public MarkerAttribute(Type target) { Target = target; } } - [Marker(typeof(Box))] - public class Probe { - private static void Check(bool condition, string name) { if (!condition) throw new Exception(name); } - public static string Run() { - var log = new List(); - Game.Validate(typeof(Probe).Assembly, "9.8.7", log.Add); - Game.Validate(typeof(Probe).Assembly, log.Add); - Game.ValidateGame(log.Add); - var a = new Agent(); a.Value = 42; - Check(a.Value == 42 && a.Ping(7) == 7 && a.Ping("ok") == "ok", "fields and overloads"); - Check(((IThing)a).Name == "agent", "explicit interface property"); - a.Changed += (s, e) => {}; - Check(typeof(Probe).GetCustomAttribute().Target == typeof(Box), "custom attribute type arguments"); - Check(a.Echo(a) == a && new Box().Echo(a) == a, "generic methods/types"); - var box = new Box(); box.Item = a; Check(box.Item == a, "generic field"); - var derived = new Derived(); derived.Item = a; Check(derived.Echo(a) == a && derived.Item == a, "inherited generic members"); - var nested = new Box.Nested(); nested.Item = "nested"; Check(nested.Item == "nested", "nested generic field"); - var grid = new Agent[1,1]; grid[0,0] = a; Check(a.Grid(grid)[0,0] == a, "array pseudo methods"); - a.Ref(ref a); - Check(typeof(Derived).BaseType == typeof(Box), "generic base"); - Check(Game.Getter(x => x.IsPlatformLogin).Invoke(new AppManager(), null) is true, "property expression"); - Check(Game.Method(x => x.Act()).Name != "Act", "Harmony method expression"); - Check(nameof(Agent.Ping) == "Ping", "nameof is unchanged"); - const BindingFlags all = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; - var ping = typeof(Agent).GetGameMethod(nameof(Agent.Ping), all, null, new[] { typeof(int) }, null); - Check((int)ping.Invoke(a, new object[] { 12 }) == 12, "overloaded runtime lookup"); - Check(Game.ParameterName(ping, "value") == ping.GetParameters()[0].Name, "parameter mapping"); - Check(typeof(Derived).GetGameMethod("Echo", all, null, new[] { typeof(Agent) }, null).Invoke(derived, new object[] { a }) == a, "inherited generic runtime method"); - Check(typeof(Derived).GetGameField("Item", all).GetValue(derived) == a, "inherited generic runtime field"); - bool ambiguous = false; - try { typeof(Agent).GetGameMethod("Ping", all); } catch (AmbiguousMatchException) { ambiguous = true; } - Check(ambiguous, "ambiguous overloads require a signature"); - Check(Game.MemberName(typeof(Agent), "PauseRoutine") != "PauseRoutine", "coroutine string mapping"); - Check(typeof(Agent).GetGameField("secret", all).GetValue(a) is 3, "private field"); - Check(Game.MemberName(typeof(Agent), "secret", GameMemberKind.Field) != "secret", "typed member kind"); - bool nullRejected = false; - try { Game.TypeName(null); } catch (ArgumentNullException) { nullRejected = true; } - Check(nullRejected, "null input uses .NET exception contract"); - Check(typeof(Agent).GetGameEvent("Changed", all).Name != "Changed", "event string mapping"); - Check(Game.FindType("Readable.Agent+d__0").DeclaringType == typeof(Agent), "generated nested type"); - Check(typeof(Agent).GetGameMethod("Awake", all).Name == "Awake", "Unity literal fallback"); - Check(Game.TypeName("Unmapped.Type") == "Unmapped.Type" && Game.MemberName(typeof(Agent), "Missing") == "Missing", "unknown literal fallback"); - Check(typeof(string).GetGameMethod("Contains", all, null, new[] { typeof(string) }, null) != null, "external member unaffected"); - bool rejected = false; - try { Game.Validate(typeof(Probe).Assembly, "wrong", log.Add); } catch { rejected = true; } - Check(rejected && log.Exists(s => s.Contains("SELF-CHECK FAILED")), "version mismatch diagnosed"); - return "All SDK/runtime integration probes passed; UI Ping remains Ping"; - } - public static Agent[] Signature(Agent[] value) { return value; } - } - """); - Build(probeProject); - string readablePlugin = Path.Combine(probeDir, "bin/Release/netstandard2.1/Probe.dll"), pluginPath = Path.Combine(root, "Probe.dll"); - using (ModuleDefinition plugin = Program.ReadModule(readablePlugin)) - { - var foreign = new TypeReference("Readable", "Agent", plugin, new AssemblyNameReference("Foreign", new Version(1, 0))); - var holder = new TypeDefinition("", "ExternalHolder", TypeAttributes.Public, plugin.TypeSystem.Object); - holder.Fields.Add(new FieldDefinition("Other", FieldAttributes.Public, foreign)); plugin.Types.Add(holder); - plugin.Write(readablePlugin); - } - Program.Reobfuscate(tablePath, readablePlugin, pluginPath, gamePath); - using (ModuleDefinition rewritten = Program.ReadModule(pluginPath)) - { - Assert(rewritten.GetType("ExternalHolder").Fields[0].FieldType.FullName == "Readable.Agent", "external assembly scope isolation"); - Assert(!rewritten.AssemblyReferences.Any(a => a.Name == "System.Private.CoreLib"), "target framework preserved"); - Assert(Strings(readablePlugin).SequenceEqual(Strings(pluginPath)), "all ldstr operands preserved byte-for-byte"); - } - var context = new AssemblyLoadContext("BD2 GameSdk self-test", isCollectible: true); - context.Resolving += (_, name) => name.Name switch - { - "Assembly-CSharp" => context.LoadFromAssemblyPath(gamePath), - "BD2.GameNames" => context.LoadFromAssemblyPath(runtimePath), - _ => null - }; - try { Console.WriteLine(context.LoadFromAssemblyPath(pluginPath).GetType("Probe").GetMethod("Run").Invoke(null, null)); } - finally { context.Unload(); } - string wrongTable = Path.Combine(root, "wrong-names.json"); - generated.game_version = "wrong"; - File.WriteAllText(wrongTable, System.Text.Json.JsonSerializer.Serialize(generated, Program.Json)); - bool mismatchRejected = false; - string protectedOutput = Path.Combine(root, "protected-output.dll"); - File.WriteAllText(protectedOutput, "untouched"); - try { Program.Reobfuscate(wrongTable, readablePlugin, protectedOutput); } catch (InvalidDataException) { mismatchRejected = true; } - Assert(mismatchRejected && File.ReadAllText(protectedOutput) == "untouched", "mismatched table rejected before publishing"); - File.AppendAllText(mappingPath, "\nὠὡ⇨Conflicting.Name\n"); - bool conflictRejected = false; - try { Program.Prepare(gamePath, mappingPath, versionPath, sdkDir); } catch (InvalidDataException) { conflictRejected = true; } - Assert(conflictRejected, "ambiguous mapping rejected"); - Console.WriteLine("Self-test artifacts: " + root); - } - private static string[] Strings(string path) - { - using ModuleDefinition module = Program.ReadModule(path); - return module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody).SelectMany(m => m.Body.Instructions).Where(i => i.OpCode == OpCodes.Ldstr).Select(i => (string)i.Operand).ToArray(); - } - internal static void VerifyRuntime(string tablePath, string runtimePath) - { - GameNames.Internal.NameTable table = Program.ReadTable(tablePath); - using (ModuleDefinition runtime = Program.ReadModule(runtimePath)) - { - var resource = (EmbeddedResource)runtime.Resources.Single(r => r.Name == "BD2.GameNames.names.json.gz"); - Assert(resource.GetResourceData().SequenceEqual(File.ReadAllBytes(tablePath + ".gz")), "embedded runtime table equals the shared table"); - } - var context = new AssemblyLoadContext("BD2 full runtime-table check", isCollectible: true); - try - { - System.Reflection.Assembly runtime = context.LoadFromAssemblyPath(Path.GetFullPath(runtimePath)); - Assert(runtime.GetExportedTypes().Select(t => t.FullName).OrderBy(n => n).SequenceEqual(PublicRuntimeTypes), "serialization models are not public API"); - Type game = runtime.GetType("BD2.GameNames.Game"); - Assert((string)game.GetProperty("GameVersion").GetValue(null) == table.game_version, "runtime deserializes the full table"); - Func translate = game.GetMethod("TypeName").CreateDelegate>(); - foreach (GameNames.Internal.TypeName type in table.types) Assert(translate(type.readable) == type.original, "runtime type translation: " + type.readable); - Assert(translate("Unknown.Type") == "Unknown.Type", "runtime literal fallback"); - Console.WriteLine($"Verified embedded runtime table: game={table.game_version}, {table.types.Count} type lookups; {runtimePath}"); - } - finally { context.Unload(); } - } -} diff --git a/plugins/GameSdk/SourceNavigation.cs b/plugins/GameSdk/SourceNavigation.cs index f47bae0..7b5104e 100644 --- a/plugins/GameSdk/SourceNavigation.cs +++ b/plugins/GameSdk/SourceNavigation.cs @@ -1,156 +1,23 @@ using System.Globalization; -using System.Collections.Concurrent; -using System.Collections.Immutable; -using System.Diagnostics; using System.Reflection.Metadata; using System.Reflection.Metadata.Ecma335; using System.Reflection.PortableExecutable; using System.Security.Cryptography; -using System.Text; using System.Text.Json; -using ICSharpCode.Decompiler; -using ICSharpCode.Decompiler.CSharp; -using ICSharpCode.Decompiler.CSharp.OutputVisitor; -using ICSharpCode.Decompiler.CSharp.Syntax; -using ICSharpCode.Decompiler.Metadata; -using ICSharpCode.Decompiler.TypeSystem; -using Mono.Cecil; -using Mono.Cecil.Cil; -using EntityHandle = System.Reflection.Metadata.EntityHandle; -using ModuleDefinition = Mono.Cecil.ModuleDefinition; -using MethodDefinition = Mono.Cecil.MethodDefinition; -using MethodDebugInformation = Mono.Cecil.Cil.MethodDebugInformation; + +using BD2.GameSdk.Generation; +using static BD2.GameSdk.Generation.SourceNavigation; namespace BD2.GameSdk; -/// Build local, offline external-source navigation from the readable implementation. -internal static class SourceNavigation +internal static class SourceNavigationVerifier { - internal static readonly Guid EmbeddedSource = new("0E8A571B-6926-466E-B4AD-8AB04611F5FE"); - internal static readonly Guid TypeDocuments = new("932E74BC-DBA9-4478-8D46-0F32A7BAB3D3"); - private static readonly Guid CompilationOptions = new("B5FEEC05-8CD0-4A83-96DA-466284BB4BD8"); - private static readonly Guid CompilationReferences = new("7E4D4708-096E-4C5C-AEDA-CB10BA6A740D"); - private static readonly Guid CSharpLanguage = new("3F5162F8-07C6-11D3-9053-00C04FA302A1"); - - internal sealed record Declaration(int Token, string Name, string Kind, string File, int Line, int Column, int EndLine, int EndColumn, bool GeneratedView); - internal sealed record Point(int Offset, int Line, int Column, int EndLine, int EndColumn); - private sealed record Source(string Path, byte[] Content, Dictionary Declarations, Dictionary> Methods); - internal sealed record Manifest(int SchemaVersion, string AssemblySha256, string PdbSha256, string DecompilerVersion, string SourceRoot, - int Documents, int Declarations, int MethodsWithBodies, int MethodsWithSymbols, List Diagnostics, List Symbols); - - internal static void Generate(string shellPath, string dependencyDirectory) - { - string directory = Path.GetDirectoryName(Path.GetFullPath(shellPath)); - string sourceRoot = Path.Combine(directory, "sources"); - Directory.CreateDirectory(sourceRoot); - using ModuleDefinition module = Program.ReadModule(shellPath, dependencyDirectory); - using var pe = new PEFile(shellPath, new MemoryStream(File.ReadAllBytes(shellPath)), PEStreamOptions.PrefetchEntireImage); - MetadataReader reader = pe.Metadata; - TypeDefinitionHandle[] types = reader.TypeDefinitions.Where(h => reader.GetTypeDefinition(h).GetDeclaringType().IsNil && reader.GetString(reader.GetTypeDefinition(h).Name) != "").ToArray(); - var sources = new ConcurrentBag(); - var timer = Stopwatch.StartNew(); - int completed = 0; - var expected = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32(), t => - t.Methods.Cast().Concat(t.Fields).Concat(t.Properties).Concat(t.Events).Select(m => m.MetadataToken.ToInt32()).Append(t.MetadataToken.ToInt32()).ToHashSet()); - var definitionsByToken = module.GetTypes().ToDictionary(t => t.MetadataToken.ToInt32()); - // Each worker owns its decompiler and resolver. PE metadata is immutable; - // mutable decompiler state is never shared between workers. - int workers = Math.Clamp(Environment.ProcessorCount / 2, 1, 4); - Parallel.ForEach(types, new ParallelOptions { MaxDegreeOfParallelism = workers }, - () => new Worker(shellPath, dependencyDirectory), (handle, _, worker) => - { - int token = MetadataTokens.GetToken(handle); - string relative = PathFor(reader, handle); - Source primary = worker.Decompile(handle, Path.Combine(sourceRoot, relative), raw: false); - ApplyImplicitDeclarations(primary.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]); - sources.Add(primary); - int[] typeTokens = Descendants(reader, handle).Select(h => MetadataTokens.GetToken(h)).ToArray(); - var wanted = typeTokens.SelectMany(t => expected[t]).ToHashSet(); - var covered = primary.Declarations.Keys.ToHashSet(); - if (wanted.Except(covered).Any()) - { - // Keep idiomatic async/iterator/lambda code in the primary view, and - // expose the compiler-generated implementation in a companion view. - // Roslyn materializes embedded documents by basename, so primary - // and generated views must have distinct filenames as well as paths. - string generatedPath = Path.Combine(Path.GetDirectoryName(relative), Path.GetFileNameWithoutExtension(relative) + ".generated.cs"); - Source raw = worker.Decompile(handle, Path.Combine(sourceRoot, "generated", generatedPath), raw: true); - ApplyImplicitDeclarations(raw.Declarations, definitionsByToken[MetadataTokens.GetToken(handle)]); - sources.Add(raw); - covered.UnionWith(raw.Declarations.Keys); - } - foreach (int missing in wanted.Except(covered)) - { - // ILSpy hides some runtime-only definitions even with transformations - // disabled. Decompile them explicitly, retaining exact token identity. - Source supplemental = worker.DecompileMember(MetadataTokens.EntityHandle(missing), - Path.Combine(sourceRoot, "metadata", missing.ToString("X8", CultureInfo.InvariantCulture) + ".cs")); - sources.Add(supplemental); - if (!supplemental.Declarations.ContainsKey(missing)) - throw new InvalidDataException($"Decompiler omitted declaration 0x{missing:X8}; complete navigation cannot be published"); - } - int done = Interlocked.Increment(ref completed); - if (done % 500 == 0 || done == types.Length) - Console.WriteLine($"Game source navigation: {done}/{types.Length} top-level types ({timer.Elapsed.TotalSeconds:F0}s)"); - return worker; - }, worker => worker.Dispose()); - - Source[] ordered = [.. sources.OrderBy(s => s.Path, StringComparer.Ordinal)]; - var sourceByPath = ordered.ToDictionary(s => s.Path, StringComparer.Ordinal); - var declarations = new Dictionary(); - var methodPoints = new Dictionary Points)>(); - foreach (Source source in ordered.OrderBy(s => s.Declarations.Values.FirstOrDefault()?.GeneratedView == true)) - { - foreach (KeyValuePair declaration in source.Declarations) declarations.TryAdd(declaration.Key, declaration.Value); - foreach (KeyValuePair> method in source.Methods) methodPoints.TryAdd(method.Key, (source, method.Value)); - } - // Accessors may be folded into properties/events. Their declaration navigation - // maps to the exact accessor when present, otherwise its owning declaration. - foreach (Mono.Cecil.TypeDefinition type in module.GetTypes()) - { - foreach (Mono.Cecil.PropertyDefinition property in type.Properties) - Alias(property, property.GetMethod, property.SetMethod); - foreach (Mono.Cecil.EventDefinition @event in type.Events) - Alias(@event, @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod); - } - void Alias(IMemberDefinition owner, params MethodDefinition[] accessors) - { - if (!declarations.TryGetValue(owner.MetadataToken.ToInt32(), out Declaration location)) return; - foreach (MethodDefinition method in accessors.Where(m => m != null)) - declarations.TryAdd(method.MetadataToken.ToInt32(), location with { Token = method.MetadataToken.ToInt32(), Name = method.FullName, Kind = "Method" }); - } - foreach (MethodDefinition method in module.GetTypes().SelectMany(t => t.Methods).Where(m => m.HasBody)) - { - int token = method.MetadataToken.ToInt32(); - if (!methodPoints.ContainsKey(token) && declarations.TryGetValue(token, out Declaration declaration)) - { - Source source = sourceByPath[declaration.File]; - methodPoints[token] = (source, new List { new(0, declaration.Line, declaration.Column, declaration.EndLine, declaration.EndColumn) }); - } - } - int bodies = module.GetTypes().Sum(t => t.Methods.Count(m => m.HasBody)); - if (methodPoints.Count < bodies) throw new InvalidDataException($"Navigation coverage incomplete: {methodPoints.Count} symbols for {bodies} method bodies"); - string pdbPath = Path.ChangeExtension(shellPath, ".pdb"); - (byte[] Bytes, BlobContentId Id) = BuildPdb(pe, module, ordered, declarations, methodPoints, dependencyDirectory); - File.WriteAllBytes(pdbPath, Bytes); - // Attach exactly the PDB we generated to this PE. No implementation is executed. - module.Write(shellPath, new WriterParameters { WriteSymbols = true, SymbolWriterProvider = new NavigationSymbolWriterProvider(Id, pdbPath, Bytes) }); - // Store the source root once instead of repeating a long machine-specific - // cache path for hundreds of thousands of declarations. - var manifest = new Manifest(2, Program.Hash(shellPath), Program.Hash(pdbPath), typeof(CSharpDecompiler).Assembly.GetName().Version.ToString(), sourceRoot, - ordered.Length, declarations.Count, bodies, methodPoints.Count, [], - declarations.Values.OrderBy(d => d.Token).Select(d => d with { File = Path.GetRelativePath(sourceRoot, d.File) }).ToList()); - File.WriteAllText(Path.Combine(directory, "navigation.json"), JsonSerializer.Serialize(manifest)); - WriteXmlDocumentation(shellPath, pe, declarations); - Console.WriteLine($"Game source navigation ready: {ordered.Length} source files, {declarations.Count} declarations, {methodPoints.Count} method symbols"); - } - internal static void Verify(string sdkDirectory) { sdkDirectory = Program.ResolveSdkDirectory(sdkDirectory); - string library = Path.Combine(sdkDirectory, "lib", Program.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb"); + string library = Path.Combine(sdkDirectory, "lib", ShellGenerator.ShellName + ".dll"), pdbPath = Path.ChangeExtension(library, ".pdb"); Manifest manifest = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(sdkDirectory, "navigation.json"))); - if (Program.Hash(library) != manifest.AssemblySha256 || Program.Hash(pdbPath) != manifest.PdbSha256) + if (GenerationInputs.Hash(library) != manifest.AssemblySha256 || GenerationInputs.Hash(pdbPath) != manifest.PdbSha256) throw new InvalidDataException("Source navigation manifest, DLL and PDB do not match"); using FileStream peStream = File.OpenRead(library); using var pe = new PEReader(peStream); using FileStream pdbStream = File.OpenRead(pdbPath); using var provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); @@ -200,7 +67,7 @@ internal static class SourceNavigation internal static void RestoreSources(string sdkDirectory) { - using FileStream stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", Program.ShellName + ".pdb")); + using FileStream stream = File.OpenRead(Path.Combine(sdkDirectory, "lib", ShellGenerator.ShellName + ".pdb")); using var provider = MetadataReaderProvider.FromPortablePdbStream(stream); MetadataReader reader = provider.GetMetadataReader(); foreach (DocumentHandle handle in reader.Documents) @@ -224,251 +91,4 @@ internal static class SourceNavigation return output.ToArray(); } - private static void ApplyImplicitDeclarations(Dictionary declarations, Mono.Cecil.TypeDefinition type) - { - foreach (Mono.Cecil.PropertyDefinition property in type.Properties) - if (declarations.TryGetValue(property.MetadataToken.ToInt32(), out Declaration location)) - foreach (MethodDefinition accessor in new[] { property.GetMethod, property.SetMethod }.Where(m => m != null)) - declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" }); - foreach (Mono.Cecil.EventDefinition @event in type.Events) - if (declarations.TryGetValue(@event.MetadataToken.ToInt32(), out Declaration location)) - foreach (MethodDefinition accessor in new[] { @event.AddMethod, @event.RemoveMethod, @event.InvokeMethod }.Where(m => m != null)) - declarations.TryAdd(accessor.MetadataToken.ToInt32(), location with { Token = accessor.MetadataToken.ToInt32(), Name = accessor.FullName, Kind = "Method" }); - if (declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration declaration)) - { - foreach (MethodDefinition constructor in type.Methods.Where(m => m.IsConstructor && !declarations.ContainsKey(m.MetadataToken.ToInt32()))) - declarations.TryAdd(constructor.MetadataToken.ToInt32(), declaration with { Token = constructor.MetadataToken.ToInt32(), Name = constructor.FullName, Kind = "Method" }); - foreach (Mono.Cecil.FieldDefinition field in type.Fields.Where(f => f.IsRuntimeSpecialName)) - declarations.TryAdd(field.MetadataToken.ToInt32(), declaration with { Token = field.MetadataToken.ToInt32(), Name = field.FullName, Kind = "Field" }); - } - foreach (Mono.Cecil.TypeDefinition nested in type.NestedTypes) ApplyImplicitDeclarations(declarations, nested); - } - - private static IEnumerable Descendants(MetadataReader reader, TypeDefinitionHandle handle) - { - yield return handle; - foreach (TypeDefinitionHandle child in reader.GetTypeDefinition(handle).GetNestedTypes()) - foreach (TypeDefinitionHandle nested in Descendants(reader, child)) yield return nested; - } - private static string PathFor(MetadataReader reader, TypeDefinitionHandle handle) - { - System.Reflection.Metadata.TypeDefinition type = reader.GetTypeDefinition(handle); - static string Safe(string name) => string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' ? c : '_')); - string ns = reader.GetString(type.Namespace), name = reader.GetString(type.Name); - // Token suffix prevents collisions from generic arity, case-insensitive filesystems, - // and names whose invalid filename characters normalize to the same spelling. - return Path.Combine(Safe(ns), Safe(name) + "." + MetadataTokens.GetToken(handle).ToString("X8", CultureInfo.InvariantCulture) + ".cs"); - } - - private sealed class Worker : IDisposable - { - private readonly PEFile file; - private readonly UniversalAssemblyResolver resolver; - private readonly CSharpDecompiler primary; - private readonly CSharpDecompiler raw; - private readonly DecompilerSettings primarySettings; - private readonly DecompilerSettings rawSettings; - internal Worker(string path, string dependencies) - { - file = new PEFile(path, new MemoryStream(File.ReadAllBytes(path)), PEStreamOptions.PrefetchEntireImage); - resolver = new UniversalAssemblyResolver(path, true, file.DetectTargetFrameworkId()); - resolver.AddSearchDirectory(dependencies); - primarySettings = Settings(false); rawSettings = Settings(true); - primary = new CSharpDecompiler(file, resolver, primarySettings); - raw = new CSharpDecompiler((DecompilerTypeSystem)primary.TypeSystem, rawSettings); - } - private static DecompilerSettings Settings(bool raw) => new() - { - ThrowOnAssemblyResolveErrors = true, - UseDebugSymbols = false, - ShowXmlDocumentation = false, - UseNestedDirectoriesForNamespaces = true, - AnonymousMethods = !raw, - AnonymousTypes = !raw, - AsyncAwait = !raw, - YieldReturn = !raw, - AutomaticProperties = !raw, - AutomaticEvents = !raw, - GetterOnlyAutomaticProperties = !raw, - UseExpressionBodyForCalculatedGetterOnlyProperties = !raw - }; - internal Source Decompile(TypeDefinitionHandle type, string path, bool raw) - { - CSharpDecompiler decompiler = raw ? this.raw : primary; - return Render(decompiler, decompiler.DecompileTypes((TypeDefinitionHandle[])[type]), path, raw); - } - internal Source DecompileMember(EntityHandle member, string path) - { - Source source = Render(raw, raw.Decompile(member), path, true); - int token = MetadataTokens.GetToken(member); - if (!source.Declarations.ContainsKey(token)) - { - string text = Encoding.UTF8.GetString(source.Content); - source.Declarations[token] = new Declaration(token, "Metadata declaration 0x" + token.ToString("X8", CultureInfo.InvariantCulture), member.Kind.ToString(), path, 2, 1, text.Count(c => c == '\n') + 1, 1, true); - } - return source; - } - private Source Render(CSharpDecompiler decompiler, SyntaxTree tree, string path, bool generated) - { - // CLR compiler-generated names such as d__0 are not legal C#. - // Keep token identity in the index/PDB while spelling those identifiers - // legally in the generated view, so one state machine cannot break the - // semantic parser and navigation for all ordinary members in its file. - foreach (Identifier identifier in tree.DescendantsAndSelf.OfType()) - { - string name = identifier.Name; - if (name.Any(c => !(char.IsLetterOrDigit(c) || c == '_'))) - identifier.Name = "__generated_" + string.Concat(name.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c.ToString() : "u" + ((int)c).ToString("X4", CultureInfo.InvariantCulture))); - } - tree.InsertChildAfter(null, new Comment(" Decompiled from the matching game DLL using the shared BD2 names table. For navigation; not compiled or executed."), Roles.Comment); - using var text = new StringWriter(System.Globalization.CultureInfo.InvariantCulture); - var writer = TokenWriter.WrapInWriterThatSetsLocationsInAST(new TextWriterTokenWriter(text)); - tree.AcceptVisitor(new CSharpOutputVisitor(writer, (generated ? rawSettings : primarySettings).CSharpFormattingOptions)); - byte[] content = new UTF8Encoding(false).GetBytes(text.ToString()); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - File.WriteAllBytes(path, content); - var declarations = new Dictionary(); - foreach (AstNode node in tree.DescendantsAndSelf) - { - if (node is not EntityDeclaration && node is not VariableInitializer && node is not Accessor) continue; - if (node.GetSymbol() is not IEntity entity || entity.MetadataToken.IsNil) continue; - int token = MetadataTokens.GetToken(entity.MetadataToken); - TextLocation start = node is EntityDeclaration declaration && !declaration.NameToken.IsNull ? declaration.NameToken.StartLocation : node.StartLocation; - TextLocation end = node.EndLocation; - if (start.Line <= 0 || end.Line < start.Line) continue; - declarations.TryAdd(token, new Declaration(token, entity.ReflectionName, entity.SymbolKind.ToString(), path, start.Line, start.Column, end.Line, end.Column, generated)); - } - var points = new Dictionary>(); - foreach (KeyValuePair> function in decompiler.CreateSequencePoints(tree)) - { - IMethod method = function.Key.MoveNextMethod ?? function.Key.Method; - if (method == null || method.MetadataToken.IsNil) continue; - int token = MetadataTokens.GetToken(method.MetadataToken); - var sequence = function.Value.Where(p => !p.IsHidden).Select(p => new Point(p.Offset, p.StartLine, p.StartColumn, p.EndLine, p.EndColumn)).ToList(); - if (sequence.Count > 0) points.TryAdd(token, sequence); - } - return new Source(path, content, declarations, points); - } - public void Dispose() => file.Dispose(); - } - - private static (byte[] Bytes, BlobContentId Id) BuildPdb(PEFile pe, ModuleDefinition module, Source[] sources, - Dictionary declarations, Dictionary Points)> methods, string dependencies) - { - var metadata = new MetadataBuilder(); - var documents = new Dictionary(StringComparer.Ordinal); - var custom = new List<(EntityHandle Parent, Guid Kind, byte[] Bytes)>(); - GuidHandle csharp = metadata.GetOrAddGuid(CSharpLanguage); - GuidHandle sha256 = metadata.GetOrAddGuid(new Guid("8829D00F-11B8-4213-878B-770E8597AC16")); - foreach (Source source in sources) - { - DocumentHandle document = metadata.AddDocument(metadata.GetOrAddDocumentName(source.Path), sha256, metadata.GetOrAddBlob(SHA256.HashData(source.Content)), csharp); - documents.Add(source.Path, document); - using var stream = new MemoryStream(); - using (var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true)) writer.Write(source.Content.Length); - using (var deflate = new System.IO.Compression.DeflateStream(stream, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true)) deflate.Write(source.Content); - custom.Add((document, EmbeddedSource, stream.ToArray())); - } - var methodDefs = module.GetTypes().SelectMany(t => t.Methods).ToDictionary(m => m.MetadataToken.ToInt32()); - foreach (MethodDefinitionHandle handle in pe.Metadata.MethodDefinitions) - { - int token = MetadataTokens.GetToken(handle); - if (!methods.TryGetValue(token, out (Source Source, List Points) mapped)) { metadata.AddMethodDebugInformation(default, default); continue; } - Point[] points = [.. mapped.Points.Where(p => p.Offset >= 0 && p.Offset < methodDefs[token].Body.CodeSize && p.Line > 0 && p.EndLine >= p.Line) - .GroupBy(p => p.Offset).Select(g => g.First()).OrderBy(p => p.Offset)]; - var blob = new BlobBuilder(); blob.WriteCompressedInteger(0); // no local signature is needed for navigation - int previousOffset = 0, previousLine = 0, previousColumn = 0; - for (int i = 0; i < points.Length; i++) - { - Point p = points[i]; blob.WriteCompressedInteger(i == 0 ? p.Offset : p.Offset - previousOffset); - int lines = p.EndLine - p.Line; blob.WriteCompressedInteger(lines); - int columns = p.EndColumn - p.Column; - if (lines == 0) blob.WriteCompressedInteger(Math.Max(1, columns)); else blob.WriteCompressedSignedInteger(columns); - if (i == 0) { blob.WriteCompressedInteger(p.Line); blob.WriteCompressedInteger(p.Column); } - else { blob.WriteCompressedSignedInteger(p.Line - previousLine); blob.WriteCompressedSignedInteger(p.Column - previousColumn); } - previousOffset = p.Offset; previousLine = p.Line; previousColumn = p.Column; - } - metadata.AddMethodDebugInformation(documents[mapped.Source.Path], metadata.GetOrAddBlob(blob)); - } - foreach (Mono.Cecil.TypeDefinition type in module.GetTypes()) - { - if (!declarations.TryGetValue(type.MetadataToken.ToInt32(), out Declaration d)) continue; - var docs = new BlobBuilder(); docs.WriteCompressedInteger(MetadataTokens.GetRowNumber(documents[d.File])); - custom.Add((MetadataTokens.EntityHandle(type.MetadataToken.ToInt32()), TypeDocuments, docs.ToArray())); - } - byte[] options = Encoding.UTF8.GetBytes("language\0C#\0language-version\0" + "12.0\0compiler-version\0BD2.GameSdk\0output-kind\0DynamicallyLinkedLibrary\0optimization\0debug\0"); - custom.Add((MetadataTokens.EntityHandle(1), CompilationOptions, options)); - var references = new BlobBuilder(); - foreach (Mono.Cecil.AssemblyNameReference reference in module.AssemblyReferences) - { - string path = Path.Combine(dependencies, reference.Name + ".dll"); - if (!File.Exists(path)) continue; - using FileStream stream = File.OpenRead(path); using var dependency = new PEReader(stream); - MetadataReader reader = dependency.GetMetadataReader(); - references.WriteBytes(Encoding.UTF8.GetBytes(Path.GetFileName(path))); references.WriteByte(0); - references.WriteByte(0); // global alias - references.WriteByte(1); // combined embedInteropTypes=false, image kind=assembly - references.WriteUInt32((uint)dependency.PEHeaders.CoffHeader.TimeDateStamp); - references.WriteUInt32((uint)dependency.PEHeaders.PEHeader.SizeOfImage); - references.WriteBytes(reader.GetGuid(reader.GetModuleDefinition().Mvid).ToByteArray()); - } - custom.Add((MetadataTokens.EntityHandle(1), CompilationReferences, references.ToArray())); - foreach ((EntityHandle Parent, Guid Kind, byte[] Bytes) in custom.OrderBy(c => CodedIndex.HasCustomDebugInformation(c.Parent))) - metadata.AddCustomDebugInformation(Parent, metadata.GetOrAddGuid(Kind), metadata.GetOrAddBlob(Bytes)); - var counts = Enumerable.Range(0, 64).Select(i => pe.Metadata.GetTableRowCount((TableIndex)i)).ToImmutableArray(); - var builder = new PortablePdbBuilder(metadata, counts, default, blobs => - { - using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - foreach (Blob blob in blobs) hash.AppendData(blob.GetBytes()); - return BlobContentId.FromHash(hash.GetHashAndReset()); - }); - var output = new BlobBuilder(); BlobContentId id = builder.Serialize(output); - return (output.ToArray(), id); - } - - private static void WriteXmlDocumentation(string assembly, PEFile pe, Dictionary declarations) - { - var settings = new DecompilerSettings(); - var resolver = new UniversalAssemblyResolver(assembly, false, pe.DetectTargetFrameworkId()); - var decompiler = new CSharpDecompiler(pe, resolver, settings); - using var writer = System.Xml.XmlWriter.Create(Path.ChangeExtension(assembly, ".xml"), new System.Xml.XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false) }); - writer.WriteStartElement("doc"); writer.WriteStartElement("assembly"); writer.WriteElementString("name", Program.ShellName); writer.WriteEndElement(); writer.WriteStartElement("members"); - // Method signatures already provide IntelliSense, and PDB supplies exact - // navigation. Repeating the same boilerplate for every member inflated - // each XML file to hundreds of MB without adding API documentation. - foreach (IEntity entity in decompiler.TypeSystem.MainModule.TypeDefinitions) - { - if (entity.MetadataToken.IsNil || !declarations.TryGetValue(MetadataTokens.GetToken(entity.MetadataToken), out Declaration location)) continue; - string id = ICSharpCode.Decompiler.Documentation.IdStringProvider.GetIdString(entity); - writer.WriteStartElement("member"); writer.WriteAttributeString("name", id); - writer.WriteElementString("summary", "Readable game type. Decompiled source: " + Path.GetRelativePath(Path.Combine(Path.GetDirectoryName(assembly), "sources"), location.File) + ":" + location.Line); - writer.WriteElementString("remarks", "Navigation-only reference; the runtime implementation is in the matching Assembly-CSharp."); - writer.WriteEndElement(); - } - writer.WriteEndElement(); writer.WriteEndElement(); - } - - private sealed class NavigationSymbolWriterProvider(BlobContentId id, string pdbPath, byte[] pdb) : ISymbolWriterProvider - { - public ISymbolWriter GetSymbolWriter(ModuleDefinition module, string fileName) => new Writer(id, pdbPath, pdb); - public ISymbolWriter GetSymbolWriter(ModuleDefinition module, Stream symbolStream) => throw new NotSupportedException(); - private sealed class Writer(BlobContentId id, string path, byte[] pdb) : ISymbolWriter - { - public ISymbolReaderProvider GetReaderProvider() => new PortablePdbReaderProvider(); - public void Write(MethodDebugInformation info) { } - public void Write(ICustomDebugInformationProvider provider) { } - public void Write() { } - public void Dispose() { } - public ImageDebugHeader GetDebugHeader() - { - using var stream = new MemoryStream(); using var writer = new BinaryWriter(stream); - writer.Write(0x53445352); writer.Write(id.Guid.ToByteArray()); writer.Write(1); writer.Write(Encoding.UTF8.GetBytes(path)); writer.Write((byte)0); - byte[] codeview = stream.ToArray(); - return new ImageDebugHeader([ - new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.CodeView, MajorVersion = 0x100, MinorVersion = 0x504d, TimeDateStamp = (int)id.Stamp, SizeOfData = codeview.Length }, codeview), - new ImageDebugHeaderEntry(new ImageDebugDirectory { Type = ImageDebugType.PdbChecksum, SizeOfData = 39 }, [.. Encoding.UTF8.GetBytes("SHA256\0"), .. SHA256.HashData(pdb)]) - ]); - } - } - } } diff --git a/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj b/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj index 7710a8f..8efbff2 100644 --- a/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj +++ b/plugins/GameSdk/samples/ExamplePlugin/ExamplePlugin.csproj @@ -11,7 +11,7 @@ - + $(BD2ManagedDir)/Unity.TextMeshPro.dll diff --git a/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs b/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs index d4b2761..2dc6da1 100644 --- a/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs +++ b/plugins/GameSdk/samples/ExamplePlugin/Plugin.cs @@ -15,7 +15,6 @@ public sealed class Plugin : BaseUnityPlugin private void Awake() { - Game.Validate(typeof(Plugin).Assembly); MethodInfo target = Game.Method(ui => ui.SetVersionText()); versionTextField = typeof(IntroUI).GetGameField("_textVersion", BindingFlags.Instance | BindingFlags.NonPublic) ?? throw new MissingFieldException("IntroUI._textVersion"); diff --git a/plugins/LocalIdentity/CommissionNavigation.cs b/plugins/LocalIdentity/CommissionNavigation.cs new file mode 100644 index 0000000..3d7baed --- /dev/null +++ b/plugins/LocalIdentity/CommissionNavigation.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using BD2.GameNames; +using BepInEx.Logging; +using HarmonyLib; +using Proto.Net; +using gamfs; +using static BD2.GameNames.Game; + +namespace Bd2LocalIdentity; + +// Cancel the leftover arrival callback after a commission is turned in manually; +// the game's ClearQuestNav(false) would otherwise replay its dialogue. +internal static class CommissionNavigation +{ + private static ManualLogSource Log; + + internal static void Install(Harmony harmony, ManualLogSource logger) + { + Log = logger; + MethodInfo target = typeof(QuestPacket).GetGameMethod("RefreshQuestNavOnQuestClear", BindingFlags.Static | BindingFlags.NonPublic); + harmony.Patch(target, prefix: new HarmonyMethod(typeof(CommissionNavigation), nameof(CancelCompletedTalkDestination))); + } + + private static void CancelCompletedTalkDestination(QuestDTO __0) + { + if (__0 == null || __0.Type != (int)EQuestType.Today || __0.ConditionType != (int)Define_QuestConditionType.TalkManual) + { + return; + } + QuestNavigationManager navigation = Singleton.Instance; + if (navigation.IsNavigate && navigation.NavigateQuestId == __0.Id) + { + navigation.ClearQuestNav(true); + Log.LogInfo("Cancelled completed commission talk destination: quest=" + __0.Id); + } + } +} diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs index 6b1290c..cdbf39a 100644 --- a/plugins/LocalIdentity/Plugin.cs +++ b/plugins/LocalIdentity/Plugin.cs @@ -29,7 +29,6 @@ public sealed class Plugin : BaseUnityPlugin try { Log = Logger; - Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message)); Routing = ClientRouting.Load(Logger); if (Interlocked.Exchange(ref ShutdownHooksInstalled, 1) == 0) { @@ -64,6 +63,7 @@ public sealed class Plugin : BaseUnityPlugin TryInstall("maintenance timeout guard", () => ClientDiagnostics.InstallMaintenanceTimeoutGuard(harmony, introUI)); TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony)); TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony)); + TryInstall("commission navigation completion", () => CommissionNavigation.Install(harmony, Logger)); Logger.LogInfo("Local identity active: AppManager.IsPlatformLogin => false"); } catch (Exception ex) diff --git a/plugins/LoginUI/Plugin.cs b/plugins/LoginUI/Plugin.cs index 58a8ec7..6d409d5 100644 --- a/plugins/LoginUI/Plugin.cs +++ b/plugins/LoginUI/Plugin.cs @@ -43,7 +43,6 @@ public sealed class Plugin : BaseUnityPlugin DisposeGameRelay(); PlatformControlHttp.Shutdown(); }; - Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message)); EnsureRecoveryHost(); InitializeCredentials(); InitializeBranding(); diff --git a/plugins/PackageMetadata.props b/plugins/PackageMetadata.props index 6011684..b3351a2 100644 --- a/plugins/PackageMetadata.props +++ b/plugins/PackageMetadata.props @@ -1,6 +1,6 @@ - 0.2.2 + 0.2.3 $(BD2PackageVersion) Flechazo https://github.com/Flechazo098/bd2 diff --git a/tools/python/dev_mail_grant.py b/tools/python/dev_mail_grant.py index fe69d1a..6ff5508 100644 --- a/tools/python/dev_mail_grant.py +++ b/tools/python/dev_mail_grant.py @@ -8,10 +8,10 @@ by an explicitly configured local bd2server. Example: python tools/python/dev_mail_grant.py serve ` --game-data E:\\bd2\\dl\\GameData --game-data-version 20260923193640 ` - --output data\\dev\\mail-grants-spool.json + --output data\\dev\\mail-grants-spool.json --account-id OWNER_ACCOUNT_ID python tools/python/dev_mail_grant.py grant ` - --output data\\dev\\currency-grants.json --identity test-grant-1 ` + --output data\\dev\\currency-grants.json --account-id OWNER_ACCOUNT_ID --identity test-grant-1 ` --attachment 4:0:10000 --attachment 3:0:100 """ @@ -486,8 +486,11 @@ def attachment(value: str) -> dict[str, int]: def _validate_grant(value: Any) -> dict[str, Any]: - if not isinstance(value, dict) or set(value) != {"identity", "title", "body", "sent_at", "rewards"}: - raise ValueError("发放记录必须只包含 identity、title、body、sent_at、rewards") + if not isinstance(value, dict) or set(value) != {"account_id", "identity", "title", "body", "sent_at", "rewards"}: + raise ValueError("发放记录必须只包含 account_id、identity、title、body、sent_at、rewards") + account_id = value["account_id"] + if not isinstance(account_id,str) or not account_id.strip() or account_id != account_id.strip() or len(account_id)>500: + raise ValueError("account_id 必须明确指定一个有效收件账号") identity, title, body = value["identity"], value["title"], value["body"] if not isinstance(identity, str) or not identity.strip() or len(identity) > 500: raise ValueError("identity 不能为空且不超过 500 字符") @@ -504,22 +507,23 @@ def _validate_grant(value: Any) -> dict[str, Any]: def load_grants(path: Path) -> dict[str, Any]: if not path.exists(): - return {"version": 1, "grants": []} + return {"version": 2, "grants": []} try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"无法读取邮件发放文件 {path}: {exc}") from exc - if not isinstance(value, dict) or set(value) != {"version", "grants"} or type(value["version"]) is not int or value["version"] != 1 or not isinstance(value["grants"], list): - raise ValueError("邮件发放文件必须只包含 version=1 和 grants 数组") + if not isinstance(value, dict) or set(value) != {"version", "grants"} or type(value["version"]) is not int or value["version"] != 2 or not isinstance(value["grants"], list): + raise ValueError("邮件发放文件必须只包含 version=2 和 grants 数组") grants = [_validate_grant(entry) for entry in value["grants"]] - identities = [entry["identity"] for entry in grants] + identities = [(entry["account_id"],entry["identity"]) for entry in grants] if len(set(identities)) != len(identities): raise ValueError("邮件发放文件包含重复 identity") - return {"version": 1, "grants": grants} + return {"version": 2, "grants": grants} def grant(args: argparse.Namespace) -> int: entry = _validate_grant({ + "account_id": args.account_id, "identity": args.identity.strip() if args.identity is not None else str(uuid.uuid4()), "title": args.title.strip(), "body": args.body.strip(), @@ -529,7 +533,7 @@ def grant(args: argparse.Namespace) -> int: output = args.output.resolve() with grant_file_lock(output): value = load_grants(output) - existing = next((item for item in value["grants"] if item["identity"] == entry["identity"]), None) + existing = next((item for item in value["grants"] if item["account_id"] == entry["account_id"] and item["identity"] == entry["identity"]), None) if existing is not None: if any(existing[key] != entry[key] for key in ("title", "body", "rewards")): raise ValueError(f"identity {entry['identity']!r} 已存在且内容不同") @@ -543,13 +547,16 @@ def grant(args: argparse.Namespace) -> int: class MailGrantStore: - def __init__(self, output: Path, items: list[dict[str, Any]]): + def __init__(self, output: Path, items: list[dict[str, Any]], account_id: str): + if not isinstance(account_id,str) or not account_id.strip() or account_id!=account_id.strip() or len(account_id)>500: + raise ValueError("account_id 必须明确指定一个有效收件账号") + self.account_id = account_id self.output = output.resolve() self.items = items self.item_keys = {(item["element_type"], item["id"]) for item in items} self.lock = threading.Lock() if not self.output.exists(): - atomic_json(self.output, {"version": 1, "grants": []}) + atomic_json(self.output, {"version": 2, "grants": []}) def grant(self, payload: Any) -> dict[str, Any]: with self.lock: @@ -575,6 +582,7 @@ class MailGrantStore: now = int(time.time() * 1000) entry = _validate_grant({ + "account_id": self.account_id, "identity": str(uuid.uuid4()), "title": title, "body": body, @@ -706,13 +714,14 @@ class Handler(BaseHTTPRequestHandler): def serve(args: argparse.Namespace) -> int: items = load_items(args.game_data, args.game_data_version) - store = MailGrantStore(args.output, items) + store = MailGrantStore(args.output, items,args.account_id) settings = DevelopmentSettingsStore(args.settings_output, load_inventory_limits(args.game_data, args.game_data_version)) Handler.store = store Handler.settings = settings server = ThreadingHTTPServer((args.listen_host, args.listen_port), Handler) print(f"已读取 {len(items)} 个可由 ItemDBInfo 领取的 GameData 物品。") print(f"浏览器打开:http://{args.listen_host}:{args.listen_port}/") + print(f"收件账号:{store.account_id}") print(f"动态邮件队列:{store.output}") print(f"开发工具配置:{settings.path}") print("此服务不修改 data/state;bd2server 每次 /MailInfo 导入队列并唯一分配邮件 ID。") @@ -731,13 +740,15 @@ def parser() -> argparse.ArgumentParser: command = commands.add_parser("serve", help="start the loopback browser UI") command.add_argument("--game-data", type=Path, required=True, help="GameData root") command.add_argument("--game-data-version", required=True, help="validated GameData version") - command.add_argument("--output", type=Path, required=True, help="version=1 dynamic mail grant spool") + command.add_argument("--account-id", required=True, help="exact recipient account ID; no broadcast default") + command.add_argument("--output", type=Path, required=True, help="version=2 account-addressed dynamic mail grant spool") command.add_argument("--settings-output", type=Path, default=Path("data/dev/dev-tools.json"), help="development settings JSON") command.add_argument("--listen-host", default="127.0.0.1", help="loopback host (default: 127.0.0.1)") command.add_argument("--listen-port", default=8765, type=int, help="loopback port (default: 8765)") command.set_defaults(run=serve) command = commands.add_parser("grant", help="append one durable currency or draw ticket mail grant (standard library only)") - command.add_argument("--output", type=Path, required=True, help="version=1 development mail grants JSON") + command.add_argument("--account-id", required=True, help="exact recipient account ID; no broadcast default") + command.add_argument("--output", type=Path, required=True, help="version=2 account-addressed mail grants JSON") command.add_argument("--attachment", type=attachment, action="append", required=True, metavar="TYPE:ID:COUNT", help="supported currency or draw ticket reward; repeat to include multiple attachments in one mail") command.add_argument("--identity", help="stable idempotency identity (default: a new UUID)") command.add_argument("--title", default="开发测试物品", help="mail title (maximum 500 characters)") diff --git a/tools/python/tests/test_dev_mail_grant.py b/tools/python/tests/test_dev_mail_grant.py deleted file mode 100644 index 1037f26..0000000 --- a/tools/python/tests/test_dev_mail_grant.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Currency grant CLI regressions; runnable without third-party packages.""" - -from __future__ import annotations - -from contextlib import redirect_stdout -import io -import json -from pathlib import Path -import subprocess -import sys -import tempfile -import unittest -from unittest import mock - -TOOLS = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(TOOLS)) -import dev_mail_grant - - -class CurrencyMailGrantTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.output = Path(self.temporary.name) / "nested" / "grants.json" - - def args(self, *extra): - return dev_mail_grant.parser().parse_args([ - "grant", "--output", str(self.output), "--attachment", "4:0:123", *extra, - ]) - - def run_grant(self, *extra): - stream = io.StringIO() - with redirect_stdout(stream): - self.assertEqual(dev_mail_grant.grant(self.args(*extra)), 0) - return json.loads(stream.getvalue()) - - def child(self, *extra): - # -S removes site packages, proving the command can run without Crypto. - return [sys.executable, "-S", str(TOOLS / "dev_mail_grant.py"), - "grant", "--output", str(self.output), "--attachment", "4:0:123", *extra] - - def test_multiple_attachments_create_one_mail_and_preserve_duplicates(self): - result = self.run_grant("--identity", "release-1", "--title", " 发放测试 ", - "--body", " 正文 ", "--attachment", "3:0:5", - "--attachment", "4:0:2", "--attachment", "12:0:1", - "--attachment", "20:0:2147483647") - value = dev_mail_grant.load_grants(self.output) - self.assertEqual(value["version"], 1) - self.assertEqual(len(value["grants"]), 1) - self.assertEqual(value["grants"][0], result["grant"]) - self.assertEqual(result["grant"]["title"], "发放测试") - self.assertEqual(result["grant"]["body"], "正文") - self.assertGreater(result["grant"]["sent_at"], 0) - self.assertEqual(result["grant"]["rewards"], [ - {"type": 4, "id": 0, "count": 123}, {"type": 3, "id": 0, "count": 5}, - {"type": 4, "id": 0, "count": 2}, {"type": 12, "id": 0, "count": 1}, - {"type": 20, "id": 0, "count": 2147483647}, - ]) - - def test_identity_retry_keeps_original_timestamp_and_does_not_write(self): - first = self.run_grant("--identity", "once") - original = self.output.read_bytes() - with mock.patch.object(dev_mail_grant, "atomic_json", side_effect=AssertionError("must not write")): - second = self.run_grant("--identity", "once") - self.assertTrue(first["created"]) - self.assertFalse(second["created"]) - self.assertEqual(first["grant"], second["grant"]) - self.assertEqual(self.output.read_bytes(), original) - - def test_paid_jewelry_draw_tickets_and_large_gold_share_one_idempotent_mail(self): - extra = ("--identity", "large-rewards", "--attachment", "2:0:100000000", - "--attachment", "8:1000:100000000", "--attachment", "8:1104:100000000", - "--attachment", "4:0:1000000000") - first = self.run_grant(*extra) - second = self.run_grant(*extra) - self.assertTrue(first["created"]) - self.assertFalse(second["created"]) - self.assertEqual(first["grant"], second["grant"]) - self.assertEqual(first["grant"]["rewards"][1:], [ - {"type": 2, "id": 0, "count": 100000000}, - {"type": 8, "id": 1000, "count": 100000000}, - {"type": 8, "id": 1104, "count": 100000000}, - {"type": 4, "id": 0, "count": 1000000000}, - ]) - - def test_identity_conflict_is_rejected_without_changing_file(self): - self.run_grant("--identity", "once") - original = self.output.read_bytes() - for extra in (("--title", "another"), ("--body", "another"), ("--attachment", "3:0:1")): - with self.subTest(extra=extra), self.assertRaisesRegex(ValueError, "内容不同"): - self.run_grant("--identity", "once", *extra) - self.assertEqual(self.output.read_bytes(), original) - - def test_one_use_content_ticket_is_an_idempotent_single_item_attachment(self): - first = self.run_grant("--identity", "full-moon-ticket", "--attachment", "19:450030:1") - second = self.run_grant("--identity", "full-moon-ticket", "--attachment", "19:450030:1") - self.assertTrue(first["created"]) - self.assertFalse(second["created"]) - self.assertEqual(first["grant"], second["grant"]) - self.assertEqual(first["grant"]["rewards"][-1], {"type": 19, "id": 450030, "count": 1}) - - def test_omitted_identity_appends_distinct_grants(self): - first, second = self.run_grant(), self.run_grant() - self.assertNotEqual(first["grant"]["identity"], second["grant"]["identity"]) - self.assertEqual(len(dev_mail_grant.load_grants(self.output)["grants"]), 2) - - def test_invalid_attachments_are_rejected(self): - for attachment in ("8:1:1", "8:0:1", "8:1001:1", "8:1104:2147483648", "8:1000:0", "14:1000:1", "2:1:1", "19:450029:1", "19:450030:0", "19:450030:2", "4:1:1", "4:0:0", "4:0:-1", "4:0:2147483648", "4:0", "4:0:1:2", "4:0:true"): - with self.subTest(attachment=attachment), self.assertRaises(SystemExit), redirect_stdout(io.StringIO()): - with mock.patch("sys.stderr", new=io.StringIO()): - self.args("--attachment", attachment) - self.assertFalse(self.output.exists()) - - def test_invalid_title_body_or_identity_does_not_create_output(self): - for extra in (("--title", " "), ("--title", "x" * 501), ("--body", "x" * 5001), - ("--identity", " "), ("--identity", "x" * 501)): - with self.subTest(extra=extra), self.assertRaises(ValueError): - self.run_grant(*extra) - self.assertFalse(self.output.exists()) - - def test_existing_invalid_schema_is_preserved(self): - entry = {"identity": "one", "title": "title", "body": "body", "sent_at": 1, - "rewards": [{"type": 4, "id": 0, "count": 1}]} - invalid_values = [ - [], {"version": True, "grants": []}, {"version": 1, "grants": [], "extra": 1}, - {"version": "2.35.10", "mails": []}, {"version": 1, "grants": [entry, entry]}, - {"version": 1, "grants": [{**entry, "sent_at": True}]}, - {"version": 1, "grants": [{**entry, "extra": 1}]}, - {"version": 1, "grants": [{**entry, "rewards": []}]}, - {"version": 1, "grants": [{**entry, "rewards": [{"type": 4, "id": False, "count": 1}]}]}, - {"version": 1, "grants": [{**entry, "rewards": [{"type": 4, "id": 0, "count": True}]}]}, - ] - self.output.parent.mkdir(parents=True) - for value in invalid_values: - original = json.dumps(value).encode() - self.output.write_bytes(original) - with self.subTest(value=value), self.assertRaises(ValueError): - self.run_grant() - self.assertEqual(self.output.read_bytes(), original) - self.output.write_text("broken JSON", encoding="utf-8") - with self.assertRaises(ValueError): - self.run_grant() - self.assertEqual(self.output.read_text(encoding="utf-8"), "broken JSON") - - def test_failed_atomic_replace_preserves_previous_file_and_cleans_temporary(self): - self.run_grant("--identity", "one") - original = self.output.read_bytes() - with mock.patch.object(dev_mail_grant.os, "replace", side_effect=OSError("injected failure")): - with self.assertRaises(OSError): - self.run_grant("--identity", "two") - self.assertEqual(self.output.read_bytes(), original) - self.assertEqual(list(self.output.parent.glob("*.tmp")), []) - - def test_cli_uses_standard_library_and_reports_validation_errors(self): - result = subprocess.run(self.child("--identity", "standalone"), capture_output=True, text=True, timeout=20) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(json.loads(result.stdout)["created"]) - result = subprocess.run(self.child("--identity", "standalone", "--title", "conflict"), capture_output=True, text=True, timeout=20) - self.assertEqual(result.returncode, 1) - self.assertIn("dev_tools:", result.stderr) - - def test_process_waits_for_lock_then_reads_latest_file(self): - with dev_mail_grant.grant_file_lock(self.output): - process = subprocess.Popen(self.child("--identity", "child"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - try: - with self.assertRaises(subprocess.TimeoutExpired): - process.wait(timeout=0.5) - entry = {"identity": "parent", "title": "title", "body": "body", "sent_at": 1, - "rewards": [{"type": 4, "id": 0, "count": 1}]} - dev_mail_grant.atomic_json(self.output, {"version": 1, "grants": [entry]}) - except BaseException: - process.kill() - process.communicate() - raise - stdout, stderr = process.communicate(timeout=20) - self.assertEqual(process.returncode, 0, stderr) - self.assertTrue(json.loads(stdout)["created"]) - self.assertEqual([item["identity"] for item in dev_mail_grant.load_grants(self.output)["grants"]], ["parent", "child"]) - - def test_parallel_processes_preserve_all_grants_and_shared_identity_once(self): - identities = [f"child-{i}" for i in range(8)] + ["shared"] * 4 - processes = [subprocess.Popen(self.child("--identity", identity), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - for identity in identities] - try: - results = [] - for process in processes: - stdout, stderr = process.communicate(timeout=30) - self.assertEqual(process.returncode, 0, stderr) - results.append(json.loads(stdout)) - grants = dev_mail_grant.load_grants(self.output)["grants"] - self.assertEqual({entry["identity"] for entry in grants}, set(identities)) - self.assertEqual(len(grants), 9) - self.assertEqual(sum(result["created"] for result in results), 9) - finally: - for process in processes: - if process.poll() is None: - process.kill() - process.communicate() - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/python/tests/test_localize_bundles.py b/tools/python/tests/test_localize_bundles.py deleted file mode 100644 index a36efca..0000000 --- a/tools/python/tests/test_localize_bundles.py +++ /dev/null @@ -1,108 +0,0 @@ -import json -import os -import pathlib -import sys -import tempfile -import unittest - -TOOLS = pathlib.Path(__file__).resolve().parents[1] -sys.path.insert(0, str(TOOLS)) - -import localize_bundles - - -class LocalizeBundlesTest(unittest.TestCase): - def test_uses_source_catalog_and_hard_links_every_reference(self): - with tempfile.TemporaryDirectory() as root: - source = pathlib.Path(root, "release") - aa = pathlib.Path(root, "aa") - source.mkdir() - aa.mkdir() - bundle = source / "nested" / "current.bundle" - bundle.parent.mkdir() - bundle.write_bytes(b"current bundle") - builtin = aa / "builtin.bundle" - builtin.write_bytes(b"builtin") - old_catalog = {"m_InternalIds": [localize_bundles.LOCAL_PREFIX + "obsolete.bundle"]} - (aa / "catalog.json").write_text(json.dumps(old_catalog), encoding="utf-8") - current_catalog = { - "m_BuildResultHash": "current", - "m_InternalIds": [ - localize_bundles.REMOTE_PREFIX - + "StandaloneWindows64\\HD\\20260921135230\\nested/current.bundle", - localize_bundles.LOCAL_PREFIX + "builtin.bundle", - ], - } - (source / "catalog_alpha.json").write_text(json.dumps(current_catalog), encoding="utf-8") - - result = localize_bundles.prepare_catalog(str(source), str(aa)) - - localized = json.loads((aa / "catalog.json").read_text(encoding="utf-8")) - self.assertEqual("current", localized["m_BuildResultHash"]) - self.assertEqual( - localize_bundles.LOCAL_PREFIX + "nested\\current.bundle", - localized["m_InternalIds"][0], - ) - self.assertTrue(os.path.samefile(bundle, aa / "nested" / "current.bundle")) - self.assertEqual(1, result["linked"]) - self.assertTrue((aa / localize_bundles.BACKUP_NAME).is_file()) - - repeated = localize_bundles.prepare_catalog(str(source), str(aa)) - self.assertEqual(0, repeated["linked"]) - self.assertEqual(1, repeated["existing"]) - self.assertEqual(result["catalog_sha256"], repeated["catalog_sha256"]) - - def test_missing_referenced_bundle_does_not_replace_catalog(self): - with tempfile.TemporaryDirectory() as root: - source = pathlib.Path(root, "release") - aa = pathlib.Path(root, "aa") - source.mkdir() - aa.mkdir() - original = b'{"m_InternalIds":["original"]}' - (aa / "catalog.json").write_bytes(original) - catalog = { - "m_InternalIds": [ - localize_bundles.REMOTE_PREFIX - + "StandaloneWindows64\\HD\\20260921135230\\missing.bundle" - ] - } - (source / "catalog_alpha.json").write_text(json.dumps(catalog), encoding="utf-8") - - with self.assertRaises(FileNotFoundError): - localize_bundles.prepare_catalog(str(source), str(aa)) - - self.assertEqual(original, (aa / "catalog.json").read_bytes()) - self.assertFalse((aa / localize_bundles.BACKUP_NAME).exists()) - - def test_conflicting_existing_bundle_does_not_replace_catalog(self): - with tempfile.TemporaryDirectory() as root: - source = pathlib.Path(root, "release") - aa = pathlib.Path(root, "aa") - source.mkdir() - aa.mkdir() - (source / "current.bundle").write_bytes(b"source") - (aa / "current.bundle").write_bytes(b"conflict") - original = b'{"m_InternalIds":["original"]}' - (aa / "catalog.json").write_bytes(original) - (source / "catalog_alpha.json").write_text(json.dumps({ - "m_InternalIds": [ - localize_bundles.REMOTE_PREFIX - + "StandaloneWindows64\\HD\\20260921135230\\current.bundle" - ] - }), encoding="utf-8") - - with self.assertRaises(FileExistsError): - localize_bundles.prepare_catalog(str(source), str(aa)) - - self.assertEqual(original, (aa / "catalog.json").read_bytes()) - - def test_traversal_reference_is_rejected(self): - with self.assertRaises(ValueError): - localize_bundles.relative_bundle( - localize_bundles.REMOTE_PREFIX - + "StandaloneWindows64\\HD\\20260921135230\\..\\escape.bundle" - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tools/python/tests/test_offline_tools.py b/tools/python/tests/test_offline_tools.py deleted file mode 100644 index 7d1feb1..0000000 --- a/tools/python/tests/test_offline_tools.py +++ /dev/null @@ -1,438 +0,0 @@ -from __future__ import annotations - -import base64 -import inspect -import json -from pathlib import Path -import sys -import tempfile -import unittest -import zipfile -from unittest import mock - -from Crypto.Cipher import AES -from google.protobuf import descriptor_pb2 - - -TOOLS = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(TOOLS)) - -import gamedata_db -import import_seed -import deobfuscate_client_source -import extract_client_proto -import dev_mail_grant -import inherited_stage - - -class Arguments: - pass - - -class GameDataToolTests(unittest.TestCase): - def test_extracts_and_decrypts_logical_database(self): - plain = bytearray(gamedata_db.PAGE_SIZE) - plain[: len(gamedata_db.HEADER)] = gamedata_db.HEADER - key = gamedata_db.derive_key() - encrypted = AES.new(key, AES.MODE_CBC, gamedata_db.HEADER).encrypt(bytes(plain)) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - release = root / "123" / "release" - release.mkdir(parents=True) - with zipfile.ZipFile(release / gamedata_db.ARCHIVE_NAME, "w") as archive: - archive.writestr(gamedata_db.database_entry("pack21"), encrypted) - self.assertEqual(gamedata_db.read_database(root, "123", "pack21"), bytes(plain)) - - def test_wire_field_selection(self): - proto = import_seed.encode_field(1, 0, 42) + import_seed.encode_field(3, 2, b"abc") - decoded = gamedata_db.wire_fields(proto, {1, 3}) - self.assertEqual(decoded[1][0]["varint"], 42) - self.assertEqual(decoded[3][0]["utf8"], "abc") - - -class ImportToolTests(unittest.TestCase): - def test_login_import_removes_captured_key(self): - user = import_seed.encode_field(1, 0, 7) - user += import_seed.encode_field(3, 2, b"captured-secret") - proto = import_seed.encode_field(1, 2, user) + import_seed.encode_field(4, 0, 9) - with tempfile.TemporaryDirectory() as temporary: - source = Path(temporary) / "login.pb" - source.write_bytes(proto) - args = Arguments() - args.input, args.packet_code = source, 11 - result = import_seed.import_login(args) - imported_user = base64.b64decode(result["user_info_base64"]) - self.assertFalse(any(field.number == 3 for field in import_seed.fields(imported_user))) - self.assertEqual(base64.b64decode(result["response_fields_base64"]), import_seed.encode_field(4, 0, 9)) - - def test_starter_and_mail_import(self): - item = b"".join( - import_seed.encode_field(number, 0, value) - for number, value in ((1, 10), (2, 8), (3, 8), (4, 3)) - ) - costume = import_seed.encode_field(1, 0, 20) + import_seed.encode_field(2, 0, 3501) - character = import_seed.encode_field(1, 0, 30) + import_seed.encode_field(2, 0, 350) - mail = b"".join( - import_seed.encode_field(number, 0, value) - for number, value in ((1, 1), (2, 2), (7, 100), (13, 50)) - ) - mail += import_seed.encode_field(8, 2, import_seed.encode_varint(8)) - mail += import_seed.encode_field(9, 2, import_seed.encode_varint(7)) - mail += import_seed.encode_field(10, 2, import_seed.encode_varint(3)) - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - for name, proto in { - "items.pb": import_seed.encode_field(1, 2, item), - "costumes.pb": import_seed.encode_field(1, 2, costume), - "characters.pb": import_seed.encode_field(1, 2, character), - "mail.pb": import_seed.encode_field(1, 2, mail) - + import_seed.encode_field(2, 0, 2) - + import_seed.encode_field(3, 0, 1), - }.items(): - (root / name).write_bytes(proto) - args = Arguments() - args.items, args.costumes, args.characters = ( - root / "items.pb", - root / "costumes.pb", - root / "characters.pb", - ) - starter = import_seed.import_starter(args) - args.input = root / "mail.pb" - mailbox = import_seed.import_mail(args) - self.assertEqual(starter["items"][0]["count"], 3) - self.assertEqual(starter["costumes"][0]["id"], 3501) - self.assertEqual(starter["characters"][0]["id"], 350) - self.assertEqual(mailbox["mails"][0]["reward_counts"], [3]) - - -class DevelopmentMailGrantToolTests(unittest.TestCase): - def test_deterministic_random_box_is_replaced_by_direct_material(self): - items = [ - {"id": 400131, "element_type": 9, "name": "装备制作所需材料", "category": "随机箱"}, - {"id": 127, "element_type": 8, "name": "<未找到本地化文本 #32127>", "category": "资源"}, - {"id": 999, "element_type": 9, "name": "真正随机箱", "category": "随机箱"}, - ] - mapped = dev_mail_grant.map_fixed_boxes_to_direct_items( - items, - {400131: (8, 127, 1)}, - {400131: {"女神之泪": 1}}, - ) - self.assertNotIn((9, 400131), {(item["element_type"], item["id"]) for item in mapped}) - self.assertNotIn((9, 999), {(item["element_type"], item["id"]) for item in mapped}) - material = next(item for item in mapped if item["element_type"] == 8 and item["id"] == 127) - self.assertEqual(material["name"], "女神之泪") - self.assertIn("无需开箱", material["details"]) - self.assertIn("装备制作所需材料", material["details"]) - self.assertIn("400131", material["details"]) - - def test_single_consistent_box_name_becomes_the_material_display_name(self): - items = [ - {"id": 400045, "element_type": 9, "name": "精炼粉末", "category": "随机箱"}, - {"id": 10, "element_type": 8, "name": "强化袋", "category": "资源"}, - ] - mapped = dev_mail_grant.map_fixed_boxes_to_direct_items( - items, - {400045: (8, 10, 5)}, - {}, - ) - material = next(item for item in mapped if item["element_type"] == 8 and item["id"] == 10) - self.assertEqual(material["name"], "精炼粉末") - self.assertIn("原始资源名:强化袋", material["details"]) - - def test_localized_names_reads_the_requested_text_namespace(self): - import sqlite3 - - connection = sqlite3.connect(":memory:") - connection.execute("CREATE TABLE RandomBoxTextTable (id INTEGER, ProtoBuf BLOB)") - name = "精炼粉末".encode("utf-8") - connection.execute( - "INSERT INTO RandomBoxTextTable VALUES (?, ?)", - (7, b"\x10\x07\x22" + bytes([len(name)]) + name), - ) - self.assertEqual(dev_mail_grant._localized_names(connection, "RandomBoxTextTable"), {7: "精炼粉末"}) - connection.close() - - def test_static_items_use_each_sources_declared_text_namespace(self): - import sqlite3 - - def text_proto(text_id: int, value: str) -> bytes: - return import_seed.encode_field(2, 0, text_id) + import_seed.encode_field(4, 2, value.encode("utf-8")) - - connection = sqlite3.connect(":memory:") - for table in {source[0] for source in dev_mail_grant.ITEM_SOURCES}: - connection.execute(f'CREATE TABLE "{table}" (id INTEGER, ProtoBuf BLOB)') - for table in {source[5] for source in dev_mail_grant.ITEM_SOURCES}: - connection.execute(f'CREATE TABLE "{table}" (id INTEGER, ProtoBuf BLOB)') - - connection.execute("INSERT INTO NameTextTable VALUES (?, ?)", (32401, text_proto(32401, "普通布料"))) - connection.execute("INSERT INTO NameTextTable VALUES (?, ?)", (32501, text_proto(32501, "原木"))) - connection.execute("INSERT INTO RandomBoxTextTable VALUES (?, ?)", (77, text_proto(77, "材料选择箱"))) - connection.execute( - "INSERT INTO ResourceTable VALUES (?, ?)", - (401, import_seed.encode_field(4, 0, 401) + import_seed.encode_field(7, 0, 32401)), - ) - connection.execute( - "INSERT INTO ResourceTable VALUES (?, ?)", - (501, import_seed.encode_field(4, 0, 501) + import_seed.encode_field(7, 0, 32501)), - ) - connection.execute( - "INSERT INTO RandomBoxTable VALUES (?, ?)", - (700, import_seed.encode_field(4, 0, 700) + import_seed.encode_field(7, 0, 77)), - ) - - items = dev_mail_grant._static_items(connection) - by_key = {(item["element_type"], item["id"]): item["name"] for item in items} - self.assertEqual(by_key[(8, 401)], "普通布料") - self.assertEqual(by_key[(8, 501)], "原木") - self.assertEqual(by_key[(9, 700)], "材料选择箱") - connection.close() - - def test_mail_currencies_are_discovered_from_currency_table(self): - import sqlite3 - - def proto_field(number: int, value: int) -> bytes: - return import_seed.encode_field(number, 0, value) - - connection = sqlite3.connect(":memory:") - connection.execute("CREATE TABLE NameTextTable (id INTEGER, ProtoBuf BLOB)") - connection.execute("CREATE TABLE CurrencyTable (id INTEGER, ProtoBuf BLOB)") - for text_id, value in ((264, "天赋神药"), (681, "金线"), (999, "未支持货币")): - encoded = value.encode("utf-8") - connection.execute( - "INSERT INTO NameTextTable VALUES (?, ?)", - (text_id, proto_field(2, text_id) + import_seed.encode_field(4, 2, encoded)), - ) - connection.execute("INSERT INTO CurrencyTable VALUES (?, ?)", (12, proto_field(3, 12) + proto_field(5, 264))) - connection.execute("INSERT INTO CurrencyTable VALUES (?, ?)", (20, proto_field(3, 20) + proto_field(5, 681))) - connection.execute("INSERT INTO CurrencyTable VALUES (?, ?)", (99, proto_field(3, 99) + proto_field(5, 999))) - - currencies = dev_mail_grant._mail_currencies(connection) - self.assertEqual([(item["element_type"], item["id"], item["name"]) for item in currencies], [(12, 0, "天赋神药"), (20, 0, "金线")]) - connection.close() - - def test_internal_lost_resource_is_not_mail_safe(self): - self.assertFalse(dev_mail_grant._safe_direct_mail_item({ - "id": 90045, "element_type": 8, "name": "金币遗失物品", - "source_table": "ResourceTable", "resource_type": 2, - })) - - def test_gold_currency_mail_uses_type_four_id_zero_and_requested_count(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - output = root / "generated.json" - gold = {"id": 0, "element_type": 4, "name": "金币"} - store = dev_mail_grant.MailGrantStore(output, [gold]) - result = store.grant({"item_id": 0, "element_type": 4, "count": 123456789}) - self.assertEqual(result["grant"]["rewards"], [{"type": 4, "id": 0, "count": 123456789}]) - - def test_packed_varints_accepts_repeated_and_packed_fields(self): - self.assertEqual(dev_mail_grant.packed_varints({4: [3, b"\x80\x01\x02"]}, 4), [3, 128, 2]) - with self.assertRaises(ValueError): - dev_mail_grant.packed_varints({4: [b"\x80"]}, 4) - - def test_item_picker_is_collapsible_and_scrolls_its_list(self): - self.assertIn('
', dev_mail_grant.PAGE) - self.assertIn('
', dev_mail_grant.PAGE) - self.assertIn('max-height:min(40vh,28rem)', dev_mail_grant.PAGE) - self.assertIn('overflow:auto', dev_mail_grant.PAGE) - self.assertIn("$('item-picker').open=false", dev_mail_grant.PAGE) - - def test_picker_search_includes_random_box_aliases(self): - self.assertIn("(x.aliases||[]).join(' ')", dev_mail_grant.PAGE) - - def test_mail_tool_disables_browser_response_caching(self): - self.assertIn('self.send_header("Cache-Control", "no-store")', inspect.getsource(dev_mail_grant.Handler.reply)) - - def test_development_settings_are_strict_and_atomic(self): - limits = { - "baseline": {"items": 100, "equipment": 500}, - "enabled_limits": {"items": 500, "equipment": 2000}, - } - with tempfile.TemporaryDirectory() as temporary: - path = Path(temporary) / "dev-tools.json" - store = dev_mail_grant.DevelopmentSettingsStore(path, limits) - self.assertFalse(store.snapshot()["inventory"]["unlimited"]) - updated = store.set_inventory({"unlimited": True}) - self.assertTrue(updated["inventory"]["unlimited"]) - self.assertEqual(json.loads(path.read_text(encoding="utf-8")), { - "version": 1, "inventory": {"unlimited": True}, - }) - reopened = dev_mail_grant.DevelopmentSettingsStore(path, limits) - self.assertTrue(reopened.snapshot()["inventory"]["unlimited"]) - for invalid in ({"unlimited": 1}, {"unlimited": "true"}, {"unlimited": True, "extra": 1}, {}): - with self.assertRaises(ValueError): - store.set_inventory(invalid) - path.write_text('{"version":1,"inventory":{"unlimited":true},"extra":1}', encoding="utf-8") - with self.assertRaises(ValueError): - dev_mail_grant.DevelopmentSettingsStore(path, limits) - - def test_development_tool_page_places_inventory_after_mail(self): - self.assertIn("BD2 开发工具", dev_mail_grant.PAGE) - self.assertLess(dev_mail_grant.PAGE.index("开发邮件发放"), dev_mail_grant.PAGE.index("无限背包容量")) - self.assertIn("重新登录客户端后生效", dev_mail_grant.PAGE) - self.assertIn("/api/settings/inventory", dev_mail_grant.PAGE) - - def test_browser_grant_appends_spool_without_allocating_mail_id(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - output = root / "mail-grants-spool.json" - store = dev_mail_grant.MailGrantStore(output, [{ - "id": 9, "element_type": 8, "name": "slime", - }]) - self.assertTrue(output.is_file()) - self.assertEqual(json.loads(output.read_text(encoding="utf-8")), {"version": 1, "grants": []}) - result = store.grant({"item_id": 9, "element_type": 8, "count": 123, "title": "test", "body": "body"}) - written = json.loads(output.read_text(encoding="utf-8")) - self.assertEqual(written["version"], 1) - self.assertEqual(len(written["grants"]), 1) - self.assertEqual(written["grants"][0]["rewards"], [{"type": 8, "id": 9, "count": 123}]) - self.assertNotIn("mail_id", written["grants"][0]) - self.assertEqual(result["grant"], written["grants"][0]) - self.assertFalse(result["restart_required"]) - with self.assertRaises(ValueError): - store.grant({"item_id": 999, "element_type": 8, "count": 1}) - with self.assertRaises(ValueError): - store.grant({"item_id": 9, "element_type": 8, "count": dev_mail_grant.MAX_INT32 + 1}) - - def test_browser_form_blocks_duplicate_submit_until_response(self): - self.assertIn("if(submitting)return", dev_mail_grant.PAGE) - self.assertIn("$('grant-submit').disabled=true", dev_mail_grant.PAGE) - self.assertIn("x.grant.identity", dev_mail_grant.PAGE) - - -class ClientSourceToolTests(unittest.TestCase): - def test_generated_tree_staging_uses_parent_inheriting_directory_mode(self): - with tempfile.TemporaryDirectory() as temporary: - output = Path(temporary) / "dev" / "client-source-readable" - real_mkdir = inherited_stage.os.mkdir - calls = [] - - def recording_mkdir(path, *args, **kwargs): - calls.append((Path(path), args, kwargs)) - return real_mkdir(path, *args, **kwargs) - - with mock.patch.object(inherited_stage.os, "mkdir", side_effect=recording_mkdir): - stage = inherited_stage.create_inherited_stage(output) - try: - self.assertEqual(stage.parent, output.parent) - stage_call = next(call for call in calls if call[0] == stage) - self.assertEqual(stage_call[1], ()) - self.assertEqual(stage_call[2], {}) - finally: - stage.rmdir() - - def test_deobfuscates_code_not_comments_or_literals_and_records_collisions(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / "Assembly-CSharp" - source.mkdir() - mapping = root / "translation.obfuscate" - mapping.write_text( - "#ReverseOrder\n" - "α⇨Net.Player/User-Info\n" - "β⇨Other.User Info\n" - "γ⇨class\n" - "δ⇨Meaning.D\n", - encoding="utf-8", - ) - (source / "α.cs").write_text( - "// α β γ δ\n" - "class α { string v = \"α β γ δ\"; char x = 'α'; α f; β g; γ h; δ i; }\n", - encoding="utf-8", - ) - (source / "asset.bin").write_bytes(b"\x00a") - output = root / "mirror" - manifest = deobfuscate_client_source.deobfuscate(source, mapping, output) - text = (output / "User_Info.cs").read_text(encoding="utf-8") - self.assertIn("// α β γ δ", text) - self.assertIn('"α β γ δ"', text) - self.assertIn("char x = 'α'", text) - self.assertIn("class User_Info", text) - self.assertIn("User_Info__from_u03B2 g", text) - self.assertIn("_class h", text) - self.assertIn("D i", text) - self.assertEqual((output / "asset.bin").read_bytes(), b"\x00a") - self.assertEqual(manifest["statistics"]["identifier_replacements"], 5) - self.assertTrue((output / deobfuscate_client_source.MANIFEST).is_file()) - - def test_deobfuscator_skips_ambiguous_scoped_symbol(self): - entries = [ - {"line": 1, "source": "α", "meaning": "One.Value"}, - {"line": 2, "source": "α", "meaning": "Two.Value"}, - ] - replacements, report, warnings = deobfuscate_client_source.build_replacements(entries) - self.assertNotIn("α", replacements) - self.assertEqual(report, []) - self.assertTrue(any("ambiguous" in warning for warning in warnings)) - - def test_deobfuscator_refuses_output_inside_source_or_unmanaged_output(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / "source" - source.mkdir() - mapping = root / "map" - mapping.write_text("a⇨Name\n", encoding="utf-8") - with self.assertRaises(ValueError): - deobfuscate_client_source.deobfuscate(source, mapping, source / "out") - output = root / "output" - output.mkdir() - (output / "someone.txt").write_text("keep", encoding="utf-8") - with self.assertRaises(FileExistsError): - deobfuscate_client_source.deobfuscate(source, mapping, output) - - def test_reconstructs_proto_and_lossless_descriptor_set(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / "Assembly-CSharp" - net = source / "Proto" / "Net" - net.mkdir(parents=True) - descriptor = descriptor_pb2.FileDescriptorProto( - name="Request/Login.proto", package="proto.net", syntax="proto3" - ) - message = descriptor.message_type.add(name="LoginRequest") - message.field.add( - name="seq", number=1, - label=descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL, - type=descriptor_pb2.FieldDescriptorProto.TYPE_INT32, - ) - encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii") - (net / "LoginRequestReflection.cs").write_text( - "private static FileDescriptor descriptor = " - "FileDescriptor.FromGeneratedCode(Convert.FromBase64String(" - f"string.Concat(new string[] {{ \"{encoded}\" }})), new FileDescriptor[0], info);", - encoding="utf-8", - ) - output = root / "proto-view" - manifest = extract_client_proto.reconstruct(source, output) - proto = (output / "Request" / "Login.proto").read_text(encoding="utf-8") - self.assertIn('syntax = "proto3";', proto) - self.assertIn("package proto.net;", proto) - self.assertIn("message LoginRequest", proto) - self.assertIn("int32 seq = 1;", proto) - saved = descriptor_pb2.FileDescriptorSet() - saved.ParseFromString((output / extract_client_proto.DESCRIPTOR_SET).read_bytes()) - self.assertEqual(saved.file[0], descriptor) - self.assertEqual(manifest["statistics"]["proto_files"], 1) - - def test_proto_extractor_rejects_unmanaged_or_nested_outputs(self): - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - source = root / "source" - (source / "Proto" / "Net").mkdir(parents=True) - with self.assertRaises(ValueError): - extract_client_proto.reconstruct(source, source / "view") - descriptor = descriptor_pb2.FileDescriptorProto(name="X.proto", syntax="proto3") - encoded = base64.b64encode(descriptor.SerializeToString()).decode("ascii") - (source / "Proto" / "Net" / "XReflection.cs").write_text( - "Convert.FromBase64String(string.Concat(new string[] {" - f"\"{encoded}\"" + "})), new FileDescriptor[0], info);", - encoding="utf-8", - ) - output = root / "view" - output.mkdir() - with self.assertRaises(FileExistsError): - extract_client_proto.reconstruct(source, output) - - -if __name__ == "__main__": - unittest.main()