feat(all): add game SDK and harden client login and game rules
- Add costume burst upgrades with durable replay and fix permanent SR equipment gacha. - Add server game.json rules for optional collaboration UR weapons, with packaging and documentation. - Add GameNames/GameSdk NuGet tooling, readable types, reobfuscation, complete source/PDB navigation and shared-runtime installation. - Split plugin responsibilities, prefer typeof/nameof, centralize MSBuild settings and ignore local paths; remove the unused LoginUI test project. - Fix login recovery and TLS through native platform HTTP and a private game relay; read device region directly from the OS time zone. Validation: go test ./... -count=1, go vet ./..., plugin Release builds, SDK/runtime self-tests, Roslyn embedded-source navigation, gofmt check and git diff --check.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
//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} {
|
||||
if !clientDevelopmentFile(path) {
|
||||
t.Fatalf("development plugin output missing: %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ func runDevelopmentCommand(args []string) (bool, error) {
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--version-config", filepath.Join(root, "versions.json"))
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--authentication-config", filepath.Join(root, "authentication.json"))
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--resource-config", filepath.Join(root, "resources.json"))
|
||||
serveArgs = appendDefaultFlag(serveArgs, "--game-config", filepath.Join(root, "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)
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/feature"
|
||||
"bd2server/internal/server/gacha"
|
||||
"bd2server/internal/server/gameconfig"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/lifecycle"
|
||||
"bd2server/internal/server/mail"
|
||||
@@ -80,6 +81,7 @@ func serve(args []string) (serveErr error) {
|
||||
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)")
|
||||
@@ -118,6 +120,16 @@ func serve(args []string) (serveErr error) {
|
||||
}
|
||||
}
|
||||
versionconfig.Use(versions)
|
||||
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 {
|
||||
@@ -220,6 +232,13 @@ func serve(args []string) (serveErr error) {
|
||||
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)
|
||||
limitedCostumes, err := gamedata.LoadLimitedCostumes(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load limited costume GameData: %w", err)
|
||||
@@ -519,6 +538,14 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID)
|
||||
battleService.AttachTutorialWin(func() error {
|
||||
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
|
||||
@@ -538,6 +565,7 @@ func serve(args []string) (serveErr error) {
|
||||
inventorySlots,
|
||||
charAwakeService,
|
||||
costumePotentialService,
|
||||
costumeBurstService,
|
||||
starter,
|
||||
mailService,
|
||||
gachaService,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"bd2server/internal/server/authconfig"
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/gacha"
|
||||
"bd2server/internal/server/gameconfig"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/mail"
|
||||
"bd2server/internal/server/player"
|
||||
@@ -28,6 +29,7 @@ func preflight(args []string) error {
|
||||
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
|
||||
}
|
||||
@@ -39,6 +41,16 @@ func preflight(args []string) error {
|
||||
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 {
|
||||
@@ -106,15 +118,24 @@ func preflight(args []string) error {
|
||||
for _, window := range schedule.StepUps {
|
||||
steps = append(steps, window.GroupID)
|
||||
}
|
||||
if _, _, err := gamedata.LoadActiveGachaForSchedules(gameData, versions.GameDataVersion, groups, steps); err != nil {
|
||||
_, 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
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ type Spec struct {
|
||||
var (
|
||||
LocalIdentity = Spec{fileName: "BD2LocalIdentity.dll"}
|
||||
LoginUI = Spec{fileName: "BD2LoginUI.dll"}
|
||||
GameNames = Spec{fileName: "BD2.GameNames.dll"}
|
||||
)
|
||||
|
||||
func (s Spec) FileName() string { return s.fileName }
|
||||
@@ -54,8 +55,8 @@ func ResolvePackaged(spec Spec, explicit string) (string, error) {
|
||||
}
|
||||
|
||||
// Install verifies that the user installed BepInEx, then atomically stages the
|
||||
// packaged plugin into its plugins directory. It never installs or downloads
|
||||
// BepInEx itself.
|
||||
// packaged plugin and its adjacent shared game-names runtime into its plugins
|
||||
// directory. It never installs or downloads BepInEx itself.
|
||||
func Install(spec Spec, gameDir, source string) (Result, error) {
|
||||
if err := spec.validate(); err != nil {
|
||||
return Result{}, err
|
||||
@@ -80,7 +81,33 @@ func Install(spec Spec, gameDir, source string) (Result, error) {
|
||||
if len(sourceData) == 0 {
|
||||
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", spec.fileName)
|
||||
}
|
||||
pluginDir := installation.Plugins
|
||||
// Read both artifacts before touching the installation. The runtime table
|
||||
// library is shipped beside every plugin, including explicit development paths.
|
||||
var runtimeData []byte
|
||||
if spec == LocalIdentity || spec == LoginUI {
|
||||
runtimeSource := filepath.Join(filepath.Dir(source), GameNames.fileName)
|
||||
runtimeData, err = os.ReadFile(runtimeSource)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("clientplugin: read shared runtime %s: %w", runtimeSource, err)
|
||||
}
|
||||
if len(runtimeData) == 0 {
|
||||
return Result{}, fmt.Errorf("clientplugin: packaged %s is empty", GameNames.fileName)
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
if runtimeData != nil {
|
||||
runtimeResult, err := installData(GameNames, installation.Plugins, runtimeData)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
changed = runtimeResult.Changed
|
||||
}
|
||||
result, err := installData(spec, installation.Plugins, sourceData)
|
||||
result.Changed = result.Changed || changed
|
||||
return result, err
|
||||
}
|
||||
|
||||
func installData(spec Spec, pluginDir string, sourceData []byte) (Result, error) {
|
||||
destination := filepath.Join(pluginDir, spec.fileName)
|
||||
if installed, err := os.ReadFile(destination); err == nil {
|
||||
if bytes.Equal(hash(installed), hash(sourceData)) {
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestInstallRequiresBepInExWithoutCopyingPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -54,6 +57,9 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -65,6 +71,18 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
@@ -80,6 +98,34 @@ func TestInstallCopiesUpdatesAndSkipsIdenticalPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -96,6 +142,9 @@ func TestInstallKeepsPluginsSeparate(t *testing.T) {
|
||||
}
|
||||
for _, spec := range []Spec{LocalIdentity, LoginUI} {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package gameconfig loads server-authoritative gameplay rules.
|
||||
package gameconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const FileName = "game.json"
|
||||
|
||||
type Config struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Gacha GachaConfig `json:"gacha"`
|
||||
}
|
||||
|
||||
type GachaConfig struct {
|
||||
IncludeCollaborationURWeapons bool `json:"include_collaboration_ur_weapons"`
|
||||
}
|
||||
|
||||
func Default() Config { return Config{SchemaVersion: 1} }
|
||||
|
||||
// Load reads one explicit path. Older installations without the file retain
|
||||
// the default rules; an existing invalid file must never silently fall back.
|
||||
func Load(path string) (Config, error) {
|
||||
data, err := os.ReadFile(filepath.Clean(path))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Default(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: read %s: %w", path, err)
|
||||
}
|
||||
var cfg Config
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return Config{}, fmt.Errorf("game configuration: trailing data in %s", path)
|
||||
}
|
||||
// encoding/json accepts null for scalar and struct fields. These rules have
|
||||
// explicit defaults for omissions, while null is a configuration error.
|
||||
var document any
|
||||
if err := json.Unmarshal(data, &document); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: decode %s: %w", path, err)
|
||||
}
|
||||
if containsNull(document) {
|
||||
return Config{}, fmt.Errorf("game configuration: null is not allowed in %s", path)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, fmt.Errorf("game configuration: %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func containsNull(value any) bool {
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case map[string]any:
|
||||
for _, child := range value {
|
||||
if containsNull(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range value {
|
||||
if containsNull(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.SchemaVersion != 1 {
|
||||
return fmt.Errorf("unsupported schema_version %d (expected 1)", c.SchemaVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func BesideExecutable() (string, error) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("game configuration: resolve server executable: %w", err)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(executable), FileName), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 TestMissingFileDefaultsWithoutCreating(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); !os.IsNotExist(err) {
|
||||
t.Fatalf("Load created 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CostumeBurstLevel is one row of CostumeBurstTable. Level is the database
|
||||
// row id (and is the level unlocked by upgrading from Level-1).
|
||||
type CostumeBurstLevel struct {
|
||||
CostumeID uint64
|
||||
Level uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
// CostumeBurstUpgradeRule is the exact cost for the next burst level.
|
||||
type CostumeBurstUpgradeRule struct {
|
||||
CostumeID uint64
|
||||
CurrentLevel uint64
|
||||
NextLevel uint64
|
||||
MaxLevel uint64
|
||||
Costs []PromotionCost
|
||||
}
|
||||
|
||||
// CostumeBurstDesign contains only costumes which have a complete, contiguous
|
||||
// CostumeBurstTable group. GroupId is the CostumeTable id for burst-enabled
|
||||
// costumes (the table also carries CostumeType for newer costume families).
|
||||
type CostumeBurstDesign struct {
|
||||
Levels map[uint64]map[uint64]CostumeBurstLevel
|
||||
}
|
||||
|
||||
func LoadCostumeBurstDesign(root, version string) (*CostumeBurstDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadCostumeBurstDesign(db)
|
||||
}
|
||||
|
||||
func loadCostumeBurstDesign(db *sql.DB) (*CostumeBurstDesign, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("gamedata: nil costume burst database")
|
||||
}
|
||||
design := &CostumeBurstDesign{Levels: make(map[uint64]map[uint64]CostumeBurstLevel)}
|
||||
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeBurstTable ORDER BY groupId,id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var groupID, level uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&groupID, &level, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if groupID == 0 || level == 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume burst identity %d/%d", groupID, level)
|
||||
}
|
||||
groups, groupErr := packedInts(raw, 8)
|
||||
ids, idErr := packedInts(raw, 9)
|
||||
counts, countErr := packedInts(raw, 10)
|
||||
itemIDs, itemIDErr := packedInts(raw, 11)
|
||||
types, typeErr := packedInts(raw, 12)
|
||||
if groupErr != nil || idErr != nil || countErr != nil || itemIDErr != nil || typeErr != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid fields", groupID, level)
|
||||
}
|
||||
if len(groups) != 1 || groups[0] != groupID || len(ids) != 1 || ids[0] != level {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched proto identity", groupID, level)
|
||||
}
|
||||
if len(counts) == 0 || len(counts) != len(itemIDs) || len(itemIDs) != len(types) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has mismatched costs", groupID, level)
|
||||
}
|
||||
entry := CostumeBurstLevel{CostumeID: groupID, Level: level}
|
||||
for i := range counts {
|
||||
if counts[i] == 0 || (types[i] == 4 && itemIDs[i] != 0) ||
|
||||
(types[i] == 8 && itemIDs[i] == 0) || (types[i] != 4 && types[i] != 8) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d/%d has invalid cost", groupID, level)
|
||||
}
|
||||
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: itemIDs[i], Count: counts[i]})
|
||||
}
|
||||
if _, exists := design.Levels[groupID][level]; exists {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: duplicate costume burst %d/%d", groupID, level)
|
||||
}
|
||||
if design.Levels[groupID] == nil {
|
||||
design.Levels[groupID] = make(map[uint64]CostumeBurstLevel)
|
||||
}
|
||||
design.Levels[groupID][level] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(design.Levels) == 0 {
|
||||
return nil, errors.New("gamedata: CostumeBurstTable is empty")
|
||||
}
|
||||
for costumeID, levels := range design.Levels {
|
||||
for level := uint64(1); level <= uint64(len(levels)); level++ {
|
||||
if _, ok := levels[level]; !ok {
|
||||
return nil, fmt.Errorf("gamedata: costume burst %d missing level %d", costumeID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
// UpgradeRule returns the costs for currentLevel -> currentLevel+1.
|
||||
func (d *CostumeBurstDesign) UpgradeRule(costumeID, currentLevel uint64) (CostumeBurstUpgradeRule, error) {
|
||||
if d == nil || costumeID == 0 {
|
||||
return CostumeBurstUpgradeRule{}, errors.New("gamedata: invalid costume burst lookup")
|
||||
}
|
||||
levels, ok := d.Levels[costumeID]
|
||||
if !ok {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d has no burst design", costumeID)
|
||||
}
|
||||
maxLevel := uint64(len(levels))
|
||||
if currentLevel >= maxLevel {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d burst is already at maximum level %d", costumeID, maxLevel)
|
||||
}
|
||||
next := currentLevel + 1
|
||||
entry, ok := levels[next]
|
||||
if !ok {
|
||||
return CostumeBurstUpgradeRule{}, fmt.Errorf("gamedata: costume %d missing burst level %d", costumeID, next)
|
||||
}
|
||||
return CostumeBurstUpgradeRule{
|
||||
CostumeID: costumeID, CurrentLevel: currentLevel, NextLevel: next,
|
||||
MaxLevel: maxLevel, Costs: append([]PromotionCost(nil), entry.Costs...),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
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 db.Close()
|
||||
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 db.Close()
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// IncludeCollaborationURWeapons extends only the UR equipment ticket pool.
|
||||
// Limited costumes identify eligible owners; equipment IDs remain version data.
|
||||
func (c *EquipmentGachaCatalog) IncludeCollaborationURWeapons(root, version string) error {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-collaboration-equipment-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return c.includeCollaborationURWeapons(db)
|
||||
}
|
||||
|
||||
func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error {
|
||||
if c == nil || c.equipment == nil {
|
||||
return fmt.Errorf("gamedata: missing equipment catalog")
|
||||
}
|
||||
g, ok := c.Gachas[71200001]
|
||||
if !ok || !g.TicketOnly || len(g.TicketIDs) != 1 || g.TicketIDs[0] != 1104 || len(g.Pool) != 3 {
|
||||
return fmt.Errorf("gamedata: missing or malformed UR equipment ticket pool")
|
||||
}
|
||||
readRows := func(query string) (map[uint64][]byte, error) {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uint64][]byte{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = raw
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
scalar := func(raw []byte, field int) (uint64, error) {
|
||||
v, err := packedInts(raw, field)
|
||||
if err != nil || len(v) != 1 || v[0] == 0 {
|
||||
return 0, fmt.Errorf("gamedata: invalid collaboration equipment field %d", field)
|
||||
}
|
||||
return v[0], nil
|
||||
}
|
||||
owners, err := readRows("SELECT DISTINCT c.useUniqueCharId,c.ProtoBuf FROM LimitedCostumeTable l JOIN CostumeTable c ON c.id=l.id ORDER BY c.useUniqueCharId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
chars, err := readRows("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId IN (SELECT privateUniqueCharId FROM EquipmentTable WHERE privateUniqueCharId>0) ORDER BY id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grades := map[uint64]uint64{}
|
||||
for _, raw := range chars {
|
||||
owner, err := scalar(raw, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grade, err := scalar(raw, 9)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, exists := grades[owner]; exists && previous != grade {
|
||||
return fmt.Errorf("gamedata: conflicting character grade for %d", owner)
|
||||
}
|
||||
grades[owner] = grade
|
||||
}
|
||||
equipment, err := readRows("SELECT id,ProtoBuf FROM EquipmentTable ORDER BY id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool := make([]WeightedEquipment, len(g.Pool))
|
||||
branchForGrade := map[uint64]int{}
|
||||
seen := map[uint64]bool{}
|
||||
for index, branch := range g.Pool {
|
||||
if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
|
||||
return fmt.Errorf("gamedata: malformed UR ticket branch")
|
||||
}
|
||||
pool[index] = WeightedEquipment{Weight: branch.Weight, Children: append([]WeightedEquipment(nil), branch.Children...)}
|
||||
var branchGrade uint64
|
||||
for _, item := range branch.Children {
|
||||
if item.ID == 0 || item.Weight != 1 || len(item.Children) != 0 || seen[item.ID] {
|
||||
return fmt.Errorf("gamedata: malformed or duplicate UR ticket candidate %d", item.ID)
|
||||
}
|
||||
seen[item.ID] = true
|
||||
owner, err := scalar(equipment[item.ID], 16)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
grade := grades[owner]
|
||||
if grade < 3 || grade > 5 || (branchGrade != 0 && branchGrade != grade) {
|
||||
return fmt.Errorf("gamedata: unknown or mixed UR ticket character tier")
|
||||
}
|
||||
branchGrade = grade
|
||||
}
|
||||
if _, exists := branchForGrade[branchGrade]; exists {
|
||||
return fmt.Errorf("gamedata: duplicate UR ticket tier")
|
||||
}
|
||||
branchForGrade[branchGrade] = index
|
||||
}
|
||||
// Collect designs before publishing the cloned pool, so a failed load leaves
|
||||
// the catalog unchanged. Order by ID to keep repeated loads deterministic.
|
||||
designs := map[uint64]EquipmentDesign{}
|
||||
ids := make([]uint64, 0, len(equipment))
|
||||
for id := range equipment {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for _, id := range ids {
|
||||
raw := equipment[id]
|
||||
grade, _ := packedInts(raw, 3)
|
||||
quality, _ := packedInts(raw, 18)
|
||||
owner, _ := packedInts(raw, 16)
|
||||
if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 {
|
||||
continue
|
||||
}
|
||||
if _, eligible := owners[owner[0]]; !eligible || seen[id] {
|
||||
continue
|
||||
}
|
||||
index, exists := branchForGrade[grades[owner[0]]]
|
||||
if !exists {
|
||||
return fmt.Errorf("gamedata: unknown collaboration equipment tier for %d", id)
|
||||
}
|
||||
design, err := loadEquipmentDesign(db, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
designs[id] = design
|
||||
pool[index].Children = append(pool[index].Children, WeightedEquipment{ID: id, Weight: 1})
|
||||
seen[id] = true
|
||||
}
|
||||
for id, design := range designs {
|
||||
c.equipment[id] = design
|
||||
}
|
||||
g.Pool = pool
|
||||
c.Gachas[g.ID] = g
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"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 := LoadEquipmentGacha(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := map[uint64]EquipmentGacha{}
|
||||
for id, g := range c.Gachas {
|
||||
before[id] = g
|
||||
}
|
||||
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]) {
|
||||
t.Fatalf("other product %d changed", id)
|
||||
}
|
||||
}
|
||||
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 db.Close()
|
||||
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{71200001: {ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, 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[71200001].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[71200001].Pool[0].Children) != 2 {
|
||||
t.Fatal("duplicate owner or repeated inclusion duplicated equipment")
|
||||
}
|
||||
if _, err := db.Exec("UPDATE CharTable SET ProtoBuf=? WHERE id=4", proto(20, 4, 9, 2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(c.Gachas, 71200001)
|
||||
c.Gachas[71200001] = EquipmentGacha{ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}
|
||||
if err := c.includeCollaborationURWeapons(db); err == nil {
|
||||
t.Fatal("unknown tier accepted")
|
||||
}
|
||||
if len(c.Gachas[71200001].Pool[0].Children) != 1 {
|
||||
t.Fatal("failed inclusion published partial pool")
|
||||
}
|
||||
invalid := c.Gachas[71200001]
|
||||
invalid.Pool = append([]WeightedEquipment(nil), pool...)
|
||||
invalid.Pool[0].Children = []WeightedEquipment{{ID: 101, Weight: 1}, {ID: 101, Weight: 1}}
|
||||
c.Gachas[71200001] = invalid
|
||||
if err := c.includeCollaborationURWeapons(db); err == nil {
|
||||
t.Fatal("duplicate existing candidate accepted")
|
||||
}
|
||||
}
|
||||
@@ -917,17 +917,17 @@ func LoadActiveGachaForSchedules(root, version string, scheduleGroupIDs, stepUpG
|
||||
return LoadActiveGacha(root, version, costume, equipment, stepUpGroupIDs)
|
||||
}
|
||||
|
||||
// permanentGachaGroupIDs returns static selection groups. ScheduleType=0
|
||||
// permanentGachaGroupIDs returns static selection and ordinary equipment groups. ScheduleType=0
|
||||
// groups are deliberately absent from the dynamic schedule response; the
|
||||
// selection metadata is what distinguishes permanent 12PICK-style banners
|
||||
// from standalone content-ticket banners handled by other flows.
|
||||
// selection or shared pity/point metadata distinguishes these banners from
|
||||
// content-ticket banners handled by other flows.
|
||||
func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType=1 ORDER BY id")
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType IN (1,2) ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: list permanent gacha groups: %w", err)
|
||||
}
|
||||
@@ -941,7 +941,10 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
|
||||
}
|
||||
subTypes, _ := packedInts(raw, 16)
|
||||
selectCounts, _ := packedInts(raw, 29)
|
||||
if len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
|
||||
types, _ := packedInts(raw, 17)
|
||||
if len(types) == 1 && types[0] == 1 && len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
|
||||
result = append(result, id)
|
||||
} else if isPermanentEquipmentGachaGroup(raw) {
|
||||
result = append(result, id)
|
||||
}
|
||||
}
|
||||
@@ -951,6 +954,22 @@ func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isPermanentEquipmentGachaGroup(raw []byte) bool {
|
||||
types, _ := packedInts(raw, 17)
|
||||
subTypes, _ := packedInts(raw, 16)
|
||||
selectCounts, _ := packedInts(raw, 29)
|
||||
fixed, _ := packedInts(raw, 10)
|
||||
points, _ := packedInts(raw, 27)
|
||||
one, _ := packedInts(raw, 24)
|
||||
ten, _ := packedInts(raw, 33)
|
||||
// Static content-ticket groups also have one/ten IDs, but have neither
|
||||
// shared pity nor draw points. They must not enter the ordinary catalog.
|
||||
// LoadEquipmentGachaGroups validates the referenced products and pools.
|
||||
return len(types) == 1 && types[0] == 2 && (len(subTypes) == 0 || len(subTypes) == 1 && subTypes[0] == 0) && len(selectCounts) == 0 &&
|
||||
len(fixed) == 1 && fixed[0] != 0 && len(points) == 1 && points[0] != 0 &&
|
||||
len(one) == 1 && one[0] != 0 && len(ten) == 1 && ten[0] != 0
|
||||
}
|
||||
|
||||
// ClassifyActiveGachaGroups reads static type metadata for groups already
|
||||
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
|
||||
// is intentionally omitted from both ordinary catalogs.
|
||||
|
||||
@@ -3,8 +3,29 @@ 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},
|
||||
@@ -162,11 +183,26 @@ func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
|
||||
t.Errorf("step-up %d=%+v ok=%v", groupID, stepUp, ok)
|
||||
}
|
||||
}
|
||||
for _, id := range []uint64{20100082, 21000082, 20100034, 21000034, 20100070, 21000070, 20100083, 21000083} {
|
||||
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) {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type CostumeUpgrade struct {
|
||||
@@ -106,32 +110,46 @@ type CharAwakeProgress struct {
|
||||
IsAwake bool `json:"is_awake"`
|
||||
}
|
||||
|
||||
// CostumeBurstUpgradeRecord is the durable result of one costume burst level
|
||||
// transition. Digest identifies the exact protobuf request whose successful
|
||||
// response is stored in Body, allowing a committed request to be replayed
|
||||
// after a process restart without charging its materials again.
|
||||
type CostumeBurstUpgradeRecord struct {
|
||||
CostumeID uint64 `json:"costume_id"`
|
||||
Level uint64 `json:"level"`
|
||||
Digest string `json:"digest"`
|
||||
Code int `json:"code"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
// FirstGachaCompletedIdentity is the persisted account flag represented in
|
||||
// the existing collection grant ledger. It is written atomically with the
|
||||
// official GachaSubType=3 first-pick transaction.
|
||||
const FirstGachaCompletedIdentity = "account:first-gacha-completed"
|
||||
|
||||
type collectionSnapshot struct {
|
||||
Version string `json:"version"`
|
||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||
NextCostumeIndex uint64 `json:"next_costume_index"`
|
||||
LatestPreview []uint64 `json:"latest_preview"`
|
||||
PreviewEventIndex uint64 `json:"preview_event_index"`
|
||||
PreviewLocked bool `json:"preview_locked"`
|
||||
Characters []Character `json:"characters,omitempty"`
|
||||
Costumes []Costume `json:"costumes,omitempty"`
|
||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
|
||||
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
|
||||
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
|
||||
GachaCountCorrected bool `json:"gacha_count_corrected"`
|
||||
Grants map[string]CollectionGrant `json:"grants,omitempty"`
|
||||
Version string `json:"version"`
|
||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||
NextCostumeIndex uint64 `json:"next_costume_index"`
|
||||
LatestPreview []uint64 `json:"latest_preview"`
|
||||
PreviewEventIndex uint64 `json:"preview_event_index"`
|
||||
PreviewLocked bool `json:"preview_locked"`
|
||||
Characters []Character `json:"characters,omitempty"`
|
||||
Costumes []Costume `json:"costumes,omitempty"`
|
||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
|
||||
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
|
||||
CostumeBurstLevels map[string]uint64 `json:"-"`
|
||||
CostumeBurstUpgrades map[string]CostumeBurstUpgradeRecord `json:"-"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
|
||||
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
|
||||
GachaCountCorrected bool `json:"gacha_count_corrected"`
|
||||
Grants map[string]CollectionGrant `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
// CollectionStore owns non-stackable character/costume rewards as one atomic
|
||||
@@ -157,9 +175,10 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
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{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||
CostumePotential: map[string][]uint64{},
|
||||
CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
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")
|
||||
@@ -193,6 +212,9 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
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
|
||||
}
|
||||
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) {
|
||||
@@ -811,20 +833,191 @@ func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) err
|
||||
func (s *CollectionStore) Costumes() []Costume {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.costumesLocked()
|
||||
}
|
||||
|
||||
func (s *CollectionStore) costumesLocked() []Costume {
|
||||
result := append([]Costume(nil), s.base...)
|
||||
for i := range result {
|
||||
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
|
||||
key := strconv.FormatUint(result[i].InvenIndex, 10)
|
||||
if level := s.data.BaseCostumeLevels[key]; level > result[i].Level {
|
||||
result[i].Level = level
|
||||
}
|
||||
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(result[i].InvenIndex, 10)]...)
|
||||
if level, found := s.data.CostumeBurstLevels[key]; found {
|
||||
result[i].BurstLevel = level
|
||||
}
|
||||
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
|
||||
}
|
||||
collection := append([]Costume(nil), s.data.Costumes...)
|
||||
for i := range collection {
|
||||
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(collection[i].InvenIndex, 10)]...)
|
||||
key := strconv.FormatUint(collection[i].InvenIndex, 10)
|
||||
if level, found := s.data.CostumeBurstLevels[key]; found {
|
||||
collection[i].BurstLevel = level
|
||||
}
|
||||
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
|
||||
}
|
||||
return append(result, collection...)
|
||||
}
|
||||
|
||||
// CostumeByID finds the single owned costume with the logical CostumeTable ID.
|
||||
// A duplicate logical ID is invalid ownership state and is deliberately not
|
||||
// resolved by choosing an arbitrary inventory instance.
|
||||
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() {
|
||||
if costume.ID != id {
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return Costume{}, false
|
||||
}
|
||||
result, found = costume, true
|
||||
}
|
||||
return result, found
|
||||
}
|
||||
|
||||
// CostumeBurstReplay returns an owned copy of the durable response recorded
|
||||
// for one inventory instance and target burst level.
|
||||
func (s *CollectionStore) CostumeBurstReplay(invenIndex, target uint64) (CostumeBurstUpgradeRecord, bool) {
|
||||
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 {
|
||||
return costume, true
|
||||
}
|
||||
}
|
||||
return Costume{}, false
|
||||
}
|
||||
|
||||
func costumeBurstUpgradeKey(invenIndex, target uint64) string {
|
||||
return strconv.FormatUint(invenIndex, 10) + ":" + strconv.FormatUint(target, 10)
|
||||
}
|
||||
|
||||
func parseCostumeBurstUpgradeKey(key string) (uint64, uint64, bool) {
|
||||
left, right, found := strings.Cut(key, ":")
|
||||
if !found || strings.Contains(right, ":") {
|
||||
return 0, 0, false
|
||||
}
|
||||
index, indexErr := strconv.ParseUint(left, 10, 64)
|
||||
level, levelErr := strconv.ParseUint(right, 10, 64)
|
||||
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(costumeIndex uint64, nodes []uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -948,12 +1141,9 @@ func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
|
||||
}
|
||||
|
||||
func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) {
|
||||
for _, costume := range s.Costumes() {
|
||||
if costume.InvenIndex == index {
|
||||
return costume, true
|
||||
}
|
||||
}
|
||||
return Costume{}, false
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.costumeByIndexLocked(index)
|
||||
}
|
||||
|
||||
func findCostume(costumes []Costume, id uint64) (int, bool) {
|
||||
@@ -1005,6 +1195,15 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
|
||||
for k, v := range in.CostumePotential {
|
||||
out.CostumePotential[k] = append([]uint64(nil), v...)
|
||||
}
|
||||
out.CostumeBurstLevels = make(map[string]uint64, len(in.CostumeBurstLevels))
|
||||
for k, v := range in.CostumeBurstLevels {
|
||||
out.CostumeBurstLevels[k] = v
|
||||
}
|
||||
out.CostumeBurstUpgrades = make(map[string]CostumeBurstUpgradeRecord, len(in.CostumeBurstUpgrades))
|
||||
for k, v := range in.CostumeBurstUpgrades {
|
||||
v.Body = append([]byte(nil), v.Body...)
|
||||
out.CostumeBurstUpgrades[k] = v
|
||||
}
|
||||
out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake))
|
||||
for k, v := range in.CharAwake {
|
||||
out.CharAwake[k] = v
|
||||
|
||||
@@ -15,7 +15,8 @@ const collectionDomain = "collection"
|
||||
var collectionEntryBuckets = [...]string{
|
||||
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
|
||||
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential", "char_awake",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential",
|
||||
"costume_burst_levels", "costume_burst_upgrades", "char_awake",
|
||||
}
|
||||
|
||||
func rejectInlineCollectionEntries(raw []byte) error {
|
||||
@@ -88,6 +89,12 @@ func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) e
|
||||
if data.CostumePotential, err = loadEntryMap[[]uint64](store, "costume_potential"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CostumeBurstLevels, err = loadEntryMap[uint64](store, "costume_burst_levels"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CostumeBurstUpgrades, err = loadEntryMap[CostumeBurstUpgradeRecord](store, "costume_burst_upgrades"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CharAwake, err = loadEntryMap[CharAwakeProgress](store, "char_awake"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,6 +134,8 @@ func collectionCore(in collectionSnapshot) collectionSnapshot {
|
||||
in.GachaSelections = nil
|
||||
in.GachaSelectionChanges = nil
|
||||
in.CostumePotential = nil
|
||||
in.CostumeBurstLevels = nil
|
||||
in.CostumeBurstUpgrades = nil
|
||||
in.CharAwake = nil
|
||||
return in
|
||||
}
|
||||
@@ -226,6 +235,12 @@ func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMut
|
||||
if err := diffEntryMap("costume_potential", before.CostumePotential, after.CostumePotential, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("costume_burst_levels", before.CostumeBurstLevels, after.CostumeBurstLevels, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("costume_burst_upgrades", before.CostumeBurstUpgrades, after.CostumeBurstUpgrades, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("char_awake", before.CharAwake, after.CharAwake, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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 reopened.Close()
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user