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:
2026-10-03 23:04:32 +08:00
parent 5600f10b31
commit 992fa47fd5
83 changed files with 8970 additions and 2828 deletions
@@ -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)
}
}
}
+1
View File
@@ -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)
+28
View File
@@ -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,
+22 -1
View File
@@ -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
}
+30 -3
View File
@@ -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)) {
+49
View File
@@ -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)
}
})
}
}
+95
View File
@@ -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")
}
}
+24 -5
View File
@@ -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) {
+231 -32
View File
@@ -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
}
+206
View File
@@ -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)
}
}