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

This commit is contained in:
2026-10-07 22:42:16 +08:00
parent 8e8ddeddb6
commit 06a83ada28
850 changed files with 42335 additions and 79891 deletions
@@ -1,42 +0,0 @@
package account
import (
"errors"
"math"
"testing"
"bd2server/internal/server/wire"
)
type testAchievementExperience struct {
value uint64
err error
}
func (s *testAchievementExperience) AchievementExperience() (uint64, error) { return s.value, s.err }
func TestLoginReadsPersistedAchievementExperienceEachTime(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 12, 999)}
source := &testAchievementExperience{value: 7}
if err := seed.AttachAchievementExperience(source); err != nil {
t.Fatal(err)
}
for _, value := range []uint64{7, 16, 0} {
source.value = value
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(response, 1)
exp, found, err := wire.Varint(user, 12)
if err != nil || !found || exp != value {
t.Fatalf("exp=%d want=%d err=%v", exp, value, err)
}
}
for _, invalid := range []testAchievementExperience{{value: math.MaxInt32 + 1}, {err: errors.New("read failed")}} {
*source = invalid
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("invalid experience accepted")
}
}
}
@@ -1,38 +0,0 @@
package account
import (
"bd2server/internal/server/wire"
"fmt"
)
type AutoReviveSettingsProvider interface{ AutoReviveSettings() (bool, uint64, error) }
func (s *LoginSeed) AttachAutoReviveSettings(p AutoReviveSettingsProvider) error {
if p == nil {
return fmt.Errorf("account: nil automatic recovery settings")
}
s.autoReviveSettings = p
return nil
}
func (s *LoginSeed) projectAutoRevive(user []byte) ([]byte, error) {
if s.autoReviveSettings == nil {
return user, nil
}
on, index, e := s.autoReviveSettings.AutoReviveSettings()
if e != nil {
return nil, e
}
if index > 9223372036854775807 {
return nil, fmt.Errorf("account: automatic recovery caster overflow")
}
n := uint64(0)
if on {
n = 1
}
user, _, e = wire.ReplaceVarint(user, 49, n)
if e != nil {
return nil, e
}
user, _, e = wire.ReplaceVarint(user, 50, index)
return user, e
}
@@ -1,36 +0,0 @@
package account
import (
"bd2server/internal/server/wire"
"testing"
)
type autoSettingsFake struct {
on bool
index uint64
}
func (p *autoSettingsFake) AutoReviveSettings() (bool, uint64, error) { return p.on, p.index, nil }
func TestLoginProjectsCurrentRecoverySettingsEachTime(t *testing.T) {
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 42)}
p := &autoSettingsFake{true, 199}
if e := s.AttachAutoReviveSettings(p); e != nil {
t.Fatal(e)
}
for _, on := range []bool{true, false} {
p.on = on
if !on {
p.index = 0
}
b, e := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if e != nil {
t.Fatal(e)
}
user, _, _ := wire.Bytes(b, 1)
v, _, _ := wire.Varint(user, 49)
index, _, _ := wire.Varint(user, 50)
if (v != 0) != on || index != p.index {
t.Fatal("seed value leaked", v, index)
}
}
}
@@ -1,42 +0,0 @@
package account
import (
"errors"
"testing"
"bd2server/internal/server/wire"
)
type friendshipAPFixture struct {
remaining uint64
err error
}
func (f *friendshipAPFixture) FriendshipAP() (uint64, error) { return f.remaining, f.err }
func TestLoginReadsCurrentFriendshipAPInsteadOfSeed(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 69, 3)
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 70, 99)
provider := &friendshipAPFixture{remaining: 2}
if err := seed.AttachFriendshipAP(provider); err != nil {
t.Fatal(err)
}
for _, remaining := range []uint64{2, 0, 3} {
provider.remaining = remaining
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
free, _, _ := wire.Varint(user, 69)
stack, _, _ := wire.Varint(user, 70)
if free != remaining || stack != 0 {
t.Fatalf("friendship points free=%d stack=%d, want %d/0", free, stack, remaining)
}
}
provider.err = errors.New("unavailable")
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("login ignored friendship state error")
}
}
@@ -1,39 +0,0 @@
package account
import (
"errors"
"testing"
"bd2server/internal/server/wire"
)
type lastPackFixture struct {
pack uint64
err error
}
func (f *lastPackFixture) LastPlayedPackID() (uint64, error) { return f.pack, f.err }
func TestLoginUsesSavedPackAndPreservesSeedOnlyForNewAccount(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 4, 21)}
provider := &lastPackFixture{}
if err := seed.AttachLastPlayedPack(provider); err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ saved, want uint64 }{{0, 21}, {3001, 3001}, {22, 22}} {
provider.pack = tc.saved
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
pack, _, _ := wire.Varint(user, 4)
if pack != tc.want {
t.Fatalf("saved=%d login pack=%d want=%d", tc.saved, pack, tc.want)
}
}
provider.err = errors.New("position unavailable")
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("ignored saved position error")
}
}
@@ -1,42 +0,0 @@
package account
import (
"testing"
"bd2server/internal/server/wire"
)
type mutableFirstGachaStatus struct{ completed bool }
func (p *mutableFirstGachaStatus) FirstGachaCompleted() bool { return p.completed }
func TestLoginReadsFirstGachaCompletionAtEachLogin(t *testing.T) {
// An old immutable seed value must be replaced by authoritative account
// state, then immediately change on the next login after confirmation.
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 27, 1)}
status := &mutableFirstGachaStatus{}
if err := seed.AttachFirstGacha(status); err != nil {
t.Fatal(err)
}
for _, completed := range []bool{false, true, false} {
status.completed = completed
body, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(body, 1)
if err != nil || !found {
t.Fatalf("user found=%v err=%v", found, err)
}
value, _, err := wire.Varint(user, 27)
if err != nil || (value == 1) != completed {
t.Fatalf("first gacha=%d want completed=%v err=%v", value, completed, err)
}
}
}
func TestLoginRejectsMissingFirstGachaProvider(t *testing.T) {
if err := (&LoginSeed{}).AttachFirstGacha(nil); err == nil {
t.Fatal("missing provider accepted")
}
}
-267
View File
@@ -1,267 +0,0 @@
package account
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"bd2server/internal/server/cryptox"
"bd2server/internal/server/wire"
)
func TestEncodeUsesFreshLocalKey(t *testing.T) {
user := wire.AppendVarint(nil, 1, 42)
user = wire.AppendString(user, 2, "Guest_42")
user = wire.AppendVarint(user, 5, 100)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: user}
const local = "0123456789abcdef0123456789abcdef"
body, err := seed.Encode(local, time.UnixMilli(1234))
if err != nil {
t.Fatal(err)
}
var envelope struct {
PacketCode int `json:"packetCode"`
Length int `json:"length"`
Data string `json:"data"`
ServerNowTime int64 `json:"serverNowTime"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
if envelope.PacketCode != 11 || envelope.ServerNowTime != 1234 {
t.Fatalf("envelope=%+v", envelope)
}
proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key())
if err != nil {
t.Fatal(err)
}
responseUser, found, err := wire.Bytes(proto, 1)
if err != nil || !found {
t.Fatalf("response UserInfo: found=%v err=%v", found, err)
}
gotKey, found, err := wire.Bytes(responseUser, 3)
if err != nil || !found || string(gotKey) != local {
t.Fatalf("local key=%q found=%v err=%v", gotKey, found, err)
}
}
func TestLoginValidatesEncryptedRequest(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
if _, err := seed.Login([]byte("not protobuf"), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("Login accepted invalid protobuf request")
}
}
func TestLoadRejectsSeedWithUserKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}
if err := seed.Write(path); err == nil {
t.Fatal("Write accepted user_key")
}
if err := os.WriteFile(path, []byte(`{"version":"2.35.10","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("Load accepted user_key")
}
}
func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) {
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "login_user.json"))
if err != nil {
t.Fatal(err)
}
proto, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(proto, 1)
if err != nil || !found {
t.Fatalf("missing UserInfo: found=%v err=%v", found, err)
}
key, found, err := wire.Bytes(user, 3)
if err != nil || !found || string(key) != "0123456789abcdef0123456789abcdef" {
t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err)
}
}
type loginCurrencyFixture struct{}
func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) {
return 1, 2, 3, 4
}
func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 }
func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: %v", err)
}
if mileage, found, err := wire.Varint(user, 67); err != nil || !found || mileage != 17 {
t.Fatalf("equipment mileage=%d found=%v err=%v", mileage, found, err)
}
if gauge, found, err := wire.Varint(user, 68); err != nil || !found || gauge != 845 {
t.Fatalf("equipment mileage gauge=%d found=%v err=%v", gauge, found, err)
}
}
type loginPurchaseCountFixture struct {
infos [][]byte
}
func (f *loginPurchaseCountFixture) PurchaseCountDBInfos() [][]byte { return f.infos }
func TestLoginReplacesSeedPurchaseCountsFromProvider(t *testing.T) {
stale := wire.AppendVarint(nil, 1, 999)
userTemplate := wire.AppendVarint(nil, 1, 1)
userTemplate = wire.AppendBytes(userTemplate, 26, stale)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
current := wire.AppendVarint(nil, 1, 1100001)
current = wire.AppendVarint(current, 2, 9100033)
current = wire.AppendVarint(current, 4, 1)
provider := &loginPurchaseCountFixture{infos: [][]byte{current}}
if err := seed.AttachPurchaseCounts(provider); err != nil {
t.Fatal(err)
}
login := func() []byte {
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: found=%v err=%v", found, err)
}
return user
}
counts := byteFields(login(), 26)
if len(counts) != 1 || string(counts[0]) != string(current) {
t.Fatalf("purchase counts=%x want=%x", counts, current)
}
// The provider is consulted on every LoginUser response. An empty current
// state must also remove any stale count captured in the seed template.
provider.infos = nil
if counts = byteFields(login(), 26); len(counts) != 0 {
t.Fatalf("empty current state retained purchase counts: %x", counts)
}
}
func TestAttachPurchaseCountsRejectsNil(t *testing.T) {
seed := &LoginSeed{}
if err := seed.AttachPurchaseCounts(nil); err == nil {
t.Fatal("AttachPurchaseCounts accepted nil provider")
}
}
type loginPresetSlotFixture struct{ count uint64 }
func (f *loginPresetSlotFixture) PresetSlotCount() uint64 { return f.count }
func TestLoginReplacesSeedPresetSlotFromProvider(t *testing.T) {
userTemplate := wire.AppendVarint(nil, 1, 1)
userTemplate = wire.AppendVarint(userTemplate, 28, 6)
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
provider := &loginPresetSlotFixture{count: 9}
if err := seed.AttachPresetSlots(provider); err != nil {
t.Fatal(err)
}
login := func() []byte {
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing login user: found=%v err=%v", found, err)
}
return user
}
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 9 {
t.Fatalf("preset slots=%d found=%v err=%v", count, found, err)
}
provider.count = 12
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 12 {
t.Fatalf("updated preset slots=%d found=%v err=%v", count, found, err)
}
}
func TestAttachPresetSlotsRejectsNil(t *testing.T) {
seed := &LoginSeed{}
if err := seed.AttachPresetSlots(nil); err == nil {
t.Fatal("AttachPresetSlots accepted nil provider")
}
}
type loginInventorySlotFixture struct {
items, storage, equipment, equipmentStorage uint64
err error
}
func (f *loginInventorySlotFixture) UserInventorySlots() (uint64, uint64, uint64, uint64, error) {
return f.items, f.storage, f.equipment, f.equipmentStorage, f.err
}
func TestLoginReplacesAllInventorySlotFieldsFromProvider(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 100, 10: 500, 15: 100} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
provider := &loginInventorySlotFixture{items: 500, storage: 100, equipment: 2000, equipmentStorage: 100}
if err := seed.AttachInventorySlots(provider); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
result, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing UserInfo: %v", err)
}
for field, want := range map[int]uint64{5: 500, 6: 100, 10: 2000, 15: 100} {
if got, found, err := wire.Varint(result, field); err != nil || !found || got != want {
t.Fatalf("field %d=%d found=%t err=%v want=%d", field, got, found, err, want)
}
}
}
func TestSeedInventorySlotsReadsUserInfoFields(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 101, 10: 500, 15: 102} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
items, storage, equipment, equipmentStorage, err := seed.SeedInventorySlots()
if err != nil || items != 100 || storage != 101 || equipment != 500 || equipmentStorage != 102 {
t.Fatalf("slots=%d/%d/%d/%d err=%v", items, storage, equipment, equipmentStorage, err)
}
}
func byteFields(data []byte, number int) [][]byte {
var result [][]byte
_ = wire.Walk(data, func(field wire.Field) error {
if field.Number == number && field.Type == 2 {
result = append(result, append([]byte(nil), field.Value...))
}
return nil
})
return result
}
@@ -1,58 +0,0 @@
package account
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/deck"
"bd2server/internal/server/wire"
"path/filepath"
"testing"
)
func TestLoginPortraitUsesSQLiteSelectionAfterRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
r, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
seed, err := deck.LoadSeed("../../../seed/v2_35_10/decks.json")
if err != nil {
t.Fatal(err)
}
d, err := deck.OpenStore(r, seed)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3601)
if _, _, _, err = d.Handle("/UserPortraitChange", request); err != nil {
t.Fatal(err)
}
if err = r.Close(); err != nil {
t.Fatal(err)
}
r, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
d, err = deck.OpenStore(r, seed)
if err != nil {
t.Fatal(err)
}
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 14, 3501)}
if err = s.AttachPortrait(d); err != nil {
t.Fatal(err)
}
body, err := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
portrait, _, _ := wire.Varint(user, 14)
if portrait != 3601 {
t.Fatalf("reconnected portrait%d, want saved3601", portrait)
}
}
@@ -1,58 +0,0 @@
package account
import (
"errors"
"math"
"testing"
"bd2server/internal/server/wire"
)
type rewardStateSource struct {
claimed, free, bonus uint64
err error
}
func (p *rewardStateSource) LevelRewardCount() (uint64, error) { return p.claimed, p.err }
func (p *rewardStateSource) HuntingAP() (uint64, uint64, error) { return p.free, p.bonus, p.err }
func TestLoginReadsMutableRewardAndHuntingState(t *testing.T) {
user := wire.AppendVarint(nil, 1, 42)
for _, field := range []int{13, 20, 21} {
user = wire.AppendVarint(user, field, 999)
}
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
p := &rewardStateSource{claimed: 2, free: 40, bonus: 7}
if err := seed.AttachLevelReward(p); err != nil {
t.Fatal(err)
}
if err := seed.AttachHuntingAP(p); err != nil {
t.Fatal(err)
}
for _, state := range []rewardStateSource{*p, {claimed: 3, free: 20, bonus: 0}, {}} {
*p = state
response, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
actual, _, _ := wire.Bytes(response, 1)
for field, want := range map[int]uint64{13: p.claimed, 20: p.free, 21: p.bonus} {
got, _, err := wire.Varint(actual, field)
if err != nil || got != want {
t.Fatalf("field %d=%d want %d: %v", field, got, want, err)
}
}
}
for _, bad := range []rewardStateSource{{claimed: math.MaxInt32 + 1}, {free: math.MaxInt32 + 1}, {bonus: math.MaxInt32 + 1}, {err: errors.New("storage failed")}} {
*p = bad
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("invalid state accepted")
}
}
if err := seed.AttachLevelReward(nil); err == nil {
t.Fatal("nil reward provider accepted")
}
if err := seed.AttachHuntingAP(nil); err == nil {
t.Fatal("nil AP provider accepted")
}
}
@@ -1,53 +0,0 @@
package accountstate
import (
"context"
"fmt"
"bd2server/internal/server/stateio"
)
var _ stateio.AtomicEntryStore = (*Repository)(nil)
// SaveWithEntries writes a domain's bounded core and its changed entry rows
// together. Calls inside a request join that transaction; direct calls create
// their own transaction so neither half can become visible alone.
func (r *Repository) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
if domain == "" {
return fmt.Errorf("accountstate: empty domain name")
}
r.activeMu.RLock()
if r.active != nil {
err := saveWithEntries(r.active, domain, core, changes)
r.activeMu.RUnlock()
return err
}
r.activeMu.RUnlock()
tx, err := r.Begin(context.Background())
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if err := saveWithEntries(tx, domain, core, changes); err != nil {
return err
}
return tx.Commit()
}
func saveWithEntries(tx *Tx, domain string, core []byte, changes []stateio.EntryMutation) error {
if core != nil {
if _, err := tx.Save(domain, core); err != nil {
return err
}
}
for _, change := range changes {
if change.Delete {
if _, err := tx.DeleteEntry(domain, change.Bucket, change.Key); err != nil {
return err
}
} else if err := tx.PutEntry(domain, change.Bucket, change.Key, change.Payload); err != nil {
return err
}
}
return nil
}
@@ -1,48 +0,0 @@
package accountstate
import (
"bytes"
"context"
"testing"
"bd2server/internal/server/stateio"
)
func TestSaveWithEntriesAtomicAndEntryOnly(t *testing.T) {
r, _ := openTestRepository(t)
change := stateio.EntryMutation{Bucket: "granted", Key: "quest:1", Payload: []byte("true")}
if err := r.SaveWithEntries("wallet", []byte(`{"gold":10}`), []stateio.EntryMutation{change}); err != nil {
t.Fatal(err)
}
core, generation, found, err := r.LoadContext(context.Background(), "wallet")
if err != nil || !found || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
t.Fatalf("core=%q generation=%d found=%t err=%v", core, generation, found, err)
}
if err := r.SaveWithEntries("wallet", nil, []stateio.EntryMutation{{Bucket: "granted", Key: "quest:2", Payload: []byte("true")}}); err != nil {
t.Fatal(err)
}
if err := r.SaveWithEntries("wallet", []byte(`{"gold":99}`), []stateio.EntryMutation{{Bucket: "granted", Key: ""}}); err == nil {
t.Fatal("accepted invalid entry mutation")
}
core, generation, _, err = r.LoadContext(context.Background(), "wallet")
if err != nil || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
t.Fatalf("entry-only/failed write changed core: %q generation %d, %v", core, generation, err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.SaveWithEntries("wallet", []byte(`{"gold":20}`), []stateio.EntryMutation{{Bucket: "granted", Key: "quest:3", Payload: []byte("true")}}); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty rollback did not fail stop")
}
var count int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry WHERE domain_name='wallet' AND bucket='granted'`).Scan(&count); err != nil || count != 2 {
t.Fatalf("entries after rollback=%d: %v", count, err)
}
if err := r.db.QueryRow(`SELECT generation FROM domain_state WHERE name='wallet'`).Scan(&generation); err != nil || generation != 1 {
t.Fatalf("generation after rollback=%d: %v", generation, err)
}
}
@@ -1,109 +0,0 @@
package accountstate
import (
"path/filepath"
"testing"
)
func TestInitializationMarkerMakesSchemaOnlyRollbackRetryable(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repository, err := Open(path)
if err != nil {
t.Fatal(err)
}
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
t.Fatalf("initial state=%d err=%v", state, err)
}
operation, err := repository.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
t.Fatal(err)
}
if err := repository.Save("wallet", []byte(`{"version":1}`)); err != nil {
t.Fatal(err)
}
if err := repository.MarkInitializationComplete(); err != nil {
t.Fatal(err)
}
if err := operation.Rollback(); err == nil {
t.Fatal("dirty rollback did not require repository reopen")
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
repository, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := repository.Close(); err != nil {
t.Error(err)
}
}()
if repository.IsNew() {
t.Fatal("schema-only retry incorrectly depends on file creation")
}
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
t.Fatalf("post-rollback state=%d err=%v", state, err)
}
}
func TestInitializationMarkerCommitsCompleteAccountExactlyOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repository, err := Open(path)
if err != nil {
t.Fatal(err)
}
operation, err := repository.BeginOperation()
if err != nil {
t.Fatal(err)
}
for _, domain := range []string{"mail", "wallet"} {
if err := repository.Save(domain, []byte(`{"version":1}`)); err != nil {
t.Fatal(err)
}
}
if err := repository.MarkInitializationComplete(); err != nil {
t.Fatal(err)
}
if err := operation.Commit(); err != nil {
t.Fatal(err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
repository, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := repository.Close(); err != nil {
t.Error(err)
}
}()
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationComplete {
t.Fatalf("committed state=%d err=%v", state, err)
}
}
func TestInitializationStateRejectsPartialAccount(t *testing.T) {
repository, err := Open(filepath.Join(t.TempDir(), "state.db"))
if err != nil {
t.Fatal(err)
}
defer func() {
if err := repository.Close(); err != nil {
t.Error(err)
}
}()
if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
t.Fatal(err)
}
if state, err := repository.InitializationState("mail", "wallet"); err == nil || state != InitializationCorrupt {
t.Fatalf("partial state=%d err=%v", state, err)
}
}
@@ -1,163 +0,0 @@
package accountstate
import (
"bytes"
"context"
"database/sql"
"errors"
"testing"
)
func TestEntriesShareRequestTransaction(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.SaveContext(context.Background(), "collection", []byte("unchanged")); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.PutEntry("collection", "grants", "draw:1", []byte(`{"id":1}`)); err != nil {
t.Fatal(err)
}
if err := r.PutEntry("collection", "applied", "draw:1", nil); err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("charged")); err != nil {
t.Fatal(err)
}
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte(`{"id":1}`)) {
t.Fatalf("read own entry write: %q, %t, %v", got, found, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
requireState(t, r, "collection", []byte("unchanged"), 1)
requireState(t, r, "wallet", []byte("charged"), 1)
entries, err := r.ListEntries("collection", "grants")
if err != nil || len(entries) != 1 || !bytes.Equal(entries["draw:1"], []byte(`{"id":1}`)) {
t.Fatalf("grant entries: %#v, %v", entries, err)
}
if got, found, err := r.LoadEntry("collection", "applied", "draw:1"); err != nil || !found || len(got) != 0 {
t.Fatalf("empty marker: %q, %t, %v", got, found, err)
}
}
func TestEntryRollbackAndDelete(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.PutEntry("collection", "grants", "draw:1", []byte("before")); err != nil {
t.Fatal(err)
}
tx, err := r.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
if err := tx.PutEntry("collection", "grants", "draw:1", []byte("after")); err != nil {
t.Fatal(err)
}
if err := tx.PutEntry("collection", "grants", "draw:2", []byte("new")); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte("before")) {
t.Fatalf("rolled-back replacement: %q, %t, %v", got, found, err)
}
if _, found, err := r.LoadEntry("collection", "grants", "draw:2"); err != nil || found {
t.Fatalf("rolled-back insertion: %t, %v", found, err)
}
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || !deleted {
t.Fatalf("delete existing entry: %t, %v", deleted, err)
}
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || deleted {
t.Fatalf("delete missing entry: %t, %v", deleted, err)
}
if _, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || found {
t.Fatalf("deleted entry: %t, %v", found, err)
}
}
func TestEntryWriteMarksRequestRollbackDirty(t *testing.T) {
r, _ := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.PutEntry("collection", "grants", "draw:1", []byte("pending")); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty entry rollback did not require restart")
}
if err := r.Check(); err == nil {
t.Fatal("repository accepted requests after dirty entry rollback")
}
var count int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry`).Scan(&count); err != nil || count != 0 {
t.Fatalf("entry survived rollback: count %d, error %v", count, err)
}
}
func TestEntryScopeAndClosedTransaction(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.PutEntry("", "grants", "key", nil); err == nil {
t.Fatal("accepted empty domain")
}
if err := r.PutEntry("collection", "", "key", nil); err == nil {
t.Fatal("accepted empty bucket")
}
if err := r.PutEntry("collection", "grants", "", nil); err == nil {
t.Fatal("accepted empty key")
}
tx, err := r.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := tx.PutEntry("collection", "grants", "key", nil); !errors.Is(err, ErrClosed) {
t.Fatalf("put after commit: %v", err)
}
if _, err := tx.ListEntries("collection", "grants"); !errors.Is(err, ErrClosed) {
t.Fatalf("list after commit: %v", err)
}
}
func TestOldSchemaIsRejectedWithoutMutation(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.SaveContext(context.Background(), "collection", []byte("legacy snapshot")); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`UPDATE metadata SET value = '0' WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("opened unsupported schema version")
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
var version string
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "0" {
t.Fatalf("changed unsupported version %q: %v", version, err)
}
var entryTables int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 0 {
t.Fatalf("created table during failed open: %d, %v", entryTables, err)
}
}
@@ -1,366 +0,0 @@
package accountstate
import (
"bytes"
"context"
"database/sql"
"errors"
"path/filepath"
"testing"
"time"
)
func openTestRepository(t *testing.T) (*Repository, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "state.db")
r, err := Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = r.Close() })
return r, path
}
func requireState(t *testing.T, r *Repository, name string, want []byte, wantGeneration int64) {
t.Helper()
got, generation, found, err := r.LoadContext(context.Background(), name)
if err != nil {
t.Fatal(err)
}
if !found || !bytes.Equal(got, want) || generation != wantGeneration {
t.Fatalf("%s: got (%q, %d, %t), want (%q, %d, true)", name, got, generation, found, want, wantGeneration)
}
}
func TestTransactionAtomicityAndGeneration(t *testing.T) {
r, _ := openTestRepository(t)
ctx := context.Background()
tx, err := r.Begin(ctx)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"wallet", "deck", "progress"} {
if generation, err := tx.Save(name, []byte(name)); err != nil || generation != 1 {
t.Fatalf("save %s: generation %d, error %v", name, generation, err)
}
}
if generation, err := tx.Save("wallet", []byte("updated")); err != nil || generation != 2 {
t.Fatalf("second save: generation %d, error %v", generation, err)
}
if data, generation, found, err := tx.Load("wallet"); err != nil || !found || !bytes.Equal(data, []byte("updated")) || generation != 2 {
t.Fatalf("read own write: %q, %d, %t, %v", data, generation, found, err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if _, _, _, err := tx.Load("wallet"); !errors.Is(err, ErrClosed) {
t.Fatalf("load after commit: %v", err)
}
requireState(t, r, "wallet", []byte("updated"), 2)
requireState(t, r, "deck", []byte("deck"), 1)
requireState(t, r, "progress", []byte("progress"), 1)
}
func TestRollbackAndReopen(t *testing.T) {
r, path := openTestRepository(t)
ctx := context.Background()
if _, err := r.SaveContext(ctx, "wallet", []byte("before")); err != nil {
t.Fatal(err)
}
tx, err := r.Begin(ctx)
if err != nil {
t.Fatal(err)
}
if _, err := tx.Save("wallet", []byte("after")); err != nil {
t.Fatal(err)
}
if _, err := tx.Save("deck", []byte("new")); err != nil {
t.Fatal(err)
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("before"), 1)
if _, _, found, err := r.LoadContext(ctx, "deck"); err != nil || found {
t.Fatalf("rolled-back domain: found=%t err=%v", found, err)
}
if _, err := r.SaveContext(ctx, "wallet", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
reopened, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := reopened.Close(); err != nil {
t.Error(err)
}
}()
requireState(t, reopened, "wallet", []byte("committed"), 2)
var mode string
if err := reopened.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" {
t.Fatalf("journal mode %q: %v", mode, err)
}
var synchronous int
if err := reopened.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil || synchronous != 2 {
t.Fatalf("synchronous %d: %v", synchronous, err)
}
}
func TestConcurrentTransactionsSerialize(t *testing.T) {
r, _ := openTestRepository(t)
first, err := r.Begin(context.Background())
if err != nil {
t.Fatal(err)
}
entered := make(chan struct{})
finished := make(chan error, 1)
go func() {
close(entered)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
tx, err := r.Begin(ctx)
if err != nil {
finished <- err
return
}
_, err = tx.Save("wallet", []byte("second"))
if err == nil {
err = tx.Commit()
} else {
_ = tx.Rollback()
}
finished <- err
}()
<-entered
select {
case err := <-finished:
t.Fatalf("second transaction finished before first released connection: %v", err)
case <-time.After(20 * time.Millisecond):
}
if _, err := first.Save("wallet", []byte("first")); err != nil {
t.Fatal(err)
}
if err := first.Commit(); err != nil {
t.Fatal(err)
}
if err := <-finished; err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("second"), 2)
}
func TestOperationRoutesStoreWritesAcrossDomains(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.Save("wallet", []byte("before")); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("after")); err != nil {
t.Fatal(err)
}
if err := r.Save("deck", []byte("new")); err != nil {
t.Fatal(err)
}
if data, err := r.Load("wallet"); err != nil || !bytes.Equal(data, []byte("after")) {
t.Fatalf("read active write: %q, %v", data, err)
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty request rollback did not require restart")
}
if err := r.Check(); err == nil {
t.Fatal("repository accepted requests after dirty rollback")
}
if _, err := r.BeginOperation(); err == nil {
t.Fatal("began request after dirty rollback")
}
// The durable rows are rolled back even though domain memory now needs reload.
var payload []byte
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'wallet'`).Scan(&payload); err != nil || !bytes.Equal(payload, []byte("before")) {
t.Fatalf("wallet after rollback: %q, %v", payload, err)
}
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'deck'`).Scan(&payload); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("deck after rollback: %q, %v", payload, err)
}
}
func TestOperationCommitAndCleanRollback(t *testing.T) {
r, _ := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err != nil {
t.Fatal(err)
}
if err := r.Check(); err != nil {
t.Fatal(err)
}
op, err = r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err := r.Save("wallet", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := r.Save("deck", []byte("committed")); err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
requireState(t, r, "wallet", []byte("committed"), 1)
requireState(t, r, "deck", []byte("committed"), 1)
}
func TestSecondRepositoryIsRejectedUntilWriterCloses(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
first, err := Open(path)
if err != nil {
t.Fatal(err)
}
if second, err := Open(path); !errors.Is(err, ErrWriterLocked) {
if second != nil {
_ = second.Close()
}
t.Fatalf("second Open error=%v, want ErrWriterLocked", err)
}
var epoch int64
if err := first.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key = 'writer_epoch'`).Scan(&epoch); err != nil {
t.Fatal(err)
}
if epoch != first.writerEpoch {
t.Fatalf("rejected Open advanced writer epoch to %d, want %d", epoch, first.writerEpoch)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
second, err := Open(path)
if err != nil {
t.Fatalf("Open after writer Close: %v", err)
}
defer func() {
if err := second.Close(); err != nil {
t.Error(err)
}
}()
}
func TestWriterEpochStillFencesStaleRepositoryBeforeMutation(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.db.Exec(`UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'writer_epoch'`); err != nil {
t.Fatal(err)
}
if _, err := r.BeginOperation(); !errors.Is(err, ErrFenced) {
t.Fatalf("stale writer error=%v, want ErrFenced", err)
}
}
func TestSchemaVersionRejected(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.db.Exec(`UPDATE metadata SET value = '4' WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("opened unknown schema version")
}
}
func TestPopulatedDatabaseWithoutSchemaVersionRejected(t *testing.T) {
r, path := openTestRepository(t)
if err := r.Save("wallet", []byte("existing")); err != nil {
t.Fatal(err)
}
if _, err := r.db.Exec(`DELETE FROM metadata WHERE key = 'schema_version'`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("stamped current schema onto populated unversioned database")
}
}
func TestCurrentVersionWithMissingTableRejected(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
if reopened, err := Open(path); err == nil {
_ = reopened.Close()
t.Fatal("recreated a missing table in an existing current-version database")
}
}
func TestRequireDomainsRejectsPartialAccount(t *testing.T) {
r, _ := openTestRepository(t)
if err := r.RequireDomains("wallet"); err == nil {
t.Fatal("accepted empty database as a complete account")
}
if err := r.Save("wallet", []byte(`{}`)); err != nil {
t.Fatal(err)
}
if err := r.RequireDomains("wallet"); err != nil {
t.Fatal(err)
}
if err := r.RequireDomains("wallet", "items"); err == nil {
t.Fatal("accepted a missing account domain")
}
}
func TestInvalidNamesAndEmptyBlob(t *testing.T) {
r, _ := openTestRepository(t)
ctx := context.Background()
if _, err := r.SaveContext(ctx, "", []byte("bad")); err == nil {
t.Fatal("empty name accepted")
}
if _, err := r.SaveContext(ctx, "empty", nil); err != nil {
t.Fatal(err)
}
requireState(t, r, "empty", []byte{}, 1)
}
func BenchmarkRequestTransaction(b *testing.B) {
path := filepath.Join(b.TempDir(), "state.db")
r, err := Open(path)
if err != nil {
b.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
b.Error(err)
}
}()
ctx := context.Background()
payload := bytes.Repeat([]byte("x"), 4096)
b.ResetTimer()
for i := 0; i < b.N; i++ {
tx, err := r.Begin(ctx)
if err != nil {
b.Fatal(err)
}
for _, name := range []string{"wallet", "deck", "progress"} {
if _, err := tx.Save(name, payload); err != nil {
b.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
b.Fatal(err)
}
}
}
@@ -1,222 +0,0 @@
package accountstate
import (
"context"
"database/sql"
"errors"
"path/filepath"
"strings"
"testing"
)
func createV1Database(t *testing.T, path string, domain, payload string) {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
if err := createV1(context.Background(), tx); err != nil {
t.Fatal(err)
}
if domain != "" {
if _, err := tx.Exec(`INSERT INTO domain_state(name, payload, generation) VALUES (?, ?, 1)`, domain, []byte(payload)); err != nil {
t.Fatal(err)
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
func TestFreshDatabaseRunsEveryMigration(t *testing.T) {
r, _ := openTestRepository(t)
version, err := r.SchemaVersion()
if err != nil || version != schemaVersion {
t.Fatalf("schema version %d, error %v", version, err)
}
var entryTables int
if err := r.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 1 {
t.Fatalf("domain_entry tables %d, error %v", entryTables, err)
}
}
func TestMigrationV1ToV2IsRepeatSafe(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "", "")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
for range 2 {
if err := migrateV1ToV2(context.Background(), tx); err != nil {
t.Fatal(err)
}
}
if err := validateSchemaTables(tx, 2); err != nil {
t.Fatal(err)
}
}
func TestMigrationV2ToV3IsRepeatSafe(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "", "")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
if err := migrateV1ToV2(context.Background(), tx); err != nil {
t.Fatal(err)
}
for range 2 {
if err := migrateV2ToV3(context.Background(), tx); err != nil {
t.Fatal(err)
}
}
var epoch string
if err := tx.QueryRow(`SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&epoch); err != nil || epoch != "0" {
t.Fatalf("writer epoch=%q err=%v", epoch, err)
}
}
func TestOpenMigratesV1ToCurrent(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "", "")
r, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
version, err := r.SchemaVersion()
if err != nil || version != schemaVersion {
t.Fatalf("schema version %d, error %v", version, err)
}
}
func TestMigrationsRejectMissingAndNonAdjacentSteps(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "", "")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
for _, test := range []struct {
name string
steps []migration
want string
}{
{name: "missing", want: "missing migration 1->2"},
{name: "skip", steps: []migration{{from: 1, to: 3, up: func(context.Context, *sql.Tx) error { return nil }}}, want: "not adjacent"},
} {
t.Run(test.name, func(t *testing.T) {
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
err = runMigrations(context.Background(), tx, 1, 3, test.steps)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error %v, want %q", err, test.want)
}
})
}
}
func TestValidationFailureRollsBackMigrationAndVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "progress", `{"quests":{"0:1":{"QuestID":1,"PackID":0}},"cleared_quests":{}}`)
if r, err := Open(path); err == nil {
if err := r.Close(); err != nil {
t.Error(err)
}
t.Fatal("opened state rejected by final validation")
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
var version string
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "1" {
t.Fatalf("schema version %q after rollback: %v", version, err)
}
var generationColumns int
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('domain_entry') WHERE name = 'generation'`).Scan(&generationColumns); err != nil || generationColumns != 0 {
t.Fatalf("migration column survived rollback: %d, %v", generationColumns, err)
}
}
func TestMigrationFailureRollsBackEarlierStepWork(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
createV1Database(t, path, "", "")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := db.Close(); err != nil {
t.Error(err)
}
}()
tx, err := db.Begin()
if err != nil {
t.Fatal(err)
}
steps := []migration{{from: 1, to: 2, up: func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `CREATE TABLE partial_write(value INTEGER)`); err != nil {
return err
}
return errors.New("injected migration failure")
}}}
if err := runMigrations(context.Background(), tx, 1, 2, steps); err == nil {
t.Fatal("migration unexpectedly succeeded")
}
if err := tx.Rollback(); err != nil {
t.Fatal(err)
}
var tables int
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'partial_write'`).Scan(&tables); err != nil || tables != 0 {
t.Fatalf("partial migration survived rollback: %d, %v", tables, err)
}
}
@@ -1,144 +0,0 @@
package accountstate
import (
"context"
"database/sql"
"errors"
"testing"
)
func TestStartingPackPolicyPersistsAndRejectsChanges(t *testing.T) {
r, path := openTestRepository(t)
if _, err := r.LockStartingPack(21, true); err == nil {
t.Fatal("accepted policy outside startup operation")
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if got, err := r.LockStartingPack(21, true); err != nil || got != 21 {
t.Fatalf("lock=%d: %v", got, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
r, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
op, err = r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if got, err := r.LockStartingPack(21, false); err != nil || got != 21 {
t.Fatalf("reopened lock=%d: %v", got, err)
}
if _, err := r.LockStartingPack(1, true); err == nil {
t.Fatal("accepted changed policy")
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
var raw string
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); err != nil || raw != "21" {
t.Fatalf("policy=%q: %v", raw, err)
}
}
func TestStartingPackPolicyRollbackAllowsAnotherChoice(t *testing.T) {
r, path := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, err := r.LockStartingPack(21, true); err != nil {
t.Fatal(err)
}
if err := op.Rollback(); err != nil {
t.Fatal(err)
}
var raw string
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("rolled back policy survived: %q, %v", raw, err)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
r, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
op, err = r.BeginOperation()
if err != nil {
t.Fatal(err)
}
if got, err := r.LockStartingPack(1, true); err != nil || got != 1 {
t.Fatalf("retry lock=%d: %v", got, err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
}
func TestStartingPackPolicyMissingExistingRejectsInitialization(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.SaveContext(context.Background(), "progress", []byte("{}")); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
defer func() { _ = op.Rollback() }()
for _, initialize := range []bool{false, true} {
if _, err := r.LockStartingPack(1, initialize); err == nil {
t.Fatalf("accepted missing existing policy initialize=%v", initialize)
}
}
}
func TestStartingPackPolicyRejectsInvalidValues(t *testing.T) {
for _, raw := range []string{"0", "2", "abc", "01", "21 "} {
t.Run(raw, func(t *testing.T) {
r, _ := openTestRepository(t)
if _, err := r.db.Exec(`INSERT INTO metadata(key,value) VALUES(?,?)`, startingPackMetadataKey, raw); err != nil {
t.Fatal(err)
}
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
defer func() { _ = op.Rollback() }()
if _, err := r.LockStartingPack(1, false); err == nil {
t.Fatal("accepted invalid stored value")
}
})
}
r, _ := openTestRepository(t)
op, err := r.BeginOperation()
if err != nil {
t.Fatal(err)
}
defer func() { _ = op.Rollback() }()
for _, configured := range []int{0, 2, -1} {
if _, err := r.LockStartingPack(configured, true); err == nil {
t.Fatalf("accepted invalid configured value %d", configured)
}
}
if _, err := r.LockStartingPack(1, false); err == nil {
t.Fatal("initialized empty DB without authorization")
}
}
@@ -1,117 +0,0 @@
package accountstate
import (
"slices"
"testing"
)
func validValidationSnapshot() validationSnapshot {
return validationSnapshot{
formatVersion: 1, clientVersion: "2.35.10", gameDataVersion: "20260924000000",
itemNextIndex: 11, equipmentNextIndex: 21, characterNextIndex: 31, costumeNextIndex: 41,
itemIndices: []uint64{10}, equipmentIndices: []uint64{20}, characterIndices: []uint64{30}, costumeIndices: []uint64{40},
collectionCharacterIndices: []uint64{30},
equipmentGrants: []namedIndex{{identity: "quest:test", index: 20}},
equipmentUsers: []namedIndex{{identity: "20", index: 30}},
quests: []questKey{{pack: 1, quest: 2}}, cleared: []questKey{{pack: 1, quest: 2}},
granted: map[string]bool{"mail:test": true},
itemGrants: []indexedGrant{{identity: "mail:test", indices: []uint64{9}}},
}
}
func TestTimedCompanionIdentityIsSeparateFromCollectionAllocator(t *testing.T) {
s := validValidationSnapshot()
companion := uint64(1<<59) + 100
s.characterIndices = append(s.characterIndices, companion)
s.equipmentUsers[0].index = companion
if p := validateSnapshot(s); len(p) != 0 {
t.Fatalf("temporary owner rejected: %+v", p)
}
s.collectionCharacterIndices = append(s.collectionCharacterIndices, s.characterNextIndex)
if !slices.Contains(problemCodes(validateSnapshot(s)), "collection.next_character_index") {
t.Fatal("permanent allocator invariant lost")
}
}
func problemCodes(problems []Problem) []string {
codes := make([]string, 0, len(problems))
for _, problem := range problems {
codes = append(codes, problem.Code)
}
return codes
}
func TestValidateSnapshotAcceptsConsumedGrantedItem(t *testing.T) {
snapshot := validValidationSnapshot()
// Index 9 is intentionally absent from itemIndices: grant_items is an
// issuance ledger and remains valid after the stack is consumed.
if problems := validateSnapshot(snapshot); len(problems) != 0 {
t.Fatalf("unexpected problems: %#v", problems)
}
}
func TestValidateSnapshotAcceptsDismantledGrantedEquipment(t *testing.T) {
snapshot := validValidationSnapshot()
snapshot.equipmentIndices = nil
// Index 20 was issued below next_index and its grant marker must survive
// dismantling so a retried grant cannot create a duplicate instance.
if problems := validateSnapshot(snapshot); len(problems) != 0 {
t.Fatalf("unexpected problems: %#v", problems)
}
}
func TestValidateSnapshotPortsEveryExternalValidatorRule(t *testing.T) {
snapshot := validValidationSnapshot()
snapshot.formatVersion = 2
snapshot.clientVersion = ""
snapshot.gameDataVersion = ""
snapshot.itemIndices = []uint64{10, 10}
snapshot.equipmentIndices = []uint64{20, 20}
snapshot.characterIndices = []uint64{30, 30}
snapshot.costumeIndices = []uint64{40, 40}
snapshot.itemNextIndex = 10
snapshot.equipmentNextIndex = 20
snapshot.characterNextIndex = 30
snapshot.costumeNextIndex = 40
snapshot.equipmentGrants = []namedIndex{{identity: "missing", index: 999}}
snapshot.equipmentUsers = []namedIndex{{identity: "20", index: 999}}
snapshot.quests = []questKey{{}, {pack: 2, quest: 3}, {pack: 2, quest: 3}}
snapshot.cleared = []questKey{{pack: 1}, {pack: 4, quest: 5}, {pack: 4, quest: 5}}
snapshot.granted = map[string]bool{}
snapshot.itemGrants = []indexedGrant{{identity: "unmarked", indices: []uint64{0, 10, 10}}}
want := []string{
"snapshot.format_version", "snapshot.client_version", "snapshot.game_data_version",
"inventory.duplicate_index", "equipment.duplicate_index", "characters.duplicate_index", "costumes.duplicate_index",
"inventory.next_index", "equipment.next_index", "collection.next_character_index", "collection.next_costume_index",
"equipment.grant_missing_equipment", "equipment.unknown_user",
"progress.invalid_quest_key", "progress.invalid_cleared_key",
"progress.duplicate_quest_key", "progress.duplicate_cleared_key",
"inventory.grant_without_marker", "inventory.grant_invalid_index", "inventory.grant_invalid_index", "inventory.grant_invalid_index",
"inventory.grant_duplicate_index",
}
got := problemCodes(validateSnapshot(snapshot))
if !slices.Equal(got, want) {
t.Fatalf("problem codes:\n got %v\nwant %v", got, want)
}
}
func TestValidateSnapshotGrantAndOwnershipRules(t *testing.T) {
for _, test := range []struct {
name string
edit func(*validationSnapshot)
code string
}{
{name: "unissued item index", edit: func(s *validationSnapshot) { s.itemGrants[0].indices = []uint64{s.itemNextIndex} }, code: "inventory.grant_invalid_index"},
{name: "missing equipment", edit: func(s *validationSnapshot) { s.equipmentGrants[0].index = 999 }, code: "equipment.grant_missing_equipment"},
{name: "unknown equipped character", edit: func(s *validationSnapshot) { s.equipmentUsers[0].index = 999 }, code: "equipment.unknown_user"},
} {
t.Run(test.name, func(t *testing.T) {
snapshot := validValidationSnapshot()
test.edit(&snapshot)
if got := problemCodes(validateSnapshot(snapshot)); !slices.Contains(got, test.code) {
t.Fatalf("codes %v do not contain %s", got, test.code)
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
package app
import (
"bd2server/internal/server/design/gameconfig"
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/events/calendar"
"bd2server/internal/server/gateway/authconfig"
"bd2server/internal/server/gateway/bootstrap"
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/resources/policy"
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
)
type configuration struct {
logLevel string
logColor string
versionConfigPath string
authConfigPath string
resourceConfigPath string
gameConfigPath string
listen string
dataDir string
gameDataVersion string
gameDataOrigin string
accountSeed string
playerSeed string
readonlySeed string
mailSeed string
mailGrantSpool string
stateDirectory string
deckSeed string
worldSeed string
devToolsConfig string
versions versionconfig.Config
calendars *calendar.Set
gameRules gameconfig.Config
authentication authconfig.Config
authRuntime authconfig.Runtime
publicResources resourcepolicy.Public
bootstrap bootstrap.Config
gameData string
verifiedGameData gamedata.Result
}
func loadConfiguration(args []string) (result *configuration, loadErr error) {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
logLevel := fs.String("log-level", "", "log threshold: trace, debug, info, warn, error (default BD2_LOG_LEVEL or info)")
logColor := fs.String("log-color", "", "level colors: auto, always, never (default BD2_LOG_COLOR or auto)")
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
authConfigPath := fs.String("authentication-config", "", "authentication.json override for development")
resourceConfigPath := fs.String("resource-config", "", "resources.json override for development")
gameConfigPath := fs.String("game-config", "", "game.json server gameplay configuration override")
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
dataDir := fs.String("data-dir", "", "server data directory (defaults beside the executable)")
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (defaults to versions.json)")
gameDataOrigin := fs.String("game-data-origin", resourcepolicy.OfficialGameDataURL, "official GameData repair source override for development")
accountSeed := fs.String("account-seed", "", "versioned local account seed")
playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters")
readonlySeed := fs.String("readonly-seed", "", "versioned static protocol defaults")
mailSeed := fs.String("mail-seed", "", "versioned starter mailbox")
mailGrantSpool := fs.String("mail-grant-spool", "", "optional local JSON spool for idempotent dynamic system mail")
stateDirectory := fs.String("state-dir", "", "player and shared state directory")
deckSeed := fs.String("deck-seed", "", "versioned starter deck")
worldSeed := fs.String("world-seed", "", "versioned starter world")
devToolsConfig := fs.String("dev-tools-config", "", "development-tool settings JSON (defaults to DATA_DIR/dev-tools.json)")
if err := fs.Parse(args); err != nil {
return nil, err
}
if err := ConfigureLogging(os.Stderr, *logLevel, *logColor); err != nil {
return nil, err
}
var versions versionconfig.Config
var err error
if *versionConfigPath == "" {
versions, err = versionconfig.Find()
} else {
versions, err = versionconfig.Load(*versionConfigPath)
}
if err != nil {
return nil, err
}
if *gameDataVersion == "" {
*gameDataVersion = versions.GameDataVersion
} else {
// Preserve the development override as part of the effective process
// configuration so state snapshots describe the GameData actually used.
versions.GameDataVersion = *gameDataVersion
if err := versions.Validate(); err != nil {
return nil, fmt.Errorf("effective version config: %w", err)
}
}
versionconfig.Use(versions)
calendarDirectory := versions.Resolve("schedules")
calendars, err := calendar.LoadDirectory(calendarDirectory, versions.GameVersion, versions.GameDataVersion)
if err != nil {
return nil, fmt.Errorf("load project calendars: %w", err)
}
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
return nil, errors.New("project calendars require regular content and monster hunt schedules")
}
slog.Info("project calendars loaded", "directory", calendarDirectory, "revisions", calendars.Revisions, "events", len(calendars.Events))
if *gameConfigPath == "" {
*gameConfigPath, err = gameconfig.BesideExecutable()
if err != nil {
return nil, err
}
}
gameRules, err := gameconfig.Load(*gameConfigPath)
if err != nil {
return nil, err
}
if *authConfigPath == "" {
*authConfigPath, err = authconfig.BesideExecutable()
if err != nil {
return nil, err
}
}
authentication, err := authconfig.Load(*authConfigPath)
if err != nil {
return nil, err
}
authRuntime, err := authentication.ResolveEnvironment()
if err != nil {
return nil, err
}
defer func() {
if result == nil {
clear(authRuntime.MasterKey)
}
}()
if *resourceConfigPath == "" {
*resourceConfigPath, err = resourcepolicy.BesideExecutable()
if err != nil {
return nil, err
}
}
resources, err := resourcepolicy.Load(*resourceConfigPath)
if err != nil {
return nil, err
}
if *dataDir == "" {
executable, executableErr := os.Executable()
if executableErr != nil {
return nil, fmt.Errorf("resolve server data directory: %w", executableErr)
}
*dataDir = filepath.Join(filepath.Dir(executable), "data")
}
*dataDir, err = filepath.Abs(filepath.Clean(*dataDir))
if err != nil {
return nil, fmt.Errorf("resolve server data directory: %w", err)
}
*devToolsConfig = resolveDevelopmentSettingsPath(*dataDir, *devToolsConfig)
gameData := filepath.Join(*dataDir, "resources", "GameData")
if *stateDirectory == "" {
*stateDirectory = filepath.Join(*dataDir, "state")
}
seedRoot := versions.Resolve(versions.SeedDirectory)
for target, name := range map[*string]string{
accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json",
mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json",
} {
if *target == "" {
*target = filepath.Join(seedRoot, name)
}
}
clientOrigin := "http://" + *listen
if authentication.Mode == "oauth" {
clientOrigin = strings.TrimSuffix(authentication.PublicURL, "/")
}
base := clientOrigin + "/game/"
publicResources := resources.Public(versions.BundleVersion, *gameDataVersion)
cfg := bootstrap.Config{
BaseURL: base,
CDNURL: publicResources.ServerDataURL,
Version: versions.GameVersion,
BundleVer: versions.BundleVersion,
GameDataURL: publicResources.GameDataURL,
GameDataVer: *gameDataVersion,
}
if err := cfg.Validate(); err != nil {
return nil, err
}
verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, gameData, *gameDataVersion, *gameDataOrigin)
if err != nil {
return nil, fmt.Errorf("refuse to advertise unavailable or unverified GameData: %w", err)
}
if downloaded {
slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount)
}
if err := calendars.ValidateDesign(gameData, *gameDataVersion); err != nil {
return nil, fmt.Errorf("validate project calendar GameData references: %w", err)
}
return &configuration{
logLevel: *logLevel,
logColor: *logColor,
versionConfigPath: *versionConfigPath,
authConfigPath: *authConfigPath,
resourceConfigPath: *resourceConfigPath,
gameConfigPath: *gameConfigPath,
listen: *listen,
dataDir: *dataDir,
gameDataVersion: *gameDataVersion,
gameDataOrigin: *gameDataOrigin,
accountSeed: *accountSeed,
playerSeed: *playerSeed,
readonlySeed: *readonlySeed,
mailSeed: *mailSeed,
mailGrantSpool: *mailGrantSpool,
stateDirectory: *stateDirectory,
deckSeed: *deckSeed,
worldSeed: *worldSeed,
devToolsConfig: *devToolsConfig,
versions: versions, calendars: calendars, gameRules: gameRules, authentication: authentication, authRuntime: authRuntime, publicResources: publicResources, bootstrap: cfg, gameData: gameData, verifiedGameData: verifiedGameData,
}, nil
}
+370
View File
@@ -0,0 +1,370 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/domain/commerce"
"fmt"
)
type designCatalog struct {
cashCatalog *commerce.Catalog
source *gamedata.Source
world *gamedata.WorldRules
fieldReset gamedata.FieldResetSchedule
fieldBuffs map[uint64]gamedata.FieldBuffDesign
recovery *gamedata.PackRecoveryPolicy
researchCharacters map[uint64]bool
overwhelmSky []gamedata.SkyWayOverwhelmRule
eventPlay *gamedata.EventPlayCatalog
monsterHunt *monsterhunt.Rules
presetDesign *gamedata.PresetDesign
recipeDesign *gamedata.CookingRecipeDesign
randomBoxes *gamedata.RandomBoxDesign
itemStacks *gamedata.ItemStackDesign
slotDesign *gamedata.InventorySlotDesign
contentTickets *gamedata.GachaContentTicketDesign
missionDesign *gamedata.MissionDesign
levelDesign *gamedata.AchievementLevelDesign
equipmentSlots map[uint64]uint64
equipmentUpgrade *gamedata.EquipmentUpgradeDesign
equipmentCraft *gamedata.EquipmentCraftDesign
talentGrowth *gamedata.TalentGrowthDesign
equipmentSmelting *gamedata.EquipmentSmeltingDesign
equipmentOptionReroll *gamedata.EquipmentOptionRerollDesign
infiniteGacha *gamedata.InfiniteGachaDesign
fieldSettingsDesign *gamedata.FieldSettingsDesign
pictorialDesign *gamedata.PictorialDesign
equipmentStatDesign *gamedata.EquipmentStatDesign
charAwakeDesign *gamedata.CharAwakeDesign
immortalDesign *gamedata.ImmortalDesign
costumePotentialDesign *gamedata.CostumePotentialDesign
costumeBurstDesign *gamedata.CostumeBurstDesign
friendshipDesign gamedata.FriendshipDesign
contentOpeningDesign *gamedata.ContentOpeningDesign
huntingAPDesign gamedata.HuntingAPDesign
rewardGraph *gamedata.RewardGraph
rewardEquipment *gamedata.EquipmentGachaCatalog
rewardCostumes *gamedata.RegularGachaCatalog
talentUseDesign *gamedata.TalentUseDesign
dispatchDesign map[uint64]gamedata.TalentDispatchDesign
itemCraftDesign *gamedata.ItemCraftDesign
npcShopDesign gamedata.NPCShopDesign
commissionDesign *gamedata.TodayQuestCatalog
prestigeCatalog *gamedata.PrestigeSkinCatalog
ownedEventItems map[uint64]map[uint64]bool
avatarRewards *gamedata.AvatarRewardDesign
buffDesign map[uint64]gamedata.PictorialBuffStat
eventAPCaps map[uint64]uint64
eventAPReset gamedata.HuntingAPDesign
cashDesign *gamedata.CashCatalog
cashEntitlementDesign *gamedata.CashEntitlementDesign
cashRewards *gamedata.CashRewardResolver
cashMailTemplates map[uint64]bool
clearPackageDesign *gamedata.ClearPackageCatalog
cashBonusDesign *gamedata.CashBonusCatalog
eventTasksDesign *gamedata.EventTasksDesign
loginPassDesign *gamedata.LoginPassCatalog
eventExchangeDesign *gamedata.EventExchangeCatalog
eventBattleChallenges gamedata.EventBattleChallenges
eventActionsDesign *gamedata.EventActionsDesign
miniContent *gamedata.MiniContentDesign
recruitDesign gamedata.RecruitDesign
foodDesign *gamedata.FoodDesign
achievementCounterDesign *gamedata.AchievementCounterDesign
achievementGrades gamedata.GameplayAchievementGrades
regularGacha *gamedata.RegularGachaCatalog
equipmentGacha *gamedata.EquipmentGachaCatalog
limitedCostumes *gamedata.LimitedCostumeCatalog
firstGacha *gamedata.FirstGachaDesign
}
func loadDesign(c *configuration, seeds *seedCatalog) (*designCatalog, error) {
gameData, gameDataVersion := c.gameData, c.gameDataVersion
calendars, gameRules := c.calendars, c.gameRules
d := &designCatalog{source: gamedata.NewSource(gameData, gameDataVersion)}
var err error
gachaSchedule := calendars.GachaSeed
var scheduleGroupIDs, stepUpGroupIDs []uint64
for _, window := range gachaSchedule.Schedules {
scheduleGroupIDs = append(scheduleGroupIDs, window.GroupID)
}
for _, window := range gachaSchedule.StepUps {
stepUpGroupIDs = append(stepUpGroupIDs, window.GroupID)
}
d.regularGacha, d.equipmentGacha, err = gamedata.LoadActiveGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs, stepUpGroupIDs)
if err != nil {
return nil, fmt.Errorf("load active gacha GameData: %w", err)
}
if gameRules.Gacha.IncludeCollaborationURWeapons {
if err := d.equipmentGacha.IncludeCollaborationURWeapons(gameData, gameDataVersion); err != nil {
return nil, fmt.Errorf("apply collaboration UR weapon game rule: %w", err)
}
}
d.presetDesign, err = gamedata.LoadPresetDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load party preset GameData: %w", err)
}
d.recipeDesign, err = gamedata.LoadCookingRecipeDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cooking recipes: %w", err)
}
d.randomBoxes, err = gamedata.LoadRandomBoxDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load deterministic random-box GameData: %w", err)
}
d.itemStacks, err = gamedata.LoadItemStackDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load item stack GameData: %w", err)
}
d.slotDesign, err = gamedata.LoadInventorySlotDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load inventory slot GameData: %w", err)
}
d.contentTickets, err = gamedata.LoadGachaContentTicketDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load gacha content tickets: %w", err)
}
d.missionDesign, err = gamedata.LoadMissionDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load mission GameData: %w", err)
}
d.levelDesign, err = gamedata.LoadAchievementLevelDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load user level rewards: %w", err)
}
d.equipmentSlots, err = gamedata.LoadEquipmentSlots(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment slot GameData: %w", err)
}
d.equipmentUpgrade, err = gamedata.LoadEquipmentUpgradeDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment upgrade GameData: %w", err)
}
d.equipmentCraft, err = gamedata.LoadEquipmentCraftDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment crafting GameData: %w", err)
}
d.talentGrowth, err = gamedata.LoadTalentGrowthDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load talent growth GameData: %w", err)
}
d.equipmentSmelting, err = gamedata.LoadEquipmentSmeltingDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment smelting GameData: %w", err)
}
d.equipmentOptionReroll, err = gamedata.LoadEquipmentOptionRerollDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment option reroll GameData: %w", err)
}
d.infiniteGacha, err = gamedata.LoadInfiniteGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs)
if err != nil {
return nil, fmt.Errorf("load infinite gacha GameData: %w", err)
}
d.fieldSettingsDesign, err = gamedata.LoadFieldSettingsDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load field character settings: %w", err)
}
d.pictorialDesign, err = gamedata.LoadPictorialDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load pictorial GameData: %w", err)
}
d.equipmentStatDesign, err = gamedata.LoadEquipmentStatDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load equipment stat GameData: %w", err)
}
d.charAwakeDesign, err = gamedata.LoadCharAwakeDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load character awakening GameData: %w", err)
}
d.immortalDesign, err = gamedata.LoadImmortalDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load immortal talent GameData: %w", err)
}
d.costumePotentialDesign, err = gamedata.LoadCostumePotentialDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load costume potential GameData: %w", err)
}
d.costumeBurstDesign, err = gamedata.LoadCostumeBurstDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load costume burst GameData: %w", err)
}
d.friendshipDesign, err = gamedata.LoadFriendshipDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load friendship GameData: %w", err)
}
d.contentOpeningDesign, err = gamedata.LoadContentOpeningDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load content opening GameData: %w", err)
}
d.huntingAPDesign, err = gamedata.LoadHuntingAPDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load hunting AP reset: %w", err)
}
d.rewardGraph, err = gamedata.LoadRewardGraph(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event reward graph: %w", err)
}
d.rewardEquipment, err = gamedata.LoadRewardEquipmentCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load reward equipment: %w", err)
}
d.rewardCostumes, err = gamedata.LoadRewardCostumeCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load reward costumes: %w", err)
}
d.talentUseDesign, err = gamedata.LoadTalentUseDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load field talent skills: %w", err)
}
d.dispatchDesign, err = gamedata.LoadTalentDispatchDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load talent dispatch design: %w", err)
}
d.itemCraftDesign, err = gamedata.LoadItemCraftDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load item crafting design: %w", err)
}
d.npcShopDesign, err = gamedata.LoadNPCShopDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load NPC shop design: %w", err)
}
d.commissionDesign, err = gamedata.LoadTodayQuests(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load NPC commission design: %w", err)
}
d.prestigeCatalog, err = gamedata.LoadPrestigeSkinCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load reward prestige skins: %w", err)
}
d.ownedEventItems, err = gamedata.LoadOwnedEventItemDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event inventory design: %w", err)
}
d.avatarRewards, err = gamedata.LoadAvatarRewardDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load avatar rewards: %w", err)
}
d.buffDesign, err = gamedata.LoadBuffRewardDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load permanent buff rewards: %w", err)
}
d.eventAPCaps, d.eventAPReset, err = gamedata.LoadEventAPDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event AP reset: %w", err)
}
d.cashDesign, err = gamedata.LoadCashCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash products: %w", err)
}
d.cashEntitlementDesign, err = gamedata.LoadCashEntitlementDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash entitlement design: %w", err)
}
d.cashRewards, err = gamedata.LoadCashRewardResolver(gameData, gameDataVersion, d.rewardGraph)
if err != nil {
return nil, fmt.Errorf("load cash product rewards: %w", err)
}
d.cashMailTemplates, err = gamedata.LoadCashMailTemplates(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash mail templates: %w", err)
}
d.clearPackageDesign, err = gamedata.LoadClearPackageCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load clear-package rewards: %w", err)
}
d.cashBonusDesign, err = gamedata.LoadCashBonusCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load cash bonus design: %w", err)
}
d.eventTasksDesign, err = gamedata.LoadEventTasksDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event tasks design: %w", err)
}
d.loginPassDesign, err = gamedata.LoadLoginPassCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load login-pass rewards: %w", err)
}
d.eventExchangeDesign, err = gamedata.LoadEventExchangeCatalog(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event exchange design: %w", err)
}
d.eventBattleChallenges, err = gamedata.LoadEventBattleChallenges(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event battle challenges: %w", err)
}
d.eventActionsDesign, err = gamedata.LoadEventActionsDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load event action design: %w", err)
}
d.miniContent, err = gamedata.LoadMiniContentDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load mini event content: %w", err)
}
d.recruitDesign, err = gamedata.LoadRecruitDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load recruitment GameData: %w", err)
}
d.foodDesign, err = gamedata.LoadFoodDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load food GameData: %w", err)
}
d.achievementCounterDesign, err = gamedata.LoadAchievementCounterDesign(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load achievement counters: %w", err)
}
d.achievementGrades, err = gamedata.LoadGameplayAchievementGrades(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load achievement gameplay grades: %w", err)
}
d.limitedCostumes, err = gamedata.LoadLimitedCostumes(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load limited costume GameData: %w", err)
}
d.firstGacha, err = gamedata.LoadFirstGacha(gameData, gameDataVersion)
if err != nil {
return nil, fmt.Errorf("load first gacha GameData: %w", err)
}
d.world, err = gamedata.LoadWorldRules(gameData, gameDataVersion, seeds.world.PlaceholderCostumeID)
if err != nil {
return nil, err
}
d.fieldReset, err = gamedata.LoadFieldResetSchedule(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.fieldBuffs, err = gamedata.LoadFieldBuffDesign(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.recovery, err = gamedata.LoadPackRecoveryPolicy(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.researchCharacters, err = gamedata.LoadResearchCharacters(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.overwhelmSky, err = gamedata.LoadSkyWayOverwhelm(gameData, gameDataVersion)
if err != nil {
return nil, err
}
d.eventPlay, err = gamedata.LoadEventPlayCatalog(gameData, gameDataVersion)
if err != nil {
return nil, err
}
presets, err := gamedata.LoadMonsterHuntPresetDesign(gameData, gameDataVersion)
if err != nil {
return nil, err
}
seasons := monsterHuntSeasons(seeds.defaults)
d.monsterHunt, err = monsterhunt.NewRules(seasons, seeds.defaults, presets, d.source.MonsterHunt)
if err != nil {
return nil, err
}
d.cashCatalog, err = commerce.NewCatalog(c.versions.GameVersion, d.cashDesign, c.gameRules.Purchases)
if err != nil {
return nil, err
}
return d, nil
}
@@ -0,0 +1,10 @@
package app
import "path/filepath"
func resolveDevelopmentSettingsPath(dataDir, override string) string {
if override != "" {
return filepath.Clean(override)
}
return filepath.Join(dataDir, "dev-tools.json")
}
@@ -0,0 +1,304 @@
//go:build integration
package app
import (
"bytes"
"context"
"fmt"
"testing"
"time"
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/protocol/wire"
"bd2server/internal/server/runtime/player"
accountstate "bd2server/internal/server/storage/account"
)
type trapPlayerScenario struct {
factory *PlayerFactory
owner *playerInstance
account string
leader, other uint64
maximum uint64
commands int
}
func newTrapPlayerScenario(t *testing.T, pack int) *trapPlayerScenario {
t.Helper()
factory, accounts := newIntegrationFactory(t)
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
s := &trapPlayerScenario{factory: factory, owner: owner, account: accounts[0]}
t.Cleanup(func() {
if s.owner != nil {
if err := s.owner.Close(); err != nil {
t.Error(err)
}
}
})
s.run(t, true, func(ctx command.Context, p *playerAssembly) {
if _, err := p.collection.GrantCostumes(ctx, "trap-party-owned-costumes", []uint64{101, 201}, p.design.rewardCostumes); err != nil {
t.Fatal(err)
}
if rule := p.design.world.Story.Packs[pack].Open; rule != nil && rule.TicketID != 0 {
if _, err := p.ownedItems.GrantOnce(ctx, "trap-scenario-ticket", []gamedata.BattleReward{{Type: 19, ID: rule.TicketID, Count: 1}}); err != nil {
t.Fatal(err)
}
}
if _, _, _, err := p.worldService.Handle(ctx, "/PackInGameInfo", trapRequest(100, uint64(pack))); err != nil {
t.Fatal(err)
}
var selected []roster.Character
for _, c := range p.worldService.CharacterService().RawAll() {
if !roster.IsStoryCharacter(c) && !roster.IsCharmCharacter(c) && !roster.CharacterExpired(c, time.Now()) {
worn, owned := p.collection.CostumeByIndex(c.UseCostume)
if !owned || worn.UseChar != c.InvenIndex {
continue
}
connectedOwned := c.ConnectPotentialCostume == 0
for _, costume := range p.collection.Costumes() {
if costume.ID == c.ConnectPotentialCostume && p.design.costumePotentialDesign.CostumeUnique[costume.ID] == p.design.costumePotentialDesign.CharacterUnique[c.ID] {
connectedOwned = true
break
}
}
if !connectedOwned {
continue
}
maximum, err := p.worldService.CharacterService().MaxHealth(ctx, c.InvenIndex)
if err != nil {
t.Fatal(err)
}
if maximum == 0 || len(selected) == 0 && maximum <= 150 {
continue
}
selected = append(selected, c)
if len(selected) == 2 {
break
}
}
}
if len(selected) < 2 {
t.Fatal("player setup requires two owned permanent characters")
}
s.leader = selected[0].InvenIndex
s.other = selected[1].InvenIndex
s.maximum, err = p.worldService.CharacterService().MaxHealth(ctx, s.leader)
if err != nil {
t.Fatal(err)
}
for _, c := range selected {
max, err := p.worldService.CharacterService().MaxHealth(ctx, c.InvenIndex)
if err != nil {
t.Fatal(err)
}
if err := p.worldService.CharacterService().SetCurrentHealth(ctx, c.InvenIndex, max); err != nil {
t.Fatal(err)
}
}
req := wire.AppendVarint(nil, 1, 1)
for i, c := range selected {
row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(i+1)), 2, c.InvenIndex), 3, c.UseCostume)
req = wire.AppendBytes(req, 2, row)
}
if _, _, _, err := p.deckStateStore.Handle(ctx, "/FieldDeckSave", req); err != nil {
t.Fatal(err)
}
if _, _, _, err := p.deckStateStore.Handle(ctx, "/SaveFieldCharControlDeckType", wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1)); err != nil {
t.Fatal(err)
}
})
return s
}
func (s *trapPlayerScenario) run(t *testing.T, commit bool, action func(command.Context, *playerAssembly)) {
t.Helper()
tx, err := s.owner.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
ctx := command.Context{Identity: command.Identity{AccountID: s.account, SessionID: "trap-client"}, State: tx}
action(ctx, s.owner.assembly)
problems, err := tx.Validate()
if err != nil || len(problems) != 0 {
t.Fatalf("complete player validation failed: %v %v", problems, err)
}
if commit {
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
}
}
func (s *trapPlayerScenario) reopen(t *testing.T) {
t.Helper()
status, err := s.owner.repository.InitializationState("characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet")
if err != nil || status != accountstate.InitializationComplete {
t.Fatalf("trap operations changed the nine-domain account schema: status=%v err=%v", status, err)
}
if err := s.owner.repository.RequireDomains("bootstrap", "characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"); err != nil {
t.Fatal(err)
}
problems, err := s.owner.repository.Validate()
if err != nil || len(problems) != 0 {
t.Fatalf("persisted player validation: %v %v", problems, err)
}
if err := s.owner.Close(); err != nil {
t.Fatal(err)
}
s.owner = nil
s.owner, err = s.factory.open(s.account)
if err != nil {
t.Fatalf("reopen complete player after trap operation: %v", err)
}
}
func trapRequest(seq, id uint64) []byte {
return wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, id)
}
func trapHealth(t *testing.T, ctx command.Context, p *playerAssembly, index uint64) uint64 {
t.Helper()
hp, err := p.worldService.CharacterService().CurrentHealth(ctx, index)
if err != nil {
t.Fatal(err)
}
return hp
}
func (s *trapPlayerScenario) execute(path string, request []byte) (player.Reply, error) {
c := registryCommand(s.account, fmt.Sprintf("trap-command-%d", s.commands), path, request)
s.commands++
c.Identity.SessionID = "trap-client"
return s.owner.Execute(context.Background(), c)
}
func (s *trapPlayerScenario) protocol(t *testing.T, path string, request []byte) []byte {
t.Helper()
reply, err := s.execute(path, request)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
if len(reply.Responses) != 1 {
t.Fatalf("%s missing response", path)
}
return reply.Responses[0].Body
}
func (s *trapPlayerScenario) health(t *testing.T, index uint64) uint64 {
t.Helper()
var hp uint64
s.run(t, false, func(ctx command.Context, p *playerAssembly) { hp = trapHealth(t, ctx, p, index) })
return hp
}
func (s *trapPlayerScenario) position(t *testing.T, pack, mapID int) {
t.Helper()
raw := wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(s.commands+1)), 2, uint64(pack)), 3, []byte(fmt.Sprintf(`{"MapId":%d}`, mapID)))
s.protocol(t, "/SaveUserPosition", raw)
}
// The real player's leader takes spike damage; retries, failed transactions,
// and reassembly must preserve HP and the account's initialized domain set.
func TestPlayerTrapDamagePersistsHealthAndReplaysAcrossTransactions(t *testing.T) {
s := newTrapPlayerScenario(t, 2)
request := trapRequest(1, 11)
s.position(t, 2, 22)
otherHP := s.health(t, s.other)
reply := s.protocol(t, "/TrapDamage", request)
rows := appRows(reply, 1)
if len(rows) != 1 || appValue(rows[0], 1) != s.leader || appValue(rows[0], 3) != s.maximum-50 {
t.Fatalf("spikes must return the actual leader's reduced HP: %x", reply)
}
if s.health(t, s.leader) != s.maximum-50 || s.health(t, s.other) != otherHP {
t.Fatal("spikes changed the wrong party member's health")
}
s.reopen(t)
if retry := s.protocol(t, "/TrapDamage", request); !bytes.Equal(retry, reply) {
t.Fatal("reopened player changed the one-hit retry")
}
if s.health(t, s.leader) != s.maximum-50 {
t.Fatal("retry deducted HP again")
}
if _, err := s.execute("/TrapDamage", trapRequest(1, 21)); err == nil {
t.Fatal("changed replay accepted")
}
s.position(t, 2, 23)
s.protocol(t, "/TrapDamage", trapRequest(2, 11))
if s.health(t, s.leader) != s.maximum-100 {
t.Fatal("shared trap ID in its second scene did not apply damage")
}
s.position(t, 2, 21)
before := s.health(t, s.leader)
if _, err := s.execute("/TrapDamage", trapRequest(3, 11)); err == nil {
t.Fatal("wrong-scene trap request accepted")
}
if s.health(t, s.leader) != before {
t.Fatal("wrong-scene trap request changed HP")
}
s.position(t, 2, 22)
failed := registryCommand(s.account, "trap-then-rejected-batch", "/TrapDamage", trapRequest(4, 11))
failed.Identity.SessionID = "trap-client"
failed.Requests = append(failed.Requests, player.Request{Path: "/TrapDamage", Body: trapRequest(5, 999999)})
if _, err := s.owner.Execute(context.Background(), failed); err == nil {
t.Fatal("invalid batch committed its preceding trap hit")
}
s.reopen(t)
if s.health(t, s.leader) != s.maximum-100 {
t.Fatal("rolled-back HP was persisted")
}
s.protocol(t, "/TrapDamage", trapRequest(4, 11))
if s.health(t, s.leader) != s.maximum-150 {
t.Fatal("rolled-back receipt blocked the next real hit")
}
s.reopen(t)
}
// A saved Preserve override controls what GetInitialActiveState restores. Its
// map filter and disabled damage behavior must survive a complete player reopen.
func TestPlayerTrapOverrideRestoresAcrossReopenAndFiltersMaps(t *testing.T) {
s := newTrapPlayerScenario(t, 2)
query := func(mapID uint64) []byte {
t.Helper()
return s.protocol(t, "/FieldTrapInfo", wire.AppendVarint(trapRequest(10, 2), 3, mapID))
}
s.position(t, 2, 22)
if len(appRows(query(0), 1)) != 0 {
t.Fatal("new player received an override without saving one")
}
s.protocol(t, "/TrapDamage", trapRequest(11, 11))
if s.health(t, s.leader) != s.maximum-50 {
t.Fatal("new player did not use the trap's enabled default")
}
s.run(t, true, func(ctx command.Context, p *playerAssembly) {
// The setup is a legal saved state for the real Preserve trap 11. It
// does not change GameData or invent a currently absent switch chain.
payload := []byte(`{"States":{"2/22/11":{"Pack":2,"Map":22,"Trap":11,"Enabled":false,"Partial":null},"2/23/11":{"Pack":2,"Map":23,"Trap":11,"Enabled":true,"Partial":null}},"Hits":{},"Requests":{}}`)
if err := p.gameplayStore.Save(ctx.State, "field_traps", payload); err != nil {
t.Fatal(err)
}
})
s.reopen(t)
rows := appRows(query(22), 1)
if len(rows) != 1 || appValue(rows[0], 1) != 2 || appValue(rows[0], 2) != 22 || appValue(rows[0], 3) != 11 || appValue(rows[0], 4) != 0 {
t.Fatalf("disabled trap override not restored in the requested map: %x", rows)
}
if reply := s.protocol(t, "/TrapDamage", trapRequest(12, 11)); len(reply) != 0 || s.health(t, s.leader) != s.maximum-50 {
t.Fatal("restored disabled trap caused damage")
}
rows = appRows(query(23), 1)
if len(rows) != 1 || appValue(rows[0], 2) != 23 || appValue(rows[0], 3) != 11 || appValue(rows[0], 4) != 1 {
t.Fatalf("second-map enabled override not restored: %x", rows)
}
if len(appRows(query(0), 1)) != 2 {
t.Fatal("whole-pack query lost a persisted map override")
}
s.position(t, 2, 23)
s.protocol(t, "/TrapDamage", trapRequest(13, 11))
if s.health(t, s.leader) != s.maximum-100 {
t.Fatal("enabled override in the other map failed to damage the leader")
}
s.reopen(t)
}
+28
View File
@@ -0,0 +1,28 @@
package app
import (
"io"
"bd2server/internal/server/platform/logging"
)
func ConfigureLogging(writer io.Writer, levelOverride, colorOverride string) error {
options, err := logging.OptionsFromEnv()
if err != nil {
return err
}
if levelOverride != "" {
options.Level, err = logging.ParseLevel(levelOverride)
if err != nil {
return err
}
}
if colorOverride != "" {
options.Color, err = logging.ParseColorMode(colorOverride)
if err != nil {
return err
}
}
_, err = logging.Setup(writer, options)
return err
}
@@ -0,0 +1,41 @@
package app
import (
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/protocol/staticdata"
)
func monsterHuntSeasons(seed *readonly.Seed) []monsterhunt.Season {
var seasons []monsterhunt.Season
for _, field := range seed.Responses["/MonsterHuntScheduleInfo"].Fields {
if field.Number != 1 || field.Type != 2 {
continue
}
var season monsterhunt.Season
for _, value := range field.Fields {
switch value.Number {
case 1:
for _, nested := range value.Fields {
switch nested.Number {
case 1:
season.ID = nested.Varint
case 2:
season.Start = nested.Varint
case 3:
season.End = nested.Varint
}
}
case 2:
season.Hunt = value.Varint
case 4:
season.Calculate = value.Varint
case 6:
season.Independent = value.Varint != 0
case 7:
season.RankGroup = value.Varint
}
}
seasons = append(seasons, season)
}
return seasons
}
+140
View File
@@ -0,0 +1,140 @@
package app
import (
"bd2server/internal/server/domain/command"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/domain/mail"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/roster/deck"
"bd2server/internal/server/domain/world/progress"
"bd2server/internal/server/storage/account"
"fmt"
"path/filepath"
)
func (p *playerAssembly) assets(ctx command.Context) error {
var err error
p.startingPackID, err = ctx.State.(*accountstate.CommandStore).LockStartingPack(p.options.gameRules.Story.StartPackID, p.initializeAccount)
if err != nil {
return fmt.Errorf("server starting chapter policy: %w", err)
}
p.progressState, err = progress.OpenStore(ctx, p.scope)
if err != nil {
return err
}
p.deckStateStore, err = deck.OpenStore(ctx, p.scope, p.deckSeed, *p.design.presetDesign)
if err != nil {
return fmt.Errorf("load deck state: %w", err)
}
if err := p.login.AttachPresetSlots(p.deckStateStore); err != nil {
return fmt.Errorf("attach preset slots to login: %w", err)
}
if err := p.login.AttachPortrait(p.deckStateStore); err != nil {
return fmt.Errorf("attach persisted portrait to login: %w", err)
}
p.ownedItems, err = assets.OpenInventory(ctx, p.scope, p.starter.Items)
if err != nil {
return fmt.Errorf("load owned inventory: %w", err)
}
if err := p.ownedItems.AttachItemStacks(p.design.itemStacks); err != nil {
return err
}
p.recipeService, err = assets.NewRecipeService(p.design.recipeDesign, p.starter.CookingRecipes, p.ownedItems)
if err != nil {
return fmt.Errorf("load learned recipes: %w", err)
}
if err := p.ownedItems.AttachRandomBoxes(ctx, p.design.randomBoxes); err != nil {
return fmt.Errorf("attach random-box GameData: %w", err)
}
gold, freeJewelry, jewelry, mileage, err := p.login.SeedCurrencies()
if err != nil {
return fmt.Errorf("read account seed currency: %w", err)
}
hopePowder, err := p.login.SeedHopePowder()
if err != nil {
return fmt.Errorf("read account seed hope powder: %w", err)
}
catalyst, err := p.login.SeedCatalyst()
if err != nil {
return fmt.Errorf("read account seed catalyst: %w", err)
}
equipMileage, equipMileageExchangeGage, err := p.login.SeedEquipmentMileage()
if err != nil {
return fmt.Errorf("read account seed equipment mileage: %w", err)
}
p.wallet, err = assets.OpenWallet(ctx, p.scope, assets.Currency{
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Catalyst: catalyst, Mileage: mileage, HopePowder: hopePowder,
EquipMileage: equipMileage, EquipMileageExchangeGage: equipMileageExchangeGage,
})
if err != nil {
return fmt.Errorf("load wallet state: %w", err)
}
if err := p.login.AttachCurrencies(p.wallet); err != nil {
return fmt.Errorf("attach wallet to login: %w", err)
}
itemSlots, storageSlots, equipmentInventorySlots, equipmentStorageSlots, err := p.login.SeedInventorySlots()
if err != nil {
return fmt.Errorf("read account seed inventory slots: %w", err)
}
p.inventorySlots, err = assets.OpenInventorySlots(ctx, p.scope, p.design.slotDesign, assets.InventorySlotCounts{
Items: itemSlots, Storage: storageSlots, Equipment: equipmentInventorySlots, EquipmentStorage: equipmentStorageSlots,
}, p.wallet)
if err != nil {
return fmt.Errorf("load inventory slot state: %w", err)
}
p.inventorySlots.AttachDevelopmentSettings(p.options.devToolsConfig)
if err := p.login.AttachInventorySlots(p.inventorySlots); err != nil {
return fmt.Errorf("attach inventory slots to login: %w", err)
}
p.mailService, err = mail.OpenService(ctx, p.scope, p.mailbox, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load mail state: %w", err)
}
if err := p.mailService.AttachContentTickets(ctx, p.design.contentTickets); err != nil {
return fmt.Errorf("attach mailbox content tickets: %w", err)
}
if err := p.mailService.AttachSeedPath(ctx, filepath.Clean(p.options.mailSeed)); err != nil {
return fmt.Errorf("watch mail seed: %w", err)
}
if p.options.mailGrantSpool != "" {
if err := p.mailService.AttachGrantSpoolPath(ctx, p.options.mailGrantSpool); err != nil {
return fmt.Errorf("attach mail grant spool: %w", err)
}
}
p.missionService, err = missions.Open(ctx, p.scope, p.design.missionDesign, p.ownedItems)
if err != nil {
return fmt.Errorf("load mission state: %w", err)
}
if err := p.missionService.AttachWallet(ctx, p.wallet); err != nil {
return fmt.Errorf("attach mission wallet: %w", err)
}
if err := p.missionService.AttachUserLevelRewards(ctx, p.design.levelDesign); err != nil {
return fmt.Errorf("attach user level rewards: %w", err)
}
if err := p.login.AttachLevelReward(p.missionService); err != nil {
return fmt.Errorf("attach persisted user level reward: %w", err)
}
if err := p.missionService.AttachMail(ctx, p.mailService); err != nil {
return fmt.Errorf("attach mission compensation mailbox: %w", err)
}
p.ownedEquipment, err = assets.OpenEquipmentInventory(ctx, p.scope)
if err != nil {
return fmt.Errorf("load owned equipment: %w", err)
}
if err := p.ownedEquipment.AttachSlots(ctx, p.design.equipmentSlots); err != nil {
return fmt.Errorf("attach equipment slot GameData: %w", err)
}
if err := p.ownedEquipment.AttachUpgrade(ctx, p.design.equipmentUpgrade, p.wallet, p.ownedItems); err != nil {
return fmt.Errorf("attach equipment upgrade GameData: %w", err)
}
if err := p.ownedEquipment.AttachCraft(ctx, p.design.equipmentCraft); err != nil {
return fmt.Errorf("attach equipment crafting GameData: %w", err)
}
if err := p.ownedEquipment.AttachSmelting(ctx, p.design.equipmentSmelting, p.wallet, p.ownedItems); err != nil {
return fmt.Errorf("attach equipment smelting GameData: %w", err)
}
if err := p.ownedEquipment.AttachOptionReroll(ctx, p.design.equipmentOptionReroll, p.wallet, p.ownedItems); err != nil {
return fmt.Errorf("attach equipment option reroll GameData: %w", err)
}
return nil
}
+133
View File
@@ -0,0 +1,133 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce"
"bd2server/internal/server/domain/events/tasks"
"bd2server/internal/server/protocol/wire"
"fmt"
"time"
)
func (p *playerAssembly) commerce(ctx command.Context) error {
var err error
p.cashCatalog = p.design.cashCatalog
p.cashEconomy, err = commerce.NewEntitlementEconomy(ctx, p.gameplayStore, p.eventEconomy, p.design.cashRewards, p.ownedItems, p.design.cashEntitlementDesign)
if err != nil {
return fmt.Errorf("load cash entitlements: %w", err)
}
p.cashEconomy.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
if err := p.mailService.AttachCashRewards(ctx, p.cashEconomy, p.design.cashMailTemplates); err != nil {
return err
}
if err := p.cashEconomy.AttachCashMail(p.mailService); err != nil {
return err
}
p.cashService, err = commerce.NewService(ctx, p.cashCatalog, p.gameplayStore, p.cashEconomy)
if err != nil {
return fmt.Errorf("load cash purchase state: %w", err)
}
p.clearPackages, err = commerce.NewClearPackages(ctx, p.gameplayStore, p.design.clearPackageDesign, p.cashEconomy, p.ownedItems)
if err != nil {
return fmt.Errorf("load clear-package claims: %w", err)
}
p.clearPackages.AttachProgress(p.worldService.CashPackagePackCleared, nil)
p.cashService.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
if err := p.cashService.AttachPackageRules(p.design.cashDesign.Packages); err != nil {
return fmt.Errorf("attach cash package progression: %w", err)
}
if err := p.cashService.AttachShopSeed(ctx, p.seeds.defaults); err != nil {
return fmt.Errorf("attach cash product availability: %w", err)
}
if err := p.cashService.AttachEventShopSchedules(p.design.cashDesign, p.options.calendars.Events); err != nil {
return fmt.Errorf("attach event shop availability: %w", err)
}
p.cashBonuses, err = commerce.NewCashBonuses(ctx, p.gameplayStore, p.cashEconomy, p.cashService, p.design.cashBonusDesign, p.design.cashDesign.Packages)
if err != nil {
return fmt.Errorf("load cash bonus claims: %w", err)
}
p.cashService.AttachLegacyCounts(p.gachaService)
cashSpecialProducts := []gamedata.CashProductKey{{GroupID: p.design.infiniteGacha.ProductGroupID, ProductID: p.design.infiniteGacha.ProductID, SaleGroup: p.design.infiniteGacha.SaleGroup}}
for _, group := range p.design.regularGacha.Groups() {
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
cashSpecialProducts = append(cashSpecialProducts, gamedata.CashProductKey{GroupID: group.CashProductGroupID, ProductID: group.CashProductID, SaleGroup: group.CashSalesGroup})
}
}
if err := p.cashService.AttachSpecialProducts(cashSpecialProducts); err != nil {
return fmt.Errorf("attach special cash products: %w", err)
}
p.cashService.AttachDelegate(func(ctx command.Context, key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
known := key.GroupID == p.design.infiniteGacha.ProductGroupID && key.ProductID == p.design.infiniteGacha.ProductID && key.SaleGroup == p.design.infiniteGacha.SaleGroup
for _, group := range p.design.regularGacha.Groups() {
if key.GroupID == group.CashProductGroupID && key.ProductID == group.CashProductID && key.SaleGroup == group.CashSalesGroup {
known = true
break
}
}
if !known {
return nil, false, nil
}
_, response, handled, err := p.gachaService.Handle(ctx, "/CashShopBuy", request)
if err != nil || !handled {
return nil, handled, err
}
bundle, _, err := wire.Bytes(response, 1)
return bundle, true, err
})
if err := p.login.AttachPurchaseCounts(p.cashService); err != nil {
return fmt.Errorf("attach cash purchase counts: %w", err)
}
p.eventTasksService, err = eventtasks.Open(ctx, p.gameplayStore, p.design.eventTasksDesign, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event tasks state: %w", err)
}
if err := p.mailService.AttachAttendanceRewardEconomy(ctx, p.eventEconomy); err != nil {
return fmt.Errorf("attach attendance mail rewards: %w", err)
}
p.eventTasksService.AttachAttendanceMail(p.mailService)
newbieStep, _, err := wire.Varint(p.login.UserInfo, 39)
if err != nil {
return err
}
if err = p.eventTasksService.SetNewbieStep(ctx, newbieStep); err != nil {
return err
}
if err = p.login.AttachNewbieStep(p.eventTasksService); err != nil {
return err
}
p.eventTasksService.AttachCashAuthorization(func(ctx command.Context, passID, buyType uint64) bool {
for _, buy := range p.design.eventTasksDesign.PassBuys[passID] {
if buy.Type == buyType && buy.CashID != 0 {
return p.cashService.ConsumeEntitlement(ctx, gamedata.CashProductKey{GroupID: buy.CashGroup, ProductID: buy.CashID, SaleGroup: buy.CashSales})
}
}
return false
})
p.eventTasksService.AttachAttendancePremium(func(ctx command.Context, ticket uint64) bool {
for _, item := range p.ownedItems.All(ctx) {
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(time.Now().UnixMilli())) {
return true
}
}
return false
})
p.loginPasses, err = commerce.NewLoginPasses(ctx, p.gameplayStore, p.design.loginPassDesign, p.cashEconomy, p.ownedItems, func(ctx command.Context, group uint64) bool {
for _, pack := range p.design.cashDesign.Packages {
if pack.PackageType == 7 && pack.ID == group && p.cashService.IsAvailable(ctx, gamedata.CashProductKey{GroupID: pack.GroupID, ProductID: pack.ID, SaleGroup: pack.SaleGroup}) {
return true
}
}
return false
})
if err != nil {
return fmt.Errorf("load login-pass progress: %w", err)
}
p.loginPasses.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
p.eventTasksService.AttachUnlockResolver(p.worldService.MissionsUnlocked)
if err = p.missionService.AttachEventHandler(ctx, p.eventTasksService); err != nil {
return fmt.Errorf("attach mission event handler: %w", err)
}
return nil
}
+184
View File
@@ -0,0 +1,184 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/events"
"bd2server/internal/server/domain/events/actions"
"bd2server/internal/server/domain/events/exchange"
"bd2server/internal/server/domain/events/games"
"bd2server/internal/server/domain/events/play"
"bd2server/internal/server/domain/events/tasks"
"bd2server/internal/server/domain/progression/achievements"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/roster"
"fmt"
"log/slog"
"path/filepath"
)
func (p *playerAssembly) events(ctx command.Context) error {
var err error
p.eventGamesService, err = eventgames.Open(ctx, p.gameplayStore, p.design.source.EventGame, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event games state: %w", err)
}
p.eventExchangeService, err = eventexchange.Open(ctx, p.gameplayStore, p.design.eventExchangeDesign, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event exchange state: %w", err)
}
p.boxService, err = events.OpenBoxes(p.gameplayStore, p.ownedItems, p.eventEconomy)
if err != nil {
return fmt.Errorf("load random box state: %w", err)
}
p.eventPlayService, err = eventplay.Open(ctx, p.gameplayStore, p.design.eventPlay, func(id uint64) (*gamedata.EventField, error) {
return p.design.source.EventField(p.design.eventPlay, id)
}, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event play state: %w", err)
}
p.eventPlayService.AttachBattleChallenges(p.design.eventBattleChallenges)
p.eventPlayService.AttachHubCalendars(p.seeds.defaults)
if err := p.eventPlayService.AttachFieldBindingsFile(filepath.Join(filepath.Dir(p.options.worldSeed), "event_field_bindings.json")); err != nil {
return fmt.Errorf("attach hidden field bindings: %w", err)
}
if err := p.worldService.AttachEventFieldPacks(p.eventPlayService); err != nil {
return fmt.Errorf("attach event field packs: %w", err)
}
p.battleService.AttachEventBattle(p.eventPlayService)
p.eventActionsService, err = eventactions.Open(ctx, p.gameplayStore, p.design.eventActionsDesign, p.eventRegistry, p.eventEconomy)
if err != nil {
return fmt.Errorf("load event action state: %w", err)
}
if err = p.eventActionsService.AttachMiniContent(ctx, p.eventPlayService, p.design.miniContent); err != nil {
return fmt.Errorf("attach mini event content: %w", err)
}
p.eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 {
for _, entry := range p.collection.FriendshipEntries() {
if entry.State != nil && entry.State.CostumeID == id {
return entry.State.Level
}
}
return 0
})
p.eventActionsService.AttachChargeInfo(func(ctx command.Context) ([]byte, error) {
rows, err := p.eventEconomy.ChargeInfo(ctx)
if err != nil {
return nil, err
}
huntingRows, err := p.huntingService.APChargeInfo(ctx)
if err != nil {
return nil, err
}
return append(rows, huntingRows...), nil
})
p.eventActionsService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
if err := p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked); err != nil {
return err
}
return p.eventTasksService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
})
p.eventPlayService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
return p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
})
p.eventTasksService.AttachAssociatedMissionGroup(func(schedule events.Schedule) uint64 {
if group := p.eventActionsService.AssociatedMissionGroup(schedule); group != 0 {
return group
}
group, err := p.eventPlayService.AssociatedMissionGroup(schedule)
if err != nil {
slog.Error("event mission design unavailable", "event_uid", schedule.UID, "event_id", schedule.ID, "error", err)
}
return group
})
p.battleService.AttachEventBattle(p.eventActionsService)
p.battleService.AttachCurrentDifficulty(p.worldService.CurrentQuestDifficulty)
p.battleService.AttachQuestBattleValidation(p.worldService.ValidateQuestBattle)
p.battleService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
})
if err := p.worldService.AttachBattleActive(p.battleService.Active); err != nil {
return fmt.Errorf("attach world battle guard: %w", err)
}
p.monsterHuntService, err = monsterhunt.Open(ctx, p.gameplayStore, p.design.monsterHunt, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load monster hunt state: %w", err)
}
if err := p.monsterHuntService.AttachPresetRuntime(ctx, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
return fmt.Errorf("attach monster hunt preset ownership: %w", err)
}
if err := p.login.AttachMonsterHuntSlots(p.monsterHuntService); err != nil {
return fmt.Errorf("attach monster hunt preset slots: %w", err)
}
p.battleService.AttachMonsterHunt(p.monsterHuntService)
p.monsterHuntService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
})
p.recruitService, err = roster.NewRecruitService(&p.design.recruitDesign, &p.design.recruitDesign, p.collection, p.ownedItems, p.wallet,
func(ctx command.Context, npcID uint64) (uint64, error) {
return p.worldService.ResolveRecruitNPC(ctx, npcID, p.design.source, &p.design.recruitDesign)
})
if err != nil {
return fmt.Errorf("load recruitment service: %w", err)
}
p.battleService.AttachCommittedHealth(func(ctx command.Context, health map[uint64]uint64) error {
for index, hp := range health {
maximum, err := p.worldService.CharacterService().MaxHealth(ctx, index)
if err != nil {
return fmt.Errorf("invalid completed battle health for character %d: %w", index, err)
}
if hp > maximum {
// Battle-only HP buffs are not persisted into field health.
// This is our settlement policy, not an inferred provider rule.
health[index] = maximum
}
}
for index, hp := range health {
if err := p.worldService.CharacterService().SetCurrentHealth(ctx, index, hp); err != nil {
return err
}
}
return nil
})
p.foodService, err = roster.OpenFoodService(ctx, p.scope, p.design.foodDesign, p.ownedItems, p.worldService.CharacterService())
if err != nil {
return fmt.Errorf("load food state: %w", err)
}
if err := p.foodService.AttachContext(ctx, p.worldService.CurrentPackID, p.battleService.Active); err != nil {
return err
}
p.battleService.AttachMonsterWinMission(func(ctx command.Context) error {
return p.missionService.CompleteSingleTargetEvent(ctx, missions.ConditionMonsterKill, p.worldService.MissionsUnlocked)
})
p.battleService.AttachPictorialBuffs(func(ctx command.Context) ([]gamedata.PictorialBuffStat, error) {
_, buffs, err := p.pictorialService.Snapshot(ctx)
return buffs, err
})
p.achievementCounters, err = achievements.NewAchievementService(p.design.achievementCounterDesign, p.scope, p.missionService)
if err != nil {
return fmt.Errorf("load achievement counter state: %w", err)
}
if err := p.missionService.AttachAchievementProgress(ctx, p.achievementCounters); err != nil {
return fmt.Errorf("attach achievement completion validation: %w", err)
}
p.commissionService.CompleteAchievement = func(ctx command.Context, identity string) error {
_, err := p.achievementCounters.RecordEvent(ctx, identity, 17, 0, 1)
return err
}
if err := p.login.AttachAchievementExperience(p.missionService); err != nil {
return fmt.Errorf("attach persisted achievement experience: %w", err)
}
achievementProvider := p.worldService.GameplayAchievementProvider(ctx, p.design.achievementCounterDesign, p.design.achievementGrades)
achievementProvider.StateVersion = p.stateRepository.ObservationVersion
p.achievementObserver, err = achievements.NewGameplayAchievementObserver(p.achievementCounters, achievementProvider)
if err != nil {
return fmt.Errorf("initialize achievement gameplay observer: %w", err)
}
if err := p.achievementObserver.SyncRecordedHistory(ctx); err != nil {
return fmt.Errorf("restore recorded achievement history: %w", err)
}
p.eventTasksService.AttachInventoryProvider(&eventtasks.InventoryProjection{Items: p.ownedItems, Equipment: p.ownedEquipment, Costumes: p.collection, StateVersion: p.stateRepository.ObservationVersion})
return nil
}
+211
View File
@@ -0,0 +1,211 @@
package app
import (
"bd2server/internal/server/domain/command"
"bd2server/internal/server/runtime/player"
accountstate "bd2server/internal/server/storage/account"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"path/filepath"
"time"
)
type commandReceipt struct {
Digest string `json:"digest"`
Responses []player.Response `json:"responses"`
}
func (p *playerInstance) Execute(ctx context.Context, request player.Command) (reply player.Reply, result error) {
if request.Identity.AccountID != p.accountID || p.assembly == nil {
return reply, player.Failure{Cause: player.ErrUnavailable, RecoveryRequired: true}
}
started := time.Now()
tx, err := p.repository.BeginCommand(ctx)
reply.Timing.Begin = time.Since(started)
if err != nil {
return reply, player.Failure{Cause: err, RecoveryRequired: true}
}
finished := false
defer func() {
if !finished {
started := time.Now()
result = errors.Join(result, tx.Rollback())
reply.Timing.Rollback += time.Since(started)
p.assembly = nil
if result != nil {
result = player.Failure{Cause: result, RecoveryRequired: true}
}
}
}()
identity := command.Context{Identity: request.Identity, Cancellation: request.Cancellation, State: tx}
isLogin := len(request.Requests) == 1 && request.Requests[0].Path == "/LoginUser"
receiptKey := request.Identity.SessionID + "/" + request.Identity.RequestID
if !isLogin {
raw, found, err := tx.LoadEntry("missions", "command_receipts", receiptKey)
if err != nil {
return reply, err
}
if found {
var receipt commandReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
return reply, player.Failure{Cause: fmt.Errorf("invalid command receipt: %w", err), RecoveryRequired: true}
}
if receipt.Digest != hex.EncodeToString(request.Digest[:]) {
finished = true
if err := tx.Rollback(); err != nil {
p.assembly = nil
return reply, player.Failure{Cause: errors.Join(player.ErrIdentityConflict, err), RecoveryRequired: true}
}
return reply, player.ErrIdentityConflict
}
reply.Responses = receipt.Responses
finished = true
if err := tx.Rollback(); err != nil {
p.assembly = nil
return reply, player.Failure{Cause: err, RecoveryRequired: true}
}
return reply, nil
}
}
assembly := p.assembly
transientBefore := assembly.transientVersion()
for _, item := range request.Requests {
var response player.Response
if isLogin {
started := time.Now()
err = assembly.beginLogin(identity)
if err == nil {
response.Body, err = assembly.login.Login(identity, item.Body, request.LoginSessionKey)
response.PacketCode = 3
}
reply.Timing.Execute += time.Since(started)
} else {
started := time.Now()
for _, observer := range assembly.observers {
if err = observer.BeforeDispatch(identity, item.Path, item.Body); err != nil {
break
}
}
reply.Timing.Observer += time.Since(started)
if err == nil {
started = time.Now()
response.PacketCode, response.Body, err = assembly.dispatch(identity, item.Path, item.Body)
reply.Timing.Execute += time.Since(started)
}
if err == nil {
started = time.Now()
for _, observer := range assembly.observers {
var notify []byte
notify, err = observer.AfterDispatch(identity, item.Path, item.Body, response.Body)
if err != nil {
break
}
response.Notification = append(response.Notification, notify...)
}
reply.Timing.Observer += time.Since(started)
}
}
if err != nil {
changed := tx.Dirty() || assembly.transientVersion() != transientBefore
started := time.Now()
rollbackErr := tx.Rollback()
reply.Timing.Rollback += time.Since(started)
finished = true
if rollbackErr != nil {
p.assembly = nil
return reply, player.Failure{Cause: errors.Join(err, rollbackErr), RecoveryRequired: true}
}
if !changed {
return reply, err
}
p.assembly = nil
if recoveryErr := p.Recover(context.WithoutCancel(ctx)); recoveryErr != nil {
return reply, player.Failure{Cause: errors.Join(err, recoveryErr), RecoveryRequired: true}
}
return reply, player.Failure{Cause: err, RecoveryRequired: true, AlreadyRecovered: true}
}
reply.Responses = append(reply.Responses, response)
}
if tx.Dirty() && !isLogin {
raw, err := json.Marshal(commandReceipt{Digest: hex.EncodeToString(request.Digest[:]), Responses: reply.Responses})
if err != nil {
return reply, err
}
if err := tx.PutEntry("missions", "command_receipts", receiptKey, raw); err != nil {
return reply, err
}
}
started = time.Now()
err = tx.Commit()
reply.Timing.Commit += time.Since(started)
finished = true
if err != nil {
p.assembly = nil
return reply, player.Failure{Cause: err, RecoveryRequired: true}
}
return reply, nil
}
func (p *playerAssembly) dispatch(ctx command.Context, path string, request []byte) (int, []byte, error) {
for _, handler := range p.handlers {
code, body, handled, err := handler.Handle(ctx, path, request)
if handled || err != nil {
return code, body, err
}
}
return 0, nil, fmt.Errorf("packet not implemented: %s", path)
}
func (p *playerAssembly) beginLogin(ctx command.Context) error {
for _, observer := range p.observers {
if hook, ok := observer.(interface{ BeginLogin(command.Context) }); ok {
hook.BeginLogin(ctx)
}
}
for _, handler := range p.handlers {
if hook, ok := handler.(interface{ BeginLogin(command.Context) }); ok {
hook.BeginLogin(ctx)
}
}
return p.missionService.RecordLogin(ctx, p.worldService.MissionsUnlocked)
}
func (p *playerInstance) Recover(ctx context.Context) error {
if err := p.repository.Check(); err != nil {
if errors.Is(err, accountstate.ErrFenced) {
return err
}
if closeErr := p.repository.Close(); closeErr != nil {
return errors.Join(err, closeErr)
}
path := filepath.Join(p.factory.options.stateDirectory, "accounts", accountDirectoryName(p.accountID), "state.db")
repository, openErr := accountstate.Open(path)
if openErr != nil {
return errors.Join(err, openErr)
}
p.repository = repository
}
assembly, err := p.factory.assemble(ctx, p.accountID, p.repository)
if err != nil {
return err
}
p.assembly = assembly
slog.Warn("player state recovered from committed snapshot", "account_id", p.accountID)
return nil
}
func (p *playerInstance) Close() error {
p.assembly = nil
return p.repository.Close()
}
func (p *playerAssembly) transientVersion() [4]uint64 {
return [4]uint64{p.battleService.TransientVersion(), p.monsterHuntService.TransientVersion(), p.gachaService.TransientVersion(), p.worldService.TransientVersion()}
}
+218
View File
@@ -0,0 +1,218 @@
package app
import (
"bd2server/internal/server/domain/battle"
"bd2server/internal/server/domain/battle/hunting"
"bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce"
"bd2server/internal/server/domain/commerce/gacha"
"bd2server/internal/server/domain/commerce/npcinn"
"bd2server/internal/server/domain/commerce/npcshop"
"bd2server/internal/server/domain/events"
"bd2server/internal/server/domain/events/actions"
"bd2server/internal/server/domain/events/exchange"
"bd2server/internal/server/domain/events/games"
"bd2server/internal/server/domain/events/play"
"bd2server/internal/server/domain/events/tasks"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/domain/mail"
"bd2server/internal/server/domain/progression/achievements"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/progression/pictorial"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/roster/deck"
"bd2server/internal/server/domain/world"
"bd2server/internal/server/domain/world/progress"
"bd2server/internal/server/domain/world/todayquest"
"bd2server/internal/server/gateway/session"
loginprotocol "bd2server/internal/server/protocol/login"
"bd2server/internal/server/protocol/wire"
"bd2server/internal/server/storage/account"
identitystore "bd2server/internal/server/storage/identity"
"bd2server/internal/server/storage/stateio"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
)
type PlayerFactory struct {
options *configuration
design *designCatalog
seeds *seedCatalog
profiles interface {
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
}
}
type playerAssembly struct {
worldSeed world.Seed
mailbox *mail.Starter
deckSeed deck.Seed
*PlayerFactory
progressState *progress.Store
deckStateStore *deck.Store
ownedItems *assets.Inventory
recipeService *assets.RecipeService
wallet *assets.Wallet
inventorySlots *assets.InventorySlots
mailService *mail.Service
missionService *missions.Service
ownedEquipment *assets.EquipmentInventory
worldService *world.Service
collection *roster.CollectionStore
gachaService *gacha.Service
pictorialService *pictorial.Service
charAwakeService *roster.CharAwakeService
costumePotentialService *roster.CostumePotentialService
costumeBurstService *roster.CostumeBurstService
friendshipService *roster.FriendshipService
masterTitleService *roster.MasterTitleService
battleService *battle.Service
gameplayStore stateio.EntrySnapshotStore
contentOpenService *assets.ContentOpenService
huntingService *hunting.Service
eventRegistry *events.Registry
eventEconomy *events.Economy
talentUseService *roster.TalentUseService
dispatchService *roster.TalentDispatchService
itemCraftService *roster.ItemCraftService
innService *npcinn.Service
npcShopService *npcshop.Service
commissionService *todayquest.Service
buffRewards *events.BuffRewards
cashCatalog *commerce.Catalog
cashEconomy *commerce.EntitlementEconomy
cashService *commerce.Service
clearPackages *commerce.ClearPackages
cashBonuses *commerce.CashBonuses
eventTasksService *eventtasks.Service
loginPasses *commerce.LoginPasses
eventGamesService *eventgames.Service
eventExchangeService *eventexchange.Service
boxService *events.BoxService
eventPlayService *eventplay.Service
eventActionsService *eventactions.Service
monsterHuntService *monsterhunt.Service
recruitService *roster.RecruitService
foodService *roster.FoodService
achievementCounters *achievements.AchievementService
achievementObserver *achievements.GameplayAchievementObserver
handlers []session.Handler
observers []session.ResponseObserver
stateRepository *accountstate.Repository
scope stateio.RootStore
startingPackID int
initializeAccount bool
login *loginprotocol.LoginSeed
starter *roster.Starter
}
type playerInstance struct {
accountID string
factory *PlayerFactory
repository *accountstate.Repository
assembly *playerAssembly
}
func (f *PlayerFactory) open(accountID string) (instance *playerInstance, openErr error) {
statePath := filepath.Join(f.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
return nil, err
}
repository, err := accountstate.Open(statePath)
if err != nil {
return nil, err
}
defer func() {
if recovered := recover(); recovered != nil {
openErr = fmt.Errorf("initialize player panic: %v", recovered)
instance = nil
}
if openErr != nil {
openErr = errors.Join(openErr, repository.Close())
}
}()
assembly, err := f.assemble(context.Background(), accountID, repository)
if err != nil {
return nil, err
}
return &playerInstance{accountID: accountID, factory: f, repository: repository, assembly: assembly}, nil
}
func (f *PlayerFactory) assemble(ctx context.Context, accountID string, repository *accountstate.Repository) (_ *playerAssembly, openErr error) {
login := &loginprotocol.LoginSeed{Version: f.seeds.login.Version, PacketCode: f.seeds.login.PacketCode,
UserInfo: slices.Clone(f.seeds.login.UserInfo), ResponseFields: slices.Clone(f.seeds.login.ResponseFields)}
if f.profiles != nil {
profile, err := f.profiles.GameIdentity(ctx, accountID)
if err != nil {
return nil, err
}
login.UserInfo, _, err = wire.ReplaceVarint(login.UserInfo, 1, uint64(profile.OwnerIndex))
if err != nil {
return nil, err
}
login.UserInfo, _, err = wire.ReplaceBytes(login.UserInfo, 2, []byte(profile.UserID))
if err != nil {
return nil, err
}
}
starter, err := cloneSeed(f.seeds.starter)
if err != nil {
return nil, err
}
mailbox, err := cloneSeed(f.seeds.mailbox)
if err != nil {
return nil, err
}
deckSeed, err := cloneSeed(&f.seeds.deck)
if err != nil {
return nil, err
}
worldSeed, err := cloneSeed(&f.seeds.world)
if err != nil {
return nil, err
}
p := &playerAssembly{PlayerFactory: f, stateRepository: repository, login: login, starter: starter, mailbox: mailbox, deckSeed: *deckSeed, worldSeed: *worldSeed}
accountDomains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"}
initializationState, err := p.stateRepository.InitializationState(accountDomains...)
if err != nil {
return nil, fmt.Errorf("reject incomplete account database: %w", err)
}
if initializationState == accountstate.InitializationCorrupt {
return nil, errors.New("reject incomplete account database: corrupt initialization state")
}
p.initializeAccount = initializationState == accountstate.InitializationPending
startupTransaction, err := p.stateRepository.BeginCommand(ctx)
if err != nil {
return nil, fmt.Errorf("begin startup state transaction: %w", err)
}
startupCommitted := false
defer func() {
if startupCommitted {
return
}
if rollbackErr := startupTransaction.Rollback(); rollbackErr != nil {
openErr = errors.Join(openErr, rollbackErr)
}
}()
identity := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "startup", RequestID: "initialize"}, Cancellation: ctx, State: startupTransaction}
for _, assemble := range []func(command.Context) error{p.assets, p.worldRoster, p.gameplay, p.commerce, p.events, p.session} {
if err := assemble(identity); err != nil {
return nil, err
}
}
if err := startupTransaction.Commit(); err != nil {
return nil, err
}
startupCommitted = true
if err := repository.Check(); err != nil {
return nil, err
}
return p, nil
}
+166
View File
@@ -0,0 +1,166 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/battle"
"bd2server/internal/server/domain/battle/hunting"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce/npcinn"
"bd2server/internal/server/domain/commerce/npcshop"
"bd2server/internal/server/domain/events"
assets "bd2server/internal/server/domain/inventory"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/world/todayquest"
"bd2server/internal/server/protocol/wire"
"bd2server/internal/server/storage/stateio"
"fmt"
)
func (p *playerAssembly) gameplay(ctx command.Context) error {
var err error
p.battleService = battle.NewService(p.options.gameData, p.options.gameDataVersion, p.worldService.CurrentPackID)
freeHuntingAP, bonusHuntingAP, err := p.login.SeedHuntingAP()
if err != nil {
return fmt.Errorf("read initial hunting AP: %w", err)
}
p.gameplayStore = stateio.EntrySnapshotStore{Domain: "missions", Bucket: "gameplay"}
if err := p.costumePotentialService.AttachConnectStore(p.gameplayStore); err != nil {
return fmt.Errorf("attach costume potential connection state: %w", err)
}
if err := p.worldService.AttachFieldMonsterState(p.gameplayStore); err != nil {
return fmt.Errorf("attach field monster state: %w", err)
}
p.battleService.AttachFieldMonsters(p.worldService)
p.battleService.AttachFieldBuffConsume(p.worldService.ConsumeFieldBattleBuff)
if err := p.worldService.AttachFieldBuffRuntime(p.design.fieldBuffs); err != nil {
return fmt.Errorf("attach field monster damage: %w", err)
}
p.contentOpenService, err = assets.NewContentOpenService(ctx, p.design.contentOpeningDesign, p.ownedItems, p.gameplayStore, func() (uint64, error) {
experience, err := p.missionService.AchievementExperience()
if err != nil {
return 0, err
}
return p.design.levelDesign.Level(experience), nil
})
if err != nil {
return fmt.Errorf("load content opening state: %w", err)
}
p.huntingService, err = hunting.Open(ctx, p.gameplayStore, p.design.source, p.ownedItems, p.wallet,
p.worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP)
if err != nil {
return fmt.Errorf("load hunting state: %w", err)
}
if err := p.login.AttachHuntingAP(p.huntingService); err != nil {
return fmt.Errorf("attach persisted hunting AP: %w", err)
}
if err = p.huntingService.AttachAPRefresh(p.design.huntingAPDesign); err != nil {
return err
}
p.battleService.AttachHunting(p.huntingService)
p.huntingService.AttachEligibility(p.worldService.HuntingEligibility)
if err := p.worldService.AttachHuntingGround(p.huntingService); err != nil {
return err
}
p.eventRegistry = events.NewRegistry()
if err := p.eventRegistry.Replace(p.options.calendars.Events); err != nil {
return err
}
initialEventCurrency := map[uint64]uint64{}
for itemType, field := range events.AdditionalCurrencyFields {
value, _, readErr := wire.Varint(p.login.UserInfo, field)
if readErr != nil {
return readErr
}
initialEventCurrency[itemType] = value
}
p.eventEconomy, err = events.NewEconomy(ctx, p.gameplayStore, p.ownedItems, p.wallet, p.collection, p.ownedEquipment, p.design.rewardCostumes, p.design.rewardEquipment, p.design.rewardGraph, initialEventCurrency)
if err != nil {
return fmt.Errorf("load event economy: %w", err)
}
p.eventEconomy.AttachHuntingAP(p.huntingService)
p.talentUseService, err = roster.NewTalentUseService(p.design.talentUseDesign, p.gameplayStore, p.worldService.CharacterService(), p.ownedItems, p.wallet, p.eventEconomy)
if err != nil {
return fmt.Errorf("load field talent state: %w", err)
}
p.talentUseService.AttachContext(ctx, p.worldService.TalentFieldContext)
if err := p.worldService.AttachAutoRecoveryPolicy(p.design.recovery); err != nil {
return fmt.Errorf("attach automatic recovery policy: %w", err)
}
p.deckStateStore.AttachAutoRecoveryAllowed(p.worldService.AutoRecoveryAllowed)
p.deckStateStore.AttachAutoRecovery(p.talentUseService.AutoRecover)
p.worldService.AttachTalentPackInfo(p.talentUseService.PackInfo)
p.worldService.AttachOverwhelmAuthorization(p.talentUseService.ConsumeOverwhelm)
p.worldService.AttachOverwhelmHunting(p.huntingService)
if err := p.worldService.AttachOverwhelmDesign(p.design.source, p.design.overwhelmSky); err != nil {
return fmt.Errorf("attach overwhelm design: %w", err)
}
p.talentUseService.AttachEffect(4, p.worldService.ApplyTalentFieldAbsorb)
p.talentUseService.AttachEffect(20, p.worldService.ApplyTalentMonsterSummon)
p.dispatchService, err = roster.OpenTalentDispatch(p.gameplayStore, p.design.dispatchDesign, p.eventEconomy)
if err != nil {
return fmt.Errorf("load talent dispatch state: %w", err)
}
p.talentUseService.AttachEffect(18, p.dispatchService.Start)
p.itemCraftService, err = roster.NewItemCraftService(p.design.itemCraftDesign, p.design.talentUseDesign, p.gameplayStore, p.ownedItems, p.worldService.CharacterService(), p.wallet, p.recipeService.Knows)
if err != nil {
return fmt.Errorf("load item crafting state: %w", err)
}
p.itemCraftService.AttachContext(ctx, func(ctx command.Context) (int, bool, error) {
pack, err := p.worldService.CurrentPackID(ctx)
return pack, p.battleService.Active(ctx), err
})
if err := p.worldService.ConfigureNPCRuntime(p.design.source, p.gameplayStore); err != nil {
return fmt.Errorf("configure NPC world runtime: %w", err)
}
p.innService, err = npcinn.New(p.gameplayStore, p.worldService.CharacterService(), p.wallet, p.worldService.InnContext,
func(ctx command.Context) (uint64, error) {
experience, err := p.missionService.AchievementExperience()
if err != nil {
return 0, err
}
return p.design.levelDesign.Level(experience), nil
}, p.battleService.Active)
if err != nil {
return fmt.Errorf("load inn recovery: %w", err)
}
p.npcShopService, err = npcshop.New(ctx, p.design.npcShopDesign, p.gameplayStore, p.eventEconomy, p.ownedItems, p.worldService.PackAvailable)
if err != nil {
return fmt.Errorf("load NPC shop state: %w", err)
}
p.npcShopService.SetReputationSource(p.worldService.NPCShopReputation)
p.npcShopService.SetTalentDiscountSource(p.talentUseService.ShopDiscount)
p.commissionService, err = todayquest.Open(p.gameplayStore, p.design.commissionDesign, p.eventEconomy, p.ownedItems, p.worldService.CommissionPackUnlocked)
if err != nil {
return fmt.Errorf("load NPC commission state: %w", err)
}
p.commissionService.CompleteReputation = p.worldService.CompleteNPCReputation
if err := p.worldService.AttachTodayQuests(p.commissionService); err != nil {
return fmt.Errorf("attach NPC commissions: %w", err)
}
if err = p.worldService.AttachResearchRuntime(p.design.source, p.design.researchCharacters, p.eventEconomy); err != nil {
return fmt.Errorf("attach field research: %w", err)
}
p.eventEconomy.AttachPrestigeSkins(p.design.prestigeCatalog.Skins)
p.eventEconomy.AttachPrestigePortrait(p.deckStateStore.PortraitCostume)
if err := p.worldService.AttachPrestigeSelections(ctx, p.eventEconomy.PrestigeSkinSelections); err != nil {
return fmt.Errorf("attach prestige skin selections: %w", err)
}
p.eventEconomy.AttachOwnedItemDesign(p.design.ownedEventItems)
p.eventEconomy.AttachAvatarRewards(p.design.avatarRewards)
p.buffRewards, err = events.OpenBuffRewards(ctx, p.gameplayStore, p.design.buffDesign)
if err != nil {
return fmt.Errorf("load permanent buff ownership: %w", err)
}
p.eventEconomy.AttachBuffRewards(p.buffRewards)
p.pictorialService.AttachPermanentBuffs(p.buffRewards.SnapshotBuffs)
if err = p.eventEconomy.AttachAPRefresh(p.design.eventAPCaps, p.design.eventAPReset); err != nil {
return err
}
if err = p.login.AttachAdditionalCurrencies(p.eventEconomy); err != nil {
return err
}
p.huntingService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
})
return nil
}
@@ -0,0 +1,598 @@
//go:build integration
package app
import (
"bd2server/internal/server/design/gameconfig"
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/events/calendar"
"bd2server/internal/server/domain/identity"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/runtime/player"
identitystore "bd2server/internal/server/storage/identity"
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"path/filepath"
"slices"
"testing"
"time"
)
// This integration uses the selected repository GameData and seeds. Missing
// resources are a setup failure, never a silently skipped asset-isolation test.
// Network fields are independently taken from the 2.35.10 client classes:
// DeckSaveRequest/DeckDBInfo, EquipUseRequest, SaveUserPositionRequest,
// MailOpenRequest/RewardDBInfoBundle, CostumeDBInfo and QuestUpdateRequest.
func newIntegrationFactory(t *testing.T) (*PlayerFactory, []string) {
t.Helper()
versions, err := versionconfig.Find()
if err != nil {
t.Fatal(err)
}
versionconfig.Use(versions)
resources := versions.Resolve("data/resources/GameData")
if _, err = gamedata.Validate(resources, versions.GameDataVersion); err != nil {
t.Fatalf("selected GameData required; fetch repository resources before integration tests: %v", err)
}
calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion)
if err != nil {
t.Fatal(err)
}
if err = calendars.ValidateDesign(resources, versions.GameDataVersion); err != nil {
t.Fatal(err)
}
seed := versions.Resolve(versions.SeedDirectory)
root := t.TempDir()
config := &configuration{versions: versions, calendars: calendars, gameRules: gameconfig.Default(), gameData: resources, gameDataVersion: versions.GameDataVersion, stateDirectory: filepath.Join(root, "state"), devToolsConfig: filepath.Join(root, "development.json"), accountSeed: filepath.Join(seed, "login_user.json"), playerSeed: filepath.Join(seed, "starter_player.json"), readonlySeed: filepath.Join(seed, "readonly.json"), mailSeed: filepath.Join(seed, "mail.json"), deckSeed: filepath.Join(seed, "decks.json"), worldSeed: filepath.Join(seed, "world.json")}
if err = lockServerState(config.stateDirectory, config.gameRules.Story.StartPackID); err != nil {
t.Fatal(err)
}
seeds, err := loadSeeds(config)
if err != nil {
t.Fatal(err)
}
design, err := loadDesign(config, seeds)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := gamedata.CloseDatabaseCache(); err != nil {
t.Error(err)
}
})
key := make([]byte, 32)
for i := range key {
key[i] = byte(i + 1)
}
profileStore, err := identitystore.Open(filepath.Join(root, "identity.db"), key)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := profileStore.Close(); err != nil {
t.Error(err)
}
})
registration, err := identity.New(identity.Config{Providers: map[string]string{"discord": "integration-client"}, DeviceTTL: time.Minute, AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour}, profileStore)
if err != nil {
t.Fatal(err)
}
accounts := []string{}
for _, subject := range []string{"11111111", "22222222"} {
device, err := registration.CreateDevice("discord", "127.0.0.1")
if err != nil {
t.Fatal(err)
}
authorization, err := registration.Start("discord", device.ID, device.StartTicket)
if err != nil {
t.Fatal(err)
}
if err = registration.CompleteDevice(authorization.ID, "discord", identity.ProviderIdentity{Issuer: "https://discord.com", Subject: subject}); err != nil {
t.Fatal(err)
}
result, err := registration.Poll(device.ID, device.Secret)
if err != nil {
t.Fatal(err)
}
id, err := registration.ValidateAccess(result.Tokens.AccessToken)
if err != nil {
t.Fatal(err)
}
accounts = append(accounts, id)
}
return &PlayerFactory{options: config, seeds: seeds, design: design, profiles: profileStore}, accounts
}
func appScalar(field int, value uint64) []byte {
return binary.AppendUvarint(binary.AppendUvarint(nil, uint64(field<<3)), value)
}
func appMessage(field int, value []byte) []byte {
p := binary.AppendUvarint(nil, uint64(field<<3|2))
p = binary.AppendUvarint(p, uint64(len(value)))
return append(p, value...)
}
func appRows(body []byte, wanted int) [][]byte {
var rows [][]byte
for len(body) > 0 {
k, n := binary.Uvarint(body)
if n <= 0 {
return nil
}
body = body[n:]
if k&7 == 0 {
_, n = binary.Uvarint(body)
if n <= 0 {
return nil
}
body = body[n:]
continue
}
if k&7 != 2 {
return nil
}
size, n := binary.Uvarint(body)
if n <= 0 || size > uint64(len(body)-n) {
return nil
}
value := body[n : n+int(size)]
if int(k>>3) == wanted {
rows = append(rows, value)
}
body = body[n+int(size):]
}
return rows
}
func appValue(body []byte, wanted int) uint64 {
for len(body) > 0 {
k, n := binary.Uvarint(body)
if n <= 0 {
return 0
}
body = body[n:]
if k&7 == 0 {
v, m := binary.Uvarint(body)
if m <= 0 {
return 0
}
if int(k>>3) == wanted {
return v
}
body = body[m:]
continue
}
if k&7 != 2 {
return 0
}
size, m := binary.Uvarint(body)
if m <= 0 || size > uint64(len(body)-m) {
return 0
}
body = body[m+int(size):]
}
return 0
}
func appPacked(body []byte, field int) []uint64 {
var values []uint64
for _, row := range appRows(body, field) {
for len(row) > 0 {
v, n := binary.Uvarint(row)
if n <= 0 {
return nil
}
values = append(values, v)
row = row[n:]
}
}
return values
}
func appItemTotals(body []byte) map[[2]uint64]uint64 {
totals := map[[2]uint64]uint64{}
for _, row := range appRows(body, 1) {
totals[[2]uint64{appValue(row, 3), appValue(row, 2)}] += appValue(row, 4)
}
return totals
}
func TestTwoRealPlayerBundlesKeepEquipmentFormationAndWorldSeparate(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
aID, bID := accounts[0], accounts[1]
instances := map[string]*playerInstance{}
runtimes := map[string]*player.Runtime{}
var equipmentID uint64
ids := make([]uint64, 0, len(factory.design.equipmentSlots))
for id, slot := range factory.design.equipmentSlots {
if slot == 1 {
ids = append(ids, id)
}
}
slices.Sort(ids)
if len(ids) == 0 {
t.Fatal("real GameData has no weapon slot")
}
equipmentID = ids[0]
equipmentIndex := map[string]uint64{}
characterIndex := map[string]uint64{}
for _, accountID := range accounts {
instance, err := factory.open(accountID)
if err != nil {
t.Fatal("assemble actual account", accountID, err)
}
instances[accountID] = instance
// Establish one owned weapon as an explicit test grant before the actor is
// exposed. Both players may legitimately use equal inventory numbers.
tx, err := instance.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
ctx := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "fixture", RequestID: "weapon"}, State: tx}
equipment, err := instance.assembly.ownedEquipment.GrantOnce(ctx, "test-owned-weapon", equipmentID)
if err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
equipmentIndex[accountID] = equipment.InvenIndex
for _, c := range instance.assembly.worldService.CharacterService().RawAll() {
if !roster.IsStoryCharacter(c) && !roster.IsCharmCharacter(c) && !roster.CharacterExpired(c, time.Now()) {
characterIndex[accountID] = c.InvenIndex
break
}
}
if characterIndex[accountID] == 0 {
t.Fatal("seed has no permanent character")
}
if err = tx.Commit(); err != nil {
t.Fatal(err)
}
runtime, err := player.New(accountID, instance, player.Limits{CompletedReceipts: 1})
if err != nil {
t.Fatal(err)
}
runtimes[accountID] = runtime
}
t.Cleanup(func() {
for _, runtime := range runtimes {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := runtime.Close(ctx); err != nil {
t.Error(err)
}
cancel()
}
})
makeCommand := func(accountID, identity string, requests []player.Request) player.Command {
content := []byte{}
for _, r := range requests {
content = append(content, []byte(r.Path)...)
content = append(content, r.Body...)
}
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "client-session", RequestID: identity}, Digest: sha256.Sum256(content), Requests: requests}
}
run := func(c player.Command) (player.Reply, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
future, err := runtimes[c.Identity.AccountID].Submit(ctx, c)
if err != nil {
return player.Reply{}, err
}
return future.Wait(ctx)
}
query := func(accountID, path string, sequence uint64) []byte {
t.Helper()
reply, err := run(makeCommand(accountID, fmt.Sprint("query-", path, "-", sequence), []player.Request{{Path: path, Body: appScalar(1, sequence)}}))
if err != nil {
t.Fatal(accountID, path, err)
}
return reply.Responses[0].Body
}
// New-player ownership is instantiated separately even though versioned seeds
// contain the same public design IDs. Mail claims must affect only the claimant.
beforeBCostumes := query(bID, "/CostumeInfo", 100)
beforeBItems := appItemTotals(query(bID, "/ItemInfo", 101))
beforeAItems := appItemTotals(query(aID, "/ItemInfo", 102))
beforeACostumes := query(aID, "/CostumeInfo", 103)
if len(appRows(beforeACostumes, 1)) == 0 || len(appRows(beforeBCostumes, 1)) == 0 {
t.Fatal("new player received no starter costume ownership")
}
owned := map[uint64]bool{}
for _, row := range appRows(beforeACostumes, 1) {
owned[appValue(row, 2)] = true
}
mailbox := query(aID, "/MailInfo", 104)
var costumeMail uint64
expectedCostumes := map[uint64]uint64{}
for _, row := range appRows(mailbox, 1) {
types, ids, counts := appPacked(row, 8), appPacked(row, 9), appPacked(row, 10)
if len(types) != len(ids) || len(types) != len(counts) {
t.Fatal("client mail reward arrays differ in length")
}
candidate := map[uint64]uint64{}
valid := len(types) > 0
for i, kind := range types {
if kind != 11 || counts[i] != 6 || owned[ids[i]] {
valid = false
break
}
candidate[ids[i]] = counts[i] - 1
}
if valid {
costumeMail = appValue(row, 1)
expectedCostumes = candidate
break
}
}
if costumeMail == 0 {
t.Fatal("versioned new-player entitlement has no unowned six-copy costume mail")
}
claim := func(id, seq uint64) player.Command {
return makeCommand(aID, fmt.Sprint("mail-claim-", seq), []player.Request{{Path: "/MailOpen", Body: append(appScalar(1, seq), appScalar(2, id)...)}})
}
costumeClaim := claim(costumeMail, 105)
costumeReply, err := run(costumeClaim)
if err != nil {
t.Fatal("claim real costume entitlement", err)
}
bundles := appRows(costumeReply.Responses[0].Body, 1)
if len(bundles) != 1 {
t.Fatal("mail claim omitted reward bundle")
}
// During the introductory chapter CostumeInfo intentionally shows the story
// roster. MailOpen's RewardDBInfoBundle is the client's ownership update.
for id, level := range expectedCostumes {
found := false
for _, row := range appRows(bundles[0], 3) {
if appValue(row, 2) == id {
found = true
if appValue(row, 3) != level {
t.Fatal("six copies did not yield acquisition plus five enhancements", id, appValue(row, 3), level)
}
}
}
if !found {
t.Fatal("costume reward message omitted listed costume", id)
}
}
collection := instances[aID].assembly.collection.Costumes()
for id, level := range expectedCostumes {
found := false
for _, entry := range collection {
if entry.ID == id {
found = true
if entry.Level != level {
t.Fatal("durable costume level differs from gift")
}
}
}
if !found {
t.Fatal("costume ownership not committed", id)
}
}
if !bytes.Equal(beforeBCostumes, query(bID, "/CostumeInfo", 107)) {
t.Fatal("A costume entitlement granted to B")
}
var starterMail uint64
expectedItems := map[[2]uint64]uint64{}
for _, entry := range factory.seeds.mailbox.Mails {
valid := len(entry.RewardTypes) > 0
for i, kind := range entry.RewardTypes {
if kind != 8 || entry.RewardIDs[i] == 0 {
valid = false
}
}
if valid && entry.ExpiresAt > uint64(time.Now().UnixMilli()) {
starterMail = entry.MailID
for i, kind := range entry.RewardTypes {
expectedItems[[2]uint64{kind, entry.RewardIDs[i]}] += entry.RewardCounts[i]
}
break
}
}
if starterMail == 0 {
t.Fatal("versioned starter mail requires a current resource gift")
}
resourceClaim := claim(starterMail, 108)
if _, err := run(resourceClaim); err != nil {
t.Fatal("claim versioned starter resources", err)
}
actualItems := appItemTotals(query(aID, "/ItemInfo", 109))
for key, gift := range expectedItems {
if actualItems[key] != beforeAItems[key]+gift {
t.Fatal("mail claim did not deliver listed resource count", key, actualItems[key], gift)
}
}
for key, old := range beforeBItems {
if appItemTotals(query(bID, "/ItemInfo", 110))[key] != old {
t.Fatal("starter mail changed unrelated player's inventory")
}
}
// Memo is one item; query eviction forces the next claim retry to use SQL.
if _, err := run(resourceClaim); err != nil {
t.Fatal("retry starter claim", err)
}
afterRetry := appItemTotals(query(aID, "/ItemInfo", 111))
for key, value := range actualItems {
if afterRetry[key] != value {
t.Fatal("starter claim retry issued duplicate resources")
}
}
// QuestUpdate stores the client's counters without granting QuestClear's
// GameData rewards. The selected starting quest is a genuine active quest.
selection, selected := instances[aID].assembly.progressState.Selection(factory.options.gameRules.Story.StartPackID)
if !selected || selection.QuestID <= 0 {
t.Fatal("new-player active quest missing")
}
beforeGold := instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold
questBody := append(appScalar(1, 120), appScalar(2, uint64(selection.QuestID))...)
questBody = append(questBody, appScalar(3, uint64(factory.options.gameRules.Story.StartPackID))...)
questBody = append(questBody, appScalar(4, 1)...)
if _, err := run(makeCommand(aID, "quest-counter", []player.Request{{Path: "/QuestUpdate", Body: questBody}})); err != nil {
t.Fatal("actual quest progress", err)
}
current, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
if !found || len(current.Values) != 1 || current.Values[0] != 1 {
t.Fatal("quest update did not retain player counter")
}
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
t.Fatal("quest counter leaked to B")
}
if instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold {
t.Fatal("quest update incorrectly granted clear reward")
}
beforeBDeck := query(bID, "/DeckInfo", 10)
makeDeck := func(index, position, sequence uint64) []byte {
entry := append(appScalar(1, index), appScalar(2, position)...)
entry = append(entry, appScalar(3, 1)...)
return append(appScalar(1, sequence), appMessage(2, entry)...)
}
equip := append(appScalar(1, 11), appScalar(2, equipmentIndex[aID])...)
equip = append(equip, appScalar(3, characterIndex[aID])...)
position := []byte(`{"MapId":1,"PlayerPosition":{"x":12,"y":3,"z":4},"ColleaguePositions":[]}`)
save := append(appScalar(1, 13), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
save = append(save, appMessage(3, position)...)
changes := makeCommand(aID, "asset-batch", []player.Request{{Path: "/EquipUse", Body: equip}, {Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 5, 12)}, {Path: "/SaveUserPosition", Body: save}})
if _, err := run(changes); err != nil {
t.Fatal("actual asset batch", err)
}
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 20)) {
t.Fatal("another account's formation changed")
}
bEquipment := appRows(query(bID, "/EquipInfo", 21), 1)
if len(bEquipment) != 1 || appValue(bEquipment[0], 2) != 0 {
t.Fatal("A equipped B's equal-index weapon")
}
aEquipment := appRows(query(aID, "/EquipInfo", 22), 1)
if len(aEquipment) != 1 || appValue(aEquipment[0], 2) != characterIndex[aID] {
t.Fatal("owner weapon not bound to owner's character")
}
aDeck := query(aID, "/DeckInfo", 23)
entries := appRows(aDeck, 1)
if len(entries) != 1 || appValue(entries[0], 1) != characterIndex[aID] || appValue(entries[0], 2) != 5 {
t.Fatal("ordinary deck did not preserve selected battle-grid position")
}
if _, found := instances[bID].assembly.progressState.Position(); found {
t.Fatal("world position leaked to unrelated account")
}
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
t.Fatal("owner world position missing")
}
// Evict the small in-memory memo before replaying an asset operation. The
// committed database receipt must preserve the exact response and ownership.
replay, err := run(changes)
if err != nil || len(replay.Responses) != 3 {
t.Fatal("durable asset retry failed", err)
}
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 24)) {
t.Fatal("retry modified formation")
}
// A later invalid equipment member must roll back an earlier valid deck
// replacement in the same client batch and recover only this player's state.
invalid := append(appScalar(1, 31), appScalar(2, ^uint64(0))...)
invalid = append(invalid, appScalar(3, characterIndex[aID])...)
failed := makeCommand(aID, "rejected-batch", []player.Request{{Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 8, 30)}, {Path: "/EquipUse", Body: invalid}})
if _, err = run(failed); err == nil {
t.Fatal("invalid equipment accepted")
}
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 32)) {
t.Fatal("failed batch published uncommitted formation")
}
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 33)) {
t.Fatal("A recovery replaced B state")
}
// Unload and recreate real bundles from their separate SQLite databases.
for _, accountID := range accounts {
if err := runtimes[accountID].Close(context.Background()); err != nil {
t.Fatal(err)
}
instance, err := factory.open(accountID)
if err != nil {
t.Fatal("reopen account", err)
}
instances[accountID] = instance
runtime, err := player.New(accountID, instance, player.Limits{})
if err != nil {
t.Fatal(err)
}
runtimes[accountID] = runtime
}
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 40)) || !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 41)) {
t.Fatal("reopen combined player formations")
}
if _, found := instances[bID].assembly.progressState.Position(); found {
t.Fatal("reopen mixed world progress")
}
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
t.Fatal("owner progress lost on reopen")
}
retainedQuest, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
if !found || len(retainedQuest.Values) != 1 || retainedQuest.Values[0] != 1 {
t.Fatal("owner task counter lost on reopen")
}
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
t.Fatal("reopen mixed task counters")
}
for id, level := range expectedCostumes {
found := false
for _, entry := range instances[aID].assembly.collection.Costumes() {
if entry.ID == id {
found = true
if entry.Level != level {
t.Fatal("reopen changed costume enhancement")
}
}
}
if !found {
t.Fatal("reopen lost claimed costume", id)
}
}
bProfile, err := factory.profiles.GameIdentity(context.Background(), bID)
if err != nil {
t.Fatal(err)
}
aProfile, err := factory.profiles.GameIdentity(context.Background(), aID)
if err != nil {
t.Fatal(err)
}
aLogin := makeCommand(aID, "fresh-login-A", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
aLogin.LoginSessionKey = []byte("0123456789abcdef0123456789abcdef")
bLogin := makeCommand(bID, "fresh-login-B", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
bLogin.LoginSessionKey = []byte("fedcba9876543210fedcba9876543210")
aReply, err := run(aLogin)
if err != nil {
t.Fatal(err)
}
bReply, err := run(bLogin)
if err != nil {
t.Fatal(err)
}
aUser, bUser := appRows(aReply.Responses[0].Body, 1), appRows(bReply.Responses[0].Body, 1)
if len(aUser) != 1 || len(bUser) != 1 || appValue(aUser[0], 1) != uint64(aProfile.OwnerIndex) || appValue(bUser[0], 1) != uint64(bProfile.OwnerIndex) || appValue(aUser[0], 1) == appValue(bUser[0], 1) {
t.Fatal("actual login responses share player numeric identity")
}
aUserID, bUserID := appRows(aUser[0], 2), appRows(bUser[0], 2)
if len(aUserID) != 1 || len(bUserID) != 1 || string(aUserID[0]) != aProfile.UserID || string(bUserID[0]) != bProfile.UserID || bytes.Equal(aUserID[0], bUserID[0]) {
t.Fatal("actual login responses share player string identity")
}
for _, instance := range instances {
tx, err := instance.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
records, err := tx.ListEntries("missions", "command_receipts")
if err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
for _, raw := range records {
if bytes.Contains(raw, aLogin.LoginSessionKey) || bytes.Contains(raw, bLogin.LoginSessionKey) {
_ = tx.Rollback()
t.Fatal("ephemeral login encryption key persisted as command receipt")
}
}
if err = tx.Rollback(); err != nil {
t.Fatal(err)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
package app
import (
"bd2server/internal/server/runtime/player"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"sync"
"time"
)
const (
playerLoadWorkers = 4
playerLoadQueue = 128
maxResidentPlayers = 1024
)
var errPlayerLoadCapacity = errors.New("player load capacity exhausted")
type playerSlot struct {
ready chan struct{}
closed chan struct{}
runtime *player.Runtime
err error
leases int
lastUsed time.Time
retiring bool
retiredErr error
retireOnce sync.Once
}
type playerLoad struct {
accountID string
slot *playerSlot
}
type playerRegistry struct {
factory *PlayerFactory
mu sync.Mutex
players map[string]*playerSlot
closing bool
stop chan struct{}
done chan struct{}
loadDone chan struct{}
closeDone chan struct{}
loads chan playerLoad
closeErr error
idle time.Duration
}
func newPlayerRegistry(factory *PlayerFactory, idle time.Duration) *playerRegistry {
r := &playerRegistry{factory: factory, players: make(map[string]*playerSlot), stop: make(chan struct{}), done: make(chan struct{}), loadDone: make(chan struct{}), closeDone: make(chan struct{}), loads: make(chan playerLoad, playerLoadQueue), idle: idle}
var workers sync.WaitGroup
for range playerLoadWorkers {
workers.Go(func() {
for load := range r.loads {
r.load(load.accountID, load.slot)
}
})
}
go func() { workers.Wait(); close(r.loadDone) }()
go r.sweep()
return r
}
func accountDirectoryName(accountID string) string {
digest := sha256.Sum256([]byte(accountID))
return hex.EncodeToString(digest[:])
}
func (r *playerRegistry) Acquire(ctx context.Context, accountID string) (*player.Runtime, func(), error) {
if accountID == "" {
return nil, nil, errors.New("player account identity is empty")
}
for {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
r.mu.Lock()
if r.closing {
r.mu.Unlock()
return nil, nil, player.ErrClosed
}
slot := r.players[accountID]
if slot != nil && slot.retiring {
if slot.retiredErr != nil {
err := slot.retiredErr
r.mu.Unlock()
return nil, nil, errors.Join(player.ErrUnavailable, err)
}
r.mu.Unlock()
select {
case <-slot.closed:
continue
case <-ctx.Done():
return nil, nil, ctx.Err()
}
}
if slot == nil {
if len(r.players) >= maxResidentPlayers {
r.mu.Unlock()
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
}
slot = &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
select {
case r.loads <- playerLoad{accountID: accountID, slot: slot}:
r.players[accountID] = slot
default:
r.mu.Unlock()
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
}
}
slot.leases++
r.mu.Unlock()
var once sync.Once
release := func() { once.Do(func() { r.mu.Lock(); slot.leases--; slot.lastUsed = time.Now(); r.mu.Unlock() }) }
select {
case <-slot.ready:
r.mu.Lock()
err := slot.err
if ctx.Err() != nil {
err = errors.Join(err, ctx.Err())
}
if r.closing {
err = errors.Join(player.ErrClosed, err)
}
runtime := slot.runtime
r.mu.Unlock()
if err != nil {
release()
return nil, nil, err
}
return runtime, release, nil
case <-ctx.Done():
release()
return nil, nil, ctx.Err()
}
}
}
func (r *playerRegistry) load(accountID string, slot *playerSlot) {
r.mu.Lock()
if r.closing {
slot.err = player.ErrClosed
close(slot.ready)
delete(r.players, accountID)
r.mu.Unlock()
return
}
r.mu.Unlock()
runtime, err := r.open(accountID)
r.mu.Lock()
slot.runtime, slot.err = runtime, err
close(slot.ready)
if err != nil && r.players[accountID] == slot {
delete(r.players, accountID)
}
r.mu.Unlock()
if err != nil {
slog.Error("player load failed", "account_id", accountID, "error", err)
}
}
func (r *playerRegistry) open(accountID string) (runtime *player.Runtime, err error) {
defer func() {
if value := recover(); value != nil {
err = fmt.Errorf("load player panic: %v", value)
}
}()
owner, err := r.factory.open(accountID)
if err != nil {
return nil, err
}
runtime, err = player.New(accountID, owner, player.Limits{})
if err != nil {
err = errors.Join(err, owner.Close())
}
return runtime, err
}
func (r *playerRegistry) sweep() {
defer close(r.done)
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case now := <-ticker.C:
r.expire(now)
case <-r.stop:
return
}
}
}
func (r *playerRegistry) expire(now time.Time) {
r.mu.Lock()
var expired []playerLoad
if !r.closing {
for accountID, slot := range r.players {
select {
case <-slot.ready:
if !slot.retiring && slot.leases == 0 && now.Sub(slot.lastUsed) >= r.idle && slot.runtime != nil {
slot.retiring = true
expired = append(expired, playerLoad{accountID: accountID, slot: slot})
}
default:
}
}
}
r.mu.Unlock()
for _, load := range expired {
r.retire(load.accountID, load.slot)
}
}
func (r *playerRegistry) retire(accountID string, slot *playerSlot) {
slot.retireOnce.Do(func() {
err := slot.runtime.Close(context.Background())
r.mu.Lock()
slot.retiredErr = err
if err == nil && r.players[accountID] == slot {
delete(r.players, accountID)
}
r.closeErr = errors.Join(r.closeErr, err)
close(slot.closed)
r.mu.Unlock()
if err != nil {
slog.Error("player close failed", "account_id", accountID, "error", err)
}
})
}
func (r *playerRegistry) shutdown() {
defer close(r.closeDone)
<-r.loadDone
<-r.done
r.mu.Lock()
var loaded []playerLoad
for accountID, slot := range r.players {
if slot.runtime != nil {
slot.retiring = true
loaded = append(loaded, playerLoad{accountID: accountID, slot: slot})
}
}
r.mu.Unlock()
for _, load := range loaded {
r.retire(load.accountID, load.slot)
}
}
func (r *playerRegistry) Close(ctx context.Context) error {
r.mu.Lock()
if !r.closing {
r.closing = true
close(r.stop)
close(r.loads)
go r.shutdown()
}
r.mu.Unlock()
select {
case <-r.closeDone:
return r.closeErr
case <-ctx.Done():
return fmt.Errorf("player registry shutdown: %w", ctx.Err())
}
}
@@ -0,0 +1,301 @@
//go:build integration
package app
import (
"bd2server/internal/server/domain/command"
"bd2server/internal/server/runtime/player"
accountstate "bd2server/internal/server/storage/account"
identitystore "bd2server/internal/server/storage/identity"
"context"
"crypto/sha256"
"errors"
"path/filepath"
"sync"
"testing"
"time"
)
type gatedPlayerExecutor struct {
owner *playerInstance
entered chan struct{}
release chan struct{}
closed chan struct{}
once sync.Once
}
type gatedIdentityProfile struct {
source interface {
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
}
accountID string
entered chan struct{}
release chan struct{}
once sync.Once
}
func (g *gatedIdentityProfile) GameIdentity(ctx context.Context, accountID string) (identitystore.GameProfile, error) {
if accountID == g.accountID {
g.once.Do(func() { close(g.entered); <-g.release })
}
return g.source.GameIdentity(ctx, accountID)
}
func (g *gatedPlayerExecutor) Execute(ctx context.Context, c player.Command) (player.Reply, error) {
g.once.Do(func() { close(g.entered); <-g.release })
return g.owner.Execute(ctx, c)
}
func (g *gatedPlayerExecutor) Recover(ctx context.Context) error { return g.owner.Recover(ctx) }
func (g *gatedPlayerExecutor) Close() error { err := g.owner.Close(); close(g.closed); return err }
func registryCommand(accountID, identity, path string, body []byte) player.Command {
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "registry-client", RequestID: identity}, Digest: sha256.Sum256(append([]byte(path), body...)), Requests: []player.Request{{Path: path, Body: body}}}
}
func registryExecute(t *testing.T, runtime *player.Runtime, c player.Command) player.Reply {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
future, err := runtime.Submit(ctx, c)
if err != nil {
t.Fatal(err)
}
reply, err := future.Wait(ctx)
if err != nil {
t.Fatal(err)
}
return reply
}
func TestRegistryKeepsOnePlayerOwnerUntilAcceptedProgressDrains(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
gate := &gatedPlayerExecutor{owner: owner, entered: make(chan struct{}), release: make(chan struct{}), closed: make(chan struct{})}
old, err := player.New(accounts[0], gate, player.Limits{})
if err != nil {
t.Fatal(err)
}
registry := newPlayerRegistry(factory, time.Second)
t.Cleanup(func() {
if err := registry.Close(context.Background()); err != nil {
t.Error(err)
}
})
defer func() {
select {
case <-gate.release:
default:
close(gate.release)
}
}()
ready := make(chan struct{})
close(ready)
registry.mu.Lock()
registry.players[accounts[0]] = &playerSlot{ready: ready, closed: make(chan struct{}), runtime: old, lastUsed: time.Now()}
registry.mu.Unlock()
leased, release, err := registry.Acquire(context.Background(), accounts[0])
if err != nil {
t.Fatal(err)
}
registry.expire(time.Now().Add(time.Hour))
registry.mu.Lock()
held := registry.players[accounts[0]]
registry.mu.Unlock()
if held == nil || held.retiring {
t.Fatal("idle unload closed a leased player")
}
position := []byte(`{"MapId":1,"PlayerPosition":{"x":61,"y":2,"z":3},"ColleaguePositions":[]}`)
body := append(appScalar(1, 7), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
body = append(body, appMessage(3, position)...)
ctx, cancelWait := context.WithCancel(context.Background())
future, err := leased.Submit(ctx, registryCommand(accounts[0], "save-position-before-idle", "/SaveUserPosition", body))
if err != nil {
t.Fatal(err)
}
select {
case <-gate.entered:
case <-time.After(20 * time.Second):
t.Fatal("accepted progress did not enter owner")
}
cancelWait()
release()
release()
expired := make(chan struct{})
go func() { registry.expire(time.Now().Add(time.Hour)); close(expired) }()
deadline := time.Now().Add(5 * time.Second)
for {
registry.mu.Lock()
retiring := registry.players[accounts[0]].retiring
registry.mu.Unlock()
if retiring {
break
}
if time.Now().After(deadline) {
t.Fatal("idle player never entered retirement")
}
time.Sleep(time.Millisecond)
}
// The same account must wait for the old writer's accepted save to settle.
short, stop := context.WithTimeout(context.Background(), 30*time.Millisecond)
if _, _, err := registry.Acquire(short, accounts[0]); !errors.Is(err, context.DeadlineExceeded) {
stop()
t.Fatalf("second writer opened during drain: %v", err)
}
stop()
// Another account can load and process independently during A's retirement.
type acquiredPlayer struct {
runtime *player.Runtime
release func()
err error
}
loaded := make(chan acquiredPlayer, 8)
var callers sync.WaitGroup
for range cap(loaded) {
callers.Go(func() {
owner, release, err := registry.Acquire(context.Background(), accounts[1])
loaded <- acquiredPlayer{owner, release, err}
})
}
callers.Wait()
close(loaded)
var other *player.Runtime
var releaseOther []func()
defer func() {
for _, release := range releaseOther {
release()
}
}()
for acquired := range loaded {
if acquired.err != nil {
t.Fatal(acquired.err)
}
if other != nil && other != acquired.runtime {
t.Fatal("simultaneous same-account loads created different owners")
}
other = acquired.runtime
releaseOther = append(releaseOther, acquired.release)
}
registryExecute(t, other, registryCommand(accounts[1], "independent-equipment", "/EquipInfo", appScalar(1, 8)))
close(gate.release)
settlement, stop := context.WithTimeout(context.Background(), 20*time.Second)
defer stop()
if _, err := future.Wait(settlement); err != nil {
t.Fatal("cancelled caller lost accepted progress", err)
}
select {
case <-expired:
case <-settlement.Done():
t.Fatal(settlement.Err())
}
select {
case <-gate.closed:
default:
t.Fatal("retirement completed before writer close")
}
_, releaseReloaded, err := registry.Acquire(settlement, accounts[0])
if err != nil {
t.Fatal(err)
}
releaseReloaded()
if err := registry.Close(settlement); err != nil {
t.Fatal(err)
}
reopened, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
defer func() {
if err := reopened.Close(); err != nil {
t.Error(err)
}
}()
if saved, exists := reopened.assembly.progressState.Position(); !exists || saved.Position.PlayerPosition.X != 61 {
t.Fatal("idle drain discarded accepted world progress")
}
otherReopened, err := factory.open(accounts[1])
if err != nil {
t.Fatal(err)
}
defer func() {
if err := otherReopened.Close(); err != nil {
t.Error(err)
}
}()
if _, exists := otherReopened.assembly.progressState.Position(); exists {
t.Fatal("world progress crossed account retirement")
}
}
func TestRegistryShutdownClosesLoadingAndLeasedAccountWriters(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
gate := &gatedIdentityProfile{source: factory.profiles, accountID: accounts[0], entered: make(chan struct{}), release: make(chan struct{})}
factory.profiles = gate
registry := newPlayerRegistry(factory, time.Minute)
t.Cleanup(func() {
if err := registry.Close(context.Background()); err != nil {
t.Error(err)
}
})
defer func() {
select {
case <-gate.release:
default:
close(gate.release)
}
}()
registry.mu.Lock()
slots := make([]*playerSlot, len(accounts))
for i, accountID := range accounts {
slot := &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
registry.players[accountID] = slot
registry.loads <- playerLoad{accountID: accountID, slot: slot}
slots[i] = slot
}
registry.mu.Unlock()
select {
case <-gate.entered:
case <-time.After(20 * time.Second):
t.Fatal("account did not begin real player loading")
}
// Another complete account remains leased when shutdown begins.
ctx, stop := context.WithTimeout(context.Background(), 30*time.Second)
defer stop()
leased, releaseLease, err := registry.Acquire(ctx, accounts[1])
if err != nil {
t.Fatal(err)
}
registryExecute(t, leased, registryCommand(accounts[1], "query-before-close", "/EquipInfo", appScalar(1, 1)))
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if err := registry.Close(cancelled); err != nil && !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
close(gate.release)
if err := registry.Close(ctx); err != nil {
t.Fatal(err)
}
releaseLease()
for _, slot := range slots {
select {
case <-slot.ready:
default:
t.Fatal("shutdown forgot a loading account waiter")
}
}
for _, accountID := range accounts {
if _, _, err := registry.Acquire(ctx, accountID); !errors.Is(err, player.ErrClosed) {
t.Fatalf("registry accepted after shutdown: %v", err)
}
path := filepath.Join(factory.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
repository, err := accountstate.Open(path)
if err != nil {
t.Fatal("shutdown left an account writer locked", err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
}
}
+107
View File
@@ -0,0 +1,107 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce"
"bd2server/internal/server/domain/commerce/gacha"
"bd2server/internal/server/domain/events"
"bd2server/internal/server/gateway/session"
"bd2server/internal/server/protocol/defaults"
scheduleadapter "bd2server/internal/server/protocol/schedule"
"bd2server/internal/server/protocol/staticdata"
"bd2server/internal/server/storage/account"
"fmt"
"time"
)
func (p *playerAssembly) session(ctx command.Context) error {
p.handlers = []session.Handler{
p.progressState,
p.cashService,
p.cashBonuses,
p.clearPackages,
commerce.PackInfoHandler{World: p.worldService, Claims: p.clearPackages},
commerce.AttendanceHandler{Events: p.eventTasksService, Economy: p.cashEconomy, LoginPasses: p.loginPasses, Store: p.gameplayStore},
p.eventRegistry,
p.eventGamesService,
p.eventExchangeService,
p.boxService,
p.eventPlayService,
p.eventActionsService,
p.npcShopService,
p.innService,
events.SkinHandler{Economy: p.eventEconomy},
p.battleService,
p.huntingService,
p.monsterHuntService,
p.worldService,
p.worldService.CharacterService(),
p.progressState,
p.deckStateStore,
p.ownedItems,
p.ownedEquipment,
p.inventorySlots,
p.charAwakeService,
p.costumePotentialService,
p.costumeBurstService,
p.friendshipService,
p.contentOpenService,
p.masterTitleService,
p.recruitService,
p.foodService,
p.talentUseService,
p.dispatchService,
p.itemCraftService,
p.recipeService,
p.starter,
p.mailService,
p.gachaService,
p.achievementCounters,
p.missionService,
p.eventTasksService,
p.pictorialService,
&scheduleadapter.Service{Schedule: p.options.calendars.RegularService},
readonly.Service{Seed: p.seeds.defaults},
feature.Service{},
}
p.observers = []session.ResponseObserver{p.achievementObserver, p.eventTasksService, p.mailService}
featured := gacha.ActivePickupCostumes(p.design.regularGacha, p.options.calendars.GachaSeed, uint64(time.Now().UTC().UnixMilli()))
limitedIDs := p.design.limitedCostumes.Excluding(featured)
if len(limitedIDs) != 0 {
if err := p.mailService.EnsureStarterLimitedCostumes(ctx, limitedIDs, time.Now().UTC()); err != nil {
return fmt.Errorf("ensure account limited-costume entitlement: %w", err)
}
}
prestigeIDs := p.design.prestigeCatalog.Giftable(func(key gamedata.CashProductKey) bool { return p.cashService.IsAvailable(ctx, key) })
if len(prestigeIDs) != 0 {
if err := p.mailService.EnsureStarterPrestigeSkins(ctx, prestigeIDs, time.Now().UTC()); err != nil {
return fmt.Errorf("ensure account prestige-skin entitlement: %w", err)
}
}
if p.initializeAccount {
if err := ensureAccountStateInitialized(ctx,
p.progressState, p.deckStateStore, p.ownedItems, p.ownedEquipment,
p.worldService.CharacterService(), p.collection, p.wallet, p.inventorySlots, p.mailService, p.missionService,
); err != nil {
return fmt.Errorf("initialize complete account state generation: %w", err)
}
if err := p.worldService.EnsureInitialPackPurchase(ctx); err != nil {
return fmt.Errorf("grant initial pack purchase rewards: %w", err)
}
if err := ctx.State.(*accountstate.CommandStore).MarkInitializationComplete(); err != nil {
return fmt.Errorf("mark account initialization complete: %w", err)
}
}
if err := p.masterTitleService.EnsurePersisted(ctx); err != nil {
return fmt.Errorf("persist master title: %w", err)
}
problems, err := ctx.State.(*accountstate.CommandStore).Validate()
if err != nil {
return fmt.Errorf("validate account state database: %w", err)
}
if len(problems) != 0 {
return stateProblemsError("account state database rejected", problems)
}
return nil
}
@@ -0,0 +1,72 @@
//go:build integration
package app
import (
"bd2server/internal/server/domain/battle"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/gateway/session"
"bd2server/internal/server/runtime/player"
"context"
"testing"
"time"
)
// Independently verified in selected 2.35.10 GameData using gamedata_db.py:
// pack22 FieldMonsterTable id8 has phase group1; PhaseBattleTable group1 rows
// id1/id2 use decks8/9. BattleEnter/Start/PhaseChange request fields are from
// the original generated Proto.Net classes, not the server's response writer.
func TestFailedBatchDiscardsRealBattlePhaseBeforeBattleCanRestart(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
// Select the verified pack as the battle's immutable scenario resolver.
// The production battle service loads phases from real GameData; only this
// scenario selection avoids progressing a full story campaign in this test.
owner.assembly.battleService = battle.NewService(factory.options.gameData, factory.options.gameDataVersion, func(command.Context) (int, error) { return 22, nil })
owner.assembly.handlers = []session.Handler{owner.assembly.battleService}
runtime, err := player.New(accounts[0], owner, player.Limits{})
if err != nil {
_ = owner.Close()
t.Fatal(err)
}
t.Cleanup(func() {
if err := runtime.Close(context.Background()); err != nil {
t.Error(err)
}
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
run := func(c player.Command) (player.Reply, error) {
future, err := runtime.Submit(ctx, c)
if err != nil {
return player.Reply{}, err
}
return future.Wait(ctx)
}
enter := append(appScalar(1, 1), appScalar(3, 8)...)
enter = append(enter, appScalar(4, 8)...)
enter = append(enter, appScalar(5, 1)...)
if _, err := run(registryCommand(accounts[0], "enter-phase-battle", "/BattleEnter", enter)); err != nil {
t.Fatal(err)
}
start := append(appScalar(1, 2), appScalar(2, 8)...)
if _, err := run(registryCommand(accounts[0], "start-phase-one", "/BattleStart", start)); err != nil {
t.Fatal(err)
}
batch := registryCommand(accounts[0], "phase-then-invalid-member", "/BattlePhaseChange", appScalar(1, 3))
batch.Requests = append(batch.Requests, player.Request{Path: "/BattleStart", Body: append(appScalar(1, 4), appScalar(2, 999999)...)})
batch.Digest[0] ^= 1
if _, err := run(batch); err == nil {
t.Fatal("invalid next phase start committed its preceding phase transition")
}
identity := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "registry-client", RequestID: "inspect-recovered-battle"}}
if owner.assembly.battleService.Active(identity) {
t.Fatal("recovered account retained uncommitted in-battle ownership")
}
if _, err := run(registryCommand(accounts[0], "invalid-before-enter", "/BattleStart", append(appScalar(1, 5), appScalar(2, 9)...))); err == nil {
t.Fatal("post-recovery battle start bypassed required enter")
}
}
@@ -0,0 +1,22 @@
package app
import (
"bd2server/internal/server/domain/command"
"errors"
)
type accountStateInitializer interface {
EnsurePersisted(command.Context) error
}
func ensureAccountStateInitialized(ctx command.Context, stores ...accountStateInitializer) error {
for _, store := range stores {
if store == nil {
return errors.New("nil account state initializer")
}
if err := store.EnsurePersisted(ctx); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,159 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce/gacha"
"bd2server/internal/server/domain/progression/missions"
"bd2server/internal/server/domain/progression/pictorial"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/world"
"bd2server/internal/server/protocol/wire"
"errors"
"fmt"
)
func (p *playerAssembly) worldRoster(ctx command.Context) error {
var err error
p.worldService, err = world.New(ctx, p.worldSeed, p.design.world, p.design.source,
p.scope, p.progressState, p.starter, p.ownedEquipment, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load world state: %w", err)
}
if err := p.worldService.ConfigureStartPack(ctx, p.startingPackID, p.initializeAccount); err != nil {
return fmt.Errorf("configure account starting chapter: %w", err)
}
if err := p.login.AttachLastPlayedPack(p.worldService); err != nil {
return fmt.Errorf("attach persisted login destination: %w", err)
}
// Restore all earned seed ownership before validating persisted upgrades.
// A quest costume is not a p.collection entry; attaching it after opening
// p.collection would reject its otherwise valid burst ledger on restart.
baseCostumes := append([]roster.Costume(nil), p.starter.Costumes...)
if reward, earned := p.worldService.EarnedQuestCostume(); earned {
baseCostumes = append(baseCostumes, reward)
}
p.collection, err = roster.OpenCollectionStore(ctx, p.scope, baseCostumes)
if err != nil {
return fmt.Errorf("load owned collection: %w", err)
}
p.gachaService, err = gacha.NewService(p.design.infiniteGacha, p.design.regularGacha, p.collection, p.wallet)
if err != nil {
return err
}
if err := p.login.AttachPurchaseCounts(p.gachaService); err != nil {
return fmt.Errorf("attach cash purchase counts to login: %w", err)
}
if err := p.gachaService.AttachSchedule(ctx, p.options.calendars.GachaSeed); err != nil {
return fmt.Errorf("attach gacha schedule: %w", err)
}
previewEventIndex, err := p.seeds.defaults.CashProductEventIndex(p.design.infiniteGacha.ProductGroupID, p.design.infiniteGacha.ProductID)
if err != nil {
return fmt.Errorf("load infinite preview event: %w", err)
}
if err := p.gachaService.AttachPreviewEventIndex(ctx, previewEventIndex); err != nil {
return fmt.Errorf("attach infinite preview event: %w", err)
}
// The mapped client property is IsDoneFirstGachaPick. Its authoritative
// local state is the explicit GachaSubType=3 completion marker.
if err := p.login.AttachFirstGacha(p.gachaService); err != nil {
return fmt.Errorf("attach first gacha status to login: %w", err)
}
if err := p.gachaService.AttachFirstGacha(ctx, p.design.firstGacha); err != nil {
return fmt.Errorf("attach first gacha GameData: %w", err)
}
p.gachaService.AttachInventory(p.ownedItems)
p.gachaService.AttachEquipmentGacha(p.design.equipmentGacha, p.ownedEquipment)
p.gachaService.AttachDrawMission(func(ctx command.Context, count uint64) error {
return p.missionService.RecordEvent(ctx, missions.ConditionGachaBuy, 0, count, p.worldService.MissionsUnlocked)
})
var permanentBaseCharacters []roster.Character
for _, c := range p.worldService.CharacterService().RawAll() {
if !roster.IsCharmCharacter(c) {
permanentBaseCharacters = append(permanentBaseCharacters, c)
}
}
if err := p.collection.BindBaseCharacters(ctx, permanentBaseCharacters); err != nil {
return fmt.Errorf("bind base collection characters: %w", err)
}
if err := p.mailService.AttachCostumeRewards(ctx, p.collection, p.design.limitedCostumes); err != nil {
return fmt.Errorf("attach limited costume mail rewards: %w", err)
}
if err := p.worldService.AttachCollection(ctx, p.collection); err != nil {
return fmt.Errorf("attach gacha collection state: %w", err)
}
if err := p.worldService.AttachDecks(ctx, p.deckStateStore); err != nil {
return fmt.Errorf("attach world deck state: %w", err)
}
if err := p.worldService.AttachWaypointRuntime(ctx, p.design.source); err != nil {
return fmt.Errorf("attach waypoint runtime: %w", err)
}
if err := p.worldService.AttachFieldObjectRuntime(p.design.source, p.design.fieldReset); err != nil {
return fmt.Errorf("attach field object runtime: %w", err)
}
if err := p.ownedEquipment.AttachCharacters(ctx, p.worldService.CharacterService()); err != nil {
return fmt.Errorf("attach equipment character state: %w", err)
}
if err := p.deckStateStore.AttachPresetRuntime(ctx, p.wallet, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
return fmt.Errorf("attach ordinary preset runtime: %w", err)
}
if err := p.deckStateStore.AttachFieldSettingsPack(p.worldService.CurrentPackID); err != nil {
return err
}
if err := p.deckStateStore.AttachFieldSettings(ctx, p.design.fieldSettingsDesign); err != nil {
return fmt.Errorf("attach field character settings: %w", err)
}
if err := p.login.AttachAutoReviveSettings(p.deckStateStore); err != nil {
return fmt.Errorf("attach automatic revival settings: %w", err)
}
p.pictorialService = &pictorial.Service{Design: p.design.pictorialDesign, Owned: p.worldService}
if err := p.ownedEquipment.AttachStatDesign(ctx, p.design.equipmentStatDesign); err != nil {
return err
}
p.pictorialService.EquipmentContributions = func(ctx command.Context, character roster.Character) ([]gamedata.StatContribution, error) {
return p.ownedEquipment.StatContributions(ctx, character.InvenIndex)
}
p.charAwakeService, err = roster.NewCharAwakeService(p.design.charAwakeDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
if err != nil {
return err
}
p.pictorialService.AwakeContributions = p.charAwakeService.Contributions
if err := p.worldService.CharacterService().AttachMaxHealth(ctx, p.pictorialService.MaxHealth); err != nil {
return fmt.Errorf("attach pictorial character stats: %w", err)
}
if err := p.worldService.CharacterService().AttachWallet(ctx, p.wallet); err != nil {
return fmt.Errorf("attach character promotion wallet: %w", err)
}
if err := p.worldService.CharacterService().AttachTalentGrowth(ctx, p.design.talentGrowth); err != nil {
return fmt.Errorf("attach character talent growth: %w", err)
}
if err := p.worldService.CharacterService().AttachImmortalDesign(ctx, p.design.immortalDesign); err != nil {
return fmt.Errorf("attach immortal talent GameData: %w", err)
}
p.costumePotentialService, err = roster.NewCostumePotentialService(p.design.costumePotentialDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
if err != nil {
return err
}
p.pictorialService.PotentialContributions = p.costumePotentialService.Contributions
p.costumeBurstService, err = roster.NewCostumeBurstService(p.design.costumeBurstDesign, p.collection, p.ownedItems, p.wallet)
if err != nil {
return err
}
p.friendshipService, err = roster.NewFriendshipService(&p.design.friendshipDesign, p.design.charAwakeDesign, p.design.costumePotentialDesign, p.collection, p.ownedItems, p.wallet)
if err != nil {
return fmt.Errorf("load friendship state: %w", err)
}
if err := p.login.AttachFriendshipAP(p.friendshipService); err != nil {
return err
}
accountName, found, err := wire.Bytes(p.login.UserInfo, 2)
if err != nil || !found || len(accountName) == 0 {
return errors.New("account seed requires its existing display name for master title")
}
p.masterTitleService, err = roster.OpenMasterTitleService(ctx, p.scope, string(accountName))
if err != nil {
return fmt.Errorf("load master title: %w", err)
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"errors"
"fmt"
)
// Preflight validates the same shared rules as Serve without opening player or authentication databases.
func Preflight(args []string) (preflightErr error) {
defer func() { preflightErr = errors.Join(preflightErr, gamedata.CloseDatabaseCache()) }()
config, err := loadConfiguration(args)
if err != nil {
return err
}
defer clear(config.authRuntime.MasterKey)
seeds, err := loadSeeds(config)
if err != nil {
return err
}
_, err = loadDesign(config, seeds)
return err
}
func errorsVersionMismatch(want, login, player string) error {
return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", want, login, player)
}
@@ -0,0 +1,146 @@
//go:build integration
package app
import (
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/world/progress"
"bd2server/internal/server/runtime/player"
"bytes"
"context"
"maps"
"testing"
)
// Verified with gamedata_db.py in 20260923193640: pack1 monster2001 belongs
// to side quest103 and deck301 grants gold350 (type4, id0); monster1 belongs
// to main quest9 and decks1/200001 grant resource7 x2. These expected values
// are independent of the resolver and reward loader under test.
func TestQuestBattleUsesItsOwnQuestDifficultyAndRewardOwner(t *testing.T) {
factory, accounts := newIntegrationFactory(t)
for _, scenario := range []struct {
name string
monster, deck, selected uint64
difficulty, activeQuest int
gold uint64
}{
{"normal-main", 1, 1, 1, 0, 9, 0},
{"very-hard-main", 1, 1, 200001, 2, 9, 0},
{"side-with-very-hard-main-selected", 2001, 301, 301, 2, 103, 350},
} {
t.Run(scenario.name, func(t *testing.T) {
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
tx, err := owner.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
ctx := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "fixture"}, State: tx}
state := owner.assembly.progressState
if err := state.SetActivePackID(ctx, 1); err != nil {
t.Fatal(err)
}
if err := state.SelectQuest(ctx, 1, progress.QuestSelection{Difficulty: scenario.difficulty}); err != nil {
t.Fatal(err)
}
if scenario.activeQuest == 103 {
for _, id := range []int{101, 102} {
if err := state.ClearQuest(ctx, id, 1, 0); err != nil {
t.Fatal(err)
}
}
if err := state.AcceptQuest(ctx, 103, 1, 0); err != nil {
t.Fatal(err)
}
} else {
for id := 1; id < scenario.activeQuest; id++ {
if err := state.ClearQuest(ctx, id, 1, scenario.difficulty); err != nil {
t.Fatal(err)
}
}
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := owner.Recover(context.Background()); err != nil {
t.Fatal(err)
}
runtime, err := player.New(accounts[0], owner, player.Limits{CompletedReceipts: 1})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := runtime.Close(context.Background()); err != nil {
t.Error(err)
}
})
run := func(identity, path string, body []byte) []byte {
t.Helper()
reply := registryExecute(t, runtime, registryCommand(accounts[0], scenario.name+identity, path, body))
return reply.Responses[0].Body
}
beforeGold := owner.assembly.wallet.Snapshot(command.Context{}).Gold
beforeItems := appItemTotals(run("items-before", "/ItemInfo", appScalar(1, 1)))
invalid := append(appScalar(1, 99), appScalar(3, scenario.monster)...)
invalid = append(invalid, appScalar(4, 303)...)
invalid = append(invalid, appScalar(5, 1)...)
future, err := runtime.Submit(context.Background(), registryCommand(accounts[0], scenario.name+"wrong-monster-deck", "/BattleEnter", invalid))
if err != nil {
t.Fatal(err)
}
if _, err := future.Wait(context.Background()); err == nil {
t.Fatal("quest battle accepted another monster's authored deck")
}
if owner.assembly.battleService.Active(command.Context{Identity: command.Identity{SessionID: "registry-client"}}) {
t.Fatal("rejected deck installed an active battle")
}
if owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold || !maps.Equal(appItemTotals(run("items-rejected", "/ItemInfo", appScalar(1, 100))), beforeItems) {
t.Fatal("rejected deck changed owned rewards")
}
enter := append(appScalar(1, 2), appScalar(3, scenario.monster)...)
enter = append(enter, appScalar(4, scenario.deck)...)
enter = append(enter, appScalar(5, 1)...)
response := run("enter", "/BattleEnter", enter)
if appValue(response, 2) != scenario.selected {
t.Fatalf("client receives wrong enemy deck: %d, want %d", appValue(response, 2), scenario.selected)
}
run("start", "/BattleStart", append(appScalar(1, 3), appScalar(2, scenario.selected)...))
end := append(appScalar(1, 4), appScalar(2, 1)...)
response = run("end", "/BattleEnd", end)
bundles := appRows(response, 5)
if len(bundles) != 1 {
t.Fatal("victory omitted client reward bundle")
}
rewards := appItemTotals(bundles[0])
key, count := [2]uint64{8, 7}, uint64(2)
if scenario.gold > 0 {
key, count = [2]uint64{4, 0}, scenario.gold
}
if len(rewards) != 1 || rewards[key] != count {
t.Fatalf("wrong authored victory reward: %v, want %v x%d", rewards, key, count)
}
if gold := owner.assembly.wallet.Snapshot(command.Context{}).Gold; gold-beforeGold != scenario.gold {
t.Fatalf("gold did not settle in wallet: before=%d after=%d want delta=%d", beforeGold, gold, scenario.gold)
}
afterItems := appItemTotals(run("items-after", "/ItemInfo", appScalar(1, 5)))
if scenario.gold > 0 {
if afterItems[[2]uint64{4, 0}] != 0 {
t.Fatal("wallet currency leaked into owned inventory")
}
} else if afterItems[key]-beforeItems[key] != 2 {
t.Fatal("main battle resource did not settle in owned inventory")
}
// ItemInfo evicts the actor's single completed-command receipt. Retrying
// BattleEnd must still be idempotent through the battle settlement.
retry := run("end-retry", "/BattleEnd", end)
if !bytes.Equal(response, retry) || owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold+scenario.gold {
t.Fatal("settlement retry changed its response or credited gold twice")
}
if totals := appItemTotals(run("items-retry", "/ItemInfo", appScalar(1, 6))); !maps.Equal(totals, afterItems) {
t.Fatal("settlement retry credited owned items twice")
}
})
}
}
+46
View File
@@ -0,0 +1,46 @@
package app
import (
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/resources/fetch"
"context"
"errors"
"flag"
"log/slog"
)
func Resources(args []string) error {
if len(args) == 0 || args[0] != "fetch" {
return errors.New("resources requires the fetch subcommand")
}
fs := flag.NewFlagSet("resources fetch", flag.ContinueOnError)
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
output := fs.String("output", "", "resource mirror output directory (required)")
platform := fs.String("platform", "StandaloneWindows64", "official ServerData platform")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *output == "" {
return errors.New("resources fetch requires --output")
}
var versions versionconfig.Config
var err error
if *versionConfigPath == "" {
versions, err = versionconfig.Find()
} else {
versions, err = versionconfig.Load(*versionConfigPath)
}
if err != nil {
return err
}
manifest, err := resourcefetch.Fetch(context.Background(), resourcefetch.Options{
OutputRoot: *output, Platform: *platform, BundleVersion: versions.BundleVersion,
GameDataVersion: versions.GameDataVersion,
Progress: func(message string) { slog.Info(message) },
})
if err != nil {
return err
}
slog.Info("official resource mirror complete", "output", *output, "bundles", manifest.ServerData.Bundles, "bytes", manifest.ServerData.Bytes)
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package app
import (
"bd2server/internal/server/domain/mail"
"bd2server/internal/server/domain/roster"
"bd2server/internal/server/domain/roster/deck"
"bd2server/internal/server/domain/world"
calendaradapter "bd2server/internal/server/protocol/calendar"
loginprotocol "bd2server/internal/server/protocol/login"
"bd2server/internal/server/protocol/staticdata"
"encoding/json"
)
type seedCatalog struct {
login *loginprotocol.LoginSeed
starter *roster.Starter
defaults *readonly.Seed
mailbox *mail.Starter
deck deck.Seed
world world.Seed
}
func loadSeeds(c *configuration) (*seedCatalog, error) {
login, err := loginprotocol.Load(c.accountSeed)
if err != nil {
return nil, err
}
starter, err := roster.Load(c.playerSeed)
if err != nil {
return nil, err
}
if login.Version != c.versions.GameVersion || starter.Version != c.versions.GameVersion {
return nil, errorsVersionMismatch(c.versions.GameVersion, login.Version, starter.Version)
}
defaults, err := readonly.Load(c.readonlySeed)
if err != nil {
return nil, err
}
defaults, err = calendaradapter.ApplyStaticData(c.calendars, defaults)
if err != nil {
return nil, err
}
mailbox, err := mail.Load(c.mailSeed)
if err != nil {
return nil, err
}
deckSeed, err := deck.LoadSeed(c.deckSeed)
if err != nil {
return nil, err
}
worldSeed, err := world.LoadSeed(c.worldSeed)
if err != nil {
return nil, err
}
return &seedCatalog{login: login, starter: starter, defaults: defaults, mailbox: mailbox, deck: deckSeed, world: worldSeed}, nil
}
func cloneSeed[T any](source *T) (*T, error) {
data, err := json.Marshal(source)
if err != nil {
return nil, err
}
var result T
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return &result, nil
}
+124
View File
@@ -0,0 +1,124 @@
package app
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/gateway/auth"
"bd2server/internal/server/gateway/session"
"bd2server/internal/server/gateway/transport"
"bd2server/internal/server/platform/lifecycle"
identitystore "bd2server/internal/server/storage/identity"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"time"
)
func Serve(args []string) (serveErr error) {
defer func() { serveErr = errors.Join(serveErr, gamedata.CloseDatabaseCache()) }()
c, err := loadConfiguration(args)
if err != nil {
return err
}
defer clear(c.authRuntime.MasterKey)
seeds, err := loadSeeds(c)
if err != nil {
return err
}
design, err := loadDesign(c, seeds)
if err != nil {
return err
}
var authService *auth.Service
var authStore *identitystore.Store
if c.authentication.Mode == "oauth" {
authStore, err = identitystore.Open(filepath.Join(c.stateDirectory, "auth.db"), c.authRuntime.MasterKey)
if err != nil {
return fmt.Errorf("open authentication database: %w", err)
}
defer func() { serveErr = errors.Join(serveErr, authStore.Close()) }()
authService, err = auth.New(c.authRuntime, authStore)
if err != nil {
return err
}
}
if err := lockServerState(c.stateDirectory, c.gameRules.Story.StartPackID); err != nil {
return err
}
factory := &PlayerFactory{options: c, design: design, seeds: seeds}
if authStore != nil {
factory.profiles = authStore
}
registry := newPlayerRegistry(factory, 15*time.Minute)
defer func() { serveErr = errors.Join(serveErr, registry.Close(context.Background())) }()
var authenticator session.LoginAuthenticator
if authService != nil {
authenticator = authService
}
game, err := session.NewServer(registry, authenticator)
if err != nil {
return err
}
dispatcher := transport.Bootstrap{Config: c.bootstrap}
var authHandler http.Handler
if authService != nil {
authHandler = authService.Handler()
}
availability := lifecycle.NewGate()
instanceBytes := make([]byte, 16)
if _, err := rand.Read(instanceBytes); err != nil {
return fmt.Errorf("create server instance identity: %w", err)
}
instanceID := hex.EncodeToString(instanceBytes)
handler := transport.HTTP{
Dispatcher: dispatcher, Raw: game, Authentication: c.authentication,
AuthenticationHandler: authHandler, ResourcePolicy: c.publicResources,
CommerceManifest: func() any { return design.cashCatalog.Manifest() }, Availability: availability, InstanceID: instanceID,
}.Handler()
server := &http.Server{
Addr: c.listen,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 60 * time.Second,
}
slog.Info("BD2 server listening", "address", c.listen, "instance_id", instanceID, "server_version", c.versions.ServerVersion, "game_version", c.bootstrap.Version, "bundle", c.bootstrap.BundleVer, "resourceMode", c.publicResources.Mode, "gameData", c.verifiedGameData.ArchivePath, "gameDataEntries", c.verifiedGameData.EntryCount, "accountSeed", c.accountSeed)
serveResult := make(chan error, 1)
go func() { serveResult <- server.ListenAndServe() }()
signals := make(chan os.Signal, 1)
signal.Notify(signals, lifecycle.ShutdownSignals()...)
defer signal.Stop(signals)
select {
case err := <-serveResult:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case received := <-signals:
slog.Info("BD2 server draining", "signal", received.String())
}
availability.Drain()
drainContext, cancelDrain := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelDrain()
if err := availability.Wait(drainContext); err != nil {
slog.Warn("BD2 request drain timed out", "error", err)
}
if err := server.Shutdown(drainContext); err != nil {
_ = server.Close()
return fmt.Errorf("shutdown drained server: %w", err)
}
if err := <-serveResult; err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
slog.Info("BD2 server stopped after drain")
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package app
import (
"bd2server/internal/server/storage/stateio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
type serverState struct {
Version int `json:"version"`
StartPackID int `json:"start_pack_id"`
}
func lockServerState(directory string, configured int) error {
if err := os.MkdirAll(directory, 0o700); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(directory, "state.db")); err == nil {
return errors.New("single-account state layout requires explicit offline migration")
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
path := filepath.Join(directory, "server.json")
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
if entries, readErr := os.ReadDir(filepath.Join(directory, "accounts")); readErr == nil && len(entries) != 0 {
return errors.New("server policy is missing for existing player databases; explicit repair required")
} else if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
return readErr
}
raw, err = json.Marshal(serverState{Version: 1, StartPackID: configured})
if err != nil {
return err
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
_, writeErr := file.Write(raw)
syncErr := file.Sync()
closeErr := file.Close()
return errors.Join(writeErr, syncErr, closeErr)
}
if err != nil {
return err
}
if err := stateio.RequireExactJSONObject(raw, "version", "start_pack_id"); err != nil {
return err
}
var state serverState
if err := json.Unmarshal(raw, &state); err != nil {
return err
}
if state.Version != 1 || (state.StartPackID != 1 && state.StartPackID != 21) {
return errors.New("invalid permanent server story policy")
}
if state.StartPackID != configured {
return fmt.Errorf("story.start_pack_id %d conflicts with permanent server policy %d", configured, state.StartPackID)
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package app
import (
"errors"
"flag"
"fmt"
"path/filepath"
"bd2server/internal/server/storage/account"
)
func State(args []string) error {
if len(args) == 0 || args[0] != "check" {
return errors.New("usage: bd2server state check [options]")
}
return stateCheckCommand(args[1:])
}
func stateCheckCommand(args []string) error {
fs := flag.NewFlagSet("state check", flag.ContinueOnError)
stateDB := fs.String("state", "", "account SQLite database (required)")
if err := fs.Parse(args); err != nil {
return err
}
if *stateDB == "" {
return errors.New("state check requires --state with the selected account database")
}
repository, err := accountstate.Open(filepath.Clean(*stateDB))
if err != nil {
return err
}
defer func() { _ = repository.Close() }()
version, err := repository.SchemaVersion()
if err != nil {
return err
}
problems, err := repository.Validate()
if err != nil {
return err
}
fmt.Printf("schema version: %d\n", version)
for _, problem := range problems {
fmt.Printf("ERROR %s %s: %s", problem.Code, filepath.Join(problem.Path...), problem.Message)
if len(problem.RelatedIDs) != 0 {
fmt.Printf(" ids=%v", problem.RelatedIDs)
}
fmt.Println()
}
if len(problems) != 0 {
return fmt.Errorf("state check rejected with %d error(s)", len(problems))
}
fmt.Println("state check passed")
return nil
}
@@ -0,0 +1,16 @@
package app
import (
"fmt"
"strings"
"bd2server/internal/server/storage/account"
)
func stateProblemsError(prefix string, problems []accountstate.Problem) error {
parts := make([]string, 0, len(problems))
for _, problem := range problems {
parts = append(parts, problem.Code+": "+problem.Message)
}
return fmt.Errorf("%s: %s", prefix, strings.Join(parts, "; "))
}
File diff suppressed because it is too large Load Diff
-672
View File
@@ -1,672 +0,0 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"bd2server/internal/server/authconfig"
)
const testNowUnix = int64(1_800_000_000)
func testService(t *testing.T) (*Service, *Store) {
t.Helper()
master := bytes.Repeat([]byte{0x42}, 32)
store, err := Open(filepath.Join(t.TempDir(), "auth.db"), master)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
store.now = func() time.Time { return time.Unix(testNowUnix, 0) }
public, _ := url.Parse("https://login.example.test")
runtime := authconfig.Runtime{
Config: authconfig.Config{
Mode: "oauth",
PublicURL: "https://login.example.test",
Providers: map[string]authconfig.ProviderConfig{
"discord": {ClientID: "discord-client", ClientSecretEnv: "DISCORD_SECRET"},
"google": {ClientID: "google-client", ClientSecretEnv: "GOOGLE_SECRET"},
},
},
PublicURLParsed: public,
MasterKey: master,
ProviderSecrets: map[string]string{"discord": "discord-secret", "google": "google-secret"},
AccessTTL: 15 * time.Minute,
RefreshTTL: 30 * 24 * time.Hour,
DeviceTTL: 10 * time.Minute,
}
service, err := New(runtime, store)
if err != nil {
t.Fatal(err)
}
if service.config.MasterKey != nil {
t.Fatal("service retained the authentication master key")
}
for i, value := range master {
if value != 0 {
t.Fatalf("master key byte %d was not cleared", i)
}
}
return service, store
}
func insertAuthorizingDevice(t *testing.T, store *Store, id, provider string) {
t.Helper()
_, err := store.db.Exec(`INSERT INTO devices(id,client_hash,secret_hash,start_hash,provider,status,created_at,expires_at)
VALUES(?,?,?,?,?,'authorizing',?,?)`, id, store.digest("client-ip", "192.0.2.1"), store.digest("device-secret", "device-secret"), store.digest("start-ticket", "start-ticket"), provider, testNowUnix, testNowUnix+600)
if err != nil {
t.Fatal(err)
}
}
func completeAndPoll(t *testing.T, service *Service, store *Store, deviceID, provider, issuer, subject string) deviceResult {
t.Helper()
insertAuthorizingDevice(t, store, deviceID, provider)
if err := service.completeDevice(deviceID, provider, providerIdentity{issuer: issuer, subject: subject}); err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodPost, "/auth/device/"+deviceID+"/poll", nil)
request.Header.Set("Authorization", "Device device-secret")
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("poll status=%d body=%q", response.Code, response.Body.String())
}
var result deviceResult
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
func postJSON(handler http.Handler, path string, value any) *httptest.ResponseRecorder {
body, _ := json.Marshal(value)
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func TestCompleteDeviceRequiresAuthorizingTransition(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "already-consumed", "discord")
if _, err := store.db.Exec(`UPDATE devices SET status='consumed' WHERE id='already-consumed'`); err != nil {
t.Fatal(err)
}
err := service.completeDevice("already-consumed", "discord", providerIdentity{issuer: "https://discord.com", subject: "123456789"})
if !errors.Is(err, ErrConsumed) {
t.Fatalf("completeDevice error=%v, want ErrConsumed", err)
}
for _, table := range []string{"accounts", "identities", "families", "access_tokens", "refresh_tokens"} {
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("%s has %d rows after rejected completion", table, count)
}
}
}
func TestPollConsumesEncryptedResultExactlyOnce(t *testing.T) {
service, store := testService(t)
result := completeAndPoll(t, service, store, "poll-once", "discord", "https://discord.com", "123456789")
if result.AccessToken == "" || result.RefreshToken == "" {
t.Fatal("poll omitted issued tokens")
}
request := httptest.NewRequest(http.MethodPost, "/auth/device/poll-once/poll", nil)
request.Header.Set("Authorization", "Device device-secret")
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusGone {
t.Fatalf("second poll status=%d body=%q", response.Code, response.Body.String())
}
var status string
var cipher []byte
if err := store.db.QueryRow(`SELECT status,COALESCE(result_cipher,X'') FROM devices WHERE id='poll-once'`).Scan(&status, &cipher); err != nil {
t.Fatal(err)
}
if status != "consumed" || len(cipher) != 0 {
t.Fatalf("device status=%q result bytes=%d", status, len(cipher))
}
}
func TestRefreshRotationReplayRevokesFamily(t *testing.T) {
service, store := testService(t)
first := completeAndPoll(t, service, store, "refresh-device", "discord", "https://discord.com", "123456789")
handler := service.Handler()
response := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "rotation-attempt-0001"})
if response.Code != http.StatusOK {
t.Fatalf("refresh status=%d body=%q", response.Code, response.Body.String())
}
var rotated deviceResult
if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
t.Fatal(err)
}
if rotated.RefreshToken == "" || rotated.RefreshToken == first.RefreshToken || rotated.AccessToken == first.AccessToken {
t.Fatal("refresh did not rotate both credentials")
}
if _, err := service.ValidateAccess(first.AccessToken); err == nil {
t.Fatal("old access token survived refresh rotation")
}
if _, err := service.ValidateAccess(rotated.AccessToken); err != nil {
t.Fatalf("new access token rejected: %v", err)
}
replay := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "replay-attempt-000002"})
if replay.Code != http.StatusUnauthorized {
t.Fatalf("replay status=%d body=%q", replay.Code, replay.Body.String())
}
if _, err := service.ValidateAccess(rotated.AccessToken); err == nil {
t.Fatal("refresh replay did not revoke the token family")
}
next := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": rotated.RefreshToken, "attempt_id": "after-replay-attempt-3"})
if next.Code != http.StatusUnauthorized {
t.Fatalf("family refresh after replay status=%d", next.Code)
}
}
func TestRevokeInvalidatesAccessAndRefreshFamily(t *testing.T) {
service, store := testService(t)
tokens := completeAndPoll(t, service, store, "revoke-device", "discord", "https://discord.com", "123456789")
request := httptest.NewRequest(http.MethodPost, "/auth/session/revoke", nil)
request.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("revoke status=%d body=%q", response.Code, response.Body.String())
}
if _, err := service.ValidateAccess(tokens.AccessToken); err == nil {
t.Fatal("revoked access token remained valid")
}
refresh := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken, "attempt_id": "revoked-attempt-0001"})
if refresh.Code != http.StatusUnauthorized {
t.Fatalf("revoked refresh status=%d", refresh.Code)
}
}
func TestRefreshRetryWithSameAttemptReturnsCommittedRotation(t *testing.T) {
service, store := testService(t)
first := completeAndPoll(t, service, store, "refresh-retry-device", "discord", "https://discord.com", "123456789")
handler := service.Handler()
request := map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "stable-attempt-000001"}
response := postJSON(handler, "/auth/session/refresh", request)
if response.Code != http.StatusOK {
t.Fatalf("first refresh status=%d body=%q", response.Code, response.Body.String())
}
var rotated deviceResult
if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
t.Fatal(err)
}
retry := postJSON(handler, "/auth/session/refresh", request)
if retry.Code != http.StatusOK {
t.Fatalf("retry status=%d body=%q", retry.Code, retry.Body.String())
}
var replayed deviceResult
if err := json.Unmarshal(retry.Body.Bytes(), &replayed); err != nil {
t.Fatal(err)
}
if replayed.Provider != rotated.Provider || replayed.AccessToken != rotated.AccessToken || replayed.RefreshToken != rotated.RefreshToken {
t.Fatalf("retry returned a different rotation: first=%+v retry=%+v", rotated, replayed)
}
if _, err := service.ValidateAccess(rotated.AccessToken); err != nil {
t.Fatalf("idempotent retry revoked the family: %v", err)
}
var sealed []byte
if err := store.db.QueryRow(`SELECT result_cipher FROM refresh_attempts`).Scan(&sealed); err != nil {
t.Fatal(err)
}
if bytes.Contains(sealed, []byte(rotated.AccessToken)) || bytes.Contains(sealed, []byte(rotated.RefreshToken)) {
t.Fatal("refresh attempt result was stored outside AES-GCM ciphertext")
}
}
func TestRefreshRequiresStableAttemptID(t *testing.T) {
service, store := testService(t)
tokens := completeAndPoll(t, service, store, "refresh-attempt-required", "discord", "https://discord.com", "123456789")
response := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken})
if response.Code != http.StatusBadRequest {
t.Fatalf("missing attempt status=%d body=%q", response.Code, response.Body.String())
}
if _, err := service.ValidateAccess(tokens.AccessToken); err != nil {
t.Fatalf("malformed refresh request changed credential family: %v", err)
}
}
func TestRefreshCommittedAttemptReplaysAfterRequestTokenExpiry(t *testing.T) {
service, store := testService(t)
first := completeAndPoll(t, service, store, "refresh-expiry-device", "discord", "https://discord.com", "123456789")
request := map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "expiry-replay-attempt-01"}
response := postJSON(service.Handler(), "/auth/session/refresh", request)
if response.Code != http.StatusOK {
t.Fatalf("first refresh status=%d body=%q", response.Code, response.Body.String())
}
var rotated deviceResult
if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`UPDATE refresh_tokens SET expires_at=? WHERE token_hash=?`, testNowUnix-1, store.digest("refresh-token", first.RefreshToken)); err != nil {
t.Fatal(err)
}
store.now = func() time.Time { return time.Unix(testNowUnix+int64((16*time.Minute).Seconds()), 0) }
replay := postJSON(service.Handler(), "/auth/session/refresh", request)
if replay.Code != http.StatusOK {
t.Fatalf("expired request token replay status=%d body=%q", replay.Code, replay.Body.String())
}
var replayed deviceResult
if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil {
t.Fatal(err)
}
if replayed.AccessToken != rotated.AccessToken || replayed.RefreshToken != rotated.RefreshToken {
t.Fatalf("replay changed committed rotation: first=%+v replay=%+v", rotated, replayed)
}
if replayed.AccessExpiresIn != 0 {
t.Fatalf("expired replay access TTL=%d, want 0", replayed.AccessExpiresIn)
}
}
func TestSensitiveAuthenticationMaterialIsNotStoredInPlaintext(t *testing.T) {
service, store := testService(t)
handler := service.Handler()
created := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
if created.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%q", created.Code, created.Body.String())
}
var device struct {
ID string `json:"transaction_id"`
Secret string `json:"device_secret"`
StartURL string `json:"start_url"`
}
if err := json.Unmarshal(created.Body.Bytes(), &device); err != nil {
t.Fatal(err)
}
startURL, _ := url.Parse(device.StartURL)
ticket := startURL.Query().Get("ticket")
start := httptest.NewRequest(http.MethodGet, startURL.RequestURI(), nil)
started := httptest.NewRecorder()
handler.ServeHTTP(started, start)
if started.Code != http.StatusFound {
t.Fatalf("start status=%d body=%q", started.Code, started.Body.String())
}
authorize, _ := url.Parse(started.Header().Get("Location"))
state := authorize.Query().Get("state")
var secretHash, startHash, stateHash, verifierCipher, nonceCipher []byte
if err := store.db.QueryRow(`SELECT secret_hash,start_hash,state_hash,verifier_cipher,nonce_cipher FROM devices WHERE id=?`, device.ID).
Scan(&secretHash, &startHash, &stateHash, &verifierCipher, &nonceCipher); err != nil {
t.Fatal(err)
}
verifier, err := store.open(device.ID, "pkce", verifierCipher)
if err != nil {
t.Fatal(err)
}
nonce, err := store.open(device.ID, "nonce", nonceCipher)
if err != nil {
t.Fatal(err)
}
for name, pair := range map[string]struct{ stored, raw []byte }{
"device secret": {secretHash, []byte(device.Secret)},
"start ticket": {startHash, []byte(ticket)},
"oauth state": {stateHash, []byte(state)},
"pkce verifier": {verifierCipher, verifier},
"oidc nonce": {nonceCipher, nonce},
} {
if bytes.Equal(pair.stored, pair.raw) || bytes.Contains(pair.stored, pair.raw) {
t.Fatalf("%s was stored in plaintext", name)
}
}
const providerSubject = "987654321012345678"
if err := service.completeDevice(device.ID, "discord", providerIdentity{issuer: "https://discord.com", subject: providerSubject}); err != nil {
t.Fatal(err)
}
var subjectHash, sealedResult []byte
if err := store.db.QueryRow(`SELECT subject_hash FROM identities`).Scan(&subjectHash); err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow(`SELECT result_cipher FROM devices WHERE id=?`, device.ID).Scan(&sealedResult); err != nil {
t.Fatal(err)
}
plainResult, err := store.open(device.ID, "result", sealedResult)
if err != nil {
t.Fatal(err)
}
var issued deviceResult
if err := json.Unmarshal(plainResult, &issued); err != nil {
t.Fatal(err)
}
if bytes.Contains(subjectHash, []byte(providerSubject)) {
t.Fatal("provider subject was stored in plaintext")
}
for name, raw := range map[string]string{"access token": issued.AccessToken, "refresh token": issued.RefreshToken} {
if bytes.Contains(sealedResult, []byte(raw)) {
t.Fatalf("pending %s was stored outside AES-GCM ciphertext", name)
}
var count int
table := "access_tokens"
if name == "refresh token" {
table = "refresh_tokens"
}
if err := store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE token_hash=?`, []byte(raw)).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("%s was stored in plaintext", name)
}
}
}
func TestJSONLimitsAndSecurityHeaders(t *testing.T) {
service, _ := testService(t)
handler := service.Handler()
for name, body := range map[string]struct {
body string
want int
}{
"trailing": {`{"provider":"discord"}{}`, http.StatusBadRequest},
"oversize": {`{"provider":"discord","padding":"` + strings.Repeat("x", 17<<10) + `"}`, http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/auth/device", strings.NewReader(body.body))
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != body.want {
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
}
for header, want := range map[string]string{
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
} {
if got := response.Header().Get(header); got != want {
t.Fatalf("%s=%q want %q", header, got, want)
}
}
})
}
}
func TestCreateDeviceLimitsPendingTransactionsPerClient(t *testing.T) {
service, store := testService(t)
handler := service.Handler()
for i := range 5 {
response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
if response.Code != http.StatusCreated {
t.Fatalf("create %d status=%d body=%q", i, response.Code, response.Body.String())
}
}
response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
if response.Code != http.StatusTooManyRequests {
t.Fatalf("pending limit status=%d body=%q", response.Code, response.Body.String())
}
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM devices`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 5 {
t.Fatalf("device count=%d want 5", count)
}
}
func TestDecodeProviderJSONRejectsOversizeAndTrailingValues(t *testing.T) {
var target map[string]any
if err := decodeProviderJSON(strings.NewReader(`{"id":"1"}{}`), &target); err == nil {
t.Fatal("accepted provider response with trailing JSON")
}
oversize := `{"padding":"` + strings.Repeat("x", 1<<20) + `"}`
if err := decodeProviderJSON(strings.NewReader(oversize), &target); err == nil {
t.Fatal("accepted oversized provider response")
}
}
func TestRequestLimiterIsBoundedAndExpiresWindows(t *testing.T) {
limiter := requestLimiter{windows: make(map[string]limitWindow)}
now := time.Unix(testNowUnix, 0)
for i := range 4096 {
if !limiter.allow(strconv.Itoa(i), now, time.Minute, 1) {
t.Fatalf("rejected window %d before capacity", i)
}
}
if limiter.allow("overflow", now, time.Minute, 1) {
t.Fatal("accepted a limiter key beyond its bounded capacity")
}
if !limiter.allow("after-expiry", now.Add(time.Minute), time.Minute, 1) {
t.Fatal("did not clean expired limiter windows")
}
}
func TestCleanupRetainsUsedRefreshForReplayUntilFamilyExpiry(t *testing.T) {
service, store := testService(t)
tokens := completeAndPoll(t, service, store, "cleanup-device", "discord", "https://discord.com", "123456789")
if _, err := store.db.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=?`, testNowUnix, store.digest("refresh-token", tokens.RefreshToken)); err != nil {
t.Fatal(err)
}
tx, err := store.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := cleanupExpired(tx, testNowUnix+int64((29*24*time.Hour).Seconds())); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE used_at IS NOT NULL`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("used refresh token was removed before family expiry")
}
tx, err = store.db.Begin()
if err != nil {
t.Fatal(err)
}
if err := cleanupExpired(tx, testNowUnix+int64((31*24*time.Hour).Seconds())); err != nil {
_ = tx.Rollback()
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("expired family refresh token was not cleaned")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
func jsonResponse(status int, body string) *http.Response {
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
func TestProviderIdentityVerification(t *testing.T) {
service, _ := testService(t)
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch request.URL.Host + request.URL.Path {
case "discord.com/api/v10/oauth2/token":
return jsonResponse(http.StatusOK, `{"access_token":"provider-access"}`), nil
case "discord.com/api/v10/users/@me":
if request.Header.Get("Authorization") != "Bearer provider-access" {
t.Fatal("Discord bearer token missing")
}
return jsonResponse(http.StatusOK, `{"id":"123456789"}`), nil
case "oauth2.googleapis.com/token":
return jsonResponse(http.StatusOK, `{"access_token":"provider-access","id_token":"signed-id-token"}`), nil
case "oauth2.googleapis.com/tokeninfo":
return jsonResponse(http.StatusOK, `{"iss":"https://accounts.google.com","aud":"google-client","sub":"google-subject","nonce":"expected-nonce","exp":"1900000000"}`), nil
default:
t.Fatalf("unexpected provider request %s", request.URL)
return nil, nil
}
})}
discord, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
if err != nil || discord.issuer != "https://discord.com" || discord.subject != "123456789" {
t.Fatalf("Discord identity=%+v err=%v", discord, err)
}
google, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "expected-nonce")
if err != nil || google.issuer != "https://accounts.google.com" || google.subject != "google-subject" {
t.Fatalf("Google identity=%+v err=%v", google, err)
}
if _, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "wrong-nonce"); err == nil {
t.Fatal("Google identity accepted the wrong OIDC nonce")
}
}
func TestProviderFailureIsStructuredAndSanitized(t *testing.T) {
service, _ := testService(t)
secretDescription := "provider leaked secret sentinel"
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusUnauthorized, `{"error":"invalid_client","error_description":"`+secretDescription+`"}`), nil
})}
_, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
var failure *providerFailure
if !errors.As(err, &failure) {
t.Fatalf("error %T does not expose provider failure", err)
}
if failure.Provider != "discord" || failure.Stage != "token_exchange" || failure.Reason != "http_rejected" ||
failure.HTTPStatus != http.StatusUnauthorized || failure.OAuthError != "invalid_client" {
t.Fatalf("failure=%+v", failure)
}
if strings.Contains(err.Error(), secretDescription) {
t.Fatal("provider error description leaked through diagnostic error")
}
}
func TestProviderFailureRejectsUntrustedOAuthError(t *testing.T) {
service, _ := testService(t)
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusBadRequest, `{"error":"access-token-sentinel"}`), nil
})}
_, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
var failure *providerFailure
if !errors.As(err, &failure) || failure.OAuthError != "unknown" {
t.Fatalf("failure=%+v err=%v", failure, err)
}
if strings.Contains(err.Error(), "access-token-sentinel") {
t.Fatal("untrusted provider error leaked through diagnostic error")
}
}
func TestGoogleProviderNetworkFailureDoesNotLeakIDTokenURL(t *testing.T) {
service, _ := testService(t)
idToken := "signed-id-token-sentinel"
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path == "/token" {
return jsonResponse(http.StatusOK, `{"access_token":"provider-access","id_token":"`+idToken+`"}`), nil
}
return nil, &url.Error{Op: "Get", URL: "https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken, Err: errors.New("transport sentinel")}
})}
_, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "nonce")
var failure *providerFailure
if !errors.As(err, &failure) || failure.Provider != "google" || failure.Stage != "id_token_verify" || failure.Reason != "network_error" {
t.Fatalf("failure=%+v err=%v", failure, err)
}
if strings.Contains(err.Error(), idToken) || strings.Contains(err.Error(), "transport sentinel") {
t.Fatal("Google ID token URL or transport details leaked through diagnostic error")
}
}
func TestCallbackFailureClearsShortLivedOAuthMaterial(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "failed-device", "discord")
state := "failed-state"
verifier, err := store.seal("failed-device", "pkce", []byte("verifier"))
if err != nil {
t.Fatal(err)
}
nonce, err := store.seal("failed-device", "nonce", []byte("nonce"))
if err != nil {
t.Fatal(err)
}
if _, err := store.db.Exec(`UPDATE devices SET state_hash=?,verifier_cipher=?,nonce_cipher=? WHERE id='failed-device'`, store.digest("oauth-state", state), verifier, nonce); err != nil {
t.Fatal(err)
}
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
return jsonResponse(http.StatusUnauthorized, `{"error":"invalid_client"}`), nil
})}
request := httptest.NewRequest(http.MethodGet, "/auth/discord/callback?code=failed-code&state="+url.QueryEscape(state), nil)
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadGateway || !strings.Contains(response.Body.String(), "server OAuth configuration is invalid") {
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
}
var status string
var stateHash, verifierCipher, nonceCipher []byte
if err := store.db.QueryRow(`SELECT status,COALESCE(state_hash,X''),COALESCE(verifier_cipher,X''),COALESCE(nonce_cipher,X'') FROM devices WHERE id='failed-device'`).Scan(&status, &stateHash, &verifierCipher, &nonceCipher); err != nil {
t.Fatal(err)
}
if status != "failed" || len(stateHash) != 0 || len(verifierCipher) != 0 || len(nonceCipher) != 0 {
t.Fatalf("status=%q state=%d verifier=%d nonce=%d", status, len(stateHash), len(verifierCipher), len(nonceCipher))
}
}
func TestProviderScopesUseLeastPrivilege(t *testing.T) {
if got := providerScope("discord"); got != "identify" {
t.Fatalf("Discord scope=%q, want identify", got)
}
if got := providerScope("google"); got != "openid" {
t.Fatalf("Google scope=%q, want openid", got)
}
}
func TestProviderErrorConsumesAuthorizationStateWithoutExchange(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "cancelled-device", "discord")
state := "cancelled-oauth-state"
if _, err := store.db.Exec(`UPDATE devices SET state_hash=? WHERE id='cancelled-device'`, store.digest("oauth-state", state)); err != nil {
t.Fatal(err)
}
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
t.Fatal("provider error callback attempted a token exchange")
return nil, nil
})}
request := httptest.NewRequest(http.MethodGet, "/auth/discord/callback?error=access_denied&state="+url.QueryEscape(state), nil)
response := httptest.NewRecorder()
service.Handler().ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
}
var status, errorCode string
var stateHash, verifier, nonce []byte
if err := store.db.QueryRow(`SELECT status,error_code,COALESCE(state_hash,X''),COALESCE(verifier_cipher,X''),COALESCE(nonce_cipher,X'') FROM devices WHERE id='cancelled-device'`).
Scan(&status, &errorCode, &stateHash, &verifier, &nonce); err != nil {
t.Fatal(err)
}
if status != "failed" || errorCode != "provider_cancelled" || len(stateHash) != 0 || len(verifier) != 0 || len(nonce) != 0 {
t.Fatalf("cancelled device status=%q error=%q state=%d verifier=%d nonce=%d", status, errorCode, len(stateHash), len(verifier), len(nonce))
}
}
func TestCompleteDeviceRejectsMalformedProviderIdentity(t *testing.T) {
service, store := testService(t)
insertAuthorizingDevice(t, store, "bad-identity", "discord")
if err := service.completeDevice("bad-identity", "discord", providerIdentity{issuer: "https://discord.com", subject: "not-a-snowflake"}); err == nil {
t.Fatal("accepted malformed Discord identity")
}
var count int
if err := store.db.QueryRow(`SELECT COUNT(*) FROM identities`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("malformed identity was persisted")
}
}
-97
View File
@@ -1,97 +0,0 @@
package auth
import (
"bytes"
"database/sql"
"path/filepath"
"testing"
)
func TestStoreRequiresAndClearsExactMasterKey(t *testing.T) {
if _, err := Open(filepath.Join(t.TempDir(), "short.db"), make([]byte, 31)); err == nil {
t.Fatal("store accepted a non-256-bit master key")
}
key := bytes.Repeat([]byte{0x7a}, 32)
store, err := Open(filepath.Join(t.TempDir(), "auth.db"), key)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := store.Close(); err != nil {
t.Error(err)
}
}()
for index, value := range key {
if value != 0 {
t.Fatalf("master key byte %d was retained by the caller buffer", index)
}
}
}
func TestStoreMigratesSchemaV1ToV2(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.db")
store, err := Open(path, bytes.Repeat([]byte{0x61}, 32))
if err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`DROP TABLE refresh_attempts; UPDATE metadata SET value='1' WHERE key='schema_version'`); err != nil {
if err := db.Close(); err != nil {
t.Error(err)
}
t.Fatal(err)
}
if err := db.Close(); err != nil {
t.Fatal(err)
}
reopened, err := Open(path, bytes.Repeat([]byte{0x61}, 32))
if err != nil {
t.Fatal(err)
}
defer func() {
if err := reopened.Close(); err != nil {
t.Error(err)
}
}()
var version int
if err := reopened.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != 2 {
t.Fatalf("schema_version=%d, want 2", version)
}
var table string
if err := reopened.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_attempts'`).Scan(&table); err != nil {
t.Fatal(err)
}
}
func TestStorePersistsExplicitSchemaVersionAndRejectsUnknownVersion(t *testing.T) {
path := filepath.Join(t.TempDir(), "auth.db")
store, err := Open(path, bytes.Repeat([]byte{0x35}, 32))
if err != nil {
t.Fatal(err)
}
var version int
if err := store.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil {
t.Fatal(err)
}
if version != schemaVersion {
t.Fatalf("schema_version=%d, want %d", version, schemaVersion)
}
if _, err := store.db.Exec(`UPDATE metadata SET value='999' WHERE key='schema_version'`); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
if _, err := Open(path, bytes.Repeat([]byte{0x35}, 32)); err == nil {
t.Fatal("store accepted an unknown schema version")
}
}
@@ -1,99 +0,0 @@
package authconfig
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoad(t *testing.T) {
path := filepath.Join(t.TempDir(), FileName)
if err := os.WriteFile(path, []byte(`{"mode":"oauth","public_url":"https://example.com","master_key_env":"MASTER","providers":{"discord":{"client_id":"d","client_secret_env":"DS"},"google":{"client_id":"g","client_secret_env":"GS"}}}`), 0o600); err != nil {
t.Fatal(err)
}
config, err := Load(path)
if err != nil {
t.Fatal(err)
}
if config.Mode != "oauth" || len(config.Providers) != 2 || config.Providers["discord"].ClientID != "d" || config.Providers["google"].ClientID != "g" {
t.Fatalf("unexpected config: %+v", config)
}
}
func TestValidateRejectsUnsafePolicies(t *testing.T) {
for name, config := range map[string]Config{
"unknown mode": {Mode: "disabled"},
"local providers": {Mode: "local", Providers: map[string]ProviderConfig{"discord": {ClientID: "d", ClientSecretEnv: "DS"}}},
"empty oauth": {Mode: "oauth"},
"unknown provider": {Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", Providers: map[string]ProviderConfig{"github": {ClientID: "g", ClientSecretEnv: "GS"}}},
} {
t.Run(name, func(t *testing.T) {
if err := config.Validate(); err == nil {
t.Fatal("accepted invalid authentication policy")
}
})
}
}
func TestResolveEnvironmentKeepsSecretsOutOfPublicView(t *testing.T) {
master := make([]byte, 32)
for i := range master {
master[i] = byte(i + 1)
}
t.Setenv("AUTH_MASTER", base64.StdEncoding.EncodeToString(master))
t.Setenv("DISCORD_SECRET", "private-discord-secret")
config := Config{
Mode: "oauth",
PublicURL: "https://example.com",
MasterKeyEnv: "AUTH_MASTER",
Providers: map[string]ProviderConfig{
"discord": {ClientID: "public-client-id", ClientSecretEnv: "DISCORD_SECRET"},
},
}
runtime, err := config.ResolveEnvironment()
if err != nil {
t.Fatal(err)
}
if len(runtime.MasterKey) != 32 || runtime.ProviderSecrets["discord"] != "private-discord-secret" {
t.Fatal("runtime did not resolve authentication secrets")
}
publicJSON, err := json.Marshal(config.Public())
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"AUTH_MASTER", "DISCORD_SECRET", "private-discord-secret", "public-client-id"} {
if strings.Contains(string(publicJSON), forbidden) {
t.Fatalf("public authentication view leaked %q: %s", forbidden, publicJSON)
}
}
}
func TestValidateRejectsUnsafePublicURLsAndTTLs(t *testing.T) {
base := Config{
Mode: "oauth",
PublicURL: "https://example.com",
MasterKeyEnv: "MASTER",
Providers: map[string]ProviderConfig{
"discord": {ClientID: "d", ClientSecretEnv: "DS"},
},
}
for name, mutate := range map[string]func(*Config){
"http public": func(c *Config) { c.PublicURL = "http://example.com" },
"path": func(c *Config) { c.PublicURL = "https://example.com/auth" },
"query": func(c *Config) { c.PublicURL = "https://example.com?x=y" },
"missing secret": func(c *Config) { c.Providers["discord"] = ProviderConfig{ClientID: "d"} },
"invalid ttl": func(c *Config) { c.Session.AccessTTL = "0s" },
} {
t.Run(name, func(t *testing.T) {
candidate := base
candidate.Providers = map[string]ProviderConfig{"discord": base.Providers["discord"]}
mutate(&candidate)
if err := candidate.Validate(); err == nil {
t.Fatal("accepted unsafe authentication configuration")
}
})
}
}
-258
View File
@@ -1,258 +0,0 @@
package battle
import (
"fmt"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
func TestQuestBattleSelectsAndRetainsDifficultyDeck(t *testing.T) {
s := NewService("design", "version", nil, func() (int, error) { return 1, nil })
s.AttachCurrentDifficulty(func() (uint64, error) { return 2, nil })
s.loadDifficultyDeck = func(_, _ string, pack int, deck, difficulty uint64) (uint64, error) {
if pack != 1 || deck != 1 || difficulty != 2 {
t.Fatalf("selection %d/%d/%d", pack, deck, difficulty)
}
return 200001, nil
}
s.loadPhases = func(_, _ string, pack int, monster, deck uint64) ([]gamedata.BattlePhase, error) {
if pack != 1 || deck != 200001 {
t.Fatalf("phase selection %d/%d", pack, deck)
}
return nil, nil
}
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 1), 5, 1)
_, response, _, err := s.Handle("/BattleEnter", enter)
if err != nil {
t.Fatal(err)
}
deck, _, _ := wire.Varint(response, 2)
if deck != 200001 {
t.Fatalf("deck %d", deck)
}
start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 200001), 5, wire.AppendVarint(nil, 2, 101))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
_, response, _, err = s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 200001))
if err != nil {
t.Fatal(err)
}
deck, _, _ = wire.Varint(response, 3)
if deck != 200001 {
t.Fatalf("retry deck %d", deck)
}
}
func TestLocalBattleLifecycle(t *testing.T) {
s := &Service{}
enter := request(1)
enter = wire.AppendVarint(enter, 4, 1)
enter = wire.AppendVarint(enter, 5, 1)
code, response, ok, err := s.Handle("/BattleEnter", enter)
if err != nil || !ok || code != 52 {
t.Fatalf("enter: %d %v %v", code, ok, err)
}
engine, found, _ := wire.Varint(response, 6)
if !found || engine != 1 {
t.Fatalf("engine=%d/%v", engine, found)
}
red := wire.AppendVarint(nil, 1, 1)
blue := wire.AppendVarint(nil, 1, 6)
start := request(2)
start = wire.AppendVarint(start, 2, 1)
start = wire.AppendBytes(start, 4, red)
start = wire.AppendBytes(start, 5, blue)
code, response, _, err = s.Handle("/BattleStart", start)
if err != nil || code != 14 {
t.Fatalf("start: %d %v", code, err)
}
if _, found, _ := wire.Bytes(response, 1); !found {
t.Fatal("red state not echoed")
}
if _, found, _ := wire.Bytes(response, 2); !found {
t.Fatal("blue state not echoed")
}
code, response, _, err = s.Handle("/BattleVerifyState", request(3))
state, found, _ := wire.Varint(response, 1)
if err != nil || code != 142 || !found || state != 3 {
t.Fatalf("verify: code=%d state=%d/%v err=%v", code, state, found, err)
}
end := request(4)
end = wire.AppendVarint(end, 2, 2)
end = wire.AppendBytes(end, 3, blue)
code, response, _, err = s.Handle("/BattleEnd", end)
result, found, _ := wire.Varint(response, 1)
if err != nil || code != 15 || !found || result != 2 {
t.Fatalf("end: code=%d result=%d/%v err=%v", code, result, found, err)
}
}
func TestBattleOrdering(t *testing.T) {
s := &Service{}
start := request(1)
start = wire.AppendVarint(start, 2, 1)
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
t.Fatal("start without enter accepted")
}
}
func TestBattleRetryRestoresFirstSubmittedBlueTeam(t *testing.T) {
s := &Service{}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 4), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
first := wire.AppendVarint(wire.AppendVarint(nil, 2, 101), 4, 513)
second := wire.AppendVarint(wire.AppendVarint(nil, 2, 102), 4, 200)
start := wire.AppendVarint(request(2), 2, 77)
start = wire.AppendBytes(start, 5, first)
start = wire.AppendBytes(start, 5, second)
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
retry := wire.AppendVarint(request(3), 2, 77)
code, response, handled, err := s.Handle("/BattleRetry", retry)
if err != nil || !handled || code != 58 {
t.Fatalf("retry code=%d handled=%v err=%v", code, handled, err)
}
index, found, err := wire.Varint(response, 3)
if err != nil || !found || index != 77 {
t.Fatalf("retry battle index=%d found=%v err=%v", index, found, err)
}
var restored [][]byte
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 2 {
restored = append(restored, field.Value)
}
return nil
}); err != nil || len(restored) != 2 || string(restored[0]) != string(first) || string(restored[1]) != string(second) {
t.Fatalf("retry team=%x err=%v", restored, err)
}
}
func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) {
s := &Service{}
s.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) {
return []gamedata.PictorialBuffStat{{StatType: 2, Value: .0175}, {StatType: 4, Value: .01}}, nil
})
enter := wire.AppendVarint(wire.AppendVarint(request(9), 4, 1), 5, 1)
code, response, handled, err := s.Handle("/BattleEnter", enter)
if err != nil || !handled || code != 52 {
t.Fatalf("battle entry code=%d handled=%v err=%v", code, handled, err)
}
var count int
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 4 {
count++
stat, found, err := wire.Varint(field.Value, 1)
if err != nil || !found || (stat != 2 && stat != 4) {
t.Fatalf("invalid battle buff stat=%d found=%v err=%v", stat, found, err)
}
}
return nil
}); err != nil || count != 2 {
t.Fatalf("battle buffs count=%d err=%v", count, err)
}
}
func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) {
storage := stateio.NewMemory()
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
currentPack := 22
s := NewService("test-root", "test-version", inventory, func() (int, error) {
return currentPack, nil
})
var loadedPack int
var loadedDeck uint64
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) { return nil, nil }
s.loadRewards = func(_, _ string, packID int, deckID uint64) ([]gamedata.BattleReward, error) {
loadedPack, loadedDeck = packID, deckID
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}, nil
}
enter := wire.AppendVarint(request(1), 3, 7)
enter = wire.AppendVarint(enter, 4, 9)
enter = wire.AppendVarint(enter, 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
// A later world transition must not change the identity of an in-flight
// battle; the pack is captured at BattleEnter.
currentPack = 21
end := wire.AppendVarint(request(2), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
t.Fatal(err)
}
if loadedPack != 22 || loadedDeck != 9 {
t.Fatalf("reward lookup pack/deck=%d/%d, want 22/9", loadedPack, loadedDeck)
}
if got := inventory.GrantedItems("pack22:monster7:deck9"); len(got) != 1 || got[0].ID != 8 || got[0].Count != 3 {
t.Fatalf("pack22 reward grant=%+v", got)
}
if got := inventory.GrantedItems("pack21:monster7:deck9"); len(got) != 0 {
t.Fatalf("reward leaked into pack21 identity: %+v", got)
}
}
func TestNewGameSessionDiscardsUnfinishedBattleWithoutRewards(t *testing.T) {
s := NewService("", "", nil, nil)
s.BeginSession("session-a")
enter := wire.AppendVarint(request(1), 4, 10)
enter = wire.AppendVarint(enter, 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
start := wire.AppendVarint(request(2), 2, 99)
start = wire.AppendBytes(start, 5, wire.AppendVarint(nil, 1, 123))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
s.BeginSession("session-b")
end := wire.AppendVarint(request(3), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil {
t.Fatal("new session resumed unfinished battle")
}
}
func TestBattleStateIsIsolatedPerGameSession(t *testing.T) {
s := NewService("", "", nil, nil)
enter := wire.AppendVarint(request(1), 4, 10)
enter = wire.AppendVarint(enter, 5, 1)
s.BeginSession("session-a")
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
s.BeginSession("session-b")
end := wire.AppendVarint(request(2), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil {
t.Fatal("session-b observed session-a battle")
}
s.BeginSession("session-a")
if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
t.Fatalf("session-a lost its own battle: %v", err)
}
}
func TestRepeatedCurrentSessionSelectionDoesNotEvictOtherBattles(t *testing.T) {
service := NewService("", "", nil, nil)
for index := range 1024 {
service.BeginSession(fmt.Sprintf("session-%d", index))
}
service.BeginSession("session-1023")
if len(service.states) != 1024 {
t.Fatalf("reselecting current session changed cache size: %d", len(service.states))
}
if service.states["session-1023"] == nil {
t.Fatal("reselecting current session evicted its battle state")
}
}
-72
View File
@@ -1,72 +0,0 @@
package battle
import (
"bytes"
"fmt"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
type fakeEventBattle struct {
enter, end string
rejected bool
}
func (*fakeEventBattle) HandlesBattle(mode uint64) bool { return mode == 17 }
func (e *fakeEventBattle) EnterBattle(_ []byte, receipt string) ([]byte, error) {
e.enter = receipt
if e.rejected {
return nil, fmt.Errorf("stage locked")
}
return wire.AppendVarint(nil, 1, 7), nil
}
func (e *fakeEventBattle) CompleteBattle(_ []byte, receipt string) ([]byte, error) {
e.end = receipt
return wire.AppendBytes(nil, 16, wire.AppendVarint(nil, 3, 77)), nil
}
func TestEventBattleOwnsSettlementAndAvoidsWorldDeck(t *testing.T) {
s := NewService("unused", "unused", nil, func() (int, error) { return 21, nil })
s.BeginSession("event-session")
e := &fakeEventBattle{}
s.AttachEventBattle(e)
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
t.Fatal("event read ordinary world deck")
return nil, nil
}
s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("event altered world health"); return nil })
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 73), 5, 17)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(2), 2, 73)); err != nil {
t.Fatal(err)
}
code, out, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
if err != nil || code != 15 {
t.Fatalf("settlement: %d %v", code, err)
}
if e.enter != "event-session:1" || e.end != e.enter {
t.Fatalf("receipt %q/%q", e.enter, e.end)
}
row, _, _ := wire.Bytes(out, 16)
if !bytes.Equal(row, wire.AppendVarint(nil, 3, 77)) {
t.Fatal("event progress lost")
}
if s.Active() {
t.Fatal("event battle remained active")
}
_, retry, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
if err != nil || !bytes.Equal(retry, out) {
t.Fatal("settlement retry lost reply")
}
if _, _, _, err = s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 2)); err == nil {
t.Fatal("changed settlement retry accepted")
}
e.rejected = true
if _, _, _, err = s.Handle("/BattleEnter", enter); err == nil {
t.Fatal("locked stage entered")
}
}
@@ -1,56 +0,0 @@
package battle
import (
"bd2server/internal/server/wire"
"errors"
"testing"
)
func TestFieldBuffConsumptionAfterValidationAndBeforeBattleActivation(t *testing.T) {
s := NewService("", "", nil, nil)
s.BeginSession("first-session")
remaining := 3
used := map[string]bool{}
fail := false
s.AttachFieldBuffConsume(func(identity string) error {
if fail {
return errors.New("field buff persistence failed")
}
if !used[identity] {
remaining--
used[identity] = true
}
return nil
})
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", request(1)); err == nil || remaining != 3 {
t.Fatal("invalid battle consumed buff")
}
fail = true
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
t.Fatal("persistence failure activated battle")
}
fail = false
for range 2 {
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
}
if remaining != 2 {
t.Fatal("same enter receipt consumed twice")
}
start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 1), 5, wire.AppendVarint(nil, 1, 6))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 1)); err != nil {
t.Fatal(err)
}
if remaining != 2 {
t.Fatal("retry consumed extra field buff")
}
s.BeginSession("second-session")
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil || remaining != 1 {
t.Fatal("new battle did not consume once", err)
}
}
-132
View File
@@ -1,132 +0,0 @@
package battle
import (
"strconv"
"testing"
"bd2server/internal/server/wire"
)
func TestBattleDefeatAndRunCommitZeroAndInjuredHealth(t *testing.T) {
// EBattleResultType: RED_WIN=2, CONDITION_FAIL=3, RUN=4. A dead
// CharDBInfo omits hp because proto3's default is zero; it must be committed
// rather than ignored and replaced with an earlier full-health value.
for _, result := range []uint64{2, 3, 4} {
t.Run(strconv.FormatUint(result, 10), func(t *testing.T) {
s := &Service{}
calls := 0
var saved map[uint64]uint64
s.AttachCommittedHealth(func(health map[uint64]uint64) error { calls++; saved = health; return nil })
s.BeginSession("settlement")
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
start := wire.AppendVarint(request(2), 2, 11)
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 100))
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 200), 4, 100))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
end := wire.AppendVarint(request(3), 2, result)
end = wire.AppendBytes(end, 3, wire.AppendVarint(nil, 1, 100))
end = wire.AppendBytes(end, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 200), 3, 21))
_, response, _, err := s.Handle("/BattleEnd", end)
if err != nil {
t.Fatal(err)
}
if calls != 1 || len(saved) != 2 || saved[100] != 0 || saved[200] != 21 {
t.Fatalf("result=%d saved health=%v calls=%d", result, saved, calls)
}
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 3 {
index, _, _ := wire.Varint(field.Value, 1)
hp, _, _ := wire.Varint(field.Value, 3)
if hp != saved[index] {
t.Fatal("response differs from settled HP")
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleExit", request(4)); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatal("exit wrote health after settlement")
}
})
}
}
func TestBattleHealthCommitsOnlyAtEndAndChecksParticipants(t *testing.T) {
s := &Service{}
calls := 0
var health map[uint64]uint64
s.AttachCommittedHealth(func(values map[uint64]uint64) error {
calls++
for index, hp := range values {
values[index] = min(hp, 50)
}
health = values
return nil
})
s.BeginSession("first")
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
start := wire.AppendVarint(request(2), 2, 11)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 60)
start = wire.AppendBytes(start, 5, blue)
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
if calls != 0 || !s.Active() {
t.Fatal("round state was committed or battle was not active")
}
invalid := wire.AppendVarint(request(3), 2, 1)
invalid = wire.AppendBytes(invalid, 3, wire.AppendVarint(nil, 1, 999))
if _, _, _, err := s.Handle("/BattleEnd", invalid); err == nil || calls != 0 {
t.Fatal("accepted health for a character outside the blue team")
}
result := wire.AppendVarint(request(4), 2, 1)
character := wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 17)
result = wire.AppendBytes(result, 3, character)
if _, _, _, err := s.Handle("/BattleEnd", result); err != nil {
t.Fatal(err)
}
if calls != 1 || health[100] != 17 || s.Active() {
t.Fatalf("health=%v calls=%d active=%t", health, calls, s.Active())
}
// A second battle interrupted by a login never commits its round health.
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
s.BeginSession("reconnected")
if calls != 1 || s.Active() {
t.Fatal("reconnect persisted unfinished battle health")
}
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
result = wire.AppendVarint(request(5), 2, 1)
character = wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 999)
result = wire.AppendBytes(result, 3, character)
_, body, _, err := s.Handle("/BattleEnd", result)
if err != nil {
t.Fatal(err)
}
returned, _, _ := wire.Bytes(body, 3)
hp, _, _ := wire.Varint(returned, 3)
if health[100] != 50 || hp != 50 {
t.Fatalf("committed and returned HP differ: saved=%v response=%d", health, hp)
}
}
-69
View File
@@ -1,69 +0,0 @@
package battle
import (
"bytes"
"errors"
"testing"
"bd2server/internal/server/wire"
)
type huntingFixture struct {
validationError error
settlementError error
pack int
mode, monster, deck uint64
receipt string
settlements int
}
func (h *huntingFixture) ValidateBattle(pack int, mode, monster, deck uint64) error {
h.pack, h.mode, h.monster, h.deck = pack, mode, monster, deck
return h.validationError
}
func (h *huntingFixture) CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) {
h.pack, h.mode, h.monster, h.deck, h.receipt = pack, mode, monster, deck, receipt
h.settlements++
return wire.AppendVarint(nil, 2, 7), [][]byte{wire.AppendVarint(nil, 1, monster)}, h.settlementError
}
func TestHuntingBattleUsesLockedEncounterAndSessionReceipt(t *testing.T) {
pack := 1
s := NewService("", "", nil, func() (int, error) { return pack, nil })
h := &huntingFixture{}
s.AttachHunting(h)
s.BeginSession("login-A")
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 11), 4, 12), 5, huntingGroundMode)
h.validationError = errors.New("locked difficulty")
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
t.Fatal("invalid encounter accepted")
}
h.validationError = nil
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
pack = 2
h.settlementError = errors.New("not enough AP")
end := wire.AppendVarint(request(2), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() {
t.Fatal("failed settlement ended battle")
}
h.settlementError = nil
code, response, _, err := s.Handle("/BattleEnd", end)
if err != nil || code != 15 {
t.Fatalf("settle: %d %v", code, err)
}
if h.pack != 1 || h.mode != huntingGroundMode || h.monster != 11 || h.deck != 12 || h.receipt != "login-A:2" {
t.Fatalf("settled wrong encounter: %+v", h)
}
if _, found, _ := wire.Bytes(response, 4); !found {
t.Fatal("missing monster progress")
}
if bundle, found, _ := wire.Bytes(response, 5); !found || len(bundle) == 0 {
t.Fatal("missing hunting reward")
}
settlements := h.settlements
if _, retry, _, err := s.Handle("/BattleEnd", end); err != nil || !bytes.Equal(retry, response) || h.settlements != settlements {
t.Fatal("completed battle retry settled twice or lost response")
}
}
@@ -1,72 +0,0 @@
package battle
import (
"errors"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
type monsterHuntFixture struct {
enter, end []byte
enterReceipt, endReceipt string
err error
}
func (h *monsterHuntFixture) EnterBattle(request []byte, receipt string) ([]byte, error) {
h.enter, h.enterReceipt = append([]byte(nil), request...), receipt
return wire.AppendBytes(nil, 5, wire.AppendVarint(nil, 1, 11)), h.err
}
func (h *monsterHuntFixture) CompleteBattle(request []byte, receipt string) ([]byte, error) {
h.end, h.endReceipt = append([]byte(nil), request...), receipt
return wire.AppendBytes(nil, 13, wire.AppendVarint(nil, 1, 11)), h.err
}
func TestMonsterHuntBattleUsesSpecialRuntimeAndLeavesFieldHealthAlone(t *testing.T) {
for _, mode := range []uint64{8, 24} {
s := NewService("", "", nil, func() (int, error) { return 1, nil })
h := &monsterHuntFixture{}
s.AttachMonsterHunt(h)
s.BeginSession("login-A")
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
t.Fatal("monster hunt used pack phases")
return nil, nil
}
s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("monster hunt changed field HP"); return nil })
s.AttachMonsterWinMission(func() error { t.Fatal("monster hunt awarded field monster mission"); return nil })
s.AttachTutorialWin(func() error { t.Fatal("monster hunt awarded tutorial progress"); return nil })
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 4, 12), 5, mode), 6, 91)
h.err = errors.New("hunt unavailable")
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
t.Fatal("rejected hunt entered")
}
h.err = nil
code, response, _, err := s.Handle("/BattleEnter", enter)
if err != nil || code != 52 {
t.Fatalf("enter: %d %v", code, err)
}
if _, found, _ := wire.Bytes(response, 5); !found {
t.Fatal("missing hunt user info at enter")
}
end := wire.AppendVarint(wire.AppendVarint(request(2), 2, 1), 7, 1234)
h.err = errors.New("invalid hunt settlement")
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() {
t.Fatal("failed hunt settlement finished battle")
}
h.err = nil
code, response, _, err = s.Handle("/BattleEnd", end)
if err != nil || code != 15 || h.enterReceipt != "login-A:1" || h.endReceipt != h.enterReceipt {
t.Fatalf("end: %d %v %+v", code, err, h)
}
if _, found, _ := wire.Bytes(response, 13); !found {
t.Fatal("missing hunt settlement progress")
}
if _, found, _ := wire.Bytes(response, 5); found {
t.Fatal("hunt used ordinary pack reward bundle")
}
if s.Active() {
t.Fatal("hunt battle still active")
}
}
}
-208
View File
@@ -1,208 +0,0 @@
package battle
import (
"bytes"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func phaseService(t *testing.T) *Service {
t.Helper()
s := &Service{loadPhases: func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
return []gamedata.BattlePhase{{GroupID: 70, ID: 101, DeckID: 11}, {GroupID: 70, ID: 205, DeckID: 12}, {GroupID: 70, ID: 309, DeckID: 13}}, nil
}}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
return s
}
func phaseStart(t *testing.T, s *Service, seq, deck uint64, blue []byte) []byte {
t.Helper()
start := wire.AppendVarint(request(seq), 2, deck)
start = wire.AppendBytes(start, 5, blue)
code, reply, _, err := s.Handle("/BattleStart", start)
if err != nil || code != 14 {
t.Fatalf("start %d: code=%d err=%v", deck, code, err)
}
return reply
}
func TestPhaseLifecyclePreservesClientStateAndRejectsSkipping(t *testing.T) {
s := phaseService(t)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 1234)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(2)); err == nil {
t.Fatal("phase advanced before start")
}
phaseStart(t, s, 3, 11, blue)
commits := 0
s.AttachCommittedHealth(func(map[uint64]uint64) error { commits++; return nil })
win := wire.AppendVarint(request(4), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("early victory accepted")
}
if commits != 0 {
t.Fatal("early victory committed health")
}
code, reply, _, err := s.Handle("/BattlePhaseChange", request(5))
if err != nil || code != 632 {
t.Fatalf("phase response=%d %v", code, err)
}
for field, want := range map[int]uint64{1: 70, 2: 205, 8: 12} {
got, found, err := wire.Varint(reply, field)
if err != nil || !found || got != want {
t.Fatalf("field %d = %d/%v: %v", field, got, found, err)
}
}
if result, found, _ := wire.Bytes(reply, 3); found && len(result) != 0 {
t.Fatal("fabricated verified battle result")
}
verify, _, _ := wire.Varint(reply, 7)
if verify != 0 {
t.Fatal("verification enabled without authoritative combat state")
}
_, replay, _, err := s.Handle("/BattlePhaseChange", request(5))
if err != nil || !bytes.Equal(reply, replay) || s.stateLocked().phase != 1 {
t.Fatal("replay advanced phase")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err == nil {
t.Fatal("skipped unstarted phase")
}
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(7), 2, 13)); err == nil {
t.Fatal("skipped deck accepted")
}
changedBlue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 600)
response := phaseStart(t, s, 8, 12, changedBlue)
got, _, _ := wire.Bytes(response, 2)
if !bytes.Equal(got, changedBlue) || !bytes.Equal(s.stateLocked().initialBlue[0], blue) {
t.Fatal("phase start reset player state or retry baseline")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(9)); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("victory before final start")
}
phaseStart(t, s, 10, 13, changedBlue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(11)); err == nil {
t.Fatal("advanced past final phase")
}
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(12)); err == nil {
t.Fatal("inactive phase request accepted")
}
}
func TestPhaseRetryRestoresFirstDeckAndInitialBlue(t *testing.T) {
s := phaseService(t)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 401), 4, 200)
phaseStart(t, s, 2, 11, blue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 4, 12, wire.AppendVarint(nil, 4, 1))
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(5), 2, 11)); err == nil {
t.Fatal("retry accepted wrong current deck")
}
code, response, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(6), 2, 12))
index, _, _ := wire.Varint(response, 3)
restored, _, _ := wire.Bytes(response, 2)
if err != nil || code != 58 || index != 11 || !bytes.Equal(restored, blue) {
t.Fatalf("retry: code=%d index=%d blue=%x err=%v", code, index, restored, err)
}
state := s.stateLocked()
if state.phase != 0 || state.deck != 11 || state.phaseStarted || state.phaseReply != nil {
t.Fatal("retry did not reset phase state")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(7)); err == nil {
t.Fatal("retry advanced before start")
}
}
func TestMalformedPhaseStartDoesNotConsumeRound(t *testing.T) {
s := phaseService(t)
start := append(wire.AppendVarint(request(2), 2, 11), 0x2a, 0x80)
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
t.Fatal("malformed start accepted")
}
if state := s.stateLocked(); state.round != 0 || state.index != 0 || state.phaseStarted {
t.Fatal("malformed request mutated battle")
}
}
func TestPhaseEntryRejectsLaterDeckWithoutReplacingActiveBattle(t *testing.T) {
s := phaseService(t)
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
enter := wire.AppendVarint(wire.AppendVarint(request(3), 4, 12), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil {
t.Fatal("entered a later phase directly")
}
state := s.stateLocked()
if state.deck != 11 || state.index != 11 || !state.phaseStarted {
t.Fatal("rejected enter replaced active battle")
}
}
func TestOrdinaryBattleRejectsPhaseChange(t *testing.T) {
s := &Service{}
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err == nil {
t.Fatal("ordinary battle accepted phase change")
}
}
func TestPhaseVictoryRewardsOnlyFinalDeck(t *testing.T) {
s := phaseService(t)
var err error
s.inventory, err = player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s.gameDataRoot = "test-root"
s.stateLocked().pack, s.stateLocked().monster = 77, 42
loaded := uint64(0)
s.loadRewards = func(_, _ string, pack int, deck uint64) ([]gamedata.BattleReward, error) {
if pack != 77 {
t.Fatalf("reward pack = %d", pack)
}
loaded = deck
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 1}}, nil
}
blue := wire.AppendVarint(nil, 2, 400)
phaseStart(t, s, 2, 11, blue)
win := wire.AppendVarint(request(3), 2, 1)
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
t.Fatal("early win accepted")
}
if loaded != 0 {
t.Fatal("early win loaded rewards")
}
if _, _, _, err := s.Handle("/BattlePhaseChange", request(4)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 5, 12, blue)
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err != nil {
t.Fatal(err)
}
phaseStart(t, s, 7, 13, blue)
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
t.Fatal(err)
}
if loaded != 13 || len(s.inventory.GrantedItems("pack77:monster42:deck13")) != 1 {
t.Fatalf("final reward deck=%d", loaded)
}
if len(s.inventory.GrantedItems("pack77:monster42:deck11")) != 0 {
t.Fatal("granted first phase reward")
}
}
@@ -1,82 +0,0 @@
package bootstrap
import (
"testing"
"time"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
func TestMaintenance(t *testing.T) {
req := wire.AppendVarint(nil, 1, 2)
req = wire.AppendVarint(req, 2, 8)
response, err := Maintenance(versionconfig.Game(), versionconfig.Bundle(), req)
if err != nil {
t.Fatal(err)
}
market, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("market info missing: %v", err)
}
typ, _, _ := wire.Varint(market, 1)
if typ != 4 {
t.Fatalf("market type does not match response contract: %d", typ)
}
connect, _, _ := wire.Varint(response, 3)
user, _, _ := wire.Varint(response, 4)
if connect != 1 || user == 0 {
t.Fatalf("client would enter update branch: connect=%d user=%d", connect, user)
}
}
func TestServerInfoNoOfficialEndpoints(t *testing.T) {
c := Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: versionconfig.Game(), BundleVer: versionconfig.Bundle()}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
response := ServerInfo(c)
info, _, _ := wire.Bytes(response, 1)
address, _, _ := wire.Bytes(info, 2)
if string(address) != c.BaseURL {
t.Fatalf("game URL: %q", address)
}
if _, exists, _ := wire.Bytes(info, 8); exists {
t.Fatal("unexpected GameData URL")
}
if v, _, _ := wire.Varint(ServerNowTime(time.UnixMilli(1234567)), 1); v != 1234567 {
t.Fatalf("time: %d", v)
}
}
func TestConfigAcceptsHTTPSPublicOrigin(t *testing.T) {
cfg := Config{
BaseURL: "https://bd2.example.com/game/", CDNURL: "https://bd2.example.com/assets/ServerData",
Version: "client", BundleVer: "bundle",
}
if err := cfg.Validate(); err != nil {
t.Fatalf("HTTPS self-hosted server config rejected: %v", err)
}
}
func TestServerInfoIncludesLocalGameData(t *testing.T) {
c := Config{
BaseURL: "http://127.0.0.1:8080/game/",
CDNURL: "http://127.0.0.1:8080/assets/ServerData",
Version: versionconfig.Game(), BundleVer: versionconfig.Bundle(),
GameDataURL: "http://127.0.0.1:8080/assets/GameData",
GameDataVer: "20260921140855",
}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
info, found, err := wire.Bytes(ServerInfo(c), 1)
if err != nil || !found {
t.Fatalf("server info missing: %v", err)
}
url, _, _ := wire.Bytes(info, 8)
version, _, _ := wire.Bytes(info, 9)
if string(url) != c.GameDataURL || string(version) != c.GameDataVer {
t.Fatalf("GameData mismatch: url=%q version=%q", url, version)
}
}
-194
View File
@@ -1,194 +0,0 @@
package calendar
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"fmt"
"unicode/utf8"
)
const MaxFileSize = 8 * 1024 * 1024
const maxRows = 100000
const maxString = 64 * 1024
const headerSize = 46
var magic = []byte{'B', 'D', '2', 'S', 'C', 'H', 0, 0}
type encoder struct {
data []byte
err error
rows uint64
}
func (e *encoder) u64(v uint64) {
if e.room(8) {
e.data = binary.LittleEndian.AppendUint64(e.data, v)
}
}
func (e *encoder) u32(v uint32) {
if e.room(4) {
e.data = binary.LittleEndian.AppendUint32(e.data, v)
}
}
func (e *encoder) b(v bool) {
if !e.room(1) {
return
}
if v {
e.data = append(e.data, 1)
} else {
e.data = append(e.data, 0)
}
}
func (e *encoder) str(v string) {
if e.err != nil {
return
}
if len(v) > maxString || !utf8.ValidString(v) {
e.err = fmt.Errorf("calendar: invalid/oversized UTF8 string")
return
}
if !e.room(4 + len(v)) {
return
}
e.u32(uint32(len(v)))
e.data = append(e.data, v...)
}
func (e *encoder) count(n int) {
if e.err != nil {
return
}
e.rows += uint64(n)
if n > maxRows || e.rows > maxRows {
e.err = fmt.Errorf("calendar: row limit exceeded")
return
}
e.u32(uint32(n))
}
func (e *encoder) room(n int) bool {
if e.err != nil {
return false
}
if n > MaxFileSize-headerSize-len(e.data) {
e.err = fmt.Errorf("calendar: file size limit exceeded")
return false
}
return true
}
type decoder struct {
data []byte
pos int
err error
rows uint64
}
func (d *decoder) take(n int) []byte {
if d.err != nil {
return nil
}
if n < 0 || n > len(d.data)-d.pos {
d.err = fmt.Errorf("calendar: truncated payload")
return nil
}
v := d.data[d.pos : d.pos+n]
d.pos += n
return v
}
func (d *decoder) u64() uint64 {
v := d.take(8)
if len(v) != 8 {
return 0
}
return binary.LittleEndian.Uint64(v)
}
func (d *decoder) u32() uint32 {
v := d.take(4)
if len(v) != 4 {
return 0
}
return binary.LittleEndian.Uint32(v)
}
func (d *decoder) b() bool {
v := d.take(1)
if len(v) != 1 {
return false
}
if v[0] > 1 {
d.err = fmt.Errorf("calendar: invalid boolean/presence")
}
return v[0] == 1
}
func (d *decoder) str() string {
n := d.u32()
if n > maxString {
d.err = fmt.Errorf("calendar: string limit exceeded")
return ""
}
v := d.take(int(n))
if !utf8.Valid(v) {
d.err = fmt.Errorf("calendar: invalid UTF8")
}
return string(v)
}
func (d *decoder) count() int {
n := d.u32()
d.rows += uint64(n)
if n > maxRows || d.rows > maxRows || uint64(n) > uint64(len(d.data)-d.pos) {
d.err = fmt.Errorf("calendar: row count limit/truncation")
return 0
}
return int(n)
}
// MarshalBinary emits format 1's fixed-order record payload and checksum.
func MarshalBinary(m Manifest) ([]byte, error) {
if m.SchemaVersion != 1 {
return nil, fmt.Errorf("calendar: unsupported schema")
}
e := &encoder{}
e.manifest(m)
if e.err != nil {
return nil, e.err
}
if len(e.data) > MaxFileSize-headerSize {
return nil, fmt.Errorf("calendar: file size limit exceeded")
}
out := append([]byte(nil), magic...)
out = binary.LittleEndian.AppendUint16(out, 1)
out = binary.LittleEndian.AppendUint32(out, uint32(len(e.data)))
sum := sha256.Sum256(e.data)
out = append(out, sum[:]...)
return append(out, e.data...), nil
}
// UnmarshalBinary rejects damaged, unsupported, oversized and trailing data.
func UnmarshalBinary(raw []byte) (Manifest, error) {
var m Manifest
if len(raw) < headerSize || len(raw) > MaxFileSize {
return m, fmt.Errorf("calendar: invalid file size")
}
if !bytes.Equal(raw[:8], magic) || binary.LittleEndian.Uint16(raw[8:10]) != 1 {
return m, fmt.Errorf("calendar: unsupported magic/format")
}
n := binary.LittleEndian.Uint32(raw[10:14])
if uint64(n) != uint64(len(raw)-headerSize) {
return m, fmt.Errorf("calendar: payload length mismatch")
}
payload := raw[headerSize:]
sum := sha256.Sum256(payload)
if !bytes.Equal(raw[14:46], sum[:]) {
return m, fmt.Errorf("calendar: checksum mismatch")
}
d := &decoder{data: payload}
m = d.manifest()
if d.err != nil {
return Manifest{}, d.err
}
if d.pos != len(payload) {
return Manifest{}, fmt.Errorf("calendar: trailing payload")
}
m.SchemaVersion = 1
return m, nil
}
-115
View File
@@ -1,115 +0,0 @@
package calendar
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"os"
"path/filepath"
"reflect"
"testing"
)
func TestInstalledBinaryFilesReencodeExactly(t *testing.T) {
paths, e := filepath.Glob("../../../../schedules/*.bd2schedule")
if e != nil || len(paths) != 6 {
t.Fatalf("installed files=%d err=%v", len(paths), e)
}
for _, path := range paths {
raw, e := os.ReadFile(path)
if e != nil {
t.Fatal(e)
}
m, e := UnmarshalBinary(raw)
if e != nil {
t.Fatal(e)
}
actual, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
if !bytes.Equal(raw, actual) {
t.Fatalf("canonical bytes changed %s", path)
}
}
}
func TestBinaryRejectsInvalidPresence(t *testing.T) {
raw, e := MarshalBinary(baseManifest())
if e != nil {
t.Fatal(e)
}
d := &decoder{data: raw[headerSize:]}
d.str()
d.str()
d.str()
d.count()
d.count()
d.count()
raw[headerSize+d.pos] = 2
sum := sha256.Sum256(raw[headerSize:])
copy(raw[14:46], sum[:])
if _, e = UnmarshalBinary(raw); e == nil {
t.Fatal("invalid optional presence accepted despite valid checksum")
}
}
func TestBinaryRoundTripAllRecords(t *testing.T) {
m := baseManifest()
m.Events = []Event{{UID: 5, Type: 11, ID: 7, Start: "2026-01-01T00:00:00Z", End: "2026-02-01T00:00:00Z"}}
m.Gacha = []Gacha{{GroupID: 2, FreeCountBonus: true}}
m.StepUps = []Gacha{{GroupID: 3}}
m.Regular = &Regular{Contents: []Content{{ID: 4, Current: Season{ID: 8, Error: true}, Next: Season{ID: 9, Return: true}}}}
m.MonsterHunt = &MonsterHunt{Seasons: []Hunt{{HuntID: 6, CostumeBanIDs: []uint64{5}, BurstBanIDs: []uint64{7}, IndependentFlag: true}}, History: []HuntHistory{{Season: 5, HuntID: 6, Hidden: true}}}
m.CashProducts = []CashProduct{{GroupID: 1, ProductID: 2, EventIndex: 3}}
m.EventHubs = []EventHub{{HubID: 4, Settings: []HubSetting{{Slot: 5, EventUIDs: []uint64{6, 7}}}}}
m.MiniGameHubs = []MiniGameHub{{Slot: 8, EventUID: 5}}
raw, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
actual, e := UnmarshalBinary(raw)
if e != nil {
t.Fatal(e)
}
if !reflect.DeepEqual(actual, m) {
t.Fatalf("roundtrip changed records: %+v", actual)
}
}
func TestBinaryRejectsCorruptionLimitsAndTrailing(t *testing.T) {
raw, e := MarshalBinary(baseManifest())
if e != nil {
t.Fatal(e)
}
for n := range len(raw) {
if _, e = UnmarshalBinary(raw[:n]); e == nil {
t.Fatalf("accepted truncation %d", n)
}
}
for _, offset := range []int{0, 8, 10, 14, 46} {
bad := append([]byte(nil), raw...)
bad[offset] ^= 0xff
if _, e = UnmarshalBinary(bad); e == nil {
t.Fatalf("accepted corruption %d", offset)
}
}
if _, e = UnmarshalBinary(append(raw, 0)); e == nil {
t.Fatal("accepted trailing data")
}
m := baseManifest()
m.Revision = string(make([]byte, maxString+1))
if _, e = MarshalBinary(m); e == nil {
t.Fatal("oversized string accepted")
}
m = baseManifest()
m.Events = make([]Event, maxRows+1)
if _, e = MarshalBinary(m); e == nil {
t.Fatal("oversized count accepted")
}
bad := append([]byte(nil), raw...)
binary.LittleEndian.PutUint32(bad[46:50], ^uint32(0))
sum := sha256.Sum256(bad[46:])
copy(bad[14:46], sum[:])
if _, e = UnmarshalBinary(bad); e == nil {
t.Fatal("overflow string length accepted")
}
}
-133
View File
@@ -1,133 +0,0 @@
package calendar
import (
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"fmt"
)
// ValidateDesign checks playable identities against their domain's installed
// GameData. Announcement-only types 3/6/14/15/16/18 have no local gameplay
// design resolver and receive only the manifest's protocol/format validation.
func (s *Set) ValidateDesign(root, version string) error {
cash, e := gamedata.LoadCashCatalog(root, version)
if e != nil {
return e
}
keys := map[gamedata.CashProductKey]bool{}
for _, v := range cash.Products {
keys[v.Key] = true
}
for _, v := range s.CashProducts {
if !keys[gamedata.CashProductKey{GroupID: v.GroupID, ProductID: v.ProductID, SaleGroup: v.SaleGroup}] {
return fmt.Errorf("calendar: cash product %d/%d/%d missing GameData", v.GroupID, v.ProductID, v.SaleGroup)
}
}
if s.MonsterHunt != nil {
ids, e := gamedata.LoadMonsterHuntIDs(root, version)
if e != nil {
return e
}
for _, v := range s.MonsterHunt.Seasons {
if !ids[v.HuntID] {
return fmt.Errorf("calendar: active/playable hunt %d missing GameData", v.HuntID)
}
}
}
play, e := gamedata.LoadEventPlayCatalog(root, version)
if e != nil {
return e
}
tasks, e := gamedata.LoadEventTasksDesign(root, version)
if e != nil {
return e
}
exchange, e := gamedata.LoadEventExchangeCatalog(root, version)
if e != nil {
return e
}
actions, e := gamedata.LoadEventActionsDesign(root, version)
if e != nil {
return e
}
packs, e := gamedata.LoadCalendarPackIDs(root, version)
if e != nil {
return e
}
for _, h := range s.EventHubs {
if _, e = play.Row("PackEventHubTable", 14, h.HubID); e != nil {
return fmt.Errorf("calendar: hub %d: %w", h.HubID, e)
}
}
if err := s.validateMiniHubBindings(play); err != nil {
return err
}
uid := map[uint64]events.Schedule{}
for _, v := range s.Events {
if v.UID != 0 {
uid[v.UID] = v
}
}
for _, h := range s.MiniGameHubs {
v, ok := uid[h.EventUID]
if !ok || v.Type != 11 {
return fmt.Errorf("calendar: mini game slot %d references non-minigame event %d", h.Slot, h.EventUID)
}
if _, e := play.Row("PackEventMiniGameTable", 8, v.ID); e != nil {
return e
}
}
for _, v := range s.Events {
valid := true
var err error
switch v.Type {
case 0:
_, valid = tasks.Attendance[v.ID]
case 1:
valid = false
for k := range tasks.LimitRewards {
if k[0] == v.ID {
valid = true
break
}
}
case 4:
_, valid = tasks.MissionGroups[v.ID]
case 5:
_, valid = tasks.Passes[v.ID]
case 7:
_, valid = exchange.Groups[v.ID]
case 8:
valid = packs[v.ID]
case 9:
_, err = play.Row("PackEventBattleGroupTable", 3, v.ID)
case 10:
_, err = play.Row("PackEventStoryGroupTable", 1, v.ID)
case 11:
_, err = play.Row("PackEventMiniGameTable", 8, v.ID)
case 12, 13, 17, 19:
_, err = gamedata.LoadEventGame(root, version, v.Type, v.ID)
case 20:
_, valid = actions.Row("TacticsBingoGroupTable", 3, v.ID)
case 21:
_, valid = actions.Row("FieldSpawnEventTable", 5, v.ID)
case 22:
_, valid = actions.Row("FireworksTable", 4, v.ID)
case 23:
_, valid = actions.Row("VotingEventTable", 6, v.ID)
case 24:
_, valid = actions.Row("FriendshipSpecialEpisodeTable", 5, v.ID)
case 25:
id := v.SubID
if id == 0 {
id = v.ID
}
_, valid = actions.Row("CafeteriaEventTable", 5, id)
}
if !valid || err != nil {
return fmt.Errorf("calendar: event uid %d type %d id %d missing GameData: %v", v.UID, v.Type, v.ID, err)
}
}
return nil
}
-138
View File
@@ -1,138 +0,0 @@
package calendar
import (
"bd2server/internal/server/readonly"
"bd2server/internal/server/wire"
"os"
"path/filepath"
"testing"
)
func writeManifest(t *testing.T, dir, name string, m Manifest) {
t.Helper()
raw, e := MarshalBinary(m)
if e != nil {
t.Fatal(e)
}
if e = os.WriteFile(filepath.Join(dir, name), raw, 0600); e != nil {
t.Fatal(e)
}
}
func baseManifest() Manifest {
return Manifest{SchemaVersion: 1, Revision: "2026-10-05", GameVersion: "2.35.10", GameDataVersion: "2.35.10"}
}
func TestInstalledCalendarsContainAllRuntimeDomains(t *testing.T) {
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if e != nil {
t.Fatal(e)
}
if len(set.Events) != 54 || len(set.GachaSeed.Schedules) != 11 || len(set.GachaSeed.StepUps) != 2 || set.RegularService == nil || len(set.RegularService.Contents) != 9 || set.MonsterHunt == nil || len(set.MonsterHunt.History) != 80 || len(set.CashProducts) != 61 || len(set.EventHubs) != 4 || len(set.MiniGameHubs) != 6 {
t.Fatalf("installed calendar omitted a domain: events=%d gacha=%d steps=%d cash=%d hubs=%d mini=%d", len(set.Events), len(set.GachaSeed.Schedules), len(set.GachaSeed.StepUps), len(set.CashProducts), len(set.EventHubs), len(set.MiniGameHubs))
}
var mooncake, chained *EventHub
for i := range set.EventHubs {
h := &set.EventHubs[i]
if h.UID == 3 && h.HubID == 1003 {
mooncake = h
}
if h.UID == 76 && h.HubID == 59 {
chained = h
}
}
if mooncake == nil || mooncake.PlayEnd != "2026-09-30T23:59:59.000Z" || len(mooncake.Settings) != 3 || mooncake.Settings[2].EventUIDs[0] != 10000034 {
t.Fatal("published Mooncake Wishes calendar omitted its deferred board slot")
}
if chained == nil || len(chained.Settings) != 6 || chained.Settings[2].EventUIDs[0] != 80 {
t.Fatal("published Chained Soldier hub omitted its event shop")
}
}
func TestMultiFileCalendarAtomicDeterministicAndIndependentRevision(t *testing.T) {
dir := t.TempDir()
a := baseManifest()
a.Revision = "a"
a.Gacha = []Gacha{{GroupID: 9, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
writeManifest(t, dir, "z.bd2schedule", a)
b := baseManifest()
b.Revision = "b"
b.Gacha = []Gacha{{GroupID: 1, Start: "2027-01-01T09:00:00+09:00", End: "2027-01-02T09:00:00+09:00"}, {GroupID: 9, Start: "2026-10-02T00:00:00Z", End: "2026-10-03T00:00:00Z"}}
writeManifest(t, dir, "a.bd2schedule", b)
set, e := LoadDirectory(dir, "2.35.10", "2.35.10")
if e != nil {
t.Fatal(e)
}
if len(set.GachaSeed.Schedules) != 3 || set.GachaSeed.Schedules[0].GroupID != 1 || set.Revisions[0] != "b" {
t.Fatalf("unexpected merged set %+v", set)
}
b.Revision = "changed without game update"
b.Gacha[0].End = "2027-01-03T09:00:00+09:00"
writeManifest(t, dir, "a.bd2schedule", b)
changed, e := LoadDirectory(dir, "2.35.10", "2.35.10")
if e != nil || changed.GachaSeed.Schedules[0].EndTime == set.GachaSeed.Schedules[0].EndTime {
t.Fatalf("calendar update ignored: %v", e)
}
if e = os.WriteFile(filepath.Join(dir, "broken.bd2schedule"), []byte("broken binary"), 0600); e != nil {
t.Fatal(e)
}
if partial, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil || partial != nil {
t.Fatal("bad file returned partial calendar")
}
}
func TestStrictCalendarRejectsConflictsAndInvalidData(t *testing.T) {
for _, mutate := range []func(*Manifest){func(m *Manifest) { m.GameDataVersion = "wrong" }, func(m *Manifest) { m.Gacha[0].Start = "2026-10-01" }, func(m *Manifest) { m.Gacha[0].End = m.Gacha[0].Start }, func(m *Manifest) { m.Gacha = append(m.Gacha, m.Gacha[0]) }, func(m *Manifest) { m.Gacha[0].GroupID = 1 << 32 }} {
dir := t.TempDir()
m := baseManifest()
m.Gacha = []Gacha{{GroupID: 1, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
mutate(&m)
writeManifest(t, dir, "one.bd2schedule", m)
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
t.Fatal("invalid calendar accepted")
}
}
dir := t.TempDir()
m := baseManifest()
m.Events = []Event{{UID: 77, Type: 12, ID: 3, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
writeManifest(t, dir, "a.bd2schedule", m)
writeManifest(t, dir, "b.bd2schedule", m)
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
t.Fatal("duplicate UID across files accepted")
}
}
func TestReadonlyCalendarReplacementPreservesStaticResponses(t *testing.T) {
set := &Set{CashProducts: []CashProduct{{GroupID: 11, ProductID: 22, EventIndex: 99}}, MonsterHunt: &MonsterHunt{StartRegularSeason: 5, History: []HuntHistory{{Season: 78, HuntID: 73, Hidden: true}}}}
old := &readonly.Seed{Version: "2.35.10", Responses: map[string]readonly.Response{"/Static": {PacketCode: 17, Fields: []readonly.Field{scalar(1, 10)}}, "/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{scalar(2, 1)}}}}
result, e := set.ApplyReadonly(old)
if e != nil {
t.Fatal(e)
}
if len(old.Responses["/CashShopInfo"].Fields) != 1 || result.Responses["/Static"].PacketCode != 17 {
t.Fatal("source seed changed/static endpoint lost")
}
_, raw, handled, e := result.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
if e != nil || !handled {
t.Fatal(e)
}
p, found, e := wire.Bytes(raw, 1)
if e != nil || !found {
t.Fatal("missing product")
}
id, _, _ := wire.Varint(p, 8)
if id != 99 {
t.Fatalf("event index=%d", id)
}
if _, found, _ = wire.Varint(raw, 2); found {
t.Fatal("stale reset timestamp retained")
}
}
func TestInstalledCalendarRealGameData(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if e != nil {
t.Fatal(e)
}
if e = set.ValidateDesign(root, "20260923193640"); e != nil {
t.Fatal(e)
}
}
-113
View File
@@ -1,113 +0,0 @@
package calendar
import (
"fmt"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
// validateMiniHubBindings rejects invalid published routes at startup instead
// of letting a client open an incompatible prefab or use another activity UID.
func (s *Set) validateMiniHubBindings(design *gamedata.EventPlayCatalog) error {
uid := make(map[uint64]events.Schedule)
for _, v := range s.Events {
if v.UID != 0 {
uid[v.UID] = v
}
}
value := func(row []byte, field int) uint64 { v, _, _ := wire.Varint(row, field); return v }
for _, hub := range s.EventHubs {
table, err := design.Row("PackEventHubTable", 14, hub.HubID)
if err != nil {
return err
}
if value(table, 13) != 1 {
continue
}
if hub.UID == 0 {
return fmt.Errorf("calendar: mini hub %d requires a nonzero UID", hub.HubID)
}
start, err := timestamp(hub.Start)
if err != nil {
return err
}
playEnd, err := timestamp(hub.PlayEnd)
if err != nil {
return err
}
end, err := timestamp(hub.End)
if err != nil {
return err
}
for _, binding := range hub.Settings {
var slot []byte
for _, row := range design.Rows("PackEventListTable", 6, hub.HubID) {
if value(row, 11) != binding.Slot {
continue
}
if slot != nil {
return fmt.Errorf("calendar: mini hub %d slot %d is ambiguous in GameData", hub.HubID, binding.Slot)
}
slot = row
}
if slot == nil {
return fmt.Errorf("calendar: mini hub %d slot %d missing GameData", hub.HubID, binding.Slot)
}
contentType, contentID := value(slot, 9), value(slot, 7)
if binding.ProgressType != contentType {
return fmt.Errorf("calendar: mini hub %d slot %d content type %d, want %d", hub.HubID, binding.Slot, binding.ProgressType, contentType)
}
eventType, supported := gamedata.MiniHubEventType(contentType)
// Mini stories and NPC quizzes have their own slot UID namespace,
// distinct from Define_EventType (13 there means bingo). Their group
// and availability derive from this static slot and the hub window.
if contentType == 13 || contentType == 14 {
if len(binding.EventUIDs) != 1 || binding.EventUIDs[0] == 0 {
return fmt.Errorf("calendar: mini hub %d slot %d requires one local content UID", hub.HubID, binding.Slot)
}
if _, collision := uid[binding.EventUIDs[0]]; collision {
return fmt.Errorf("calendar: mini hub %d slot %d content UID collides with global event", hub.HubID, binding.Slot)
}
if value(slot, 4) != 0 {
return fmt.Errorf("calendar: mini hub %d story/quiz slot has invalid end type", hub.HubID)
}
continue
}
if !supported {
return fmt.Errorf("calendar: mini hub %d slot %d content type %d has no supported scheduled route", hub.HubID, binding.Slot, contentType)
}
endType := value(slot, 4)
if endType > 1 {
return fmt.Errorf("calendar: mini hub %d slot %d unsupported end type %d", hub.HubID, binding.Slot, endType)
}
matches := 0
for _, id := range binding.EventUIDs {
child, ok := uid[id]
if !ok || child.Type != eventType || child.ID != contentID || child.SubID != 0 {
return fmt.Errorf("calendar: mini hub %d slot %d references incompatible event UID %d", hub.HubID, binding.Slot, id)
}
childStart, childEnd := child.Start, child.End
if childStart < int64(start) {
childStart = int64(start)
}
if childEnd > int64(end) {
childEnd = int64(end)
}
// Project policy: EndDateType=0 closes the slot at PlayEnd;
// EndDateType=1 allows it through the final hub End window.
if endType == 0 && childEnd > int64(playEnd) {
childEnd = int64(playEnd)
}
if childStart < childEnd {
matches++
}
}
if matches != 1 {
return fmt.Errorf("calendar: mini hub %d slot %d has %d schedules in its window, want one", hub.HubID, binding.Slot, matches)
}
}
}
return nil
}
@@ -1,81 +0,0 @@
package calendar
import (
"testing"
"time"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
func TestMiniHubPublishedRoutesValidateIndependentEnumsAndWindows(t *testing.T) {
row := func(fields ...uint64) []byte {
var b []byte
for i := 0; i < len(fields); i += 2 {
b = wire.AppendVarint(b, int(fields[i]), fields[i+1])
}
return b
}
start := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
format := func(d time.Duration) string { return start.Add(d).Format(time.RFC3339) }
design := &gamedata.EventPlayCatalog{Tables: map[string][][]byte{
"PackEventHubTable": {row(14, 7, 13, 1)},
"PackEventListTable": {row(6, 7, 10, 42, 11, 5, 9, 12, 7, 99, 4, 1)},
}}
fresh := func() *Set {
return &Set{
Events: []events.Schedule{{UID: 21, Type: 19, ID: 99, Start: start.UnixMilli(), End: start.Add(48 * time.Hour).UnixMilli()}},
EventHubs: []EventHub{{UID: 1, HubID: 7, Start: format(0), PlayEnd: format(24 * time.Hour), End: format(48 * time.Hour), Settings: []HubSetting{{Slot: 5, ProgressType: 12, EventUIDs: []uint64{21}}}}},
}
}
if err := fresh().validateMiniHubBindings(design); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
change func(*Set)
}{
{"wrong content enum", func(s *Set) { s.EventHubs[0].Settings[0].ProgressType = 19 }},
{"table id is not slot index", func(s *Set) { s.EventHubs[0].Settings[0].Slot = 42 }},
{"missing UID", func(s *Set) { s.EventHubs[0].Settings[0].EventUIDs = []uint64{22} }},
{"wrong event enum", func(s *Set) { s.Events[0].Type = 12 }},
{"wrong design id", func(s *Set) { s.Events[0].ID = 7 }},
{"wrong sub identity", func(s *Set) { s.Events[0].SubID = 7 }},
{"no window overlap", func(s *Set) {
s.Events[0].Start = start.Add(72 * time.Hour).UnixMilli()
s.Events[0].End = start.Add(96 * time.Hour).UnixMilli()
}},
{"ambiguous schedule", func(s *Set) {
other := s.Events[0]
other.UID = 22
s.Events = append(s.Events, other)
s.EventHubs[0].Settings[0].EventUIDs = append(s.EventHubs[0].Settings[0].EventUIDs, 22)
}},
} {
t.Run(test.name, func(t *testing.T) {
s := fresh()
test.change(s)
if err := s.validateMiniHubBindings(design); err == nil {
t.Fatal("invalid published route accepted")
}
})
}
// A future hub must validate without comparing its dates to the current clock.
design.Tables["PackEventListTable"][0] = row(6, 7, 10, 42, 11, 5, 9, 13, 7, 99, 4, 1)
s := fresh()
s.EventHubs[0].Settings[0].ProgressType = 13
if err := s.validateMiniHubBindings(design); err == nil {
t.Fatal("quiz borrowed global bingo event type")
}
design.Tables["PackEventListTable"][0] = row(6, 7, 10, 42, 11, 11, 9, 13, 7, 99)
s = fresh()
s.EventHubs[0].Settings = []HubSetting{{Slot: 11, ProgressType: 13, EventUIDs: []uint64{10000032}}}
if err := s.validateMiniHubBindings(design); err != nil {
t.Fatalf("independent quiz content UID rejected: %v", err)
}
s.EventHubs[0].Settings[0].EventUIDs = []uint64{21}
if err := s.validateMiniHubBindings(design); err == nil {
t.Fatal("quiz content UID collided with global event")
}
}
@@ -1,41 +0,0 @@
package calendar
import "testing"
// Current-version publication facts were verified against the client schemas,
// GameData identities and 2026-10-05 schedule comparison. No capture is read.
func TestPublishedRegularAndGachaCalendarCompleteness(t *testing.T) {
set, err := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
if err != nil {
t.Fatal(err)
}
want := map[uint64][2]uint64{1: {163, 164}, 2: {58, 58}, 3: {131, 999999}, 4: {877, 999999}, 5: {30, 31}, 6: {27, 28}, 7: {15, 999999}, 8: {44, 45}, 9: {2, 999999}}
if len(set.RegularService.Contents) != len(want) || set.RegularService.CalculateMilliseconds != 32400000 {
t.Fatal("regular content calendar incomplete")
}
for _, c := range set.RegularService.Contents {
if want[c.ID] != [2]uint64{c.Current.ID, c.Next.ID} {
t.Fatalf("content %d stale seasons %d/%d", c.ID, c.Current.ID, c.Next.ID)
}
}
current := map[uint64]bool{166: true, 30010: true, 30011: true, 206: true, 205: true, 208: true, 153: true, 72: true, 71: true, 207: true}
for _, g := range set.GachaSeed.Schedules {
if current[g.GroupID] {
delete(current, g.GroupID)
if g.EndTime != 1791417599000 {
t.Fatalf("gacha %d truncated window", g.GroupID)
}
}
}
if len(current) != 0 {
t.Fatalf("missing published gacha groups %v", current)
}
if len(set.GachaSeed.StepUps) != 2 {
t.Fatal("step-up schedule omitted")
}
for _, g := range set.GachaSeed.StepUps {
if g.GroupID != 29 && g.GroupID != 30 || g.EndTime != 1791417599000 {
t.Fatalf("step-up schedule incorrect %+v", g)
}
}
}
-312
View File
@@ -1,312 +0,0 @@
package calendar
import "bd2server/internal/server/schedule"
func (e *encoder) manifest(v Manifest) {
e.str(v.Revision)
e.str(v.GameVersion)
e.str(v.GameDataVersion)
e.count(len(v.Events))
for _, item := range v.Events {
e.event(item)
}
e.count(len(v.Gacha))
for _, item := range v.Gacha {
e.gacha(item)
}
e.count(len(v.StepUps))
for _, item := range v.StepUps {
e.gacha(item)
}
e.b(v.Regular != nil)
if v.Regular != nil {
e.regular(*v.Regular)
}
e.b(v.MonsterHunt != nil)
if v.MonsterHunt != nil {
e.monsterhunt(*v.MonsterHunt)
}
e.count(len(v.CashProducts))
for _, item := range v.CashProducts {
e.cashproduct(item)
}
e.count(len(v.EventHubs))
for _, item := range v.EventHubs {
e.eventhub(item)
}
e.count(len(v.MiniGameHubs))
for _, item := range v.MiniGameHubs {
e.minigamehub(item)
}
}
func (d *decoder) manifest() Manifest {
var v Manifest
v.Revision = d.str()
v.GameVersion = d.str()
v.GameDataVersion = d.str()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Events = append(v.Events, d.event())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Gacha = append(v.Gacha, d.gacha())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.StepUps = append(v.StepUps, d.gacha())
}
if d.b() {
item := d.regular()
v.Regular = &item
}
if d.b() {
item := d.monsterhunt()
v.MonsterHunt = &item
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.CashProducts = append(v.CashProducts, d.cashproduct())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.EventHubs = append(v.EventHubs, d.eventhub())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.MiniGameHubs = append(v.MiniGameHubs, d.minigamehub())
}
return v
}
func (e *encoder) event(v Event) {
e.u64(v.UID)
e.u64(v.Type)
e.u64(v.ID)
e.u64(v.SubID)
e.str(v.Start)
e.str(v.End)
}
func (d *decoder) event() Event {
var v Event
v.UID = d.u64()
v.Type = d.u64()
v.ID = d.u64()
v.SubID = d.u64()
v.Start = d.str()
v.End = d.str()
return v
}
func (e *encoder) gacha(v Gacha) {
e.u64(v.GroupID)
e.str(v.Start)
e.str(v.End)
e.b(v.FreeCountBonus)
e.b(v.CashCountBonus)
}
func (d *decoder) gacha() Gacha {
var v Gacha
v.GroupID = d.u64()
v.Start = d.str()
v.End = d.str()
v.FreeCountBonus = d.b()
v.CashCountBonus = d.b()
return v
}
func (e *encoder) season(v Season) {
e.u64(v.ID)
e.str(v.Start)
e.str(v.End)
e.u64(v.RankRewardGroupID)
e.b(v.Error)
e.b(v.Return)
}
func (d *decoder) season() Season {
var v Season
v.ID = d.u64()
v.Start = d.str()
v.End = d.str()
v.RankRewardGroupID = d.u64()
v.Error = d.b()
v.Return = d.b()
return v
}
func (e *encoder) content(v Content) {
e.u64(v.ID)
e.season(v.Current)
e.season(v.Next)
}
func (d *decoder) content() Content {
var v Content
v.ID = d.u64()
v.Current = d.season()
v.Next = d.season()
return v
}
func (e *encoder) regular(v Regular) {
e.u64(v.CalculateMilliseconds)
e.count(len(v.Contents))
for _, item := range v.Contents {
e.content(item)
}
e.count(len(v.Regular))
for _, item := range v.Regular {
e.regularseason(item)
}
}
func (d *decoder) regular() Regular {
var v Regular
v.CalculateMilliseconds = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Contents = append(v.Contents, d.content())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Regular = append(v.Regular, d.regularseason())
}
return v
}
func (e *encoder) hunt(v Hunt) {
e.season(v.Season)
e.u64(v.HuntID)
e.u64(v.InfoOpenDay)
e.str(v.CalculateEndAt)
e.b(v.ErrorFlag)
e.b(v.IndependentFlag)
e.u64(v.RankRewardGroupID)
e.count(len(v.CostumeBanIDs))
for _, item := range v.CostumeBanIDs {
e.u64(item)
}
e.count(len(v.BurstBanIDs))
for _, item := range v.BurstBanIDs {
e.u64(item)
}
}
func (d *decoder) hunt() Hunt {
var v Hunt
v.Season = d.season()
v.HuntID = d.u64()
v.InfoOpenDay = d.u64()
v.CalculateEndAt = d.str()
v.ErrorFlag = d.b()
v.IndependentFlag = d.b()
v.RankRewardGroupID = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.CostumeBanIDs = append(v.CostumeBanIDs, d.u64())
}
for n := d.count(); n > 0 && d.err == nil; n-- {
v.BurstBanIDs = append(v.BurstBanIDs, d.u64())
}
return v
}
func (e *encoder) hunthistory(v HuntHistory) {
e.u64(v.Season)
e.u64(v.HuntID)
e.b(v.ErrorFlag)
e.b(v.Hidden)
}
func (d *decoder) hunthistory() HuntHistory {
var v HuntHistory
v.Season = d.u64()
v.HuntID = d.u64()
v.ErrorFlag = d.b()
v.Hidden = d.b()
return v
}
func (e *encoder) monsterhunt(v MonsterHunt) {
e.count(len(v.Seasons))
for _, item := range v.Seasons {
e.hunt(item)
}
e.u64(v.StartRegularSeason)
e.count(len(v.History))
for _, item := range v.History {
e.hunthistory(item)
}
}
func (d *decoder) monsterhunt() MonsterHunt {
var v MonsterHunt
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Seasons = append(v.Seasons, d.hunt())
}
v.StartRegularSeason = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.History = append(v.History, d.hunthistory())
}
return v
}
func (e *encoder) cashproduct(v CashProduct) {
e.u64(v.GroupID)
e.u64(v.ProductID)
e.u64(v.SaleGroup)
e.str(v.Start)
e.str(v.End)
e.u64(v.EndDelayMinutes)
e.u64(v.EventIndex)
}
func (d *decoder) cashproduct() CashProduct {
var v CashProduct
v.GroupID = d.u64()
v.ProductID = d.u64()
v.SaleGroup = d.u64()
v.Start = d.str()
v.End = d.str()
v.EndDelayMinutes = d.u64()
v.EventIndex = d.u64()
return v
}
func (e *encoder) hubsetting(v HubSetting) {
e.u64(v.Slot)
e.u64(v.ProgressType)
e.count(len(v.EventUIDs))
for _, item := range v.EventUIDs {
e.u64(item)
}
}
func (d *decoder) hubsetting() HubSetting {
var v HubSetting
v.Slot = d.u64()
v.ProgressType = d.u64()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.EventUIDs = append(v.EventUIDs, d.u64())
}
return v
}
func (e *encoder) eventhub(v EventHub) {
e.u64(v.UID)
e.u64(v.HubID)
e.str(v.Start)
e.str(v.PlayEnd)
e.str(v.End)
e.count(len(v.Settings))
for _, item := range v.Settings {
e.hubsetting(item)
}
}
func (d *decoder) eventhub() EventHub {
var v EventHub
v.UID = d.u64()
v.HubID = d.u64()
v.Start = d.str()
v.PlayEnd = d.str()
v.End = d.str()
for n := d.count(); n > 0 && d.err == nil; n-- {
v.Settings = append(v.Settings, d.hubsetting())
}
return v
}
func (e *encoder) minigamehub(v MiniGameHub) {
e.u64(v.Slot)
e.u64(v.EventUID)
e.u64(v.ProgressType)
}
func (d *decoder) minigamehub() MiniGameHub {
var v MiniGameHub
v.Slot = d.u64()
v.EventUID = d.u64()
v.ProgressType = d.u64()
return v
}
func (e *encoder) regularseason(v schedule.RegularSeason) {
e.u64(v.ContentID)
e.u64(v.Season)
}
func (d *decoder) regularseason() schedule.RegularSeason {
var v schedule.RegularSeason
v.ContentID = d.u64()
v.Season = d.u64()
return v
}
-142
View File
@@ -1,142 +0,0 @@
package commerce
import (
"crypto/sha256"
"encoding/json"
"fmt"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type attendanceHandler interface {
Handle(string, []byte) (int, []byte, bool, error)
}
// AttendanceHandler preserves the original event progress response and adds
// subscription grants in extension fields understood by the commerce plugin.
// All operations execute inside the transport's account transaction.
type AttendanceHandler struct {
Events attendanceHandler
Economy *EntitlementEconomy
LoginPasses *LoginPasses
Store stateio.Store
}
type attendanceReceipt struct {
Digest string `json:"digest"`
Bundle []byte `json:"bundle"`
}
func (h AttendanceHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
return h.HandleSession(path, request, "")
}
func (h AttendanceHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
if path != "/Attendance" {
return 0, nil, false, nil
}
if session == "" || h.Events == nil || h.Economy == nil || h.Store == nil {
return 0, nil, true, fmt.Errorf("commerce: attendance dependencies/session unavailable")
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, nil, true, fmt.Errorf("commerce: missing attendance sequence")
}
code, response, handled, err := h.Events.Handle(path, request)
if err != nil || !handled {
return code, response, handled, err
}
// The event handler may already have granted ordinary attendance rewards.
// The client accepts exactly one reward envelope, so combine every grant in
// execution order under this wrapper's replay identity.
response, eventBundle, err := takeAttendanceRewardEnvelope(response)
if err != nil {
return code, nil, true, err
}
key := fmt.Sprintf("commerce_attendance:%x:%d", sha256.Sum256([]byte(session)), seq)
digest := fmt.Sprintf("%x", sha256.Sum256(request))
var receipt attendanceReceipt
previous, err := h.Store.Load(key)
if err != nil {
return code, nil, true, err
}
var loginBundle []byte
if h.LoginPasses != nil {
var infos [][]byte
loginBundle, infos, err = h.LoginPasses.ClaimAndInfo(key)
if err != nil {
return code, nil, true, err
}
for _, info := range infos {
response = wire.AppendBytes(response, 6, info)
}
}
if previous != nil {
if err = json.Unmarshal(previous, &receipt); err != nil || receipt.Digest != digest {
return code, nil, true, fmt.Errorf("commerce: conflicting attendance replay")
}
} else {
receipt.Digest = digest
subscriptionBundle, err := h.Economy.ClaimSubscriptions(key)
if err != nil {
return code, nil, true, err
}
// Preserve events -> login pass -> subscription execution order and all
// repeated reward entries in the single client envelope.
receipt.Bundle = append(receipt.Bundle, eventBundle...)
receipt.Bundle = append(receipt.Bundle, loginBundle...)
receipt.Bundle = append(receipt.Bundle, subscriptionBundle...)
raw, err := json.Marshal(receipt)
if err != nil {
return code, nil, true, err
}
if err = h.Store.Save(key, raw); err != nil {
return code, nil, true, err
}
}
response, err = h.Economy.MergeAttendance(response)
if err != nil {
return code, nil, true, err
}
if len(receipt.Bundle) != 0 {
response = wire.AppendBytes(response, 1001, receipt.Bundle)
response = wire.AppendString(response, 1002, key)
}
return code, response, true, nil
}
// Strip the child envelope before adding the combined one. Keep native and
// unrelated unknown fields byte-for-byte; reject ambiguous child envelopes
// rather than returning a response the client would silently ignore.
func takeAttendanceRewardEnvelope(response []byte) ([]byte, []byte, error) {
var native, bundle []byte
var receipt string
var hasBundle, hasReceipt bool
err := wire.Walk(response, func(f wire.Field) error {
switch f.Number {
case 1001:
if f.Type != 2 || hasBundle {
return fmt.Errorf("commerce: ambiguous attendance reward bundle")
}
hasBundle = true
bundle = append([]byte(nil), f.Value...)
case 1002:
if f.Type != 2 || hasReceipt {
return fmt.Errorf("commerce: ambiguous attendance reward receipt")
}
hasReceipt = true
receipt = string(f.Value)
default:
native = append(native, response[f.Start:f.End]...)
}
return nil
})
if err != nil {
return nil, nil, err
}
if hasBundle != hasReceipt || hasReceipt && (receipt == "" || len(receipt) > 1024) {
return nil, nil, fmt.Errorf("commerce: incomplete attendance reward envelope")
}
return native, bundle, nil
}
@@ -1,256 +0,0 @@
package commerce
import (
"bytes"
"testing"
"time"
"bd2server/internal/server/events"
"bd2server/internal/server/eventtasks"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
type originalAttendance struct{}
func (originalAttendance) Handle(path string, _ []byte) (int, []byte, bool, error) {
return 0, wire.AppendVarint(nil, 55, 123), path == "/Attendance", nil
}
type attendanceBalances struct {
balance uint64
calls int
}
func (e *attendanceBalances) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) {
e.calls++
var bundle []byte
for _, r := range rewards {
e.balance += r.Count
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count}))
}
return bundle, nil
}
func TestAttendanceMailsOrdinaryRewardsAndCombinesLoginPassAndSubscription(t *testing.T) {
e, items, _, _, now := entitlementFixture(t)
// eventtasks uses the production clock; align the commerce fixture with it
// while buying the subscription on the previous reset day.
*now = time.Now().UTC().Add(-24 * time.Hour)
balances := &attendanceBalances{}
e.base = balances
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
registry := events.NewRegistry()
if err := registry.Replace([]events.Schedule{{UID: 17, Type: 0, ID: 1, Start: 1, End: now.Add(time.Hour).UnixMilli()}}); err != nil {
t.Fatal(err)
}
design := &gamedata.EventTasksDesign{
Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 10}},
AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{10: {{Group: 10, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 3, Count: 2}}}},
}
tasks, err := eventtasks.Open(e.store, design, registry, e)
if err != nil {
t.Fatal(err)
}
tasks.SetSession("combined-session")
mailWallet, err := player.OpenWallet(e.store, player.Currency{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(e.store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, mailWallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachAttendanceRewardEconomy(e); err != nil {
t.Fatal(err)
}
tasks.AttachAttendanceMail(mailbox)
passes, err := NewLoginPasses(e.store, &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
20: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}}},
}}, e, items, func(uint64) bool { return true })
if err != nil {
t.Fatal(err)
}
passes.SetClock(func() time.Time { return *now }, 0)
h := AttendanceHandler{Events: tasks, Economy: e, LoginPasses: passes, Store: e.store}
request := wire.AppendVarint(nil, 1, 1)
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil {
t.Fatal(err)
}
if balances.calls != 3 || balances.balance != 17 {
t.Fatalf("wrong combined grant: calls=%d balance=%d", balances.calls, balances.balance)
}
counts := map[int]int{}
if err := wire.Walk(response, func(f wire.Field) error { counts[f.Number]++; return nil }); err != nil {
t.Fatal(err)
}
if counts[1001] != 1 || counts[1002] != 1 || counts[5] != 1 || counts[6] != 1 {
t.Fatalf("client requires a single combined envelope and native notices: %v", counts)
}
bundle, _, _ := wire.Bytes(response, 1001)
var snapshots []uint64
if err := wire.Walk(bundle, func(f wire.Field) error {
if f.Number == 1 {
count, _, err := wire.Varint(f.Value, 4)
if err != nil {
return err
}
snapshots = append(snapshots, count)
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(snapshots) != 2 || snapshots[0] != 11 || snapshots[1] != 5 {
t.Fatalf("combined reward entries lost or reordered: %v", snapshots)
}
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || !bytes.Equal(response, replay) || balances.calls != 3 {
t.Fatal("combined retry changed rewards", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
if err != nil || balances.calls != 3 {
t.Fatal("fresh request granted again", err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok {
t.Fatal("fresh request replays old reward envelope")
}
if _, ok, _ := wire.Bytes(next, 5); ok {
t.Fatal("fresh request replays old attendance stamps")
}
_, mailInfo, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3))
if err != nil {
t.Fatal(err)
}
mailCount := 0
var mailID uint64
if err := wire.Walk(mailInfo, func(f wire.Field) error {
if f.Number == 1 {
mailCount++
mailID, _, _ = wire.Varint(f.Value, 1)
}
return nil
}); err != nil || mailCount != 1 {
t.Fatalf("ordinary attendance did not issue exactly one mail: count=%d err=%v", mailCount, err)
}
open := wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, mailID)
if _, _, _, err := mailbox.Handle("/MailOpen", open); err != nil {
t.Fatal(err)
}
if balances.calls != 4 || balances.balance != 19 {
t.Fatalf("ordinary reward must arrive only after opening mail: calls=%d balance=%d", balances.calls, balances.balance)
}
}
func TestAttendanceRejectsAmbiguousChildRewardEnvelope(t *testing.T) {
native := wire.AppendVarint(nil, 55, 123)
bundle := wire.AppendBytes(native, 1001, wire.AppendVarint(nil, 7, 1))
valid := wire.AppendString(bundle, 1002, "event-receipt")
stripped, rewards, err := takeAttendanceRewardEnvelope(valid)
if err != nil || !bytes.Equal(stripped, native) || len(rewards) == 0 {
t.Fatal("valid envelope was not preserved", err)
}
for _, malformed := range [][]byte{
bundle,
wire.AppendString(native, 1002, "event-receipt"),
wire.AppendBytes(valid, 1001, nil),
wire.AppendString(valid, 1002, "second"),
wire.AppendString(bundle, 1002, ""),
wire.AppendVarint(native, 1001, 1),
} {
if _, _, err := takeAttendanceRewardEnvelope(malformed); err == nil {
t.Fatalf("ambiguous envelope accepted: %x", malformed)
}
}
}
func TestAttendanceExtensionReplaysRewardAndPreservesNativeResponse(t *testing.T) {
e, _, base, _, now := entitlementFixture(t)
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, Store: e.store}
request := wire.AppendVarint(nil, 1, 1)
_, response, handled, err := h.HandleSession("/Attendance", request, "session")
if err != nil || !handled {
t.Fatal(err)
}
if v, ok, _ := wire.Varint(response, 55); !ok || v != 123 {
t.Fatal("native response lost")
}
bundle, ok, err := wire.Bytes(response, 1001)
if err != nil || !ok || len(bundle) == 0 {
t.Fatal("reward extension missing")
}
if receipt, ok, _ := wire.Bytes(response, 1002); !ok || len(receipt) == 0 {
t.Fatal("receipt extension missing")
}
calls := base.Calls
_, replay, _, err := h.HandleSession("/Attendance", request, "session")
if err != nil || !bytes.Equal(response, replay) || base.Calls != calls {
t.Fatal("retry lost reward or granted twice", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "session")
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok || base.Calls != calls {
t.Fatal("same day duplicate grant")
}
_, _, _, err = h.HandleSession("/Attendance", append(request, wire.AppendVarint(nil, 2, 1)...), "session")
if err == nil {
t.Fatal("conflicting sequence accepted")
}
}
func TestAttendanceCombinesLoginPassAndSubscriptionWithoutDuplicateClaims(t *testing.T) {
e, items, base, _, now := entitlementFixture(t)
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
*now = now.Add(24 * time.Hour)
design := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}, Premium: gamedata.Reward{Type: 3, Count: 22}}},
}}
passes, err := NewLoginPasses(e.store, design, e, items, func(group uint64) bool { return group == 10 })
if err != nil {
t.Fatal(err)
}
passes.SetClock(func() time.Time { return *now }, 0)
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, LoginPasses: passes, Store: e.store}
calls := base.Calls
request := wire.AppendVarint(nil, 1, 1)
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || base.Calls != calls+2 {
t.Fatalf("daily claims not combined: calls=%d err=%v", base.Calls-calls, err)
}
info, ok, err := wire.Bytes(response, 6)
if err != nil || !ok {
t.Fatal("login-pass metadata lost", err)
}
if group, _, _ := wire.Varint(info, 1); group != 10 {
t.Fatal("wrong login-pass group")
}
bundle, ok, err := wire.Bytes(response, 1001)
if err != nil || !ok || len(bundle) == 0 {
t.Fatal("combined reward envelope missing", err)
}
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
if err != nil || !bytes.Equal(replay, response) || base.Calls != calls+2 {
t.Fatal("retry changed the envelope or repeated claims", err)
}
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
if err != nil || base.Calls != calls+2 {
t.Fatal("fresh request claimed the same day again", err)
}
if _, ok, _ := wire.Bytes(next, 1001); ok {
t.Fatal("fresh request replayed old rewards")
}
}
-216
View File
@@ -1,216 +0,0 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"sync"
)
type bonusGroup struct{ ProductGroup, ContentsGroup uint64 }
type bonusPurchases interface {
LifetimePurchaseTotal([]gamedata.CashProductKey) (uint64, error)
}
type bonusState struct {
Claims map[string]bool `json:"claims"`
Receipts map[string]purchaseReceipt `json:"receipts"`
}
type CashBonuses struct {
mu sync.Mutex
store stateio.Store
economy Economy
counts bonusPurchases
groups map[bonusGroup][]gamedata.CashProductKey
rewards map[bonusGroup][]gamedata.CashBonusReward
ordered []bonusGroup
claimIDs map[string]bool
}
func bonusClaimID(group bonusGroup, id uint64) string {
return fmt.Sprintf("%d:%d:%d", group.ProductGroup, group.ContentsGroup, id)
}
func NewCashBonuses(store stateio.Store, economy Economy, counts bonusPurchases, design *gamedata.CashBonusCatalog, packages []gamedata.CashPackageDesign) (*CashBonuses, error) {
if store == nil || economy == nil || counts == nil || design == nil {
return nil, fmt.Errorf("commerce: missing cash bonus dependency")
}
s := &CashBonuses{store: store, economy: economy, counts: counts, groups: map[bonusGroup][]gamedata.CashProductKey{}, rewards: map[bonusGroup][]gamedata.CashBonusReward{}, claimIDs: map[string]bool{}}
seen := map[gamedata.CashProductKey]bool{}
for _, p := range packages {
// EPackages.BonusBundleGroup=8; unrelated packages can reuse a contents ID.
if p.PackageType != 8 || len(design.Groups[p.ContentsGroupID]) == 0 {
continue
}
k := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup}
if seen[k] {
return nil, fmt.Errorf("commerce: duplicate cash bonus product %+v", k)
}
seen[k] = true
group := bonusGroup{p.GroupID, p.ContentsGroupID}
s.groups[group] = append(s.groups[group], k)
s.rewards[group] = design.Groups[p.ContentsGroupID]
}
for group, rows := range s.rewards {
s.ordered = append(s.ordered, group)
for _, row := range rows {
s.claimIDs[bonusClaimID(group, row.ID)] = true
}
}
sort.Slice(s.ordered, func(i, j int) bool {
if s.ordered[i].ProductGroup != s.ordered[j].ProductGroup {
return s.ordered[i].ProductGroup < s.ordered[j].ProductGroup
}
return s.ordered[i].ContentsGroup < s.ordered[j].ContentsGroup
})
_, err := s.load()
return s, err
}
func (s *CashBonuses) load() (bonusState, error) {
v := bonusState{Claims: map[string]bool{}, Receipts: map[string]purchaseReceipt{}}
raw, err := s.store.Load("commerce_cash_bonuses")
if err != nil || raw == nil {
return v, err
}
if err = stateio.RequireExactJSONObject(raw, "claims", "receipts"); err != nil {
return v, err
}
if err = json.Unmarshal(raw, &v); err != nil {
return v, err
}
if v.Claims == nil || v.Receipts == nil {
return v, fmt.Errorf("commerce: malformed cash bonus state")
}
for id, claimed := range v.Claims {
if !claimed || !s.claimIDs[id] {
return v, fmt.Errorf("commerce: invalid cash bonus claim %s", id)
}
}
for id, r := range v.Receipts {
digest, err := hex.DecodeString(r.Digest)
if id == "" || err != nil || len(digest) != sha256.Size || len(r.Response) == 0 {
return v, fmt.Errorf("commerce: invalid cash bonus receipt")
}
}
return v, nil
}
func (s *CashBonuses) rewarded(v bonusState, group bonusGroup, field int) []byte {
var out []byte
for _, row := range s.rewards[group] {
if v.Claims[bonusClaimID(group, row.ID)] {
out = wire.AppendVarint(out, field, row.ID)
}
}
return out
}
func (s *CashBonuses) Handle(path string, request []byte) (int, []byte, bool, error) {
return s.HandleSession(path, request, "")
}
func (s *CashBonuses) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
code := 588
if path == "/CashBonusReward" {
code = 589
} else if path != "/CashBonusInfo" {
return 0, nil, false, nil
}
seq, present, err := wire.Varint(request, 1)
if err != nil || !present || seq == 0 || seq > math.MaxInt32 {
return code, nil, true, fmt.Errorf("commerce: invalid cash bonus sequence")
}
if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil {
return code, nil, true, err
}
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return code, nil, true, err
}
if path == "/CashBonusInfo" {
var out []byte
for _, group := range s.ordered {
n, err := s.counts.LifetimePurchaseTotal(s.groups[group])
if err != nil {
return code, nil, true, err
}
// Preserve the native empty response for groups with no purchases.
if n == 0 {
continue
}
b := wire.AppendVarint(nil, 1, group.ProductGroup)
b = wire.AppendVarint(b, 2, group.ContentsGroup)
b = wire.AppendVarint(b, 3, n)
b = append(b, s.rewarded(v, group, 4)...)
out = wire.AppendBytes(out, 1, b)
}
return code, out, true, nil
}
if session == "" {
return code, nil, true, fmt.Errorf("commerce: cash bonus session unavailable")
}
identity := fmt.Sprintf("cash-bonus:%x:%d", sha256.Sum256([]byte(session)), seq)
digest := fmt.Sprintf("%x", sha256.Sum256(request))
if receipt, ok := v.Receipts[identity]; ok {
if receipt.Digest != digest {
return code, nil, true, fmt.Errorf("commerce: conflicting cash bonus replay")
}
return code, append([]byte(nil), receipt.Response...), true, nil
}
groupID, _, err := wire.Varint(request, 2)
if err != nil {
return code, nil, true, err
}
contents, _, err := wire.Varint(request, 3)
if err != nil {
return code, nil, true, err
}
id, _, err := wire.Varint(request, 4)
if err != nil {
return code, nil, true, err
}
group := bonusGroup{groupID, contents}
var chosen *gamedata.CashBonusReward
for _, row := range s.rewards[group] {
if row.ID == id {
copy := row
chosen = &copy
break
}
}
if chosen == nil {
return code, nil, true, fmt.Errorf("commerce: unknown cash bonus group=%d contents=%d bonus=%d", groupID, contents, id)
}
n, err := s.counts.LifetimePurchaseTotal(s.groups[group])
if err != nil {
return code, nil, true, err
}
if n < chosen.RequireCount {
return code, nil, true, fmt.Errorf("commerce: cash bonus purchase threshold not reached group=%d contents=%d bonus=%d count=%d require=%d", groupID, contents, id, n, chosen.RequireCount)
}
claimID := bonusClaimID(group, id)
var bundle []byte
if !v.Claims[claimID] {
bundle, err = s.economy.Apply("cash-bonus-grant:"+claimID, nil, []gamedata.Reward{chosen.Reward})
if err != nil {
return code, nil, true, err
}
v.Claims[claimID] = true
}
response := wire.AppendBytes(nil, 1, bundle)
response = append(response, s.rewarded(v, group, 2)...)
v.Receipts[identity] = purchaseReceipt{Digest: digest, Response: response}
raw, err := json.Marshal(v)
if err == nil {
err = s.store.Save("commerce_cash_bonuses", raw)
}
return code, response, true, err
}
-114
View File
@@ -1,114 +0,0 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
"bytes"
"testing"
)
func bonusRequest(seq, group, contents, bonus uint64) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 2, group)
b = wire.AppendVarint(b, 3, contents)
return wire.AppendVarint(b, 4, bonus)
}
func TestCashBonusUsesCommittedSeriesPurchasesAndNativeClaimProtocol(t *testing.T) {
shop, eco, store := serviceFixture(t, 1)
packages := []gamedata.CashPackageDesign{
{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7},
{GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7},
// Another package kind can reuse contents=7 but must not count.
{GroupID: 1, ID: 1, PackageType: 3, ContentsGroupID: 7},
}
design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{
7: {{ID: 1, RequireCount: 2, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}, {ID: 2, RequireCount: 3, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 15}}},
}}
s, err := NewCashBonuses(store, eco, shop, design, packages)
if err != nil {
t.Fatal(err)
}
infoRequest := wire.AppendVarint(nil, 1, 1)
if code, info, handled, err := s.Handle("/CashBonusInfo", infoRequest); err != nil || code != 588 || !handled || len(info) != 0 {
t.Fatal("new account bonus info must be empty", code, info, err)
}
claim := bonusRequest(1, 2, 7, 1)
if _, _, _, err := s.HandleSession("/CashBonusReward", claim, "s"); err == nil || eco.calls != 0 {
t.Fatal("unearned bonus granted")
}
for seq, sale := range []uint64{0, 1} {
if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(uint64(seq+1), 2, 1, sale, ""), "s"); err != nil {
t.Fatal(err)
}
}
// Normal recharge is not a member of the bonus series.
if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(3, 1, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
_, info, _, err := s.Handle("/CashBonusInfo", infoRequest)
if err != nil {
t.Fatal(err)
}
row, present, _ := wire.Bytes(info, 1)
count, _, _ := wire.Varint(row, 3)
if !present || count != 2 {
t.Fatal("incorrect committed series purchase count", count)
}
calls := eco.calls
code, reply, handled, err := s.HandleSession("/CashBonusReward", claim, "s")
if err != nil || code != 589 || !handled || eco.calls != calls+1 || len(eco.rewards) != 1 || eco.rewards[0].Count != 2 || len(eco.costs) != 0 {
t.Fatal("earned bonus not granted through native protocol", code, err, eco.rewards)
}
if id, _, _ := wire.Varint(reply, 2); id != 1 {
t.Fatal("native rewarded ID list missing")
}
reopened, err := NewCashBonuses(store, eco, shop, design, packages)
if err != nil {
t.Fatal(err)
}
_, replay, _, err := reopened.HandleSession("/CashBonusReward", claim, "s")
if err != nil || !bytes.Equal(reply, replay) || eco.calls != calls+1 {
t.Fatal("restart replay duplicated bonus", err)
}
_, noOp, _, err := reopened.HandleSession("/CashBonusReward", bonusRequest(2, 2, 7, 1), "s")
bundle, present, _ := wire.Bytes(noOp, 1)
if err != nil || !present || len(bundle) != 0 || eco.calls != calls+1 {
t.Fatal("fresh duplicate must return empty grant with claimed IDs", err)
}
for _, request := range [][]byte{bonusRequest(1, 2, 7, 2), bonusRequest(4, 1, 7, 1), bonusRequest(5, 2, 7, 99), bonusRequest(6, 2, 7, 2)} {
if _, _, _, err := reopened.HandleSession("/CashBonusReward", request, "s"); err == nil || eco.calls != calls+1 {
t.Fatal("conflicting replay, wrong series or unmet bonus accepted")
}
}
_, info, _, err = reopened.Handle("/CashBonusInfo", infoRequest)
row, _, _ = wire.Bytes(info, 1)
if id, _, _ := wire.Varint(row, 4); err != nil || id != 1 {
t.Fatal("claimed IDs missing after restart", err)
}
}
func TestCashBonusFailedGrantDoesNotMarkClaimed(t *testing.T) {
shop, eco, store := serviceFixture(t, 0)
if _, _, _, err := shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
design := &gamedata.CashBonusCatalog{Groups: map[uint64][]gamedata.CashBonusReward{7: {{ID: 1, RequireCount: 1, Reward: gamedata.Reward{Type: 8, ID: 1000, Count: 2}}}}}
s, err := NewCashBonuses(store, eco, shop, design, []gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7}})
if err != nil {
t.Fatal(err)
}
eco.fail = true
request := bonusRequest(1, 2, 7, 1)
if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err == nil {
t.Fatal("failed grant accepted")
}
v, err := s.load()
if err != nil || len(v.Claims) != 0 || len(v.Receipts) != 0 {
t.Fatal("failed grant marked bonus claimed", err)
}
eco.fail = false
if _, _, _, err := s.HandleSession("/CashBonusReward", request, "s"); err != nil {
t.Fatal("failed grant could not be retried", err)
}
}
@@ -1,95 +0,0 @@
package commerce
import (
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"math"
"testing"
)
func testDesign() *gamedata.CashCatalog {
return &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100},
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, GoogleSKU: "recharge", AppleSKU: "recharge-ios", PriceType: 1, PriceCount: 2200, Recharge: true, NominalPaidDiamonds: 100},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "skin", AppleSKU: "skin-ios", PriceType: 1, PriceCount: 5501},
{Key: gamedata.CashProductKey{GroupID: 3, ProductID: 1}, GoogleSKU: "native", PriceType: 2, PriceCount: 100},
}}
}
func TestCashPricesAliasesAndIsolation(t *testing.T) {
for _, currency := range []string{"free", "gold", "diamonds", "ban", ""} {
cfg := gameconfig.Default().Purchases
cfg.DiamondRecharge.Currency = currency
c, err := NewCatalog("test", testDesign(), cfg)
if err != nil {
t.Fatal(err)
}
m := c.Manifest()
if m.SchemaVersion != 1 || len(m.Products) != 6 {
t.Fatalf("manifest %+v", m)
}
skin, err := c.Quote(gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, 2)
if err != nil || skin.Cost != 502 || skin.ItemType != 2 || skin.Currency != "paid_diamonds" {
t.Fatalf("ordinary goods affected by %q: %+v %v", currency, skin, err)
}
recharge, err := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 1, SaleGroup: 1}, 1)
if currency == "ban" || currency == "" {
if err == nil {
t.Fatal("disabled recharge accepted")
}
continue
}
if err != nil {
t.Fatal(err)
}
want := uint64(0)
if currency == "gold" {
want = 100000
}
if currency == "diamonds" {
want = 100
}
if recharge.Cost != want {
t.Fatalf("currency %s cost=%d", currency, recharge.Cost)
}
second, _ := c.Quote(gamedata.CashProductKey{GroupID: 1, ProductID: 2, SaleGroup: 1}, 1)
if second.Cost != recharge.Cost {
t.Fatal("first purchase incorrectly doubled cost")
}
m.Products[0].SKU = "changed"
if c.Manifest().Products[0].SKU == "changed" {
t.Fatal("manifest mutable through caller")
}
if _, err = c.Quote(gamedata.CashProductKey{GroupID: 3, ProductID: 1}, 1); err == nil {
t.Fatal("native wallet item intercepted")
}
}
}
func TestMonetaryBoundsAndBaseline(t *testing.T) {
for _, v := range [][3]uint64{{math.MaxUint64, 2, 1}, {1, 0, 1}, {1, 1, 0}, {math.MaxInt32 + 1, 1, 1}} {
if _, err := ceilRatio(v[0], v[1], v[2]); err == nil {
t.Fatalf("accepted %v", v)
}
}
d := testDesign()
d.Products[0].PriceCount = 2000
d.Products[1].PriceCount = 2000
c, err := NewCatalog("test", d, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
p, _ := c.Quote(d.Products[2].Key, 1)
if p.Cost != 276 {
t.Fatalf("baseline not derived dynamically: %+v", p)
}
if _, err = c.Quote(d.Products[2].Key, math.MaxInt32); err == nil {
t.Fatal("overflow accepted")
}
if _, err = c.Quote(d.Products[2].Key, 0); err == nil {
t.Fatal("zero purchase accepted")
}
d.Products[0].Recharge = false
d.Products[1].Recharge = false
if _, err = NewCatalog("test", d, gameconfig.Default().Purchases); err == nil {
t.Fatal("missing baseline accepted")
}
}
@@ -1,258 +0,0 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"sort"
"sync"
"time"
)
type ClearPackageInventory interface{ All() []player.Item }
type clearClaim struct{ Kind, GroupID, TicketID, TargetID, Level uint64 }
type clearClaimReceipt struct {
Claim clearClaim `json:"claim"`
Response []byte `json:"response"`
}
type ClearPackages struct {
mu sync.Mutex
store stateio.Store
economy Economy
items ClearPackageInventory
design map[clearClaim]gamedata.ClearPackageRewardDesign
packCleared func(uint64, uint64) bool
towerCleared func(uint64, uint64) bool
now func() time.Time
}
func NewClearPackages(store stateio.Store, design *gamedata.ClearPackageCatalog, economy Economy, items ClearPackageInventory) (*ClearPackages, error) {
if store == nil || design == nil || economy == nil || items == nil {
return nil, fmt.Errorf("commerce: invalid clear package dependencies")
}
s := &ClearPackages{store: store, economy: economy, items: items, design: map[clearClaim]gamedata.ClearPackageRewardDesign{}, now: time.Now}
for _, r := range design.Rewards {
k := clearClaim{r.Kind, r.GroupID, r.TicketID, r.TargetID, r.Level}
if _, ok := s.design[k]; ok {
return nil, fmt.Errorf("commerce: duplicate clear reward")
}
s.design[k] = r
}
_, err := s.load()
return s, err
}
func (s *ClearPackages) AttachProgress(pack, tower func(uint64, uint64) bool) {
s.packCleared = pack
s.towerCleared = tower
}
func (s *ClearPackages) load() (map[string]clearClaimReceipt, error) {
v := map[string]clearClaimReceipt{}
raw, err := s.store.Load("commerce_clear_claims")
if err != nil || raw == nil {
return v, err
}
err = json.Unmarshal(raw, &v)
if err == nil && v == nil {
err = fmt.Errorf("commerce: invalid clear claim state")
}
if err == nil {
for identity, receipt := range v {
if identity != clearClaimID(receipt.Claim) || len(receipt.Response) == 0 {
err = fmt.Errorf("commerce: invalid saved clear claim")
break
}
if _, ok := s.design[receipt.Claim]; !ok {
err = fmt.Errorf("commerce: unknown saved clear claim")
break
}
}
}
return v, err
}
func clearClaimID(c clearClaim) string {
return fmt.Sprintf("clear-package:%d:%d:%d:%d:%d", c.Kind, c.GroupID, c.TicketID, c.TargetID, c.Level)
}
func (s *ClearPackages) entitled(ticket uint64) bool {
for _, item := range s.items.All() {
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(s.now().UnixMilli())) {
return true
}
}
return false
}
func (s *ClearPackages) Handle(path string, request []byte) (int, []byte, bool, error) {
return s.HandleSession(path, request, "")
}
// Both clear-info fields are ordinary proto3 messages, not a oneof. The native
// client creates an empty message for the inactive kind (CommonPacket), and
// omits scalar zero values such as ClearPackagePack and the normal pack level.
func parseClearClaim(request []byte) (clearClaim, error) {
var scalar [2]uint64
var rows [2][]byte
var seen [4]bool
err := wire.Walk(request, func(f wire.Field) error {
if f.Number < 1 || f.Number > 4 {
return nil
}
if seen[f.Number-1] {
return fmt.Errorf("commerce: duplicate clear claim field %d", f.Number)
}
seen[f.Number-1] = true
if f.Number <= 2 {
if f.Type != 0 {
return fmt.Errorf("commerce: invalid clear claim scalar %d", f.Number)
}
scalar[f.Number-1], _ = binary.Uvarint(f.Value)
if scalar[f.Number-1] > math.MaxInt32 {
return fmt.Errorf("commerce: clear claim scalar %d exceeds int32", f.Number)
}
} else {
if f.Type != 2 {
return fmt.Errorf("commerce: invalid clear claim row %d", f.Number)
}
rows[f.Number-3] = f.Value
}
return nil
})
if err != nil {
return clearClaim{}, err
}
if scalar[0] == 0 {
return clearClaim{}, fmt.Errorf("commerce: invalid clear claim sequence")
}
kind := scalar[1]
if kind > 1 {
return clearClaim{}, fmt.Errorf("commerce: invalid clear claim type")
}
if !seen[kind+2] {
return clearClaim{}, fmt.Errorf("commerce: missing clear claim row")
}
var active [4]uint64
for i, row := range rows {
var values [4]uint64
var fields [4]bool
if err := wire.Walk(row, func(f wire.Field) error {
if f.Number < 1 || f.Number > 4 {
// Unknown active fields retain normal protobuf compatibility. An
// inactive row must contain only the schema's default scalars.
if uint64(i) != kind {
return fmt.Errorf("commerce: nonempty inactive clear claim row")
}
return nil
}
if fields[f.Number-1] || f.Type != 0 {
return fmt.Errorf("commerce: invalid clear claim row scalar %d", f.Number)
}
fields[f.Number-1] = true
v, _ := binary.Uvarint(f.Value)
if v > math.MaxInt32 {
return fmt.Errorf("commerce: clear claim row scalar %d exceeds int32", f.Number)
}
if uint64(i) != kind && v != 0 {
return fmt.Errorf("commerce: conflicting inactive clear claim row")
}
values[f.Number-1] = v
return nil
}); err != nil {
return clearClaim{}, err
}
if uint64(i) == kind {
active = values
}
}
if active[0] == 0 || active[1] == 0 {
return clearClaim{}, fmt.Errorf("commerce: invalid clear claim identity")
}
return clearClaim{kind, active[0], active[1], active[2], active[3]}, nil
}
func (s *ClearPackages) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
if path != "/ClearPackageReward" {
return 0, nil, false, nil
}
claim, err := parseClearClaim(request)
if err != nil {
return 286, nil, true, err
}
d, ok := s.design[claim]
if !ok {
return 286, nil, true, fmt.Errorf("commerce: unknown clear reward")
}
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return 286, nil, true, err
}
identity := clearClaimID(claim)
if receipt, ok := v[identity]; ok {
return 286, append([]byte(nil), receipt.Response...), true, nil
}
if d.Type == 1 && !s.entitled(d.TicketID) {
return 286, nil, true, fmt.Errorf("commerce: clear reward premium ticket required")
}
proof := s.packCleared
if claim.Kind == 1 {
proof = s.towerCleared
}
if proof == nil || !proof(d.TargetID, d.Level) {
return 286, nil, true, fmt.Errorf("commerce: clear reward progression incomplete")
}
rewards := []gamedata.Reward{{Type: 9, ID: d.RandomBoxID, Count: 1}}
var bundle []byte
if delivery, ok := s.economy.(interface {
ApplyPurchase(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}); ok {
// Clear reward groups carry MailId just like cash products. The
// delivery resolver selects the versioned mail template and keeps
// mailed contents out of the direct inventory response.
bundle, err = delivery.ApplyPurchase(identity, nil, rewards)
} else {
bundle, err = s.economy.Apply(identity, nil, rewards)
}
if err != nil {
return 286, nil, true, err
}
response := wire.AppendBytes(nil, 1, bundle)
v[identity] = clearClaimReceipt{Claim: claim, Response: response}
raw, err := json.Marshal(v)
if err == nil {
err = s.store.Save("commerce_clear_claims", raw)
}
return 286, response, true, err
}
// RewardDBInfos is attached to PackInfoResponse fields 3 and 4 so reconnects
// restore claimed reward buttons from server state.
func (s *ClearPackages) RewardDBInfos() (pack, evil [][]byte, err error) {
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return nil, nil, err
}
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
c := v[k].Claim
b := wire.AppendVarint(nil, 1, c.GroupID)
b = wire.AppendVarint(b, 2, c.TicketID)
b = wire.AppendVarint(b, 3, c.TargetID)
b = wire.AppendVarint(b, 4, c.Level)
if c.Kind == 0 {
pack = append(pack, b)
} else {
evil = append(evil, b)
}
}
return pack, evil, nil
}
@@ -1,478 +0,0 @@
package commerce
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
type clearInventory struct{ items []player.Item }
func (i *clearInventory) All() []player.Item { return i.items }
func clearRequest(kind, ticket uint64) []byte {
b := wire.AppendVarint(nil, 1, 1)
if kind != 0 {
b = wire.AppendVarint(b, 2, kind)
}
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, ticket)
row = wire.AppendVarint(row, 3, 2)
b = wire.AppendBytes(b, 3+int(kind), row)
return wire.AppendBytes(b, 4-int(kind), nil)
}
func TestClearPackageNativeProtoDefaultsAndInactiveRows(t *testing.T) {
for _, kind := range []uint64{0, 1} {
for _, placeholder := range []bool{false, true} {
b := wire.AppendVarint(nil, 1, 1)
if kind != 0 {
b = wire.AppendVarint(b, 2, kind)
}
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, 12)
row = wire.AppendVarint(row, 3, 2)
if placeholder {
// The native tower request serializes its empty pack placeholder
// before the active tower row; pack serializes it afterwards.
if kind == 1 {
b = wire.AppendBytes(b, 3, nil)
}
}
b = wire.AppendBytes(b, int(kind)+3, row)
if placeholder && kind == 0 {
b = wire.AppendBytes(b, 4, nil)
}
got, err := parseClearClaim(b)
want := clearClaim{kind, 10, 12, 2, 0}
if err != nil || got != want {
t.Fatalf("kind %d placeholder %t: %+v %v", kind, placeholder, got, err)
}
}
}
// Explicit defaults are also valid protobuf; they must not look like a
// second active claim, nor be confused with absent active message presence.
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, 12)
row = wire.AppendVarint(row, 3, 2)
b := wire.AppendVarint(nil, 1, 1)
b = wire.AppendVarint(b, 2, 0)
b = wire.AppendBytes(b, 3, wire.AppendVarint(row, 4, 0))
var defaults []byte
for f := 1; f <= 4; f++ {
defaults = wire.AppendVarint(defaults, f, 0)
}
if _, err := parseClearClaim(wire.AppendBytes(b, 4, defaults)); err != nil {
t.Fatal(err)
}
}
func TestClearPackageRejectsConflictingMalformedAndSpoofedRows(t *testing.T) {
row := wire.AppendVarint(nil, 1, 10)
row = wire.AppendVarint(row, 2, 12)
row = wire.AppendVarint(row, 3, 2)
prefix := wire.AppendVarint(nil, 1, 1)
row = row[:len(row):len(row)] //nolint:modernize // slicesclip
prefix = prefix[:len(prefix):len(prefix)] //nolint:modernize // slicesclip
active := wire.AppendBytes(append([]byte(nil), prefix...), 3, row)
active = active[:len(active):len(active)] //nolint:modernize // slicesclip
requests := map[string][]byte{
"missing row": prefix,
"empty active": wire.AppendBytes(prefix, 3, nil),
"only inactive": wire.AppendBytes(prefix, 4, nil),
"conflicting inactive": wire.AppendBytes(active, 4, row),
"duplicate active": wire.AppendBytes(active, 3, row),
"duplicate inactive": wire.AppendBytes(wire.AppendBytes(active, 4, nil), 4, nil),
"duplicate kind": wire.AppendVarint(wire.AppendVarint(active, 2, 0), 2, 1),
"duplicate seq": wire.AppendVarint(active, 1, 1),
"kind wire type": wire.AppendBytes(active, 2, nil),
"active wire type": wire.AppendVarint(prefix, 3, 1),
"inactive wire type": wire.AppendVarint(active, 4, 0),
"row scalar wire type": wire.AppendBytes(prefix, 3, wire.AppendBytes(row, 4, nil)),
"duplicate row scalar": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 3, 2)),
"overflow seq": wire.AppendBytes(wire.AppendVarint(nil, 1, uint64(math.MaxInt32)+1), 3, row),
"negative kind": wire.AppendVarint(active, 2, math.MaxUint64),
"negative level": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 4, math.MaxUint64)),
"truncated inactive": append(append([]byte(nil), active...), 34, 2, 8),
"unknown inactive data": wire.AppendBytes(active, 4, wire.AppendVarint(nil, 5, 1)),
"unknown static row": wire.AppendBytes(prefix, 3, wire.AppendVarint(row, 4, 1)),
}
for name, request := range requests {
t.Run(name, func(t *testing.T) {
eco := &purchaseEconomy{}
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100}}}
s, err := NewClearPackages(stateio.NewMemory(), design, eco, &clearInventory{})
if err != nil {
t.Fatal(err)
}
s.AttachProgress(func(uint64, uint64) bool { return true }, nil)
if _, _, _, err := s.Handle("/ClearPackageReward", request); err == nil || eco.calls != 0 {
t.Fatal("invalid request granted a reward", err, eco.calls)
}
})
}
}
func TestClearPackagePremiumTicketExpiryAndTowerProof(t *testing.T) {
items := &clearInventory{items: []player.Item{{Type: 19, ID: 77, Count: 1, ExpiryTime: 1000}}}
eco := &purchaseEconomy{}
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 100, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 101}}}
s, err := NewClearPackages(stateio.NewMemory(), design, eco, items)
if err != nil {
t.Fatal(err)
}
s.now = func() time.Time { return time.UnixMilli(1000) }
s.AttachProgress(func(uint64, uint64) bool { return true }, nil)
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil || eco.calls != 0 {
t.Fatal("expired premium accepted", err)
}
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil || eco.calls != 0 {
t.Fatal("pack proof authorized tower", err)
}
s.AttachProgress(nil, func(tower, floor uint64) bool { return tower == 2 && floor == 0 })
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(1, 12)); err != nil {
t.Fatal(err)
}
p, e, err := s.RewardDBInfos()
if err != nil || len(p) != 0 || len(e) != 1 {
t.Fatal("tower receipt not separated", p, e, err)
}
if got, _, _ := wire.Varint(e[0], 3); got != 2 {
t.Fatal("tower_type field wrong", got)
}
}
type clearReceiptFailure struct {
stateio.Store
fail bool
}
func (s *clearReceiptFailure) Save(name string, payload []byte) error {
if s.fail && name == "commerce_clear_claims" {
return fmt.Errorf("injected clear receipt failure")
}
return s.Store.Save(name, payload)
}
func TestClearPackageMailSQLiteAtomicRetryAndReconnect(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100}}}
graph := &deliveryGraph{}
open := func(fail bool) (*accountstate.Repository, *ClearPackages, *mail.Service, *player.Wallet, *player.Inventory) {
t.Helper()
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted} {
if err := persist(); err != nil {
t.Fatal(err)
}
}
economy, err := NewEntitlementEconomy(repo, deliveryBase{wallet, items}, graph, items, &gamedata.CashEntitlementDesign{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachCashRewards(economy, map[uint64]bool{40: true}); err != nil {
t.Fatal(err)
}
if err := economy.AttachCashMail(mailbox); err != nil {
t.Fatal(err)
}
s, err := NewClearPackages(&clearReceiptFailure{Store: repo, fail: fail}, design, economy, items)
if err != nil {
t.Fatal(err)
}
s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil)
return repo, s, mailbox, wallet, items
}
repo, s, _, _, _ := open(true)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil {
t.Fatal("receipt failure lost")
}
if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, s, mailbox, wallet, items := open(false)
if ids, _ := cashList(t, mailbox, 0, 100); len(ids) != 0 {
t.Fatal("mail survived failed transaction", ids)
}
for _, name := range []string{"commerce_clear_claims", "commerce_entitlements"} {
if raw, err := repo.Load(name); err != nil || raw != nil {
t.Fatal("receipt survived rollback", name, err)
}
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, response, _, err := s.Handle("/ClearPackageReward", clearRequest(0, 12))
if err != nil {
_ = op.Rollback()
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 0 || len(items.All()) != 0 {
t.Fatal("mail contents directly granted")
}
bundle, ok, err := wire.Bytes(response, 1)
if err != nil || !ok {
t.Fatal("response bundle missing", err)
}
if _, direct, _ := wire.Bytes(bundle, 1); direct {
t.Fatal("mailed items returned as inventory")
}
// CommonPacket updates the claimed row from the request and shows the
// localized "sent to mail" notice. An empty bundle is valid for mail-only
// delivery; it must not claim that those attachments entered inventory.
if len(bundle) != 0 {
t.Fatal("mail-only clear returned direct rewards", bundle)
}
ids, _ := cashList(t, mailbox, 0, 100)
if len(ids) != 1 {
t.Fatal("one clear reward should issue one mail", ids)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
_, s, mailbox, _, _ = open(false)
// A replay must restore the exact response without requiring progress again
// or issuing a second mail, including after sequence/session changes.
s.AttachProgress(nil, nil)
request, _, err := wire.ReplaceVarint(clearRequest(0, 12), 1, 9)
if err != nil {
t.Fatal(err)
}
_, replay, _, err := s.HandleSession("/ClearPackageReward", request, "reconnected")
if err != nil || !bytes.Equal(replay, response) {
t.Fatal("reconnect response differs", err)
}
if got, _ := cashList(t, mailbox, 0, 100); !reflect.DeepEqual(got, ids) {
t.Fatal("retry duplicated mail", got, ids)
}
p, e, err := s.RewardDBInfos()
if err != nil || len(p) != 1 || len(e) != 0 {
t.Fatal("claimed row not restored", p, e, err)
}
for f, want := range []uint64{10, 12, 2, 0} {
if got, _, err := wire.Varint(p[0], f+1); err != nil || got != want {
t.Fatal("claim protocol field", f+1, got, want, err)
}
}
}
func TestInstalledClearPackageDeliveryAndNativeClaims23510(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
const version = "20260923193640"
design, err := gamedata.LoadClearPackageCatalog(root, version)
if err != nil {
t.Fatal(err)
}
resolver, err := gamedata.LoadCashRewardResolver(root, version)
if err != nil {
t.Fatal(err)
}
templates, err := gamedata.LoadCashMailTemplates(root, version)
if err != nil {
t.Fatal(err)
}
store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
eco, err := NewEntitlementEconomy(store, deliveryBase{wallet, items}, resolver, items, &gamedata.CashEntitlementDesign{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet)
if err != nil {
t.Fatal(err)
}
if err := mailbox.AttachCashRewards(eco, templates); err != nil {
t.Fatal(err)
}
if err := eco.AttachCashMail(mailbox); err != nil {
t.Fatal(err)
}
s, err := NewClearPackages(store, design, eco, items)
if err != nil {
t.Fatal(err)
}
// Current design has normal and premium rows for both pack and tower.
// Every row must derive its mail from GameData and retain its claim identity.
counts := [2][2]int{}
for index, row := range design.Rewards {
counts[row.Kind][row.Type]++
plan, err := resolver.ResolveDelivery([]gamedata.BattleReward{{Type: 9, ID: row.RandomBoxID, Count: 1}})
if err != nil || len(plan.Direct) != 0 || len(plan.Mail) != 1 || plan.Mail[0].TemplateID == 0 || len(plan.Mail[0].Rewards) == 0 {
t.Fatalf("clear row %+v delivery %+v: %v", row, plan, err)
}
if row.Type == 1 {
if _, err := items.GrantOnce(fmt.Sprintf("installed-clear-ticket:%d", index), []gamedata.BattleReward{{Type: 19, ID: row.TicketID, Count: 1}}); err != nil {
t.Fatal(err)
}
}
proof := func(target, level uint64) bool { return target == row.TargetID && level == row.Level }
if row.Kind == 0 {
s.AttachProgress(proof, nil)
} else {
s.AttachProgress(nil, proof)
}
request := wire.AppendVarint(nil, 1, 1)
if row.Kind != 0 {
request = wire.AppendVarint(request, 2, row.Kind)
}
var nested []byte
for f, value := range []uint64{row.GroupID, row.TicketID, row.TargetID, row.Level} {
if value != 0 {
nested = wire.AppendVarint(nested, f+1, value)
}
}
request = wire.AppendBytes(request, 3+int(row.Kind), nested)
request = wire.AppendBytes(request, 4-int(row.Kind), nil)
_, response, _, err := s.Handle("/ClearPackageReward", request)
if err != nil {
t.Fatalf("native clear row %+v: %v", row, err)
}
bundle, present, err := wire.Bytes(response, 1)
if err != nil || !present || len(bundle) != 0 {
t.Fatal("mail-only native response", row, response, err)
}
_, replay, _, err := s.Handle("/ClearPackageReward", request)
if err != nil || !bytes.Equal(response, replay) {
t.Fatal("native clear retry", row, err)
}
listRequest := wire.AppendVarint(nil, 1, 1)
listRequest = wire.AppendVarint(listRequest, 3, 1)
_, list, _, err := mailbox.Handle("/CashMailInfo", listRequest)
if err != nil {
t.Fatal(err)
}
if total, _, err := wire.Varint(list, 2); err != nil || total != uint64(index+1) {
t.Fatal("mail duplicated or missing", row, total, err)
}
entry, present, err := wire.Bytes(list, 1)
if err != nil || !present {
t.Fatal("newest clear mail missing", row, err)
}
if template, _, err := wire.Varint(entry, 3); err != nil || template != plan.Mail[0].TemplateID {
t.Fatal("GameData clear mail template mismatch", row, template, plan.Mail[0].TemplateID, err)
}
// MailDBInfo represents attachment vectors as packed repeated int32.
for field, expected := range map[int]func(gamedata.BattleReward) uint64{
8: func(r gamedata.BattleReward) uint64 { return r.Type },
9: func(r gamedata.BattleReward) uint64 { return r.ID },
10: func(r gamedata.BattleReward) uint64 { return r.Count },
} {
packed, present, err := wire.Bytes(entry, field)
if err != nil || !present {
t.Fatal("clear mail attachments missing", row, field, err)
}
for _, reward := range plan.Mail[0].Rewards {
value, n := binary.Uvarint(packed)
if n <= 0 || value != expected(reward) {
t.Fatal("clear mail attachment mismatch", row, field, value, reward)
}
packed = packed[n:]
}
if len(packed) != 0 {
t.Fatal("extra clear mail attachment", row, field)
}
}
}
if wallet.Snapshot() != (player.Currency{}) {
t.Fatal("mail contents directly credited", wallet.Snapshot())
}
p, e, err := s.RewardDBInfos()
if err != nil || len(p) != counts[0][0]+counts[0][1] || len(e) != counts[1][0]+counts[1][1] {
t.Fatal("native claims not restored by kind", len(p), len(e), counts, err)
}
for kind, byType := range counts {
for typ, n := range byType {
if n == 0 {
t.Fatalf("missing kind %d type %d design rows", kind, typ)
}
}
}
}
func TestClearPackageRequiresServerProgressAndPremiumEntitlement(t *testing.T) {
store := stateio.NewMemory()
eco := &purchaseEconomy{}
items := &clearInventory{}
design := &gamedata.ClearPackageCatalog{Rewards: []gamedata.ClearPackageRewardDesign{{Kind: 0, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 100, Type: 0}, {Kind: 0, GroupID: 10, TicketID: 77, TargetID: 2, RandomBoxID: 101, Type: 1}, {Kind: 1, GroupID: 10, TicketID: 12, TargetID: 2, RandomBoxID: 102, Type: 0}}}
s, err := NewClearPackages(store, design, eco, items)
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 12)); err == nil || eco.calls != 0 {
t.Fatal("client spoofed clear progress")
}
s.AttachProgress(func(pack, level uint64) bool { return pack == 2 && level == 0 }, nil)
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err == nil {
t.Fatal("unpaid premium claim accepted")
}
code, _, ok, err := s.Handle("/ClearPackageReward", clearRequest(0, 12))
if err != nil || !ok || code != 286 || eco.rewards[0].ID != 100 {
t.Fatal(code, err)
}
items.items = []player.Item{{Type: 19, ID: 77, Count: 1}}
if _, _, _, err = s.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil {
t.Fatal(err)
}
reloaded, err := NewClearPackages(store, design, eco, items)
if err != nil {
t.Fatal(err)
}
if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(0, 77)); err != nil || eco.calls != 2 {
t.Fatal("restart duplicated grant", err, eco.calls)
}
p, e, err := reloaded.RewardDBInfos()
if err != nil || len(p) != 2 || len(e) != 0 {
t.Fatal(p, e, err)
}
if _, _, _, err = reloaded.Handle("/ClearPackageReward", clearRequest(1, 12)); err == nil {
t.Fatal("unimplemented evil progress granted reward")
}
}
-95
View File
@@ -1,95 +0,0 @@
package commerce
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"time"
"bd2server/internal/server/gamedata"
)
type CashMailIssuer interface {
IssueCashOnce(string, uint64, []gamedata.Reward, time.Time) error
}
func (e *EntitlementEconomy) AttachCashMail(issuer CashMailIssuer) error {
if issuer == nil {
return fmt.Errorf("commerce: cash mail issuer unavailable")
}
if _, ok := e.graph.(interface {
ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error)
}); !ok {
return fmt.Errorf("commerce: cash delivery resolver unavailable")
}
e.mail = issuer
return nil
}
// ApplyPurchase selects rewards once, activates direct entitlements, and issues
// durable cash mail. All writes run in the enclosing account transaction.
func (e *EntitlementEconomy) ApplyPurchase(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.mail == nil {
return nil, fmt.Errorf("commerce: cash mail issuer unavailable")
}
definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards})
digest := sha256.Sum256(definition)
key := identity + ":delivery"
state, err := e.load()
if err != nil {
return nil, err
}
if receipt, ok := state.Receipts[key]; ok {
if !bytes.Equal(receipt.Definition, digest[:]) {
return nil, fmt.Errorf("commerce: delivery identity reused")
}
return append([]byte(nil), receipt.Bundle...), nil
}
resolver, ok := e.graph.(interface {
ResolveDelivery([]gamedata.BattleReward) (gamedata.CashDelivery, error)
})
if !ok {
return nil, fmt.Errorf("commerce: missing cash delivery resolver")
}
input := make([]gamedata.BattleReward, len(rewards))
for i, r := range rewards {
input[i] = gamedata.BattleReward(r)
}
plan, err := resolver.ResolveDelivery(input)
if err != nil {
return nil, err
}
direct := make([]gamedata.Reward, len(plan.Direct))
for i, r := range plan.Direct {
direct[i] = gamedata.Reward(r)
}
var mailed []gamedata.BattleReward
for _, mail := range plan.Mail {
mailed = append(mailed, mail.Rewards...)
}
bundle, err := e.applyPrepared(identity+":direct", costs, direct, true, mailed)
if err != nil {
return nil, err
}
for i, mail := range plan.Mail {
attachments := make([]gamedata.Reward, len(mail.Rewards))
for j, r := range mail.Rewards {
attachments[j] = gamedata.Reward(r)
}
if err := e.mail.IssueCashOnce(fmt.Sprintf("%s:%d", identity, i), mail.TemplateID, attachments, e.now()); err != nil {
return nil, err
}
}
state, err = e.load()
if err != nil {
return nil, err
}
state.Receipts[key] = entitlementReceipt{Definition: digest[:], Bundle: append([]byte(nil), bundle...)}
if err = e.save(state); err != nil {
return nil, err
}
return bundle, nil
}
@@ -1,320 +0,0 @@
package commerce
import (
"bytes"
"errors"
"fmt"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type deliveryGraph struct{ selections int }
func (g *deliveryGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
return r, nil
}
func (g *deliveryGraph) ResolveDelivery(r []gamedata.BattleReward) (gamedata.CashDelivery, error) {
g.selections++
return gamedata.CashDelivery{Mail: []gamedata.CashMailReward{{TemplateID: 40, Rewards: []gamedata.BattleReward{{Type: 4, Count: 40}, {Type: 9, ID: 200, Count: 1}}}}}, nil
}
type deliveryBase struct {
wallet *player.Wallet
items *player.Inventory
}
func (e deliveryBase) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
var currencies []gamedata.Reward
for _, r := range rewards {
if r.Type != 9 {
currencies = append(currencies, r)
}
}
if err := e.wallet.ExchangeOnce(identity, costs, currencies); err != nil {
return nil, err
}
var items []gamedata.BattleReward
var bundle []byte
for _, r := range rewards {
if r.Type == 9 {
items = append(items, gamedata.BattleReward(r))
} else {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count}))
}
}
granted, err := e.items.GrantOnce(identity, items)
if err != nil {
return nil, err
}
if len(granted) == 0 {
granted = e.items.GrantedItems(identity)
}
for _, item := range granted {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
}
return bundle, nil
}
func cashList(t *testing.T, m *mail.Service, start, count uint64) ([]uint64, []byte) {
t.Helper()
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, start)
request = wire.AppendVarint(request, 3, count)
code, response, handled, err := m.Handle("/CashMailInfo", request)
if err != nil || !handled || code != 140 {
t.Fatalf("cash list: %d %t %v", code, handled, err)
}
var ids []uint64
_ = wire.Walk(response, func(f wire.Field) error {
if f.Number == 1 && f.Type == 2 {
id, _, _ := wire.Varint(f.Value, 1)
template, _, _ := wire.Varint(f.Value, 3)
typ, _, _ := wire.Varint(f.Value, 2)
cash, _, _ := wire.Varint(f.Value, 15)
if id == 0 || template != 40 || typ != 0 || cash != 1 {
t.Fatalf("invalid cash mail %x", f.Value)
}
ids = append(ids, id)
}
return nil
})
return ids, response
}
func TestCashPurchaseMailSQLiteRollbackRestartBatchAndReplay(t *testing.T) {
fixture, _, _ := serviceFixture(t, 0)
nativeKey := gamedata.CashProductKey{GroupID: 44, ProductID: 1}
fixture.catalog.designs[nativeKey] = gamedata.CashProductDesign{Key: nativeKey, PriceType: 2, PriceCount: 1000, RandomBoxID: 20}
path := filepath.Join(t.TempDir(), "state.db")
graph := &deliveryGraph{}
open := func() (*accountstate.Repository, *Service, *mail.Service, *player.Wallet, *player.Inventory) {
t.Helper()
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 4000})
if err != nil {
t.Fatal(err)
}
items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
if err = wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if err = items.EnsurePersisted(); err != nil {
t.Fatal(err)
}
economy, err := NewEntitlementEconomy(repo, deliveryBase{wallet, items}, graph, items, &gamedata.CashEntitlementDesign{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(repo, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet)
if err != nil {
t.Fatal(err)
}
if err = mailbox.AttachCashRewards(economy, map[uint64]bool{40: true}); err != nil {
t.Fatal(err)
}
if err = economy.AttachCashMail(mailbox); err != nil {
t.Fatal(err)
}
shop, err := NewService(fixture.catalog, repo, economy)
if err != nil {
t.Fatal(err)
}
return repo, shop, mailbox, wallet, items
}
rollback := func(op stateio.RequestOperation) {
t.Helper()
if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
}
var mailbox *mail.Service
var wallet *player.Wallet
var items *player.Inventory
repo, shop, _, _, _ := open()
shop.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error {
return fmt.Errorf("injected failure after issuing mail")
})
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("failure ignored")
}
rollback(op)
_ = repo.Close()
repo, shop, mailbox, wallet, items = open()
if wallet.Snapshot().Jewelry != 4000 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 {
t.Fatal("rollback retained debit/grant")
}
if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 0 {
t.Fatal("rollback retained cash mail")
}
for seq := uint64(1); seq <= 2; seq++ {
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if err = mailbox.BeforeDispatch("/CashShopBuy", nil); err != nil {
t.Fatal(err)
}
request := buyRequest(seq, 2, 1, 0, "")
if seq == 2 {
request = nativeBuyRequest(seq, 44, [3]uint64{1, 0, 1})
}
_, response, _, err := shop.HandleSession("/CashShopBuy", request, "s")
if err != nil {
t.Fatal(err)
}
bundle, present, _ := wire.Bytes(response, 1)
if !present || len(bundle) != 0 {
t.Fatalf("mail-only purchase must not return direct rewards: %x", response)
}
notify, err := mailbox.AfterDispatch("/CashShopBuy", nil, nil)
flag, _, _ := wire.Varint(notify, 1)
if err != nil || flag != 1 {
t.Fatal("new mail notification missing")
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
}
if wallet.Snapshot().Jewelry != 2000 || wallet.Snapshot().Gold != 0 || len(items.All()) != 0 {
t.Fatal("unclaimed attachments granted early")
}
ids, list := cashList(t, mailbox, 0, 1)
total, _, _ := wire.Varint(list, 2)
if len(ids) != 1 || ids[0] != 2 || total != 2 {
t.Fatal("cash first page", ids, total)
}
next, _ := cashList(t, mailbox, ids[0], 1)
if len(next) != 1 || next[0] != 1 {
t.Fatal("cash cursor skipped/duplicated", next)
}
_, ordinary, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 1))
if err != nil {
t.Fatal(err)
}
if _, present, _ := wire.Bytes(ordinary, 1); present {
t.Fatal("cash mail leaked into ordinary list")
}
beforeSelections := graph.selections
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = shop.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if graph.selections != beforeSelections || wallet.Snapshot().Jewelry != 2000 {
t.Fatal("purchase replay selected or charged again")
}
_ = repo.Close()
repo, _, mailbox, _, _ = open()
claim := wire.AppendVarint(nil, 1, 3)
claim = wire.AppendVarint(claim, 2, 1)
claim = wire.AppendVarint(claim, 2, 2)
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = mailbox.Handle("/MailOpen", claim); err != nil {
t.Fatal(err)
}
rollback(op)
_ = repo.Close()
repo, _, mailbox, wallet, items = open()
if wallet.Snapshot().Gold != 0 || len(items.All()) != 0 {
t.Fatal("claim rollback retained rewards")
}
if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 2 {
t.Fatal("claim rollback hid mails")
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, claimed, _, err := mailbox.Handle("/MailOpen", claim)
if err != nil {
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
countBoxes := func(items *player.Inventory) uint64 {
var n uint64
for _, r := range items.All() {
if r.Type == 9 && r.ID == 200 {
n += r.Count
}
}
return n
}
if wallet.Snapshot().Gold != 80 || countBoxes(items) != 2 {
t.Fatal("batch claim failed", wallet.Snapshot(), items.All())
}
_ = repo.Close()
repo, _, mailbox, wallet, items = open()
defer func() {
if err := repo.Close(); err != nil {
t.Error(err)
}
}()
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, replayed, _, err := mailbox.Handle("/MailOpen", claim)
if err != nil {
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if !bytes.Equal(claimed, replayed) || wallet.Snapshot().Gold != 80 || countBoxes(items) != 2 {
t.Fatal("restart replay duplicated claim")
}
if ids, _ := cashList(t, mailbox, 0, 20); len(ids) != 0 {
t.Fatal("claimed mails still visible")
}
historyReq := wire.AppendVarint(nil, 1, 4)
historyReq = wire.AppendVarint(historyReq, 3, 20)
_, history, _, err := mailbox.Handle("/MailHistoryInfo", historyReq)
n, _, _ := wire.Varint(history, 2)
if err != nil || n != 2 {
t.Fatal("claim history missing", n, err)
}
}
func TestDirectSubscriptionDoesNotDuplicateFirstDayAlreadyInMail(t *testing.T) {
e, _, base, _, _ := entitlementFixture(t)
e.mu.Lock()
_, err := e.applyPrepared("direct", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}, true, []gamedata.BattleReward{{Type: 3, Count: 1}})
e.mu.Unlock()
if err != nil || len(base.Rewards) != 0 {
t.Fatal("mailed first day was granted directly", base.Rewards, err)
}
if _, err = e.ApplyResolved("mail", nil, []gamedata.Reward{{Type: 3, Count: 1}}); err != nil {
t.Fatal(err)
}
if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 {
t.Fatal("claim did not grant exactly one first day", base.Rewards)
}
if _, err = e.ClaimSubscriptions("same day"); err != nil || len(base.Rewards) != 1 {
t.Fatal("same-day login duplicated first day", err)
}
}
-331
View File
@@ -1,331 +0,0 @@
package commerce
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type grantedResolver interface {
ResolveGranted([]gamedata.BattleReward) ([]gamedata.BattleReward, error)
}
type entitlementReceipt struct {
Definition []byte `json:"definition"`
Bundle []byte `json:"bundle"`
}
type cashSubscription struct {
Start, Expiry int64
Claimed uint64
LastDay string
}
type entitlementState struct {
Receipts map[string]entitlementReceipt `json:"receipts"`
Subscriptions map[string]cashSubscription `json:"subscriptions"`
}
type EntitlementEconomy struct {
mu sync.Mutex
store stateio.Store
base Economy
graph grantedResolver
items *player.Inventory
design *gamedata.CashEntitlementDesign
now func() time.Time
resetSeconds int64
mail CashMailIssuer
}
func NewEntitlementEconomy(store stateio.Store, base Economy, graph grantedResolver, items *player.Inventory, design *gamedata.CashEntitlementDesign) (*EntitlementEconomy, error) {
if store == nil || base == nil || graph == nil || items == nil || design == nil {
return nil, fmt.Errorf("commerce: missing entitlement dependency")
}
e := &EntitlementEconomy{store: store, base: base, graph: graph, items: items, design: design, now: time.Now}
_, err := e.load()
return e, err
}
func (e *EntitlementEconomy) SetClock(now func() time.Time, resetSeconds int64) {
e.now = now
e.resetSeconds = resetSeconds
}
func (e *EntitlementEconomy) day() string {
return e.now().UTC().Add(-time.Duration(e.resetSeconds) * time.Second).Format("2006-01-02")
}
func (e *EntitlementEconomy) load() (entitlementState, error) {
s := entitlementState{Receipts: map[string]entitlementReceipt{}, Subscriptions: map[string]cashSubscription{}}
raw, err := e.store.Load("commerce_entitlements")
if err != nil || raw == nil {
return s, err
}
if err = stateio.RequireExactJSONObject(raw, "receipts", "subscriptions"); err != nil {
return s, err
}
if err = json.Unmarshal(raw, &s); err != nil {
return s, err
}
if s.Receipts == nil || s.Subscriptions == nil {
return s, fmt.Errorf("commerce: malformed entitlement state")
}
for id, r := range s.Receipts {
if id == "" || len(r.Definition) != sha256.Size {
return s, fmt.Errorf("commerce: malformed entitlement receipt")
}
}
for id, sub := range s.Subscriptions {
ticket, err := strconv.ParseUint(id, 10, 64)
if err != nil || len(e.design.Attendance[ticket]) == 0 || sub.Start <= 0 || sub.Expiry != 0 && sub.Expiry < sub.Start || sub.Claimed == 0 || sub.LastDay == "" {
return s, fmt.Errorf("commerce: malformed subscription")
}
}
return s, nil
}
func (e *EntitlementEconomy) save(s entitlementState) error {
b, err := json.Marshal(s)
if err != nil {
return err
}
return e.store.Save("commerce_entitlements", b)
}
func (e *EntitlementEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
return e.apply(identity, costs, rewards)
}
func (e *EntitlementEconomy) ApplyResolved(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
return e.applyPrepared(identity, costs, rewards, true, nil)
}
func (e *EntitlementEconomy) apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
return e.applyPrepared(identity, costs, rewards, false, nil)
}
func (e *EntitlementEconomy) applyPrepared(identity string, costs, rewards []gamedata.Reward, resolved bool, mailed []gamedata.BattleReward) ([]byte, error) {
if identity == "" {
return nil, fmt.Errorf("commerce: missing entitlement identity")
}
definition, _ := json.Marshal(struct{ Costs, Rewards []gamedata.Reward }{costs, rewards})
digest := sha256.Sum256(definition)
s, err := e.load()
if err != nil {
return nil, err
}
if r, ok := s.Receipts[identity]; ok {
if !bytes.Equal(r.Definition, digest[:]) {
return nil, fmt.Errorf("commerce: entitlement identity reused")
}
return append([]byte(nil), r.Bundle...), nil
}
input := make([]gamedata.BattleReward, len(rewards))
for i, r := range rewards {
input[i] = gamedata.BattleReward{Type: r.Type, ID: r.ID, Count: r.Count} //nolint:staticcheck // S1016
}
leaves := input
if !resolved {
leaves, err = e.graph.ResolveGranted(input)
}
if err != nil {
return nil, err
}
// Some product boxes include the first attendance reward (draw-ticket
// subscriptions); others include only the upfront paid currency. Add only
// the missing first-day components on activation, then mark row one claimed.
initial := append([]gamedata.BattleReward(nil), leaves...)
for _, r := range initial {
rows := e.design.Attendance[r.ID]
if r.Type != 19 || len(rows) == 0 {
continue
}
sub := s.Subscriptions[strconv.FormatUint(r.ID, 10)]
if sub.Start != 0 && (sub.Expiry == 0 || sub.Expiry > e.now().UnixMilli()) {
continue
}
first, resolveErr := e.graph.ResolveGranted([]gamedata.BattleReward{rows[0].Reward})
if resolveErr != nil {
return nil, resolveErr
}
available := map[[2]uint64]uint64{}
for _, leaf := range append(append([]gamedata.BattleReward(nil), leaves...), mailed...) {
k := [2]uint64{leaf.Type, leaf.ID}
available[k] += leaf.Count
}
for _, leaf := range first {
k := [2]uint64{leaf.Type, leaf.ID}
if available[k] < leaf.Count {
leaf.Count -= available[k]
leaves = append(leaves, leaf)
}
}
}
var regular []gamedata.Reward
var special []player.Item
for _, r := range leaves {
if r.Count == 0 || r.Count > math.MaxInt32 {
return nil, fmt.Errorf("commerce: invalid entitlement quantity")
}
switch {
case r.Type == 62:
if !e.design.AvatarSets[r.ID] {
return nil, fmt.Errorf("commerce: unknown avatar set %d", r.ID)
}
// The shared gameplay economy expands AvatarSetTable members and
// emits real AvatarItem/AvatarMotion/AvatarChar ownership.
regular = append(regular, gamedata.Reward(r))
case r.Type == 19 && e.design.TicketTypes[r.ID] == 2:
if len(e.design.Attendance[r.ID]) == 0 {
return nil, fmt.Errorf("commerce: subscription reward schedule missing")
}
now := e.now().UnixMilli()
expiry := max(int64(e.items.ContentTicketExpiry(r.ID)), now)
if r.Count > uint64((math.MaxInt64-expiry)/(30*86400000)) {
return nil, fmt.Errorf("commerce: subscription expiry overflow")
}
expiry += int64(r.Count) * 30 * 86400000
special = append(special, player.Item{Type: 19, ID: r.ID, Count: r.Count, ExpiryTime: uint64(expiry), TimeValue: uint64(e.now().UnixMilli())})
key := strconv.FormatUint(r.ID, 10)
sub := s.Subscriptions[key]
if sub.Start == 0 || sub.Expiry <= now {
sub = cashSubscription{Start: now, Claimed: 1, LastDay: e.day()}
}
sub.Expiry = expiry
s.Subscriptions[key] = sub
default:
regular = append(regular, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count}) //nolint:staticcheck // S1016
if r.Type == 19 && e.design.TicketTypes[r.ID] == 3 && len(e.design.Attendance[r.ID]) > 0 {
key := strconv.FormatUint(r.ID, 10)
if _, exists := s.Subscriptions[key]; !exists {
s.Subscriptions[key] = cashSubscription{Start: e.now().UnixMilli(), Claimed: 1, LastDay: e.day()}
}
}
}
}
bundle, err := e.base.Apply(identity+":base", costs, regular)
if err != nil {
return nil, err
}
items, err := e.items.GrantCommerceOnce(identity+":special", special)
if err != nil {
return nil, err
}
for _, i := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(i))
bundle = wire.AppendBytes(bundle, 6, player.ItemWire(player.Item{ID: i.ID, Type: i.Type, Count: i.Count}))
}
s.Receipts[identity] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)}
if err = e.save(s); err != nil {
return nil, err
}
return bundle, nil
}
// ClaimSubscriptions grants the next row once per server reset day. Missing
// login days are not retroactively claimed. First-row purchase rewards are
// already present in the product box, matching the client's first-row marker.
func (e *EntitlementEconomy) ClaimSubscriptions(identity string) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
if identity == "" {
return nil, fmt.Errorf("commerce: missing attendance receipt identity")
}
s, err := e.load()
if err != nil {
return nil, err
}
receiptKey := "attendance-reply:" + identity
if receipt, ok := s.Receipts[receiptKey]; ok {
return append([]byte(nil), receipt.Bundle...), nil
}
keys := make([]string, 0, len(s.Subscriptions))
for k := range s.Subscriptions {
keys = append(keys, k)
}
sort.Strings(keys)
var bundle []byte
for _, key := range keys {
sub := s.Subscriptions[key]
ticket, _ := strconv.ParseUint(key, 10, 64)
rows := e.design.Attendance[ticket]
if sub.LastDay == e.day() || sub.Expiry != 0 && sub.Expiry <= e.now().UnixMilli() || sub.Expiry == 0 && sub.Claimed >= uint64(len(rows)) {
continue
}
index := sub.Claimed % uint64(len(rows))
r := rows[index]
grant, err := e.apply(fmt.Sprintf("commerce:attendance:%s:%s", key, e.day()), nil, []gamedata.Reward{{Type: r.Reward.Type, ID: r.Reward.ID, Count: r.Reward.Count}})
if err != nil {
return nil, err
}
bundle = append(bundle, grant...)
// apply persists its receipt; reload before saving progress so it survives.
latest, err := e.load()
if err != nil {
return nil, err
}
sub.Claimed++
sub.LastDay = e.day()
latest.Subscriptions[key] = sub
if err = e.save(latest); err != nil {
return nil, err
}
s = latest
}
latest, err := e.load()
if err != nil {
return nil, err
}
digest := sha256.Sum256([]byte(receiptKey))
latest.Receipts[receiptKey] = entitlementReceipt{Definition: append([]byte(nil), digest[:]...), Bundle: append([]byte(nil), bundle...)}
if err = e.save(latest); err != nil {
return nil, err
}
return bundle, nil
}
func (e *EntitlementEconomy) MergeAttendance(response []byte) ([]byte, error) {
e.mu.Lock()
defer e.mu.Unlock()
s, err := e.load()
if err != nil {
return nil, err
}
out := append([]byte(nil), response...)
keys := make([]string, 0, len(s.Subscriptions))
for k := range s.Subscriptions {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
sub := s.Subscriptions[key]
ticket, _ := strconv.ParseUint(key, 10, 64)
if sub.Expiry != 0 {
b := wire.AppendVarint(nil, 1, ticket)
b = wire.AppendVarint(b, 2, uint64(sub.Start))
b = wire.AppendVarint(b, 3, uint64(sub.Expiry))
out = wire.AppendBytes(out, 3, b)
}
if typ := e.design.AttendanceTypes[ticket]; typ != 0 {
rewards := []byte{}
n := min(sub.Claimed, uint64(len(e.design.Attendance[ticket])))
for i := uint64(1); i <= n; i++ {
rewards = wire.AppendVarint(rewards, 1, i)
}
entry := wire.AppendVarint(nil, 1, typ)
entry = wire.AppendBytes(entry, 2, rewards)
out = wire.AppendBytes(out, 7, entry)
} else {
for i := uint64(1); i <= sub.Claimed; i++ {
b := wire.AppendVarint(nil, 1, ticket)
b = wire.AppendVarint(b, 2, i)
b = wire.AppendVarint(b, 3, 1)
out = wire.AppendBytes(out, 4, b)
}
}
}
return out, nil
}
@@ -1,143 +0,0 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
"time"
)
type entitlementGraph struct{ Calls int }
func (g *entitlementGraph) ResolveGranted(r []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
g.Calls++
return r, nil
}
type entitlementBase struct {
Calls int
Rewards []gamedata.Reward
}
func (b *entitlementBase) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
b.Calls++
b.Rewards = append(b.Rewards, r...)
return wire.AppendVarint(nil, 7, 1), nil
}
func entitlementFixture(t *testing.T) (*EntitlementEconomy, *player.Inventory, *entitlementBase, *entitlementGraph, *time.Time) {
t.Helper()
store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
d := &gamedata.CashEntitlementDesign{AvatarSets: map[uint64]bool{10: true}, TicketTypes: map[uint64]uint64{38: 2, 99: 3}, Attendance: map[uint64][]gamedata.CashAttendanceReward{38: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 5}}}, 99: {{ID: 1, Reward: gamedata.BattleReward{Type: 3, Count: 1}}, {ID: 2, Reward: gamedata.BattleReward{Type: 3, Count: 7}}}}, AttendanceTypes: map[uint64]uint64{38: 1}}
base := &entitlementBase{}
graph := &entitlementGraph{}
e, err := NewEntitlementEconomy(store, base, graph, items, d)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
e.SetClock(func() time.Time { return now }, 0)
return e, items, base, graph, &now
}
func TestEntitlementTypedSkinExpiryRetryAndRenewal(t *testing.T) {
e, items, base, graph, now := entitlementFixture(t)
rewards := []gamedata.Reward{{Type: 62, ID: 10, Count: 1}, {Type: 19, ID: 38, Count: 1}, {Type: 45, ID: 999, Count: 1}}
first, err := e.Apply("buy1", nil, rewards)
if err != nil {
t.Fatal(err)
}
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+30*86400000) || len(base.Rewards) != 3 || base.Rewards[0].Type != 62 || base.Rewards[1].Type != 45 {
t.Fatal("expiry or prestige dispatch invalid")
}
for _, item := range items.All() {
if item.Type == 62 {
t.Fatal("commerce stored synthetic avatar set")
}
}
again, err := e.Apply("buy1", nil, rewards)
if err != nil || !bytes.Equal(first, again) || graph.Calls != 2 || base.Calls != 1 {
t.Fatal("retry rerolled or double granted")
}
if _, err = e.Apply("buy1", nil, nil); err == nil {
t.Fatal("identity reuse accepted")
}
if _, err = e.Apply("invalid", nil, []gamedata.Reward{{Type: 62, ID: 888, Count: 1}}); err == nil || base.Calls != 1 {
t.Fatal("unknown skin partially applied")
}
if _, err = e.Apply("buy2", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
t.Fatal(err)
}
if items.ContentTicketExpiry(38) != uint64(now.UnixMilli()+60*86400000) {
t.Fatal("renewal lost prior period")
}
merged, err := e.MergeAttendance(nil)
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(merged, 3); !ok {
t.Fatal("subscription info missing")
}
if _, ok, _ := wire.Bytes(merged, 7); !ok {
t.Fatal("monthly reward map missing")
}
}
func TestSubscriptionDailyClaimResetExpiryAndPermanentCompletion(t *testing.T) {
e, _, base, _, now := entitlementFixture(t)
_, err := e.Apply("buy", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 19, ID: 99, Count: 1}})
if err != nil {
t.Fatal(err)
}
calls := base.Calls
if _, err = e.ClaimSubscriptions("login"); err != nil || base.Calls != calls {
t.Fatal("purchase day double claim")
}
*now = now.Add(24 * time.Hour)
if _, err = e.ClaimSubscriptions("login2"); err != nil || base.Calls != calls+2 {
t.Fatalf("daily claims %v calls%d", err, base.Calls)
}
calls = base.Calls
if _, err = e.ClaimSubscriptions("retry"); err != nil || base.Calls != calls {
t.Fatal("daily retry grants again")
}
*now = now.Add(31 * 24 * time.Hour)
if _, err = e.ClaimSubscriptions("expired"); err != nil || base.Calls != calls {
t.Fatal("expired or completed claim")
}
merged, err := e.MergeAttendance(nil)
if err != nil {
t.Fatal(err)
}
if _, ok, _ := wire.Bytes(merged, 4); !ok {
t.Fatal("permanent attendance markers missing")
}
}
func TestSubscriptionFirstRewardAndLostReply(t *testing.T) {
e, _, base, _, now := entitlementFixture(t)
_, err := e.Apply("covered", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}, {Type: 3, Count: 1}})
if err != nil {
t.Fatal(err)
}
if len(base.Rewards) != 1 || base.Rewards[0].Count != 1 {
t.Fatalf("first-day reward duplicated: %+v", base.Rewards)
}
*now = now.Add(24 * time.Hour)
first, err := e.ClaimSubscriptions("same-session-request")
if err != nil || len(first) == 0 {
t.Fatalf("claim %x %v", first, err)
}
calls := base.Calls
replay, err := e.ClaimSubscriptions("same-session-request")
if err != nil || !bytes.Equal(first, replay) || base.Calls != calls {
t.Fatal("lost reply not replayed exactly")
}
fresh, err := e.ClaimSubscriptions("new-request-same-day")
if err != nil || len(fresh) != 0 || base.Calls != calls {
t.Fatal("new same-day request repeated award")
}
}
@@ -1,74 +0,0 @@
package commerce
import (
"fmt"
"sort"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
// AttachEventShopSchedules joins event type 15 to EventShopTable and then to
// CashProductTable. Hub IDs are presentation identities, never product groups.
// All scheduled goods are published; purchase handlers enforce their windows.
// Call once before serving sessions.
func (s *Service) AttachEventShopSchedules(design *gamedata.CashCatalog, schedules []events.Schedule) error {
if design == nil || s.shopWindows == nil {
return fmt.Errorf("commerce: missing event shop catalog or shop seed")
}
shops := map[uint64]uint64{}
groups := map[uint64]bool{}
for _, shop := range design.EventShops {
if shop.ID == 0 || shop.ProductGroupID == 0 || shops[shop.ID] != 0 || groups[shop.ProductGroupID] {
return fmt.Errorf("commerce: invalid or duplicate event shop identity")
}
shops[shop.ID], groups[shop.ProductGroupID] = shop.ProductGroupID, true
}
windows := map[gamedata.CashProductKey][][2]uint64{}
var products [][]byte
for _, raw := range s.shopProducts {
group, _, _ := wire.Varint(raw, 1)
if !groups[group] {
products = append(products, raw)
}
}
rows := append([]events.Schedule(nil), schedules...)
sort.Slice(rows, func(i, j int) bool { return rows[i].UID < rows[j].UID })
for _, row := range rows {
if row.Type != 15 {
continue
}
group := shops[row.ID]
if group == 0 || row.UID == 0 || row.Start <= 0 || row.Start >= row.End {
return fmt.Errorf("commerce: invalid event shop schedule %d", row.UID)
}
found := false
start, end := uint64(row.Start), uint64(row.End)
for _, product := range s.catalog.Designs() {
key := product.Key
if key.GroupID != group {
continue
}
found = true
for _, prior := range windows[key] {
if start < prior[1] && prior[0] < end {
return fmt.Errorf("commerce: overlapping event shop product windows")
}
}
windows[key] = append(windows[key], [2]uint64{start, end})
raw := wire.AppendVarint(nil, 1, key.GroupID)
raw = wire.AppendVarint(raw, 2, key.ProductID)
raw = wire.AppendVarint(raw, 3, key.SaleGroup)
raw = wire.AppendVarint(raw, 4, start)
raw = wire.AppendVarint(raw, 5, end)
raw = wire.AppendVarint(raw, 8, row.UID)
products = append(products, raw)
}
if !found {
return fmt.Errorf("commerce: event shop %d has no products", row.ID)
}
}
s.shopProducts, s.eventShopWindows, s.eventShopGroups = products, windows, groups
return nil
}
@@ -1,166 +0,0 @@
package commerce
import (
"bytes"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestEventShopPublishesAllSchedulesAndNativePurchaseReplay(t *testing.T) {
s, eco, store := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 700, ProductID: 3}
d := gamedata.CashProductDesign{Key: key, PriceType: 8, PriceID: 99, PriceCount: 5, RandomBoxID: 20, PurchaseLimitType: 4, PurchaseLimitCount: 2, BulkOrderAvailability: 1}
s.catalog.designs[key] = d
s.shopWindows = map[gamedata.CashProductKey][2]uint64{}
design := &gamedata.CashCatalog{EventShops: []gamedata.EventShopDesign{{ID: 7, ProductGroupID: 700}}}
rows := []events.Schedule{{UID: 9, Type: 15, ID: 7, Start: 100, End: 200}}
if err := s.AttachEventShopSchedules(design, rows); err != nil {
t.Fatal(err)
}
for _, now := range []int64{50, 150, 250} {
s.SetClock(func() time.Time { return time.UnixMilli(now) }, 0)
_, out, _, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s")
raw, found, _ := wire.Bytes(out, 1)
group, _, _ := wire.Varint(raw, 1)
uid, _, _ := wire.Varint(raw, 8)
if err != nil || !found || group != 700 || uid != 9 {
t.Fatalf("calendar filtered at %d: %v", now, err)
}
if s.IsAvailable(key) != (now == 150) {
t.Fatal("window authorization differs")
}
}
req := wire.AppendVarint(nil, 1, 1)
req = wire.AppendVarint(req, 3, 700)
line := wire.AppendVarint(nil, 1, 3)
line = wire.AppendVarint(line, 3, 2)
req = wire.AppendBytes(req, 4, line)
if _, _, handled, err := s.HandleSession("/CashShopBuy", req, "s"); !handled || err == nil || eco.calls != 0 {
t.Fatal("expired purchase authorized")
}
s.SetClock(func() time.Time { return time.UnixMilli(150) }, 0)
_, out, handled, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil || !handled || eco.calls != 1 {
t.Fatal(err, handled, eco.calls)
}
if len(eco.costs) != 1 || eco.costs[0] != (gamedata.Reward{Type: 8, ID: 99, Count: 10}) || eco.rewards[0].Count != 2 {
t.Fatal(eco)
}
// Reload service state, then replay the same request without another grant.
s2, err := NewService(s.catalog, store, eco)
if err != nil {
t.Fatal(err)
}
s2.shopWindows = map[gamedata.CashProductKey][2]uint64{}
if err = s2.AttachEventShopSchedules(design, rows); err != nil {
t.Fatal(err)
}
s2.SetClock(func() time.Time { return time.UnixMilli(250) }, 0)
_, replay, _, err := s2.HandleSession("/CashShopBuy", req, "s")
if err != nil || !bytes.Equal(out, replay) || eco.calls != 1 {
t.Fatal("replay failed", err)
}
req[1] = 2
if _, _, _, err = s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 1 {
t.Fatal("purchase limit ignored")
}
}
func TestNativeEventShopSQLiteRollsBackDebitGrantAndPurchaseCount(t *testing.T) {
fixture, _, _ := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 700, ProductID: 3}
fixture.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 3, PriceCount: 5, RandomBoxID: 20, PurchaseLimitType: 4, PurchaseLimitCount: 1}
design := &gamedata.CashCatalog{EventShops: []gamedata.EventShopDesign{{ID: 7, ProductGroupID: 700}}}
rows := []events.Schedule{{UID: 9, Type: 15, ID: 7, Start: 100, End: 200}}
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(repo, player.Currency{FreeJewelry: 10})
if err != nil {
t.Fatal(err)
}
if err = wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
open := func() *Service {
t.Helper()
s, err := NewService(fixture.catalog, repo, walletPurchaseEconomy{wallet, repo})
if err != nil {
t.Fatal(err)
}
s.shopWindows = map[gamedata.CashProductKey][2]uint64{}
if err = s.AttachEventShopSchedules(design, rows); err != nil {
t.Fatal(err)
}
s.SetClock(func() time.Time { return time.UnixMilli(150) }, 0)
return s
}
s := open()
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("grant hook failed") })
req := wire.AppendVarint(nil, 1, 1)
req = wire.AppendVarint(req, 3, 700)
line := wire.AppendVarint(nil, 1, 3)
line = wire.AppendVarint(line, 3, 1)
req = wire.AppendBytes(req, 4, line)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, handled, err := s.HandleSession("/CashShopBuy", req, "s"); !handled || err == nil {
t.Fatal("hook failure swallowed")
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := repo.Close(); err != nil {
t.Error(err)
}
}()
wallet, err = player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
if wallet.Snapshot().FreeJewelry != 10 {
t.Fatal("debit survived rollback")
}
for _, name := range []string{"commerce", "commerce_test_rewards"} {
raw, err := repo.Load(name)
if err != nil || raw != nil {
t.Fatal("grant or receipt survived rollback", name, err)
}
}
s = open()
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", req, "s"); err != nil {
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().FreeJewelry != 5 || !s.HasPurchased(key) {
t.Fatal("retry did not persist")
}
}
@@ -1,484 +0,0 @@
package commerce
import (
"bd2server/internal/server/calendar"
"bd2server/internal/server/events"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/hunting"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestInstalledCashProductRewardCoverage23510(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
catalog, err := gamedata.LoadCashCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
resolver, err := gamedata.LoadCashRewardResolver(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
// Infinite draw products grant the user's confirmed preview, rather than
// the placeholder cash box. Validate their dedicated design and route.
db, release, err := gamedata.OpenDatabase(root, "20260923193640", "common")
if err != nil {
t.Fatal(err)
}
rowsDB, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable")
if err != nil {
release()
t.Fatal(err)
}
type drawGroup struct{ id, gacha uint64 }
var groups []drawGroup
for rowsDB.Next() {
var id uint64
var raw []byte
if err := rowsDB.Scan(&id, &raw); err != nil {
t.Fatal(err)
}
typ, _, _ := wire.Varint(raw, 17)
sub, _, _ := wire.Varint(raw, 16)
if typ == 1 && sub == 5 {
gacha, _, _ := wire.Varint(raw, 33)
groups = append(groups, drawGroup{id, gacha})
}
}
if err := rowsDB.Err(); err != nil {
t.Fatal(err)
}
if err := rowsDB.Close(); err != nil {
t.Fatal(err)
}
release()
policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
shop, err := NewService(policy, stateio.NewMemory(), &purchaseEconomy{})
if err != nil {
t.Fatal(err)
}
seed, err := readonly.Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "readonly.json"))
if err != nil {
t.Fatal(err)
}
calendars, err := calendar.LoadDirectory(filepath.Join("..", "..", "..", "..", "schedules"), "2.35.10", "20260923193640")
if err != nil {
t.Fatal(err)
}
seed, err = calendars.ApplyReadonly(seed)
if err != nil {
t.Fatal(err)
}
shop.SetClock(func() time.Time { return time.Date(2026, 10, 5, 5, 0, 0, 0, time.UTC) }, 0)
if err := shop.AttachShopSeed(seed); err != nil {
t.Fatal(err)
}
if err := shop.AttachEventShopSchedules(catalog, calendars.Events); err != nil {
t.Fatal(err)
}
graph, err := gamedata.LoadRewardGraph(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
costumes, err := gamedata.LoadRewardCostumeCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
equipment, err := gamedata.LoadRewardEquipmentCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
skins, err := gamedata.LoadPrestigeSkins(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
ownedDesign, err := gamedata.LoadOwnedEventItemDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
avatarRewards, err := gamedata.LoadAvatarRewardDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
templates, err := gamedata.LoadCashMailTemplates(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
newEconomy := func() (*EntitlementEconomy, *player.Wallet) {
store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(store, player.Currency{Jewelry: 100000000, FreeJewelry: 100000000, Gold: 100000000})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(store, nil)
if err != nil {
t.Fatal(err)
}
ownedEquipment, err := player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
for _, persist := range []func() error{items.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted, ownedEquipment.EnsurePersisted} {
if err := persist(); err != nil {
t.Fatal(err)
}
}
base, err := events.NewEconomy(store, items, wallet, collection, ownedEquipment, costumes, equipment, graph, nil)
if err != nil {
t.Fatal(err)
}
base.AttachPrestigeSkins(skins)
base.AttachOwnedItemDesign(ownedDesign)
base.AttachAvatarRewards(avatarRewards)
ap, err := hunting.Open(store, root, "20260923193640", items, wallet, func() (int, error) { return 21, nil }, 0, 0)
if err != nil {
t.Fatal(err)
}
base.AttachHuntingAP(ap)
e, err := NewEntitlementEconomy(store, base, resolver, items, entitlements)
if err != nil {
t.Fatal(err)
}
e.SetClock(func() time.Time { return time.Date(2026, 10, 5, 5, 0, 0, 0, time.UTC) }, 0)
mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet)
if err != nil {
t.Fatal(err)
}
if err = mailbox.AttachCashRewards(e, templates); err != nil {
t.Fatal(err)
}
if err = e.AttachCashMail(mailbox); err != nil {
t.Fatal(err)
}
return e, wallet
}
t.Run("event shop complete calendar and native purchase", func(t *testing.T) {
e, wallet := newEconomy()
shop, err := NewService(policy, e.store, e)
if err != nil {
t.Fatal(err)
}
if err = shop.AttachShopSeed(seed); err != nil {
t.Fatal(err)
}
rows := append([]events.Schedule(nil), calendars.Events...)
var current events.Schedule
var lastEnd int64
for _, row := range rows {
if row.Type == 15 && row.ID == 71 {
current = row
if row.End > lastEnd {
lastEnd = row.End
}
}
}
if current.UID == 0 {
t.Fatal("real event shop 71 calendar missing")
}
future := events.Schedule{UID: 99999999, Type: 15, ID: 71, Start: lastEnd + 86400000, End: lastEnd + 2*86400000}
rows = append(rows, future)
if err = shop.AttachEventShopSchedules(catalog, rows); err != nil {
t.Fatal(err)
}
shop.SetClock(func() time.Time { return time.UnixMilli(current.Start + 1) }, 0)
_, info, handled, err := shop.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "audit")
if err != nil || !handled {
t.Fatal(err)
}
actual := map[uint64]map[gamedata.CashProductKey]bool{}
_ = wire.Walk(info, func(f wire.Field) error {
if f.Number != 1 || f.Type != 2 {
return nil
}
uid, _, _ := wire.Varint(f.Value, 8)
if uid == 0 {
return nil
}
group, _, _ := wire.Varint(f.Value, 1)
id, _, _ := wire.Varint(f.Value, 2)
sale, _, _ := wire.Varint(f.Value, 3)
if actual[uid] == nil {
actual[uid] = map[gamedata.CashProductKey]bool{}
}
actual[uid][gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}] = true
return nil
})
seenOld := false
for _, row := range rows {
if row.Type != 15 {
continue
}
group := uint64(0)
for _, design := range catalog.EventShops {
if design.ID == row.ID {
group = design.ProductGroupID
}
}
if row.ID == 70 {
seenOld = true
}
if row.ID == 71 && group != 900071 {
t.Fatal("event shop 71 product group mismatch")
}
expected := 0
for _, product := range catalog.Products {
if product.Key.GroupID == group {
expected++
if !actual[row.UID][product.Key] {
t.Fatalf("shop %d UID %d missing product %+v", row.ID, row.UID, product.Key)
}
}
}
if len(actual[row.UID]) != expected {
t.Fatalf("shop %d UID %d count=%d expected=%d", row.ID, row.UID, len(actual[row.UID]), expected)
}
}
if !seenOld || len(actual[future.UID]) == 0 {
t.Fatal("old or future event shop filtered")
}
fund := []gamedata.Reward{{Type: 8, ID: 2074, Count: 100}}
if _, err = e.Apply("audit funds", nil, fund); err != nil {
t.Fatal(err)
}
beforeWallet := wallet.Snapshot()
countItem := func(id uint64) uint64 {
var n uint64
for _, item := range e.items.All() {
if item.Type == 8 && item.ID == id {
n += item.Count
}
}
return n
}
beforeCoin, beforeReward := countItem(2074), countItem(710)
request := wire.AppendVarint(nil, 1, 2)
request = wire.AppendVarint(request, 3, 900071)
for _, id := range []uint64{1, 5} {
line := wire.AppendVarint(nil, 1, id)
line = wire.AppendVarint(line, 3, 1)
request = wire.AppendBytes(request, 4, line)
}
code, response, handled, err := shop.HandleSession("/CashShopBuy", request, "audit")
if err != nil || !handled || code != 61 {
t.Fatalf("native batch code=%d handled=%t err=%v", code, handled, err)
}
if wallet.Snapshot().FreeJewelry != beforeWallet.FreeJewelry-100 || countItem(2074) != beforeCoin-12 || countItem(710) != beforeReward+1 {
t.Fatal("native batch debit or real reward missing", wallet.Snapshot(), e.items.All())
}
for _, id := range []uint64{1, 5} {
found := false
for _, raw := range shop.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
p, _, _ := wire.Varint(raw, 2)
n, _, _ := wire.Varint(raw, 4)
if g == 900071 && p == id && n == 1 {
found = true
}
}
if !found {
t.Fatal("native purchase count missing", id)
}
}
balance := wallet.Snapshot()
coins := countItem(2074)
rewardCount := countItem(710)
_, replay, _, err := shop.HandleSession("/CashShopBuy", request, "audit")
if err != nil || !bytes.Equal(response, replay) || wallet.Snapshot() != balance || countItem(2074) != coins || countItem(710) != rewardCount {
t.Fatal("native purchase replay duplicated", err)
}
})
delegated := map[gamedata.CashProductKey]bool{}
retired := map[gamedata.CashProductKey]bool{}
for _, group := range groups {
d, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{group.id})
if err != nil && strings.Contains(err.Error(), "infinite cash product missing") {
// Early infinite draws retain placeholder gold/count=0 boxes in this
// version. They are historical timed products, not grantable purchases.
found := false
for _, p := range catalog.Products {
if p.Key.ProductID != group.gacha {
continue
}
found = true
if p.TimeLimitType == 0 || shop.IsAvailable(p.Key) {
t.Fatalf("retired infinite product %+v is purchasable without a valid preview design", p.Key)
}
retired[p.Key] = true
}
if !found {
t.Fatalf("infinite group=%d has no matching retired product", group.id)
}
continue
}
if err != nil || d == nil || d.Count == 0 {
t.Fatalf("infinite draw group=%d cannot grant confirmed preview: %v", group.id, err)
}
delegated[gamedata.CashProductKey{GroupID: d.ProductGroupID, ProductID: d.ProductID, SaleGroup: d.SaleGroup}] = true
}
cash, recharge, delegateCount, retiredCount, mailedProducts := 0, 0, 0, 0, 0
for _, p := range catalog.Products {
if p.PriceType != 1 {
continue
}
cash++
if p.Recharge {
recharge++
}
if delegated[p.Key] {
delegateCount++
continue
}
if retired[p.Key] {
retiredCount++
continue
}
if p.RandomBoxID == 0 {
t.Errorf("cash product %+v has no reward box", p.Key)
continue
}
for _, box := range []uint64{p.RandomBoxID, p.BonusRandomBoxID} {
if box == 0 {
continue
}
leaves, err := resolver.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: box, Count: 1}})
if err != nil || len(leaves) == 0 {
t.Errorf("cash product %+v box=%d cannot grant rewards: %v", p.Key, box, err)
continue
}
for _, r := range leaves {
if r.Type == 19 && entitlements.TicketTypes[r.ID] == 0 || r.Type == 62 && !entitlements.AvatarSets[r.ID] {
t.Errorf("cash product %+v has unknown entitlement %+v", p.Key, r)
}
}
}
// Exercise the same grant and debit modules used by CashShopBuy against
// isolated in-memory accounts; no real save or authenticated API is touched.
q, err := policy.Quote(p.Key, 1)
if err != nil {
t.Fatal(err)
}
var costs []gamedata.Reward
if q.Cost > 0 {
costs = []gamedata.Reward{{Type: q.ItemType, Count: q.Cost}}
}
rewards := []gamedata.Reward{{Type: 9, ID: p.RandomBoxID, Count: 1}}
if p.BonusRandomBoxID != 0 {
rewards = append(rewards, gamedata.Reward{Type: 9, ID: p.BonusRandomBoxID, Count: 1})
}
e, wallet := newEconomy()
bundle, err := e.ApplyPurchase("installed-audit", costs, rewards)
if err != nil {
t.Errorf("cash product %+v failed real debit/grant: %v", p.Key, err)
continue
}
_ = wire.Walk(bundle, func(f wire.Field) error {
if f.Number == 1 && f.Type == 2 {
typ, _, _ := wire.Varint(f.Value, 3)
if typ == 62 {
t.Errorf("cash product %+v returned synthetic avatar set", p.Key)
}
}
return nil
})
balance := wallet.Snapshot()
replay, err := e.ApplyPurchase("installed-audit", costs, rewards)
if err != nil || !bytes.Equal(replay, bundle) || wallet.Snapshot() != balance {
t.Errorf("cash product %+v replay repeated debit/grant: %v", p.Key, err)
}
mailbox := e.mail.(*mail.Service)
listReq := wire.AppendVarint(nil, 1, 1)
listReq = wire.AppendVarint(listReq, 3, 100)
code, list, ok, err := mailbox.Handle("/CashMailInfo", listReq)
if err != nil || !ok || code != 140 {
t.Fatalf("cash list %+v: %v", p.Key, err)
}
claim := wire.AppendVarint(nil, 1, 2)
mailCount := 0
_ = wire.Walk(list, func(f wire.Field) error {
if f.Number == 1 && f.Type == 2 {
id, _, _ := wire.Varint(f.Value, 1)
template, _, _ := wire.Varint(f.Value, 3)
cash, _, _ := wire.Varint(f.Value, 15)
if !templates[template] || cash != 1 {
t.Errorf("invalid cash mail template/flag %+v", p.Key)
}
claim = wire.AppendVarint(claim, 2, id)
mailCount++
}
return nil
})
if mailCount > 0 {
mailedProducts++
_, claimed, _, err := mailbox.Handle("/MailOpen", claim)
if err != nil {
t.Errorf("cash claim %+v: %v", p.Key, err)
continue
}
claimedBalance := wallet.Snapshot()
claimedItems := e.items.All()
_, replayed, _, err := mailbox.Handle("/MailOpen", claim)
if err != nil || !bytes.Equal(claimed, replayed) || wallet.Snapshot() != claimedBalance || !reflect.DeepEqual(e.items.All(), claimedItems) {
t.Errorf("cash claim replay %+v duplicated: %v", p.Key, err)
}
_, remaining, _, err := mailbox.Handle("/CashMailInfo", listReq)
n, _, _ := wire.Varint(remaining, 2)
if err != nil || n != 0 {
t.Errorf("claimed cash mail remains %+v", p.Key)
}
}
// Cash mailbox delivery must grant exactly what the previous immediate
// reward resolver granted, including subscriptions' first-day deduplication.
reference, referenceWallet := newEconomy()
if _, err := reference.Apply("reference", costs, rewards); err != nil {
t.Fatal(err)
}
itemTotals := func(items []player.Item) map[[3]uint64]uint64 {
totals := map[[3]uint64]uint64{}
for _, item := range items {
totals[[3]uint64{item.Type, item.ID, item.ExpiryTime}] += item.Count
}
return totals
}
if wallet.Snapshot() != referenceWallet.Snapshot() || !reflect.DeepEqual(itemTotals(e.items.All()), itemTotals(reference.items.All())) {
t.Errorf("cash product %+v mailed delivery differs from direct grant: wallet=%+v expected=%+v items=%+v expected=%+v", p.Key, wallet.Snapshot(), referenceWallet.Snapshot(), e.items.All(), reference.items.All())
}
}
if cash != 614 || recharge != 14 || delegateCount != 3 || retiredCount != 5 {
t.Fatalf("cash coverage changed: cash=%d recharge=%d delegated=%d retired=%d", cash, recharge, delegateCount, retiredCount)
}
bonus, err := gamedata.LoadCashBonusCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
rows := bonus.Groups[1]
if len(rows) != 3 || rows[0].RequireCount != 2 || rows[0].Reward.Count != 2 || rows[1].RequireCount != 6 || rows[1].Reward.Count != 15 || rows[2].RequireCount != 8 || rows[2].Reward.Count != 30 {
t.Fatalf("cash bonus thresholds or rewards changed: %+v", rows)
}
t.Logf("validated %d cash rows: %d recharge variants, %d preview designs, %d unavailable historical draws; checked %d products through cash mail creation, batch claim, and replay with typed ticket/skin entitlements", cash, recharge, delegateCount, retiredCount, mailedProducts)
}
@@ -1,235 +0,0 @@
package commerce
import (
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/readonly"
"bd2server/internal/server/wire"
"bytes"
"fmt"
"os"
"testing"
"time"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/hunting"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
)
// Covers static native goods independently of their current publication
// windows. The account is an isolated memory store; no player saves are read.
func TestInstalledAllNativeProductRewardAndPriceDomains(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not configured")
}
const version = "20260923193640"
catalog, err := gamedata.LoadCashCatalog(root, version)
if err != nil {
t.Fatal(err)
}
graph, err := gamedata.LoadRewardGraph(root, version)
if err != nil {
t.Fatal(err)
}
costumes, err := gamedata.LoadRewardCostumeCatalog(root, version)
if err != nil {
t.Fatal(err)
}
options, err := gamedata.LoadRewardEquipmentCatalog(root, version)
if err != nil {
t.Fatal(err)
}
owned, err := gamedata.LoadOwnedEventItemDesign(root, version)
if err != nil {
t.Fatal(err)
}
avatars, err := gamedata.LoadAvatarRewardDesign(root, version)
if err != nil {
t.Fatal(err)
}
skins, err := gamedata.LoadPrestigeSkins(root, version)
if err != nil {
t.Fatal(err)
}
newAccount := func() (*events.Economy, *player.Inventory, *player.Wallet, stateio.Store, *hunting.Service) {
store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(store, nil)
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
economy, err := events.NewEconomy(store, items, wallet, collection, equipment, costumes, options, graph, nil)
if err != nil {
t.Fatal(err)
}
economy.AttachOwnedItemDesign(owned)
economy.AttachAvatarRewards(avatars)
economy.AttachPrestigeSkins(skins)
ap, err := hunting.Open(store, root, version, items, wallet, func() (int, error) { return 21, nil }, 0, 0)
if err != nil {
t.Fatal(err)
}
economy.AttachHuntingAP(ap)
return economy, items, wallet, store, ap
}
economy, _, _, _, _ := newAccount()
families := map[uint64]int{}
free := 0
native := 0
// Each product reward is settled once through the actual shared economy.
// Prices are funded in that same typed domain and spent once separately,
// avoiding historical publication or predecessor constraints in this audit.
for _, product := range catalog.Products {
if product.PriceType == 1 {
continue
}
native++
if native%50 == 0 {
economy, _, _, _, _ = newAccount()
}
families[product.PriceType]++
identity := fmt.Sprintf("native-audit:%d:%d:%d", product.Key.GroupID, product.Key.ProductID, product.Key.SaleGroup)
var cost []gamedata.Reward
if product.PriceType == 0 {
free++
if product.PriceID != 0 || product.PriceCount != 0 {
t.Errorf("free product has nonzero price %+v", product.Key)
}
} else {
cost = []gamedata.Reward{{Type: product.PriceType, ID: product.PriceID, Count: product.PriceCount}}
if _, err = economy.Apply(identity+":fund", nil, cost); err != nil {
t.Errorf("native price domain %+v failed: %v", product.Key, err)
continue
}
}
rewards := []gamedata.Reward{{Type: 9, ID: product.RandomBoxID, Count: 1}}
if product.BonusRandomBoxID != 0 {
rewards = append(rewards, gamedata.Reward{Type: 9, ID: product.BonusRandomBoxID, Count: 1})
}
if _, err = economy.Apply(identity, cost, rewards); err != nil {
t.Errorf("native product %+v real reward/debit failed: %v", product.Key, err)
}
}
if native != 2274 || free != 100 || len(families) != 14 {
t.Fatalf("native design coverage changed: goods=%d free=%d families=%v", native, free, families)
}
t.Run("native dispatcher quantity free and total diamonds", func(t *testing.T) {
base, items, wallet, store, ap := newAccount()
entitlementDesign, err := gamedata.LoadCashEntitlementDesign(root, version)
if err != nil {
t.Fatal(err)
}
resolver, err := gamedata.LoadCashRewardResolver(root, version, graph)
if err != nil {
t.Fatal(err)
}
entitlement, err := NewEntitlementEconomy(store, base, resolver, items, entitlementDesign)
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, wallet)
if err != nil {
t.Fatal(err)
}
templates, err := gamedata.LoadCashMailTemplates(root, version)
if err != nil {
t.Fatal(err)
}
if err = mailbox.AttachCashRewards(entitlement, templates); err != nil {
t.Fatal(err)
}
if err = entitlement.AttachCashMail(mailbox); err != nil {
t.Fatal(err)
}
policy, err := NewCatalog("2.35.10", catalog, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
service, err := NewService(policy, store, entitlement)
if err != nil {
t.Fatal(err)
}
field := func(n int, v uint64) readonly.Field { return readonly.Field{Number: n, Type: 0, Varint: v} }
seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{field(1, 1700001), field(2, 1), field(3, 0), field(4, 1), field(5, uint64(time.Now().Add(time.Hour).UnixMilli()))}}}}}}
if err = service.AttachShopSeed(seed); err != nil {
t.Fatal(err)
}
if _, err = base.Apply("fund diamonds", nil, []gamedata.Reward{{Type: 3, Count: 1000}, {Type: 2, Count: 50000}}); err != nil {
t.Fatal(err)
}
makeBuy := func(seq, group, id, count uint64) []byte {
r := wire.AppendVarint(nil, 1, seq)
r = wire.AppendVarint(r, 3, group)
line := wire.AppendVarint(nil, 1, id)
line = wire.AppendVarint(line, 3, count)
return wire.AppendBytes(r, 4, line)
}
request := makeBuy(1, 400001, 2, 300)
_, bonusBefore, err := ap.HuntingAP()
if err != nil {
t.Fatal(err)
}
code, response, handled, err := service.HandleSession("/CashShopBuy", request, "native-audit")
if err != nil || !handled || code != 61 {
t.Fatalf("native quantity300 code%d handled%t err%v", code, handled, err)
}
balance := wallet.Snapshot()
_, bonus, err := ap.HuntingAP()
if err != nil || balance.FreeJewelry != 0 || balance.Jewelry != 0 || bonus != bonusBefore+18000 {
t.Fatal("total diamond split or AP grant wrong", balance, bonus, err)
}
_, replay, _, err := service.HandleSession("/CashShopBuy", request, "native-audit")
_, againBonus, _ := ap.HuntingAP()
if err != nil || !bytes.Equal(response, replay) || againBonus != bonus || wallet.Snapshot() != balance {
t.Fatal("bulk native replay duplicated", err)
}
found := false
for _, raw := range service.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
count, _, _ := wire.Varint(raw, 4)
if g == 400001 && id == 2 && count == 300 {
found = true
}
}
if !found {
t.Fatal("quantity300 count missing")
}
free := makeBuy(2, 1700001, 1, 1)
if code, _, handled, err = service.HandleSession("/CashShopBuy", free, "native-audit"); err != nil || !handled || code != 61 {
t.Fatal("free native goods failed", err)
}
if wallet.Snapshot().Jewelry != balance.Jewelry || wallet.Snapshot().FreeJewelry != balance.FreeJewelry {
t.Fatal("free goods charged currency")
}
if _, _, _, err = service.HandleSession("/CashShopBuy", makeBuy(3, 1700001, 1, 1), "native-audit"); err == nil {
t.Fatal("free limit bypassed")
}
if _, err = base.Apply("fund resource", nil, []gamedata.Reward{{Type: 8, ID: 1002, Count: 3000}}); err != nil {
t.Fatal(err)
}
if code, _, handled, err = service.HandleSession("/CashShopBuy", makeBuy(4, 700001, 2, 300), "native-audit"); err != nil || !handled || code != 61 {
t.Fatal("native resource quantity300 failed", err)
}
for _, item := range items.All() {
if item.Type == 8 && item.ID == 1002 && item.Count != 0 {
t.Fatal("resource price not consumed", item)
}
}
})
t.Logf("validated %d native goods across %d typed price domains, including %d free goods", native, len(families), free)
}
@@ -1,80 +0,0 @@
package commerce
import (
"os"
"testing"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
)
// Opt in against the installed release; synthetic tests also run without it.
func TestInstalledCashCatalogAndRewardPrograms(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("BD2_REAL_GAMEDATA not set")
}
design, err := gamedata.LoadCashCatalog(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
catalog, err := NewCatalog("2.35.10", design, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
rewards, err := gamedata.LoadCashRewardResolver(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
entitlements, err := gamedata.LoadCashEntitlementDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
counts, recharge := 0, 0
for _, product := range design.Products {
if product.PriceType != 1 {
continue
}
counts++
if product.Recharge {
recharge++
}
quote, err := catalog.Quote(product.Key, 1)
if err != nil {
t.Fatalf("quote %+v: %v", product.Key, err)
}
if product.Recharge && (quote.ItemType != 0 || quote.Amount != 0) {
t.Fatalf("default recharge isn't free: %+v", quote)
}
if !product.Recharge && (quote.ItemType != 2 || quote.Amount == 0) {
t.Fatalf("cash goods aren't paid diamond: %+v", quote)
}
// Infinite preview products use the gacha delegate, whose selected
// costumes replace weighted/empty placeholder reward programs.
if product.Key.GroupID == 1100001 {
continue
}
leaves, err := rewards.ResolveGranted([]gamedata.BattleReward{{Type: 9, ID: product.RandomBoxID, Count: 1}})
if err != nil || len(leaves) == 0 {
t.Fatalf("product %+v: no reward program: %v", product.Key, err)
}
for _, leaf := range leaves {
if leaf.Type == 19 && entitlements.TicketTypes[leaf.ID] == 2 && len(entitlements.Attendance[leaf.ID]) == 0 {
t.Fatalf("monthly reward schedule missing: %d", leaf.ID)
}
if leaf.Type == 62 && !entitlements.AvatarSets[leaf.ID] {
t.Fatalf("avatar set missing: %d", leaf.ID)
}
}
}
if counts != 614 || recharge != 14 {
t.Fatalf("unexpected cash coverage: goods=%d recharge=%d", counts, recharge)
}
for _, key := range []gamedata.CashProductKey{{GroupID: 1100001, ProductID: 9100033}, {GroupID: 310001, ProductID: 507}, {GroupID: 310001, ProductID: 518}} {
quote, err := catalog.Quote(key, 1)
if err != nil || quote.Amount != 1000 || quote.ItemType != 2 {
t.Fatalf("sample %+v: %+v %v", key, quote, err)
}
}
t.Logf("Verified all %d cash products, %d recharge variants and %d platform aliases", counts, recharge, len(catalog.Manifest().Products))
}
-157
View File
@@ -1,157 +0,0 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"encoding/json"
"fmt"
"slices"
"sync"
"time"
)
type loginPassProgress struct {
Free, Premium uint64
LastDay string
}
type loginPassReceipt struct {
Bundle []byte
Rewarded map[uint64]bool
}
type loginPassState struct {
Progress map[uint64]loginPassProgress
Receipts map[string]loginPassReceipt
}
type LoginPasses struct {
mu sync.Mutex
store stateio.Store
design *gamedata.LoginPassCatalog
economy Economy
items ClearPackageInventory
available func(uint64) bool
now func() time.Time
resetSeconds int64
}
func NewLoginPasses(store stateio.Store, design *gamedata.LoginPassCatalog, economy Economy, items ClearPackageInventory, available func(uint64) bool) (*LoginPasses, error) {
if store == nil || design == nil || economy == nil || items == nil || available == nil {
return nil, fmt.Errorf("commerce: invalid login-pass dependencies")
}
s := &LoginPasses{store: store, design: design, economy: economy, items: items, available: available, now: time.Now}
_, err := s.load()
return s, err
}
func (s *LoginPasses) SetClock(now func() time.Time, resetSeconds int64) {
s.now = now
s.resetSeconds = resetSeconds
}
func (s *LoginPasses) load() (loginPassState, error) {
v := loginPassState{Progress: map[uint64]loginPassProgress{}, Receipts: map[string]loginPassReceipt{}}
raw, err := s.store.Load("commerce_login_passes")
if err != nil || raw == nil {
return v, err
}
if err = json.Unmarshal(raw, &v); err != nil {
return v, err
}
if v.Progress == nil || v.Receipts == nil {
return v, fmt.Errorf("commerce: invalid login-pass state")
}
for group, p := range v.Progress {
rows, ok := s.design.Groups[group]
if !ok || p.Free > uint64(len(rows)) || p.Premium > p.Free || p.Free > 0 && p.LastDay == "" {
return v, fmt.Errorf("commerce: invalid login-pass progress")
}
}
return v, nil
}
func (s *LoginPasses) paid(ticket uint64) bool {
for _, i := range s.items.All() {
if i.Type == 19 && i.ID == ticket && i.Count > 0 && (i.ExpiryTime == 0 || i.ExpiryTime > uint64(s.now().UnixMilli())) {
return true
}
}
return false
}
// ClaimAndInfo is called by AttendanceHandler inside the account transaction.
// Free progression advances once per observed reset day, and buying premium
// catches up the already earned rows without advancing the login day count.
func (s *LoginPasses) ClaimAndInfo(identity string) ([]byte, [][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
if identity == "" {
return nil, nil, fmt.Errorf("commerce: missing login-pass identity")
}
v, err := s.load()
if err != nil {
return nil, nil, err
}
keys := make([]uint64, 0, len(s.design.Groups))
for group := range s.design.Groups {
keys = append(keys, group)
}
slices.Sort(keys)
r, seen := v.Receipts[identity]
if !seen {
r = loginPassReceipt{Rewarded: map[uint64]bool{}}
var rewards []gamedata.Reward
day := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second).Format("2006-01-02")
for _, group := range keys {
if !s.available(group) {
continue
}
rows := s.design.Groups[group]
if len(rows) == 0 {
continue
}
p := v.Progress[group]
if p.LastDay != day && p.Free < uint64(len(rows)) {
rewards = append(rewards, rows[p.Free].Free)
p.Free++
p.LastDay = day
r.Rewarded[group] = true
}
if s.paid(rows[0].TicketID) {
for p.Premium < p.Free {
rewards = append(rewards, rows[p.Premium].Premium)
p.Premium++
r.Rewarded[group] = true
}
}
v.Progress[group] = p
}
if len(rewards) > 0 {
r.Bundle, err = s.economy.Apply("login-pass:"+identity, nil, rewards)
if err != nil {
return nil, nil, err
}
}
v.Receipts[identity] = r
raw, err := json.Marshal(v)
if err != nil {
return nil, nil, err
}
if err = s.store.Save("commerce_login_passes", raw); err != nil {
return nil, nil, err
}
}
var infos [][]byte
for _, group := range keys {
if !s.available(group) {
continue
}
p := v.Progress[group]
if p.Free == 0 {
continue
}
b := wire.AppendVarint(nil, 1, group)
b = wire.AppendVarint(b, 2, p.Free)
if r.Rewarded[group] {
b = wire.AppendVarint(b, 3, 1)
}
infos = append(infos, b)
}
return append([]byte(nil), r.Bundle...), infos, nil
}
@@ -1,68 +0,0 @@
package commerce
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
"time"
)
type loginPassEconomy struct {
calls int
rewards [][]gamedata.Reward
}
func (e *loginPassEconomy) Apply(_ string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
e.calls++
e.rewards = append(e.rewards, append([]gamedata.Reward(nil), r...))
return []byte{8, 1}, nil
}
func TestLoginPassFreeDaysPaidCatchupAndRestart(t *testing.T) {
d := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 100, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 200, Count: 1}}, {ID: 2, TicketID: 77, Free: gamedata.Reward{Type: 9, ID: 101, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 201, Count: 1}}}, 20: {{ID: 1, TicketID: 88, Free: gamedata.Reward{Type: 9, ID: 300, Count: 1}, Premium: gamedata.Reward{Type: 9, ID: 400, Count: 1}}}}}
store := stateio.NewMemory()
eco := &loginPassEconomy{}
items := &clearInventory{}
available := func(group uint64) bool { return group == 10 }
s, err := NewLoginPasses(store, d, eco, items, available)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
bundle, infos, err := s.ClaimAndInfo("attendance1")
if err != nil || eco.calls != 1 || len(eco.rewards[0]) != 1 || eco.rewards[0][0].ID != 100 || len(infos) != 1 {
t.Fatal(err, eco.rewards, infos)
}
replay, _, err := s.ClaimAndInfo("attendance1")
if err != nil || !bytes.Equal(bundle, replay) || eco.calls != 1 {
t.Fatal("duplicate login grant", err)
}
_, infos, err = s.ClaimAndInfo("attendance2")
rewarded, _, _ := wire.Varint(infos[0], 3)
if err != nil || eco.calls != 1 || rewarded != 0 {
t.Fatal("same-day advanced")
}
items.items = []player.Item{{Type: 19, ID: 77, Count: 1}}
_, _, err = s.ClaimAndInfo("after-buy")
if err != nil || eco.calls != 2 || len(eco.rewards[1]) != 1 || eco.rewards[1][0].ID != 200 {
t.Fatal("premium catchup missing", err, eco.rewards)
}
now = now.Add(48 * time.Hour)
_, infos, err = s.ClaimAndInfo("next-login")
day, _, _ := wire.Varint(infos[0], 2)
if err != nil || day != 2 || eco.calls != 3 || len(eco.rewards[2]) != 2 {
t.Fatal("missed day incorrectly advanced", err, day)
}
s, err = NewLoginPasses(store, d, eco, items, available)
if err != nil {
t.Fatal(err)
}
s.SetClock(func() time.Time { return now }, 0)
_, _, err = s.ClaimAndInfo("restart")
if err != nil || eco.calls != 3 {
t.Fatal("restart advanced complete pass", err)
}
}
@@ -1,197 +0,0 @@
package commerce
import (
"bytes"
"errors"
"fmt"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/events"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func nativeBuyRequest(seq, group uint64, lines ...[3]uint64) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 3, group)
for _, line := range lines {
row := wire.AppendVarint(nil, 1, line[0])
row = wire.AppendVarint(row, 2, line[1])
row = wire.AppendVarint(row, 3, line[2])
b = wire.AppendBytes(b, 4, row)
}
// Native clients repeat these display fields on subsequent purchases.
return wire.AppendBytes(b, 7, wire.AppendString(nil, 1, "2"))
}
func TestNativeGoodsSingleQuantityFreeClaimsAndDisplayBilling(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 44, ProductID: 2}
d := gamedata.CashProductDesign{Key: key, PriceType: 22, PriceCount: 10, RandomBoxID: 500, PurchaseLimitType: 3, PurchaseLimitCount: 301}
s.catalog.designs[key] = d
req := nativeBuyRequest(1, 44, [3]uint64{2, 0, 300})
code, out, handled, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil || !handled || code != 61 || eco.calls != 1 {
t.Fatal(code, handled, err)
}
if eco.costs[0] != (gamedata.Reward{Type: 22, Count: 3000}) || eco.rewards[0].Count != 300 {
t.Fatal(eco)
}
_, replay, _, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil || !bytes.Equal(out, replay) || eco.calls != 1 {
t.Fatal("duplicate native request", err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(2, 44, [3]uint64{2, 0, 1}), "s"); err != nil {
t.Fatal("display billing treated as receipt", err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(3, 44, [3]uint64{2, 0, 1}), "s"); err == nil || eco.calls != 2 {
t.Fatal("monthly limit ignored")
}
freeKey := gamedata.CashProductKey{GroupID: 45, ProductID: 1}
s.catalog.designs[freeKey] = gamedata.CashProductDesign{Key: freeKey, RandomBoxID: 501, PurchaseLimitType: 4, PurchaseLimitCount: 1}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(4, 45, [3]uint64{1, 0, 1}), "s"); err != nil || len(eco.costs) != 0 {
t.Fatal("free claim failed", err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", nativeBuyRequest(5, 45, [3]uint64{1, 0, 1}), "s"); err == nil {
t.Fatal("free claimed twice")
}
}
func TestNativeMultipleProductsAndSpecialPreviewsAreValidatedBeforeGrant(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 44, ProductID: 1}
other := gamedata.CashProductKey{GroupID: 44, ProductID: 2}
s.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 4, PriceCount: 1, RandomBoxID: 1}
s.catalog.designs[other] = gamedata.CashProductDesign{Key: other, PriceType: 4, PriceCount: 1, RandomBoxID: 2, BulkOrderAvailability: 1}
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(1, 44, [3]uint64{1, 0, 1}, [3]uint64{2, 0, 1}), "s"); err == nil || eco.calls != 0 {
t.Fatal("ineligible batch executed")
}
if err := s.AttachSpecialProducts([]gamedata.CashProductKey{key}); err != nil {
t.Fatal(err)
}
delegates := 0
s.AttachDelegate(func(k gamedata.CashProductKey, _ []byte) ([]byte, bool, error) {
delegates++
if k != key {
return nil, false, fmt.Errorf("unexpected delegate key")
}
return []byte{8, 1}, true, nil
})
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(2, 44, [3]uint64{1, 0, 2}), "s"); err == nil || delegates != 0 || eco.calls != 0 {
t.Fatal("invalid preview mutated state")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(3, 44, [3]uint64{1, 0, 1}), "s"); err != nil || delegates != 1 || len(eco.rewards) != 0 {
t.Fatal("preview did not use delegate", err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", nativeBuyRequest(4, 44, [3]uint64{2, 0, 2}), "s"); err != nil || delegates != 1 {
t.Fatal("ordinary native delegated", err)
}
}
type nativeTestGraph struct{}
func (nativeTestGraph) Resolve(rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
var out []gamedata.BattleReward
for _, r := range rewards {
out = append(out, gamedata.BattleReward{Type: 8, ID: 710, Count: r.Count})
}
return out, nil
}
func TestNativeTotalDiamondPurchaseSQLiteRollbackAndRestartReplay(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(repo, player.Currency{FreeJewelry: 5, Jewelry: 10})
if err != nil {
t.Fatal(err)
}
if err = wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
fixture, _, _ := serviceFixture(t, 0)
key := gamedata.CashProductKey{GroupID: 44, ProductID: 2}
fixture.catalog.designs[key] = gamedata.CashProductDesign{Key: key, PriceType: 3, PriceCount: 7, RandomBoxID: 1}
open := func() (*Service, *player.Inventory) {
t.Helper()
items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(repo, nil)
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(repo)
if err != nil {
t.Fatal(err)
}
economy, err := events.NewEconomy(repo, items, wallet, collection, equipment, &gamedata.RegularGachaCatalog{}, &gamedata.EquipmentGachaCatalog{}, nativeTestGraph{}, nil)
if err != nil {
t.Fatal(err)
}
s, err := NewService(fixture.catalog, repo, economy)
if err != nil {
t.Fatal(err)
}
return s, items
}
s, _ := open()
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("grant failed") })
req := nativeBuyRequest(1, 44, [3]uint64{2, 0, 1})
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil {
t.Fatal("hook failure swallowed")
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := repo.Close(); err != nil {
t.Error(err)
}
}()
wallet, err = player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
s, items := open()
if wallet.Snapshot().FreeJewelry != 5 || wallet.Snapshot().Jewelry != 10 || len(items.All()) != 0 || s.HasPurchased(key) {
t.Fatal("rollback retained partial purchase")
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, out, _, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil {
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().FreeJewelry != 0 || wallet.Snapshot().Jewelry != 8 || len(items.All()) != 1 {
t.Fatal("total diamonds not spent free-first")
}
s, items = open()
_, replay, _, err := s.HandleSession("/CashShopBuy", req, "s")
if err != nil || !bytes.Equal(out, replay) || wallet.Snapshot().Jewelry != 8 || len(items.All()) != 1 {
t.Fatal("restart replay repeated debit/grant", err)
}
}
-39
View File
@@ -1,39 +0,0 @@
package commerce
import "bd2server/internal/server/wire"
type PackInfoHandler struct {
World attendanceHandler
Claims *ClearPackages
}
func (h PackInfoHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/PackInfo" {
return 0, nil, false, nil
}
code, response, handled, err := h.World.Handle(path, request)
if err != nil || !handled {
return code, response, handled, err
}
pack, evil, err := h.Claims.RewardDBInfos()
if err != nil {
return code, nil, true, err
}
var result []byte
err = wire.Walk(response, func(field wire.Field) error {
if field.Number != 3 && field.Number != 4 {
result = append(result, response[field.Start:field.End]...)
}
return nil
})
if err != nil {
return code, nil, true, err
}
for _, info := range pack {
result = wire.AppendBytes(result, 3, info)
}
for _, info := range evil {
result = wire.AppendBytes(result, 4, info)
}
return code, result, true, nil
}
-764
View File
@@ -1,764 +0,0 @@
package commerce
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
// Economy and the receipt store must participate in the caller's account
// transaction. A failed hook, delegate or save then rolls back all purchases.
type Economy interface {
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}
type CountProvider interface{ PurchaseCountDBInfos() [][]byte }
type PurchaseDelegate func(gamedata.CashProductKey, []byte) ([]byte, bool, error)
type PurchaseHook func(string, gamedata.CashProductDesign, uint64) error
type purchaseReceipt struct {
Digest string `json:"digest"`
Response []byte `json:"response"`
}
type purchaseCount struct {
Count uint64 `json:"count"`
Period string `json:"period"`
Lifetime uint64 `json:"lifetime"`
}
type purchaseState struct {
Receipts map[string]purchaseReceipt `json:"receipts"`
Billing map[string]string `json:"billing"`
Counts map[string]purchaseCount `json:"counts"`
}
type Service struct {
mu sync.Mutex
catalog *Catalog
store stateio.Store
economy Economy
now func() time.Time
resetSeconds int64
delegate PurchaseDelegate
specialProducts map[gamedata.CashProductKey]bool
hook PurchaseHook
legacy CountProvider
shopProducts [][]byte
shopWindows map[gamedata.CashProductKey][2]uint64
eventShopWindows map[gamedata.CashProductKey][][2]uint64
eventShopGroups map[uint64]bool
predecessors map[gamedata.CashProductKey][]gamedata.CashProductKey
}
func NewService(catalog *Catalog, store stateio.Store, economy Economy) (*Service, error) {
if catalog == nil || store == nil || economy == nil {
return nil, fmt.Errorf("commerce: invalid service dependencies")
}
s := &Service{catalog: catalog, store: store, economy: economy, now: time.Now}
_, err := s.load()
return s, err
}
func (s *Service) AttachDelegate(d PurchaseDelegate) { s.delegate = d }
func (s *Service) AttachPurchaseHook(h PurchaseHook) { s.hook = h }
func (s *Service) AttachLegacyCounts(p CountProvider) { s.legacy = p }
// AttachPackageRules enforces the client's type-2/type-8 step and relay
// ordering using versioned contentsGroupId/contentsSortId, never SKU numbers.
func (s *Service) AttachPackageRules(packages []gamedata.CashPackageDesign) error {
rules := map[gamedata.CashProductKey][]gamedata.CashProductKey{}
for _, p := range packages {
if (p.PackageType != 2 && p.PackageType != 8) || p.ContentsGroupID == 0 || p.ContentsSortID <= 1 {
continue
}
key := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup}
var prior []gamedata.CashProductKey
for _, q := range packages {
if q.PackageType == p.PackageType && q.ContentsGroupID == p.ContentsGroupID && q.ContentsSortID == p.ContentsSortID-1 {
prior = append(prior, gamedata.CashProductKey{GroupID: q.GroupID, ProductID: q.ID, SaleGroup: q.SaleGroup})
}
}
// Some current-version packages have a sort label above one but an
// independent contents group. Only a real same-group predecessor is a
// progression rule; do not invent links between adjacent product IDs.
if len(prior) == 0 {
continue
}
rules[key] = prior
}
s.predecessors = rules
return nil
}
func (s *Service) predecessorBought(v purchaseState, key gamedata.CashProductKey) bool {
prior := s.predecessors[key]
if len(prior) == 0 {
return true
}
for _, p := range prior {
if v.Counts[skuKey(p)].Lifetime > 0 {
return true
}
}
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
n, _, _ := wire.Varint(raw, 4)
if n > 0 {
for _, p := range prior {
if p.GroupID == g && p.ProductID == id && p.SaleGroup == sale {
return true
}
}
}
}
}
return false
}
// AttachShopSeed retains versioned dynamic windows and event identities. It
// must run before serving sessions; absent windows never authorize timed goods.
func (s *Service) AttachShopSeed(seed *readonly.Seed) error {
if seed == nil {
return fmt.Errorf("commerce: missing shop seed")
}
_, raw, handled, err := seed.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled {
return fmt.Errorf("commerce: invalid shop seed: %w", err)
}
windows := map[gamedata.CashProductKey][2]uint64{}
var products [][]byte
err = wire.Walk(raw, func(f wire.Field) error {
if f.Number != 1 {
return nil
}
if f.Type != 2 {
return fmt.Errorf("commerce: invalid shop product")
}
g, _, e := wire.Varint(f.Value, 1)
if e != nil {
return e
}
id, _, e := wire.Varint(f.Value, 2)
if e != nil {
return e
}
sale, _, e := wire.Varint(f.Value, 3)
if e != nil {
return e
}
start, _, e := wire.Varint(f.Value, 4)
if e != nil {
return e
}
end, _, e := wire.Varint(f.Value, 5)
if e != nil {
return e
}
k := gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}
if _, ok := windows[k]; ok {
return fmt.Errorf("commerce: duplicate shop schedule")
}
if end != 0 && start >= end {
return fmt.Errorf("commerce: invalid shop window")
}
windows[k] = [2]uint64{start, end}
products = append(products, append([]byte(nil), f.Value...))
return nil
})
if err != nil {
return err
}
s.shopProducts = products
s.shopWindows = windows
return nil
}
func (s *Service) available(d gamedata.CashProductDesign) bool {
if s.eventShopGroups[d.Key.GroupID] {
now := uint64(s.now().UnixMilli())
for _, w := range s.eventShopWindows[d.Key] {
if w[0] <= now && now < w[1] {
return true
}
}
return false
}
w, ok := s.shopWindows[d.Key]
if !ok {
return d.TimeLimitType == 0
}
now := uint64(s.now().UnixMilli())
return w[0] <= now && (w[1] == 0 || now < w[1])
}
func (s *Service) IsAvailable(key gamedata.CashProductKey) bool {
d, ok := s.catalog.Design(key)
return ok && s.available(d)
}
func (s *Service) shopInfo() []byte {
var response []byte
for _, raw := range s.shopProducts {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
_, ok := s.catalog.Design(gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale})
if ok {
response = wire.AppendBytes(response, 1, raw)
}
}
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
daily := day.AddDate(0, 0, 1)
days := (8 - int(day.Weekday())) % 7
if days == 0 {
days = 7
}
weekly := day.AddDate(0, 0, days)
monthly := time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC)
for i, reset := range []time.Time{daily, weekly, monthly} {
response = wire.AppendVarint(response, i+2, uint64(reset.Add(time.Duration(s.resetSeconds)*time.Second).UnixMilli()))
}
return response
}
func (s *Service) SetClock(now func() time.Time, resetSeconds int64) {
s.now = now
s.resetSeconds = resetSeconds
}
func skuKey(k gamedata.CashProductKey) string {
return fmt.Sprintf("%d:%d:%d", k.GroupID, k.ProductID, k.SaleGroup)
}
func (s *Service) load() (purchaseState, error) {
v := purchaseState{Receipts: map[string]purchaseReceipt{}, Billing: map[string]string{}, Counts: map[string]purchaseCount{}}
b, e := s.store.Load("commerce")
if e != nil || b == nil {
return v, e
}
if e = stateio.RequireExactJSONObject(b, "receipts", "billing", "counts"); e != nil {
return v, e
}
if e = json.Unmarshal(b, &v); e != nil {
return v, e
}
if v.Receipts == nil || v.Billing == nil || v.Counts == nil {
return v, fmt.Errorf("commerce: invalid state")
}
for identity, receipt := range v.Receipts {
digest, err := hex.DecodeString(receipt.Digest)
if identity == "" || err != nil || len(digest) != sha256.Size || len(receipt.Response) == 0 {
return v, fmt.Errorf("commerce: invalid saved receipt")
}
}
for _, identity := range v.Billing {
if _, ok := v.Receipts[identity]; !ok {
return v, fmt.Errorf("commerce: billing identity has no purchase receipt")
}
}
for key, count := range v.Counts {
if key == "" || count.Period == "" || count.Count == 0 || count.Count > math.MaxInt32 || count.Lifetime < count.Count {
return v, fmt.Errorf("commerce: invalid saved purchase count")
}
}
return v, nil
}
func (s *Service) period(typ uint64) string {
t := s.now().UTC().Add(-time.Duration(s.resetSeconds) * time.Second)
switch typ {
case 1:
return t.Format("2006-01-02")
case 2:
y, w := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", y, w)
case 3:
return t.Format("2006-01")
default:
return "account"
}
}
func (s *Service) count(v purchaseState, d gamedata.CashProductDesign) uint64 {
c := v.Counts[skuKey(d.Key)]
if c.Period != s.period(d.PurchaseLimitType) {
return 0
}
return c.Count
}
func (s *Service) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
switch path {
case "/CashShopInfo":
if s.shopWindows == nil {
return 0, nil, false, nil
}
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
return 60, nil, true, fmt.Errorf("commerce: invalid shop sequence")
}
return 60, s.shopInfo(), true, nil
case "/CashShopPurchaseCountInfo":
if seq, ok, err := wire.Varint(request, 1); err != nil || !ok || seq == 0 || seq > math.MaxInt32 {
return 432, nil, true, fmt.Errorf("commerce: invalid count sequence")
}
infos, err := s.purchaseCountDBInfos()
if err != nil {
return 0, nil, true, err
}
var b []byte
for _, i := range infos {
b = wire.AppendBytes(b, 1, i)
}
return 432, b, true, nil
case "/CashShopBuy":
return s.buy(session, request)
default:
return 0, nil, false, nil
}
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
return s.HandleSession(path, request, "")
}
type buyLine struct {
Key gamedata.CashProductKey
Count uint64
}
func parseBuy(request []byte) (uint64, []buyLine, []string, error) {
seq, found, e := wire.Varint(request, 1)
if e != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, nil, fmt.Errorf("commerce: invalid sequence")
}
group, found, e := wire.Varint(request, 3)
if e != nil || !found || group == 0 {
return 0, nil, nil, fmt.Errorf("commerce: invalid product group")
}
var lines []buyLine
var billing []string
seen := map[gamedata.CashProductKey]bool{}
e = wire.Walk(request, func(f wire.Field) error {
switch f.Number {
case 4:
if f.Type != 2 {
return fmt.Errorf("commerce: invalid buy info")
}
id, ok, err := wire.Varint(f.Value, 1)
if err != nil || !ok || id == 0 {
return fmt.Errorf("commerce: invalid product id")
}
sale, _, err := wire.Varint(f.Value, 2)
if err != nil {
return err
}
n, ok, err := wire.Varint(f.Value, 3)
if err != nil || !ok || n == 0 || n > math.MaxInt32 {
return fmt.Errorf("commerce: invalid buy count")
}
k := gamedata.CashProductKey{GroupID: group, ProductID: id, SaleGroup: sale}
if seen[k] {
return fmt.Errorf("commerce: duplicate buy info")
}
seen[k] = true
lines = append(lines, buyLine{k, n})
case 7:
if f.Type != 2 {
return fmt.Errorf("commerce: invalid billing info")
}
return wire.Walk(f.Value, func(b wire.Field) error {
if b.Number == 1 || b.Number == 2 {
if b.Type != 2 {
return fmt.Errorf("commerce: invalid billing identity")
}
if len(b.Value) > 16384 {
return fmt.Errorf("commerce: excessive billing identity")
}
if len(b.Value) > 0 {
h := sha256.Sum256(b.Value)
billing = append(billing, fmt.Sprintf("%d:%x", b.Number, h))
}
}
return nil
})
}
return nil
})
if e == nil && len(lines) == 0 {
e = fmt.Errorf("commerce: empty purchase")
}
return seq, lines, billing, e
}
func (s *Service) buy(session string, request []byte) (int, []byte, bool, error) {
if session == "" {
return 61, nil, true, fmt.Errorf("commerce: authenticated session required")
}
seq, lines, billing, e := parseBuy(request)
if e != nil {
return 61, nil, true, e
}
var native, cash bool
for _, line := range lines {
if design, ok := s.catalog.Design(line.Key); ok {
if design.PriceType == 1 {
cash = true
} else {
native = true
}
}
}
if native && cash {
return 61, nil, true, fmt.Errorf("commerce: mixed cash and native purchase")
}
if !cash {
// Native BillingInfo contains display identifiers (often a product ID),
// not a unique payment receipt. Request identity provides replay safety.
billing = nil
}
h := sha256.Sum256(request)
digest := hex.EncodeToString(h[:])
sessionHash := sha256.Sum256([]byte(session))
identity := fmt.Sprintf("commerce:%x:%d", sessionHash, seq)
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return 61, nil, true, e
}
if r, ok := v.Receipts[identity]; ok {
if r.Digest != digest {
return 61, nil, true, fmt.Errorf("commerce: sequence reused with different purchase")
}
return 61, append([]byte(nil), r.Response...), true, nil
}
for _, b := range billing {
if _, ok := v.Billing[b]; ok {
return 61, nil, true, fmt.Errorf("commerce: billing receipt already used")
}
}
// Validate every line before charging the first one.
designs := make([]gamedata.CashProductDesign, len(lines))
quotes := make([]Product, len(lines))
for i, l := range lines {
if !s.predecessorBought(v, l.Key) {
return 61, nil, true, fmt.Errorf("commerce: preceding package purchase required")
}
d, ok := s.catalog.Design(l.Key)
if !ok {
return 61, nil, true, fmt.Errorf("commerce: unknown product")
}
if !s.available(d) {
return 61, nil, true, fmt.Errorf("commerce: product is not currently available")
}
if s.specialProducts[l.Key] && (len(lines) != 1 || l.Count != 1) {
return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product")
}
var q Product
if d.PriceType == 1 {
var err error
q, err = s.catalog.Quote(l.Key, l.Count)
if err != nil {
return 61, nil, true, err
}
} else {
cost, err := nativePrice(d, l.Count)
if err != nil {
return 61, nil, true, err
}
q = Product{Enabled: true, ItemType: d.PriceType, Cost: cost}
}
if !q.Enabled {
return 61, nil, true, fmt.Errorf("commerce: product disabled")
}
if len(lines) > 1 && d.BulkOrderAvailability != 1 {
return 61, nil, true, fmt.Errorf("commerce: bulk purchase disabled")
}
n := s.count(v, d)
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
legacyCount, _, _ := wire.Varint(raw, 4)
if g == l.Key.GroupID && id == l.Key.ProductID && sale == l.Key.SaleGroup && legacyCount > n {
n = legacyCount
}
}
}
if d.PurchaseLimitType > 4 {
return 61, nil, true, fmt.Errorf("commerce: unknown purchase limit")
}
if n > math.MaxInt32-l.Count {
return 61, nil, true, fmt.Errorf("commerce: purchase count overflow")
}
if d.PurchaseLimitType != 0 && (d.PurchaseLimitCount == 0 || n+l.Count > d.PurchaseLimitCount) {
return 61, nil, true, fmt.Errorf("commerce: purchase limit exceeded")
}
designs[i] = d
quotes[i] = q
}
if cash {
e = validateAcceptedQuote(request, quotes)
}
if e != nil {
return 61, nil, true, e
}
var bundle []byte
for i, l := range lines {
d, q := designs[i], quotes[i]
operation := fmt.Sprintf("%s:%d", identity, i)
var costs []gamedata.Reward
if q.Cost > 0 {
if d.PriceType != 1 {
costs = []gamedata.Reward{{Type: d.PriceType, ID: d.PriceID, Count: q.Cost}}
if resolver, ok := s.economy.(nativeCostResolver); ok {
var err error
costs, err = resolver.NativePurchaseCosts(costs[0])
if err != nil {
return 61, nil, true, err
}
}
} else {
var typ uint64
switch q.Currency {
case "paid_diamonds":
typ = 2
case "diamonds":
typ = 3
case "gold":
typ = 4
default:
return 61, nil, true, fmt.Errorf("commerce: unknown currency %q", q.Currency)
}
costs = []gamedata.Reward{{Type: typ, Count: q.Cost}}
}
}
var special []byte
handled := false
// Delegate must only select known special products. Parent account transaction
// guarantees its grant and the subsequent debit commit together.
if s.delegate != nil && (d.PriceType == 1 || s.specialProducts[l.Key]) {
special, handled, e = s.delegate(l.Key, request)
if e != nil {
return 61, nil, true, e
}
if handled && (len(lines) != 1 || l.Count != 1) {
return 61, nil, true, fmt.Errorf("commerce: special purchase requires one product")
}
}
var rewards []gamedata.Reward
if !handled {
if d.RandomBoxID == 0 {
return 61, nil, true, fmt.Errorf("commerce: product reward missing")
}
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.RandomBoxID, Count: l.Count})
if d.BonusRandomBoxID != 0 {
rewards = append(rewards, gamedata.Reward{Type: 9, ID: d.BonusRandomBoxID, Count: l.Count})
}
}
var b []byte
var err error
if delivery, ok := s.economy.(interface {
ApplyPurchase(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
}); ok {
b, err = delivery.ApplyPurchase(operation, costs, rewards)
} else {
b, err = s.economy.Apply(operation, costs, rewards)
}
if err != nil {
return 61, nil, true, err
}
bundle = append(bundle, b...)
bundle = append(bundle, special...)
if s.hook != nil {
if err = s.hook(operation, d, l.Count); err != nil {
return 61, nil, true, err
}
}
old := v.Counts[skuKey(l.Key)]
if old.Lifetime > math.MaxUint64-l.Count {
return 61, nil, true, fmt.Errorf("commerce: lifetime purchase count overflow")
}
v.Counts[skuKey(l.Key)] = purchaseCount{Count: s.count(v, d) + l.Count, Period: s.period(d.PurchaseLimitType), Lifetime: old.Lifetime + l.Count}
}
response := wire.AppendBytes(nil, 1, bundle)
v.Receipts[identity] = purchaseReceipt{digest, response}
for _, b := range billing {
v.Billing[b] = identity
}
encoded, e := json.Marshal(v)
if e == nil {
e = s.store.Save("commerce", encoded)
}
return 61, response, true, e
}
// The receipt repeats the client-visible quote for stale-catalog detection.
// Prices always come from Catalog, and a mismatch never changes the debit.
func validateAcceptedQuote(request []byte, quotes []Product) error {
var pay, receipt string
seen := false
err := wire.Walk(request, func(f wire.Field) error {
if f.Number != 7 {
return nil
}
if seen || f.Type != 2 {
return fmt.Errorf("commerce: invalid billing info")
}
seen = true
return wire.Walk(f.Value, func(b wire.Field) error {
if b.Number == 1 {
if b.Type != 2 || pay != "" {
return fmt.Errorf("commerce: invalid pay id")
}
pay = string(b.Value)
}
if b.Number == 2 {
if b.Type != 2 || receipt != "" {
return fmt.Errorf("commerce: invalid receipt")
}
receipt = string(b.Value)
}
return nil
})
})
if err != nil {
return err
}
parts := strings.Split(receipt, ":")
if len(parts) != 4 || parts[0] != "bd2-local-commerce-v1" || parts[1] != pay {
return fmt.Errorf("commerce: local billing quote required")
}
payID, err := strconv.ParseUint(pay, 10, 64)
if err != nil || payID == 0 || strconv.FormatUint(payID, 10) != pay {
return fmt.Errorf("commerce: invalid local pay id")
}
typ, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil || strconv.FormatUint(typ, 10) != parts[2] {
return fmt.Errorf("commerce: invalid accepted quote currency")
}
amount, err := strconv.ParseUint(parts[3], 10, 64)
if err != nil || strconv.FormatUint(amount, 10) != parts[3] {
return fmt.Errorf("commerce: invalid accepted quote amount")
}
var expected uint64
for _, q := range quotes {
if q.ItemType != typ || q.Cost > math.MaxInt32-expected {
return fmt.Errorf("commerce: accepted quote currency mismatch")
}
expected += q.Cost
}
if expected != amount {
return fmt.Errorf("commerce: purchase quote changed; refresh the shop")
}
return nil
}
func (s *Service) purchaseCountDBInfos() ([][]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return nil, e
}
counts := map[gamedata.CashProductKey]uint64{}
if s.legacy != nil {
for _, raw := range s.legacy.PurchaseCountDBInfos() {
g, _, _ := wire.Varint(raw, 1)
id, _, _ := wire.Varint(raw, 2)
sale, _, _ := wire.Varint(raw, 3)
n, _, _ := wire.Varint(raw, 4)
counts[gamedata.CashProductKey{GroupID: g, ProductID: id, SaleGroup: sale}] = n
}
}
for _, d := range s.catalog.Designs() {
if n := s.count(v, d); n > counts[d.Key] {
counts[d.Key] = n
}
}
keys := make([]gamedata.CashProductKey, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
if a.ProductID != b.ProductID {
return a.ProductID < b.ProductID
}
return a.SaleGroup < b.SaleGroup
})
var out [][]byte
for _, k := range keys {
b := wire.AppendVarint(nil, 1, k.GroupID)
b = wire.AppendVarint(b, 2, k.ProductID)
b = wire.AppendVarint(b, 3, k.SaleGroup)
b = wire.AppendVarint(b, 4, counts[k])
out = append(out, b)
}
return out, nil
}
func (s *Service) PurchaseCountDBInfos() [][]byte { v, _ := s.purchaseCountDBInfos(); return v }
// LifetimePurchaseTotal derives cumulative bonuses from committed purchases,
// including previous reset periods, without keeping a second purchase counter.
func (s *Service) LifetimePurchaseTotal(keys []gamedata.CashProductKey) (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return 0, err
}
var total uint64
for _, k := range keys {
n := v.Counts[skuKey(k)].Lifetime
if n > math.MaxInt32-total {
return 0, fmt.Errorf("commerce: bonus purchase count overflow")
}
total += n
}
return total, nil
}
func (s *Service) HasPurchased(k gamedata.CashProductKey) bool {
s.mu.Lock()
defer s.mu.Unlock()
v, e := s.load()
if e != nil {
return false
}
return v.Counts[skuKey(k)].Count > 0
}
// ConsumeEntitlement is an authorization callback within the caller's account
// transaction. Each successful cash activation consumes one lifetime purchase.
// It must not be called recursively from the purchase hook or delegate.
func (s *Service) ConsumeEntitlement(k gamedata.CashProductKey) bool {
s.mu.Lock()
defer s.mu.Unlock()
v, err := s.load()
if err != nil {
return false
}
n := v.Counts[skuKey(k)].Lifetime
used := map[string]uint64{}
raw, err := s.store.Load("commerce_pass_receipts")
if err != nil {
return false
}
if raw != nil {
if err = json.Unmarshal(raw, &used); err != nil || used == nil {
return false
}
}
key := skuKey(k)
if used[key] >= n {
return false
}
used[key]++
raw, err = json.Marshal(used)
if err != nil {
return false
}
return s.store.Save("commerce_pass_receipts", raw) == nil
}
-320
View File
@@ -1,320 +0,0 @@
package commerce
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gameconfig"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/readonly"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"testing"
"time"
)
type purchaseEconomy struct {
calls int
costs, rewards []gamedata.Reward
fail bool
}
func (e *purchaseEconomy) Apply(_ string, c, r []gamedata.Reward) ([]byte, error) {
e.calls++
e.costs = c
e.rewards = r
if e.fail {
return nil, fmt.Errorf("insufficient balance")
}
return []byte{8, 1}, nil
}
func serviceFixture(t *testing.T, limit uint64) (*Service, *purchaseEconomy, *stateio.Memory) {
t.Helper()
design := &gamedata.CashCatalog{Products: []gamedata.CashProductDesign{
{Key: gamedata.CashProductKey{GroupID: 1, ProductID: 1}, GoogleSKU: "topup", PriceType: 1, PriceCount: 2200, RandomBoxID: 10, Recharge: true, NominalPaidDiamonds: 100},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1}, GoogleSKU: "goods", PriceType: 1, PriceCount: 22000, RandomBoxID: 20, PurchaseLimitType: limit, PurchaseLimitCount: 1},
{Key: gamedata.CashProductKey{GroupID: 2, ProductID: 1, SaleGroup: 1}, GoogleSKU: "goods-sale", PriceType: 1, PriceCount: 11000, RandomBoxID: 21, PurchaseLimitType: 4, PurchaseLimitCount: 1},
}}
c, err := NewCatalog("test", design, gameconfig.Default().Purchases)
if err != nil {
t.Fatal(err)
}
eco := &purchaseEconomy{}
store := stateio.NewMemory()
s, err := NewService(c, store, eco)
if err != nil {
t.Fatal(err)
}
return s, eco, store
}
func buyRequest(seq, group, id, sale uint64, billing string) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 3, group)
line := wire.AppendVarint(nil, 1, id)
line = wire.AppendVarint(line, 2, sale)
line = wire.AppendVarint(line, 3, 1)
b = wire.AppendBytes(b, 4, line)
if billing == "" {
billing = fmt.Sprint(seq)
}
if billing == "pay-1" {
billing = "1"
}
typ, amount := uint64(2), uint64(1000)
if sale == 1 {
amount = 500
}
if group == 1 {
typ = 0
amount = 0
}
info := wire.AppendBytes(nil, 1, []byte(billing))
info = wire.AppendBytes(info, 2, []byte(fmt.Sprintf("bd2-local-commerce-v1:%s:%d:%d", billing, typ, amount)))
b = wire.AppendBytes(b, 7, info)
return b
}
func TestPurchaseReplayPersistsAndRejectsCollisions(t *testing.T) {
s, eco, store := serviceFixture(t, 0)
req := buyRequest(1, 2, 1, 0, "pay-1")
code, out, ok, err := s.HandleSession("/CashShopBuy", req, "session")
if err != nil || !ok || code != 61 {
t.Fatal(code, ok, err)
}
if eco.calls != 1 || len(eco.costs) != 1 || eco.costs[0].Type != 2 || eco.costs[0].Count != 1000 || eco.rewards[0].ID != 20 {
t.Fatal(eco)
}
s2, err := NewService(s.catalog, store, eco)
if err != nil {
t.Fatal(err)
}
_, replayed, _, err := s2.HandleSession("/CashShopBuy", req, "session")
if err != nil || !bytes.Equal(out, replayed) || eco.calls != 1 {
t.Fatal(err, eco.calls)
}
changed := buyRequest(1, 2, 1, 1, "")
if _, _, _, err = s2.HandleSession("/CashShopBuy", changed, "session"); err == nil {
t.Fatal("changed sequence accepted")
}
if _, _, _, err = s2.HandleSession("/CashShopBuy", req, "other-session"); err == nil {
t.Fatal("billing identity reused")
}
}
func TestSaleGroupsLimitsAndReset(t *testing.T) {
s, eco, _ := serviceFixture(t, 1)
now := time.Date(2026, 10, 5, 1, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("daily limit ignored")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
t.Fatal("sale group should be independent", err)
}
now = now.Add(24 * time.Hour)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(4, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if eco.calls != 3 {
t.Fatal(eco.calls)
}
}
func TestFailedEconomyDoesNotConsumeLimitAndFreeRecharge(t *testing.T) {
s, eco, _ := serviceFixture(t, 4)
eco.fail = true
req := buyRequest(1, 2, 1, 0, "")
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil {
t.Fatal("failure accepted")
}
if s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
t.Fatal("failed buy persisted")
}
eco.fail = false
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 1, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if len(eco.costs) != 0 || eco.rewards[0].ID != 10 {
t.Fatal("free recharge wrong", eco)
}
}
func TestAuthenticatedSessionRequired(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
if _, _, _, err := s.Handle("/CashShopBuy", buyRequest(1, 2, 1, 0, "")); err == nil || eco.calls != 0 {
t.Fatal("unauthenticated buy accepted")
}
}
func TestPassEntitlementConsumedOncePerPurchase(t *testing.T) {
s, _, _ := serviceFixture(t, 0)
k := gamedata.CashProductKey{GroupID: 2, ProductID: 1}
if s.ConsumeEntitlement(k) {
t.Fatal("unpaid entitlement authorized")
}
for i := uint64(1); i <= 2; i++ {
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(i, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if !s.ConsumeEntitlement(k) || s.ConsumeEntitlement(k) {
t.Fatal("entitlement count not enforced")
}
}
}
func TestRelayRequiresPreviousProduct(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
if err := s.AttachPackageRules([]gamedata.CashPackageDesign{{GroupID: 2, ID: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 1}, {GroupID: 2, ID: 1, SaleGroup: 1, PackageType: 8, ContentsGroupID: 7, ContentsSortID: 2}}); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 1, ""), "s"); err == nil || eco.calls != 0 {
t.Fatal("relay step skipped")
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(3, 2, 1, 1, ""), "s"); err != nil {
t.Fatal(err)
}
}
func TestStaleClientQuoteRejectedBeforeEconomy(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
req := buyRequest(1, 2, 1, 0, "")
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":3:1000"))
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
t.Fatal("stale currency accepted")
}
req = buyRequest(2, 2, 1, 0, "")
req = bytes.ReplaceAll(req, []byte(":2:1000"), []byte(":2:9999"))
if _, _, _, err := s.HandleSession("/CashShopBuy", req, "s"); err == nil || eco.calls != 0 {
t.Fatal("stale amount accepted")
}
}
func TestShopPublishesAllWindowsAndAuthorizesActiveProducts(t *testing.T) {
s, eco, _ := serviceFixture(t, 0)
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
s.SetClock(func() time.Time { return now }, 0)
seed := &readonly.Seed{Responses: map[string]readonly.Response{"/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{{Number: 1, Type: 2, Fields: []readonly.Field{{Number: 1, Type: 0, Varint: 2}, {Number: 2, Type: 0, Varint: 1}, {Number: 4, Type: 0, Varint: uint64(now.Add(-time.Hour).UnixMilli())}, {Number: 5, Type: 0, Varint: uint64(now.Add(time.Hour).UnixMilli())}, {Number: 8, Type: 0, Varint: 123}}}}}}}
if err := s.AttachShopSeed(seed); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Hour)
if _, _, _, err := s.HandleSession("/CashShopBuy", buyRequest(2, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("expired product accepted")
}
code, out, ok, err := s.HandleSession("/CashShopInfo", wire.AppendVarint(nil, 1, 1), "s")
if err != nil || !ok || code != 60 {
t.Fatal(err)
}
products := 0
_ = wire.Walk(out, func(f wire.Field) error {
if f.Number == 1 {
products++
}
return nil
})
if products != 1 || eco.calls != 1 {
t.Fatal(products, eco.calls)
}
}
type walletPurchaseEconomy struct {
wallet *player.Wallet
store stateio.Store
}
func (e walletPurchaseEconomy) Apply(identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
if err := e.wallet.ExchangeOnce(identity, costs, nil); err != nil {
return nil, err
}
raw, err := json.Marshal(rewards)
if err != nil {
return nil, err
}
if err = e.store.Save("commerce_test_rewards", raw); err != nil {
return nil, err
}
return []byte{8, 1}, nil
}
func TestSQLiteAccountTransactionRollsBackCostRewardAndReceipt(t *testing.T) {
s, _, _ := serviceFixture(t, 4)
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repo.Close() })
wallet, err := player.OpenWallet(repo, player.Currency{Jewelry: 1000})
if err != nil {
t.Fatal(err)
}
if err = wallet.EnsurePersisted(); err != nil {
t.Fatal(err)
}
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
if err != nil {
t.Fatal(err)
}
s.AttachPurchaseHook(func(string, gamedata.CashProductDesign, uint64) error { return fmt.Errorf("entitlement failure") })
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err == nil {
t.Fatal("hook failure lost")
}
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := repo.Close(); err != nil {
t.Error(err)
}
}()
wallet, err = player.OpenWallet(repo, player.Currency{})
if err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Jewelry != 1000 {
t.Fatal("paid diamonds survived rollback")
}
for _, name := range []string{"commerce", "commerce_test_rewards"} {
raw, err := repo.Load(name)
if err != nil || raw != nil {
t.Fatal("purchase state survived rollback", name, err)
}
}
s, err = NewService(s.catalog, repo, walletPurchaseEconomy{wallet, repo})
if err != nil {
t.Fatal(err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.HandleSession("/CashShopBuy", buyRequest(1, 2, 1, 0, ""), "s"); err != nil {
_ = op.Rollback()
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Jewelry != 0 || !s.HasPurchased(gamedata.CashProductKey{GroupID: 2, ProductID: 1}) {
t.Fatal("retry did not commit")
}
}
@@ -1,37 +0,0 @@
package cryptox
import (
"bytes"
"testing"
)
func TestAESRoundTripAndFixedKey(t *testing.T) {
plain := []byte("BD2 AES fixture: blocks and PKCS7")
encoded, err := EncryptBase64(plain, Key())
if err != nil {
t.Fatal(err)
}
got, err := DecryptBase64(encoded, Key())
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, plain) {
t.Fatalf("round trip = %q, want %q", got, plain)
}
}
func TestSessionKeyValidation(t *testing.T) {
key, err := SessionKey("7eeb5c2480c298f6fc4c00d22b1b0b15")
if err != nil || string(key) != "7eeb5c2480c298f6fc4c00d22b1b0b15" {
t.Fatalf("SessionKey = %q, %v", key, err)
}
if _, err := SessionKey("not-a-key"); err == nil {
t.Fatal("SessionKey accepted invalid key")
}
}
func TestRejectBadPadding(t *testing.T) {
if _, err := Decrypt(make([]byte, 16), Key()); err == nil {
t.Fatal("Decrypt accepted bad padding")
}
}
-26
View File
@@ -1,26 +0,0 @@
package dbcrypt
import (
"bytes"
"testing"
)
func TestRoundTrip(t *testing.T) {
plain := make([]byte, PageSize*2)
copy(plain, Header)
copy(plain[PageSize:], []byte("second independent page"))
encrypted, err := EncryptPages(plain)
if err != nil {
t.Fatal(err)
}
if bytes.Equal(encrypted, plain) {
t.Fatal("encryption did not change data")
}
decrypted, err := DecryptPages(encrypted)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(decrypted, plain) {
t.Fatal("round trip changed pages")
}
}
-242
View File
@@ -1,242 +0,0 @@
package deck
import (
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
)
func (s *Store) AttachAutoRecovery(f func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.autoRecovery = f
}
func (s *Store) AttachAutoRecoveryAllowed(f func() (bool, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.autoRecoveryAllowed = f
}
type autoRecoveryReceipt struct {
Digest string
Body []byte
}
func (s *Store) handleAutoRecovery(req []byte) (int, []byte, bool, error) {
fail := func(e error) (int, []byte, bool, error) { return 373, nil, true, e }
if e := checkSeq(req); e != nil {
return fail(e)
}
caster, _, e := wire.Varint(req, 2)
if e != nil {
return fail(e)
}
seq, _, _ := wire.Varint(req, 1)
s.mu.Lock()
defer s.mu.Unlock()
key := s.sessionID + ":" + fmt.Sprint(seq)
digest := fmt.Sprintf("%x", sha256.Sum256(req))
if s.storage != nil {
raw, ok, err := s.storage.LoadEntry("deck", "auto_recovery", key)
if err != nil {
return fail(err)
}
if ok {
var r autoRecoveryReceipt
if err = json.Unmarshal(raw, &r); err != nil {
return fail(err)
}
if r.Digest != digest {
return fail(fmt.Errorf("deck: changed automatic recovery replay"))
}
return 373, r.Body, true, nil
}
}
n := clone(s.state)
if len(n.Deck) == 0 {
return fail(fmt.Errorf("deck: automatic recovery has no battle deck"))
}
result := player.AutoRecoveryResult{Caster: caster, Catalyst: n.AutoReviveCatalyst}
if s.wallet != nil {
result.Catalyst = s.wallet.CatalystBalance()
}
mode := uint64(2)
var settings fieldSettings
if s.characters != nil {
settings, e = s.loadFieldSettings()
if e != nil {
return fail(e)
}
if caster != settings.Caster {
return fail(fmt.Errorf("deck: automatic recovery caster differs from saved setting"))
}
if n.FieldCharControlDeckType == 2 {
allExhausted := true
for _, v := range n.Deck {
hp, err := s.characters.CurrentHealth(v.CharacterInvenIndex)
if err != nil {
return fail(err)
}
if hp > 0 {
allExhausted = false
}
}
if allExhausted {
mode = 3
}
}
field := s.visibleFieldDeckLocked()
var targets []uint64
seen := map[uint64]bool{}
add := func(id uint64) error {
if seen[id] {
return nil
}
seen[id] = true
c, ok := s.characters.Find(id)
if !ok || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) {
return nil
}
hp, err := s.characters.CurrentHealth(id)
if err != nil {
return err
}
if hp == 0 {
targets = append(targets, id)
}
return nil
}
if n.FieldCharControlDeckType == 0 { //nolint:staticcheck // QF1003
for _, v := range n.Deck {
if e = add(v.CharacterInvenIndex); e != nil {
return fail(e)
}
}
} else if n.FieldCharControlDeckType == 1 {
for _, v := range field {
if e = add(v.CharacterInvenIndex); e != nil {
return fail(e)
}
}
}
allowed := true
if s.autoRecoveryAllowed != nil {
allowed, e = s.autoRecoveryAllowed()
if e != nil {
return fail(e)
}
}
if settings.AutoRevive && allowed && len(targets) > 0 {
if s.autoRecovery == nil {
return fail(fmt.Errorf("deck: automatic recovery executor unavailable"))
}
result, e = s.autoRecovery(seq, caster, targets)
if e != nil {
return fail(e)
}
settings.Caster = result.Caster
if len(result.Characters) > 0 {
mode = 1
}
}
// On recovery failure, replace fatigued party members with living,
// permanent owned characters in inventory order. Keep the fatigued
// member when no replacement exists so the client can show exhaustion.
if mode != 1 && len(targets) > 0 {
all := s.characters.RawAll()
sort.Slice(all, func(i, j int) bool { return all[i].InvenIndex < all[j].InvenIndex })
used := map[uint64]bool{}
for _, v := range n.Deck {
used[v.CharacterInvenIndex] = true
}
for _, v := range field {
used[v.CharacterInvenIndex] = true
}
replacements := map[uint64]player.Character{}
for _, id := range targets {
for _, c := range all {
if used[c.InvenIndex] || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) {
continue
}
hp, err := s.characters.CurrentHealth(c.InvenIndex)
if err != nil {
return fail(err)
}
if hp == 0 {
continue
}
replacements[id] = c
used[c.InvenIndex] = true
break
}
if _, ok := replacements[id]; !ok {
mode = 3
}
}
for i, v := range n.Deck {
if c, ok := replacements[v.CharacterInvenIndex]; ok {
n.Deck[i].CharacterInvenIndex = c.InvenIndex
}
}
for i, v := range n.FieldDeck {
if c, ok := replacements[v.CharacterInvenIndex]; ok {
n.FieldDeck[i].CharacterInvenIndex = c.InvenIndex
n.FieldDeck[i].CostumeInvenIndex = c.UseCostume
}
}
}
} else if caster != 0 {
return fail(fmt.Errorf("deck: automatic recovery character provider unavailable"))
}
var out []byte
for _, v := range n.Deck {
b := wire.AppendVarint(nil, 1, v.CharacterInvenIndex)
b = wire.AppendVarint(b, 2, v.CostumeInvenIndex)
b = wire.AppendVarint(b, 3, v.Slot)
out = wire.AppendBytes(out, 1, b)
}
for _, v := range n.FieldDeck {
b := wire.AppendVarint(nil, 1, v.Slot)
b = wire.AppendVarint(b, 2, v.CharacterInvenIndex)
b = wire.AppendVarint(b, 3, v.CostumeInvenIndex)
out = wire.AppendBytes(out, 2, b)
}
for _, c := range result.Characters {
out = wire.AppendVarint(out, 3, c.InvenIndex)
out = wire.AppendBytes(out, 5, player.CharacterWire(c))
}
out = wire.AppendVarint(out, 4, mode)
if result.Caster != 0 {
out = wire.AppendVarint(out, 9, result.Caster)
}
out = wire.AppendVarint(out, 6, result.Experience)
out = wire.AppendVarint(out, 7, result.Catalyst)
out = wire.AppendVarint(out, 8, result.Disabled)
if s.storage != nil {
core, err := json.Marshal(n)
if err != nil {
return fail(err)
}
raw, err := json.Marshal(autoRecoveryReceipt{digest, out})
if err != nil {
return fail(err)
}
changes := []stateio.EntryMutation{{Bucket: "auto_recovery", Key: key, Payload: raw}}
if s.characters != nil {
raw, err = json.Marshal(settings)
if err != nil {
return fail(err)
}
changes = append(changes, stateio.EntryMutation{Bucket: "field_settings", Key: "state", Payload: raw})
}
if err = s.storage.SaveWithEntries("deck", core, changes); err != nil {
return fail(err)
}
}
s.state = n
return 373, out, true, nil
}
@@ -1,114 +0,0 @@
package deck
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"bytes"
"testing"
)
func recoveryFixture(t *testing.T) *presetFixture {
f := newPresetFixture(t)
f.deck.state.FieldCharControlDeckType = 0
if e := f.deck.AttachFieldSettings(&gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 14}}); e != nil {
t.Fatal(e)
}
if err := f.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil {
t.Fatal(err)
}
if err := f.characters.SetCurrentHealth(100, 100); err != nil {
t.Fatal(err)
}
if err := f.characters.SetCurrentHealth(200, 0); err != nil {
t.Fatal(err)
}
if _, _, _, e := f.deck.Handle("/DeckSave", req(1, triple(200, 1, 1))); e != nil {
t.Fatal(e)
}
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(2, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 100))); e != nil {
t.Fatal(e)
}
return f
}
func TestAutoRecoveryPersistsResponseAndDisabledSettingNeverExecutes(t *testing.T) {
f := recoveryFixture(t)
calls := 0
f.deck.AttachAutoRecovery(func(seq, caster uint64, targets []uint64) (player.AutoRecoveryResult, error) {
calls++
if caster != 100 || len(targets) != 1 || targets[0] != 200 {
t.Fatal("wrong actual fatigue targets")
}
if err := f.characters.SetCurrentHealth(200, 25); err != nil {
return player.AutoRecoveryResult{}, err
}
c, _ := f.characters.Find(200)
c.HP = 25
return player.AutoRecoveryResult{Caster: 100, Characters: []player.Character{c}, Experience: 2, Catalyst: 10}, nil
})
b := req(3, wire.AppendVarint(nil, 2, 100))
code, out, _, e := f.deck.Handle("/DeckCharAutoRevive", b)
if e != nil || code != 373 {
t.Fatal(e)
}
mode, _, _ := wire.Varint(out, 4)
caster, _, _ := wire.Varint(out, 9)
if mode != 1 || caster != 100 {
t.Fatalf("response%x", out)
}
reopened, e := OpenStore(f.storage, f.seed, testPresetDesign)
if e != nil {
t.Fatal(e)
}
reopened.BeginSession("preset-test")
_, again, _, e := reopened.Handle("/DeckCharAutoRevive", b)
if e != nil || !bytes.Equal(out, again) || calls != 1 {
t.Fatal("automatic recovery receipt replay")
}
if _, _, _, e = f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 3, 100))); e != nil {
t.Fatal(e)
}
if err := f.characters.SetCurrentHealth(200, 0); err != nil {
t.Fatal(err)
}
_, _, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100)))
if e != nil || calls != 1 {
t.Fatal("disabled automatic recovery executed", e)
}
if got := f.deck.CurrentDeck()[0].CharacterInvenIndex; got != 100 {
t.Fatalf("fatigued member not replaced: %d", got)
}
if _, _, _, e = f.deck.Handle("/CharAutoReviveSet", req(6, wire.AppendVarint(nil, 3, 200))); e == nil {
t.Fatal("disabled setting accepted Immortal class14 caster")
}
}
func TestAutoRecoveryFailureReportsExhaustionAndStoryCannotRecover(t *testing.T) {
f := recoveryFixture(t)
if _, _, _, e := f.deck.Handle("/DeckSave", req(3, triple(100, 1, 1), triple(200, 2, 2))); e != nil {
t.Fatal(e)
}
f.deck.AttachAutoRecovery(func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) {
return player.AutoRecoveryResult{Caster: 100, Catalyst: 0, Disabled: 2}, nil
})
_, out, _, e := f.deck.Handle("/DeckCharAutoRevive", req(4, wire.AppendVarint(nil, 2, 100)))
mode, _, _ := wire.Varint(out, 4)
disabled, _, _ := wire.Varint(out, 8)
if e != nil || mode != 3 || disabled != 2 {
t.Fatalf("failure mode%d disabled%d %v", mode, disabled, e)
}
if err := f.characters.SetCurrentHealth(100, 0); err != nil {
t.Fatal(err)
}
f.deck.state.FieldCharControlDeckType = 2
f.deck.AttachAutoRecovery(func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) {
t.Fatal("story mode must not recover temporary party")
return player.AutoRecoveryResult{}, nil
})
_, out, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100)))
mode, _, _ = wire.Varint(out, 4)
if e != nil || mode != 3 {
t.Fatalf("story fatigue mode%d %v", mode, e)
}
}
-65
View File
@@ -1,65 +0,0 @@
package deck
import (
"bd2server/internal/server/wire"
"fmt"
"maps"
"math"
)
func (s *Store) handleCostumeUse(req []byte) (int, []byte, bool, error) {
fail := func(e error) (int, []byte, bool, error) { return 41, nil, true, e }
if e := checkSeq(req); e != nil {
return fail(e)
}
assignments := map[uint64]uint64{}
e := wire.Walk(req, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 {
return fmt.Errorf("deck: invalid costume use entry")
}
cost, _, e := wire.Varint(f.Value, 1)
if e != nil || cost == 0 || cost > math.MaxInt64 {
return fmt.Errorf("deck: invalid costume index")
}
char, _, e := wire.Varint(f.Value, 2)
if e != nil || char == 0 || char > math.MaxInt64 {
return fmt.Errorf("deck: invalid costume character")
}
if _, ok := assignments[char]; ok {
return fmt.Errorf("deck: repeated costume character")
}
assignments[char] = cost
return nil
})
if e != nil {
return fail(e)
}
if len(assignments) == 0 {
return fail(fmt.Errorf("deck: missing costume assignments"))
}
s.mu.Lock()
defer s.mu.Unlock()
if s.characters != nil {
if s.collection == nil {
return fail(fmt.Errorf("deck: costume collection unavailable"))
}
for char, cost := range assignments {
if _, ok := s.characters.Find(char); !ok {
return fail(fmt.Errorf("deck: unknown costume character"))
}
c, ok := s.collection.CostumeByIndex(cost)
if !ok || c.UseChar != char {
return fail(fmt.Errorf("deck: costume not owned by character"))
}
}
if _, e = s.characters.ApplyPresetCostumes(assignments); e != nil {
return fail(e)
}
}
n := clone(s.state)
maps.Copy(n.Costumes, assignments)
return 41, nil, true, s.commit(n)
}
@@ -1,93 +0,0 @@
package deck
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
"path/filepath"
"testing"
)
func TestBatchCostumeUseRestoresCharacterSelectionsFromSQLite(t *testing.T) {
p := filepath.Join(t.TempDir(), "state.db")
r, e := accountstate.Open(p)
if e != nil {
t.Fatal(e)
}
seed, e := LoadSeed("../../../seed/v2_35_10/decks.json")
if e != nil {
t.Fatal(e)
}
initial := []player.Character{{InvenIndex: 100, ID: 350, HP: 80, Level: 1, UseCostume: 1001, CostumeID: 3501, ConnectPotentialCostume: 3501}, {InvenIndex: 200, ID: 360, HP: 60, Level: 1, UseCostume: 2001, CostumeID: 3601, ConnectPotentialCostume: 3601}}
inv, e := player.OpenInventory(r, &player.Starter{Version: "2.35.10"})
if e != nil {
t.Fatal(e)
}
chars, e := player.OpenCharacterStore(r, initial, inv, "", "")
if e != nil {
t.Fatal(e)
}
coll, e := player.OpenCollectionStore(r, []player.Costume{{InvenIndex: 1001, ID: 3501, UseChar: 100}, {InvenIndex: 1002, ID: 3502, UseChar: 100}, {InvenIndex: 2001, ID: 3601, UseChar: 200}, {InvenIndex: 2002, ID: 3602, UseChar: 200}})
if e != nil {
t.Fatal(e)
}
if e = chars.AttachCollection(coll); e != nil {
t.Fatal(e)
}
if e = coll.EnsurePersisted(); e != nil {
t.Fatal(e)
}
if e = chars.EnsurePersisted(); e != nil {
t.Fatal(e)
}
d, e := OpenStore(r, seed)
if e != nil {
t.Fatal(e)
}
d.characters = chars
d.collection = coll
invalid := req(1, wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1002), 2, 100)), wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1001), 2, 200)))
if _, _, _, err := d.Handle("/CostumeUse", invalid); err == nil {
t.Fatal("accepted another character's costume")
}
if c, _ := chars.Find(100); c.UseCostume != 1001 {
t.Fatal("invalid batch partially changed first character")
}
request := req(1, wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1002), 2, 100)), wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 2002), 2, 200)))
if _, body, _, e := d.Handle("/CostumeUse", request); e != nil || len(body) != 0 {
t.Fatal("empty response protocol", e)
}
if e = r.Close(); e != nil {
t.Fatal(e)
}
r, e = accountstate.Open(p)
if e != nil {
t.Fatal(e)
}
defer func() {
if err := r.Close(); err != nil {
t.Error(err)
}
}()
inv, e = player.OpenInventory(r, &player.Starter{Version: "2.35.10"})
if e != nil {
t.Fatal(e)
}
coll, e = player.OpenCollectionStore(r, nil)
if e != nil {
t.Fatal(e)
}
chars, e = player.OpenCharacterStore(r, initial, inv, "", "")
if e != nil {
t.Fatal(e)
}
if e = chars.AttachCollection(coll); e != nil {
t.Fatal(e)
}
for _, v := range []struct{ index, cost, id, hp, connect uint64 }{{100, 1002, 3502, 80, 3501}, {200, 2002, 3602, 60, 3601}} {
c, ok := chars.Find(v.index)
if !ok || c.UseCostume != v.cost || c.CostumeID != v.id || c.HP != v.hp || c.ConnectPotentialCostume != v.connect {
t.Fatalf("reconnected character%+v", c)
}
}
}
-535
View File
@@ -1,535 +0,0 @@
// Package deck owns local deck, field-party, waypoint, and selected-costume
// state. It stores typed JSON, never captured protobuf/base64 envelopes.
package deck
import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
type DeckEntry struct {
CharacterInvenIndex uint64 `json:"character_inven_index"`
// CostumeInvenIndex is retained as the persisted Go/JSON name for the
// development save format. On the wire DeckDBInfo field 2 is Position: a
// zero-based battle-grid cell (or -1 while unassigned), not a costume
// inventory index.
CostumeInvenIndex uint64 `json:"costume_inven_index"`
Slot uint64 `json:"slot"`
}
type FieldEntry struct {
Slot uint64 `json:"slot"`
CharacterInvenIndex uint64 `json:"character_inven_index"`
CostumeInvenIndex uint64 `json:"costume_inven_index"`
}
type Seed struct {
Version string `json:"version"`
FieldDeck []FieldEntry `json:"field_deck"`
FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"`
AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"`
}
type state struct {
Version string `json:"version"`
Deck []DeckEntry `json:"deck"`
FieldDeck []FieldEntry `json:"field_deck"`
FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"`
Waypoints map[uint64][]uint64 `json:"waypoints"`
Costumes map[uint64]uint64 `json:"costumes"`
Packs map[uint64]uint64 `json:"packs"`
HighestTotalBattlePower uint64 `json:"highest_total_battle_power"`
PortraitCostumeID uint64 `json:"portrait_costume_id"`
AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"`
}
// PortraitCostume exposes the current portrait without changing the frozen deck schema.
func (s *Store) PortraitCostume() uint64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.state.PortraitCostumeID
}
type Store struct {
autoRecovery func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error)
autoRecoveryAllowed func() (bool, error)
fieldSettingsDesign *gamedata.FieldSettingsDesign
fieldSettingsPack func() (int, error)
mu sync.RWMutex
storage stateio.AtomicEntryStore
state state
presets map[uint64]Preset
presetSlots uint64
presetDesign gamedata.PresetDesign
costumeSettings map[uint64]CostumeSetting
wallet *player.Wallet
characters *player.CharacterStore
equipment *player.EquipmentInventory
collection *player.CollectionStore
sessionID string
replies map[string]deckReply
waypointDesign func(uint64) (gamedata.WaypointPack, error)
waypointPack func(uint64, bool) error
}
type deckReply struct {
code int
body []byte
}
func (s *Store) CurrentDeck() []DeckEntry {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]DeckEntry(nil), s.state.Deck...)
}
func LoadSeed(path string) (Seed, error) {
b, e := os.ReadFile(path)
if e != nil {
return Seed{}, fmt.Errorf("deck: read seed: %w", e)
}
var s Seed
if e = json.Unmarshal(b, &s); e != nil {
return Seed{}, fmt.Errorf("deck: decode seed: %w", e)
}
if e = s.validate(); e != nil {
return Seed{}, e
}
return s, nil
}
func (s Seed) validate() error {
if s.Version != versionconfig.State() {
return errors.New("deck: wrong seed version")
}
return validField(s.FieldDeck)
}
func validField(entries []FieldEntry) error {
if len(entries) == 0 || len(entries) > 5 {
return errors.New("deck: invalid field deck size")
}
characters := map[uint64]bool{}
costumes := map[uint64]bool{}
sequences := map[uint64]bool{}
for _, e := range entries {
if e.Slot == 0 || e.Slot > 5 || e.CharacterInvenIndex == 0 ||
characters[e.CharacterInvenIndex] || sequences[e.Slot] ||
(e.CostumeInvenIndex != 0 && costumes[e.CostumeInvenIndex]) {
return errors.New("deck: invalid field deck")
}
characters[e.CharacterInvenIndex] = true
sequences[e.Slot] = true
if e.CostumeInvenIndex != 0 {
costumes[e.CostumeInvenIndex] = true
}
}
for slot := uint64(1); slot <= uint64(len(entries)); slot++ {
if !sequences[slot] {
return errors.New("deck: field deck has a missing sequence")
}
}
return nil
}
func validDeck(entries []DeckEntry) error {
if len(entries) == 0 || len(entries) > 5 {
return errors.New("deck: invalid battle deck size")
}
characters := map[uint64]bool{}
positions := map[uint64]bool{}
sequences := map[uint64]bool{}
for _, entry := range entries {
position := entry.CostumeInvenIndex
unassigned := position == ^uint64(0) // int32 -1 sign-extends in protobuf varints.
if entry.CharacterInvenIndex == 0 || (!unassigned && position > 11) || entry.Slot == 0 || entry.Slot > 5 ||
characters[entry.CharacterInvenIndex] || (!unassigned && positions[position]) || sequences[entry.Slot] {
return errors.New("deck: invalid battle deck")
}
characters[entry.CharacterInvenIndex] = true
if !unassigned {
positions[position] = true
}
sequences[entry.Slot] = true
}
return nil
}
func NewStore(seed Seed, designs ...gamedata.PresetDesign) (*Store, error) {
if e := seed.validate(); e != nil {
return nil, e
}
var design gamedata.PresetDesign
if len(designs) > 1 {
return nil, errors.New("deck: multiple preset designs")
}
if len(designs) == 1 {
design = designs[0]
if err := design.Validate(); err != nil {
return nil, err
}
}
return &Store{presetDesign: design, state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64][]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: design.BaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil
}
func OpenStore(storage stateio.Store, seed Seed, designs ...gamedata.PresetDesign) (*Store, error) {
s, e := NewStore(seed, designs...)
if e != nil {
return nil, e
}
entries, ok := storage.(stateio.AtomicEntryStore)
if storage == nil || !ok {
return nil, errors.New("deck: nil storage")
}
s.storage = entries
b, e := storage.Load("deck")
if e != nil {
return nil, fmt.Errorf("deck: load state: %w", e)
}
if b == nil {
if e = stateio.RequireNoEntries(entries, "deck", "presets", "preset_config", "costume_settings", "field_settings"); e != nil {
return nil, fmt.Errorf("deck: invalid entry storage: %w", e)
}
return s, nil
}
if e = stateio.RequireExactJSONObject(b, "version", "deck", "field_deck", "field_char_control_deck_type", "waypoints", "costumes", "packs", "highest_total_battle_power", "portrait_costume_id", "auto_revive_catalyst"); e != nil {
return nil, fmt.Errorf("deck: incompatible state layout: %w", e)
}
var loaded state
if e = json.Unmarshal(b, &loaded); e != nil {
return nil, fmt.Errorf("deck: malformed state: %w", e)
}
if loaded.Version != versionconfig.State() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil {
return nil, errors.New("deck: invalid saved state")
}
if e = validWaypointState(loaded.Waypoints); e != nil {
return nil, e
}
s.state = loaded
if e = s.loadPresetEntries(); e != nil {
return nil, e
}
return s, nil
}
func (s *Store) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
b, e := s.storage.Load("deck")
if e != nil {
return e
}
if b != nil {
return nil
}
return s.commit(clone(s.state))
}
func (s *Store) commit(next state) error {
if s.storage != nil {
b, e := json.MarshalIndent(next, "", " ")
if e != nil {
return e
}
if e = s.storage.Save("deck", append(b, '\n')); e != nil {
return e
}
}
s.state = next
return nil
}
func clone(x state) state {
y := x
y.Deck = append([]DeckEntry(nil), x.Deck...)
y.FieldDeck = append([]FieldEntry(nil), x.FieldDeck...)
y.Waypoints = map[uint64][]uint64{}
for k, v := range x.Waypoints {
y.Waypoints[k] = append([]uint64(nil), v...)
}
y.Costumes = map[uint64]uint64{}
maps.Copy(y.Costumes, x.Costumes)
y.Packs = map[uint64]uint64{}
maps.Copy(y.Packs, x.Packs)
return y
}
func checkSeq(req []byte) error {
v, ok, e := wire.Varint(req, 1)
if e != nil || !ok || v == 0 || v > 2147483647 {
return errors.New("deck: invalid request sequence")
}
return nil
}
func triples(req []byte) ([]DeckEntry, error) {
var out []DeckEntry
e := wire.Walk(req, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 {
return errors.New("deck: deck field")
}
a, aok, e := wire.Varint(f.Value, 1)
if e != nil || !aok || a == 0 {
return errors.New("deck: deck character")
}
position, _, e := wire.Varint(f.Value, 2)
if e != nil || (position > 11 && position != ^uint64(0)) {
return errors.New("deck: invalid deck position")
}
sequence, sequenceOK, e := wire.Varint(f.Value, 3)
if e != nil || !sequenceOK || sequence == 0 || sequence > 5 {
return errors.New("deck: invalid deck sequence")
}
out = append(out, DeckEntry{CharacterInvenIndex: a, CostumeInvenIndex: position, Slot: sequence})
return nil
})
if e != nil {
return nil, e
}
if len(out) == 0 {
return nil, errors.New("deck: empty deck")
}
if e := validDeck(out); e != nil {
return nil, e
}
return out, nil
}
func fieldEntries(req []byte) ([]FieldEntry, error) {
var out []FieldEntry
e := wire.Walk(req, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 {
return errors.New("deck: field deck entry is not a message")
}
slot, slotOK, err := wire.Varint(f.Value, 1)
if err != nil || !slotOK || slot == 0 {
return errors.New("deck: invalid field deck slot")
}
character, characterOK, err := wire.Varint(f.Value, 2)
if err != nil || !characterOK || character == 0 {
return errors.New("deck: invalid field deck character")
}
costume, _, err := wire.Varint(f.Value, 3)
if err != nil {
return errors.New("deck: invalid field deck costume")
}
out = append(out, FieldEntry{Slot: slot, CharacterInvenIndex: character, CostumeInvenIndex: costume})
return nil
})
if e != nil {
return nil, e
}
if len(out) == 0 {
return nil, errors.New("deck: empty field deck")
}
return out, validField(out)
}
func (s *Store) validateOwnedDeckLocked(entries []DeckEntry) error {
if s.characters == nil {
return nil
}
for _, entry := range entries {
if _, found := s.characters.Find(entry.CharacterInvenIndex); !found {
return fmt.Errorf("deck: battle deck references unknown character %d", entry.CharacterInvenIndex)
}
}
return nil
}
func (s *Store) validateOwnedFieldDeckLocked(entries []FieldEntry) error {
if s.characters == nil || s.collection == nil {
return nil
}
for _, entry := range entries {
character, found := s.characters.Find(entry.CharacterInvenIndex)
if !found {
return fmt.Errorf("deck: field deck references unknown character %d", entry.CharacterInvenIndex)
}
if player.IsStoryCharacter(character) && !s.temporaryAllowed(character) {
return fmt.Errorf("deck: field character unavailable in this pack")
}
if entry.CostumeInvenIndex == 0 {
continue
}
if (player.IsStoryCharacter(character) || player.IsCharmCharacter(character)) && character.UseCostume == entry.CostumeInvenIndex {
continue
}
costume, found := s.collection.CostumeByIndex(entry.CostumeInvenIndex)
if !found {
return fmt.Errorf("deck: field deck references unknown costume %d", entry.CostumeInvenIndex)
}
if costume.UseChar != entry.CharacterInvenIndex {
return fmt.Errorf("deck: costume %d does not belong to character %d", entry.CostumeInvenIndex, entry.CharacterInvenIndex)
}
}
return nil
}
// Handle implements session.Handler. Every mutation validates its complete
// typed request before committing a replacement JSON state.
func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
switch path {
case "/TalentSlotSave", "/CharAutoReviveSet":
return s.handleFieldSettings(path, req)
case "/PresetInfo":
return s.handlePresetInfo(req)
case "/PresetSave":
return s.handlePresetSave(req)
case "/PresetAddSlot":
return s.handlePresetAddSlot(req)
case "/PresetInfoChange":
return s.handlePresetInfoChange(req)
case "/PresetDelete":
return s.handlePresetDelete(req)
case "/PresetUse":
return s.handlePresetUse(req)
case "/DeckCostumeSettingInfo":
return s.handleCostumeSettingInfo(req)
case "/DeckCostumeSettingSave":
return s.handleCostumeSettingSave(req)
case "/DeckInfo":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.RLock()
defer s.mu.RUnlock()
out := encodeDeck(s.state.Deck)
if s.fieldSettingsDesign != nil {
v, e := s.loadFieldSettings()
if e != nil {
return 0, nil, true, e
}
for _, id := range s.projectTalentSlots(v.TalentIDs) {
out = wire.AppendVarint(out, 2, id)
}
}
return 8, out, true, nil
case "/FieldDeckInfo":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.RLock()
defer s.mu.RUnlock()
return 273, encodeField(s.visibleFieldDeckLocked()), true, nil
case "/DeckCharAutoRevive":
return s.handleAutoRecovery(req)
case "/WaypointInfo":
return s.handleWaypoint(path, req)
case "/DeckSave":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
x, e := triples(req)
if e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
if e = s.validateOwnedDeckLocked(x); e != nil {
return 0, nil, true, e
}
n := clone(s.state)
n.Deck = x
e = s.commit(n)
return 10, nil, true, e
case "/FieldDeckSave":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
x, e := fieldEntries(req)
if e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
if e = s.validateOwnedFieldDeckLocked(x); e != nil {
return 0, nil, true, e
}
n := clone(s.state)
n.FieldDeck = x
e = s.commit(n)
return 274, nil, true, e
case "/SaveFieldCharControlDeckType":
// Define_FieldCharControllDeckType is a proto3 enum whose valid values
// are BATTLE=0, FIELD=1 and STORY=2. BATTLE is the protobuf default, so
// the generated client deliberately omits field 2 when it switches out
// of story mode after the final quest. An absent field is therefore a
// real value 0, not a malformed request.
v, _, e := wire.Varint(req, 2)
if e != nil || v > 2 {
return 0, nil, true, errors.New("deck: invalid field control type")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
n.FieldCharControlDeckType = v
e = s.commit(n)
return 288, nil, true, e
case "/WaypointSave", "/WaypointUse":
return s.handleWaypoint(path, req)
case "/CostumeUse":
return s.handleCostumeUse(req)
case "/SaveTotalBattlePower":
power, ok, e := wire.Varint(req, 2)
if e != nil || !ok || power == 0 {
return 0, nil, true, errors.New("deck: invalid total battle power")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
if power > n.HighestTotalBattlePower {
n.HighestTotalBattlePower = power
}
if e = s.commit(n); e != nil {
return 0, nil, true, e
}
return 258, wire.AppendVarint(nil, 1, n.HighestTotalBattlePower), true, nil
case "/UserPortraitChange":
costumeID, ok, e := wire.Varint(req, 2)
if e != nil || !ok || costumeID == 0 {
return 0, nil, true, errors.New("deck: invalid portrait costume")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
n.PortraitCostumeID = costumeID
if e = s.commit(n); e != nil {
return 0, nil, true, e
}
return 75, wire.AppendVarint(nil, 1, costumeID), true, nil
}
return 0, nil, false, nil
}
func encodeDeck(xs []DeckEntry) []byte {
var b []byte
for _, x := range xs {
v := wire.AppendVarint(nil, 1, x.CharacterInvenIndex)
v = wire.AppendVarint(v, 2, x.CostumeInvenIndex)
v = wire.AppendVarint(v, 3, x.Slot)
b = wire.AppendBytes(b, 1, v)
}
return b
}
func encodeField(xs []FieldEntry) []byte {
var b []byte
for _, x := range xs {
v := wire.AppendVarint(nil, 1, x.Slot)
v = wire.AppendVarint(v, 2, x.CharacterInvenIndex)
v = wire.AppendVarint(v, 3, x.CostumeInvenIndex)
b = wire.AppendBytes(b, 1, v)
}
return b
}
-377
View File
@@ -1,377 +0,0 @@
package deck
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"path/filepath"
"testing"
)
func seeded(t *testing.T) *Store {
t.Helper()
x, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json"))
if e != nil {
t.Fatal(e)
}
s, e := NewStore(x, testPresetDesign)
if e != nil {
t.Fatal(e)
}
return s
}
func req(seq uint64, fields ...[]byte) []byte {
b := wire.AppendVarint(nil, 1, seq)
for _, f := range fields {
b = append(b, f...)
}
return b
}
func triple(a, b, c uint64) []byte {
v := wire.AppendVarint(nil, 1, a)
v = wire.AppendVarint(v, 2, b)
v = wire.AppendVarint(v, 3, c)
return wire.AppendBytes(nil, 2, v)
}
func attachFormationOwnership(t *testing.T, store *Store) {
t.Helper()
starter := &player.Starter{
Version: versionconfig.State(),
Characters: []player.Character{
{InvenIndex: 101, ID: 350, Level: 1},
{InvenIndex: 102, ID: 351, Level: 1},
{InvenIndex: 103, ID: 352, Level: 1},
{InvenIndex: 104, ID: 353, Level: 1},
{InvenIndex: 105, ID: 354, Level: 1},
},
Costumes: []player.Costume{
{InvenIndex: 201, ID: 60101, UseChar: 101},
{InvenIndex: 202, ID: 60201, UseChar: 102},
{InvenIndex: 203, ID: 60301, UseChar: 103},
{InvenIndex: 204, ID: 60401, UseChar: 104},
{InvenIndex: 205, ID: 60501, UseChar: 105},
},
}
inventory, err := player.OpenInventory(stateio.NewMemory(), starter)
if err != nil {
t.Fatal(err)
}
characters, err := player.OpenCharacterStore(stateio.NewMemory(), starter.Characters, inventory, "", "")
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(stateio.NewMemory(), starter.Costumes)
if err != nil {
t.Fatal(err)
}
store.characters = characters
store.collection = collection
}
func TestFieldDeckSeedAndSave(t *testing.T) {
s := seeded(t)
code, b, ok, e := s.Handle("/FieldDeckInfo", req(1))
if e != nil || !ok || code != 273 {
t.Fatalf("info %d %t %v", code, ok, e)
}
n := 0
if e = wire.Walk(b, func(f wire.Field) error {
if f.Number == 1 {
n++
}
return nil
}); e != nil || n != 5 {
t.Fatalf("seed field deck: %d %v", n, e)
}
// FieldDeckDBInfo is sequence#1, character#2, costume#3.
field := triple(1, 99, 199)
code, _, ok, e = s.Handle("/FieldDeckSave", req(2, field))
if e != nil || !ok || code != 274 {
t.Fatalf("save: %d %t %v", code, ok, e)
}
_, b, _, _ = s.Handle("/FieldDeckInfo", req(3))
var got uint64
_ = wire.Walk(b, func(f wire.Field) error {
if f.Number == 1 {
got, _, _ = wire.Varint(f.Value, 2)
}
return nil
})
if got != 99 {
t.Fatalf("saved char=%d", got)
}
}
func TestDeckPersistenceAndCommands(t *testing.T) {
seed, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json"))
if e != nil {
t.Fatal(e)
}
storage := stateio.NewMemory()
s, e := OpenStore(storage, seed)
if e != nil {
t.Fatal(e)
}
code, _, _, e := s.Handle("/DeckSave", req(1, triple(100, 2, 1)))
if e != nil || code != 10 {
t.Fatalf("deck save: %d %v", code, e)
}
way := wire.AppendVarint(nil, 2, 21)
if e = s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) {
return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 212}}}, nil
}, func(uint64, bool) error { return nil }); e != nil {
t.Fatal(e)
}
way = wire.AppendVarint(way, 3, 1)
if code, _, _, e = s.Handle("/WaypointSave", req(2, way)); e != nil || code != 32 {
t.Fatalf("way: %d %v", code, e)
}
use := wire.AppendVarint(nil, 1, 200)
use = wire.AppendVarint(use, 2, 100)
if code, _, _, e = s.Handle("/CostumeUse", req(3, wire.AppendBytes(nil, 2, use))); e != nil || code != 41 {
t.Fatalf("use: %d %v", code, e)
}
if _, _, handled, err := s.Handle("/PackBuy", req(4, wire.AppendVarint(nil, 2, 21))); handled || err != nil {
t.Fatal("pack purchase must be handled by world")
}
var b []byte
reopened, e := OpenStore(storage, seed)
if e != nil {
t.Fatal(e)
}
_, b, _, e = reopened.Handle("/DeckInfo", req(5))
if e != nil {
t.Fatal(e)
}
entry, _, _ := wire.Bytes(b, 1)
id, _, _ := wire.Varint(entry, 1)
if id != 100 {
t.Fatalf("persist deck=%d", id)
}
}
func TestRejectsInvalidMutations(t *testing.T) {
s := seeded(t)
if _, _, _, e := s.Handle("/DeckSave", req(1, triple(1, 2, 1), triple(3, 4, 1))); e == nil {
t.Fatal("duplicate slots accepted")
}
if _, _, _, e := s.Handle("/WaypointSave", req(1, wire.AppendVarint(nil, 2, 21))); e == nil {
t.Fatal("waypoint missing id accepted")
}
}
func TestDeckSaveValidatesFormationShapeAndOwnership(t *testing.T) {
valid := [][]byte{
triple(101, 0, 1), triple(102, 1, 2), triple(103, 2, 3),
triple(104, 3, 4), triple(105, 4, 5),
}
tests := []struct {
name string
entries [][]byte
}{
{name: "six characters", entries: append(append([][]byte{}, valid...), triple(101, 5, 1))},
{name: "duplicate character", entries: [][]byte{triple(101, 0, 1), triple(101, 1, 2)}},
{name: "duplicate position", entries: [][]byte{triple(101, 0, 1), triple(102, 0, 2)}},
{name: "duplicate sequence", entries: [][]byte{triple(101, 0, 1), triple(102, 1, 1)}},
{name: "position outside twelve cells", entries: [][]byte{triple(101, 12, 1)}},
{name: "sequence outside formation", entries: [][]byte{triple(101, 0, 6)}},
{name: "unknown character", entries: [][]byte{triple(999, 0, 1)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
s := seeded(t)
attachFormationOwnership(t, s)
if _, _, handled, err := s.Handle("/DeckSave", req(1, test.entries...)); err == nil || !handled {
t.Fatalf("invalid deck accepted handled=%v err=%v", handled, err)
}
if len(s.state.Deck) != 0 {
t.Fatalf("invalid deck mutated state: %+v", s.state.Deck)
}
})
}
s := seeded(t)
attachFormationOwnership(t, s)
if code, _, handled, err := s.Handle("/DeckSave", req(2, valid...)); err != nil || !handled || code != 10 || len(s.state.Deck) != 5 {
t.Fatalf("valid deck rejected code=%d handled=%v deck=%+v err=%v", code, handled, s.state.Deck, err)
}
unassigned := [][]byte{
triple(101, ^uint64(0), 1), triple(102, ^uint64(0), 2), triple(103, ^uint64(0), 3),
triple(104, ^uint64(0), 4), triple(105, ^uint64(0), 5),
}
if code, _, handled, err := s.Handle("/DeckSave", req(3, unassigned...)); err != nil || !handled || code != 10 {
t.Fatalf("official unassigned positions rejected code=%d handled=%v err=%v", code, handled, err)
}
}
func TestFieldDeckSaveValidatesFormationShapeAndOwnership(t *testing.T) {
valid := [][]byte{
triple(1, 101, 201), triple(2, 102, 202), triple(3, 103, 203),
triple(4, 104, 204), triple(5, 105, 205),
}
tests := []struct {
name string
entries [][]byte
}{
{name: "six characters", entries: append(append([][]byte{}, valid...), triple(1, 101, 201))},
{name: "duplicate character", entries: [][]byte{triple(1, 101, 201), triple(2, 101, 202)}},
{name: "duplicate costume", entries: [][]byte{triple(1, 101, 201), triple(2, 102, 201)}},
{name: "duplicate sequence", entries: [][]byte{triple(1, 101, 201), triple(1, 102, 202)}},
{name: "sequence outside formation", entries: [][]byte{triple(6, 101, 201)}},
{name: "unknown character", entries: [][]byte{triple(1, 999, 201)}},
{name: "unknown costume", entries: [][]byte{triple(1, 101, 999)}},
{name: "costume owned by another character", entries: [][]byte{triple(1, 101, 202)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
s := seeded(t)
attachFormationOwnership(t, s)
before := append([]FieldEntry(nil), s.state.FieldDeck...)
if _, _, handled, err := s.Handle("/FieldDeckSave", req(1, test.entries...)); err == nil || !handled {
t.Fatalf("invalid field deck accepted handled=%v err=%v", handled, err)
}
if len(s.state.FieldDeck) != len(before) || s.state.FieldDeck[0] != before[0] {
t.Fatalf("invalid field deck mutated state: %+v", s.state.FieldDeck)
}
})
}
s := seeded(t)
attachFormationOwnership(t, s)
if code, _, handled, err := s.Handle("/FieldDeckSave", req(2, valid...)); err != nil || !handled || code != 274 || len(s.state.FieldDeck) != 5 {
t.Fatalf("valid field deck rejected code=%d handled=%v deck=%+v err=%v", code, handled, s.state.FieldDeck, err)
}
// A zero costume index means to use the character's current costume; the
// client explicitly supports this fallback when resolving its field leader.
if code, _, handled, err := s.Handle("/FieldDeckSave", req(3, triple(1, 101, 0))); err != nil || !handled || code != 274 {
t.Fatalf("current-costume fallback rejected code=%d handled=%v err=%v", code, handled, err)
}
}
func TestDeckSavesRequireRequestSequence(t *testing.T) {
s := seeded(t)
if _, _, handled, err := s.Handle("/DeckSave", triple(101, 0, 1)); err == nil || !handled {
t.Fatalf("DeckSave without seq accepted handled=%v err=%v", handled, err)
}
if _, _, handled, err := s.Handle("/FieldDeckSave", triple(1, 101, 201)); err == nil || !handled {
t.Fatalf("FieldDeckSave without seq accepted handled=%v err=%v", handled, err)
}
}
func TestSaveFieldCharControlDeckTypeAcceptsProtoDefaultBattle(t *testing.T) {
s := seeded(t)
// FCCD_BATTLE=0 is omitted by proto3 serialization. This is the exact
// shape sent when the client leaves story control after the final quest.
code, _, handled, err := s.Handle("/SaveFieldCharControlDeckType", req(1))
if err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != 0 {
t.Fatalf("battle save code=%d handled=%v type=%d err=%v", code, handled, s.state.FieldCharControlDeckType, err)
}
for seq, value := range []uint64{1, 2} {
request := req(uint64(seq+2), wire.AppendVarint(nil, 2, value))
if code, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", request); err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != value {
t.Fatalf("type %d save code=%d handled=%v stored=%d err=%v", value, code, handled, s.state.FieldCharControlDeckType, err)
}
}
if _, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", req(9, wire.AppendVarint(nil, 2, 3))); !handled || err == nil {
t.Fatalf("unknown enum accepted handled=%v err=%v", handled, err)
}
}
func TestTotalBattlePowerKeepsHighest(t *testing.T) {
s := seeded(t)
for seq, power := range []uint64{1300, 900} {
request := req(uint64(seq+1), wire.AppendVarint(nil, 2, power))
code, response, ok, err := s.Handle("/SaveTotalBattlePower", request)
if err != nil || !ok || code != 258 {
t.Fatalf("save power: code=%d ok=%v err=%v", code, ok, err)
}
highest, found, err := wire.Varint(response, 1)
if err != nil || !found || highest != 1300 {
t.Fatalf("highest=%d found=%v err=%v", highest, found, err)
}
}
}
func TestPortraitChangeEchoesAndStoresCostume(t *testing.T) {
s := seeded(t)
request := req(1, wire.AppendVarint(nil, 2, 3501))
code, response, ok, err := s.Handle("/UserPortraitChange", request)
if err != nil || !ok || code != 75 {
t.Fatalf("portrait: code=%d ok=%v err=%v", code, ok, err)
}
id, found, err := wire.Varint(response, 1)
if err != nil || !found || id != 3501 || s.state.PortraitCostumeID != 3501 {
t.Fatalf("portrait response=%d found=%v stored=%d err=%v", id, found, s.state.PortraitCostumeID, err)
}
}
func TestDeckCharAutoReviveUsesCurrentFormationWithoutInventingRevives(t *testing.T) {
s := seeded(t)
if code, _, handled, err := s.Handle("/DeckSave", req(1, triple(535607162, 10, 1), triple(535604120, 1, 2))); code != 10 || !handled || err != nil {
t.Fatalf("save code=%d handled=%v err=%v", code, handled, err)
}
code, response, handled, err := s.Handle("/DeckCharAutoRevive", req(2))
if err != nil || !handled || code != 373 {
t.Fatalf("auto revive code=%d handled=%v err=%v", code, handled, err)
}
var battle, field, revive int
if err := wire.Walk(response, func(f wire.Field) error {
switch f.Number {
case 1:
battle++
if battle == 1 {
index, _, _ := wire.Varint(f.Value, 1)
if index != 535607162 {
t.Errorf("first battle character %d", index)
}
}
case 2:
field++
case 3, 5:
revive++
}
return nil
}); err != nil || battle != 2 || field != 5 || revive != 0 {
t.Fatalf("formation battle=%d field=%d invented revive=%d err=%v", battle, field, revive, err)
}
mode, _, _ := wire.Varint(response, 4)
catalyst, _, _ := wire.Varint(response, 7)
if mode != 2 || catalyst != 200 {
t.Fatalf("official formation mode=%d catalyst=%d", mode, catalyst)
}
if _, _, handled, err := s.Handle("/DeckCharAutoRevive", req(3, wire.AppendVarint(nil, 2, 535604120))); !handled || err == nil {
t.Fatalf("unverified caster incorrectly accepted handled=%v err=%v", handled, err)
}
if _, _, handled, err := s.Handle("/DeckCharAutoRevive", nil); !handled || err == nil {
t.Fatalf("missing sequence incorrectly accepted handled=%v err=%v", handled, err)
}
}
func TestDeckCharAutoRevivePreservesExplicitZeroCatalyst(t *testing.T) {
seed, err := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json"))
if err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
s, err := OpenStore(storage, seed)
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/DeckSave", req(1, triple(88, 11, 1))); err != nil {
t.Fatal(err)
}
s.state.AutoReviveCatalyst = 0
if err := s.commit(s.state); err != nil {
t.Fatal(err)
}
reopened, err := OpenStore(storage, seed)
if err != nil || reopened.state.AutoReviveCatalyst != 0 || reopened.state.Deck[0].CharacterInvenIndex != 88 {
t.Fatalf("reloaded state=%+v err=%v", reopened.state, err)
}
}
var testPresetDesign = func() gamedata.PresetDesign {
d := gamedata.PresetDesign{BaseCount: 5, Maximum: 12, PriceType: 4, Price: 2000, Icons: map[uint64]bool{}}
for id := uint64(1); id <= 21; id++ {
d.Icons[id] = true
}
return d
}()
@@ -1,171 +0,0 @@
package deck
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"testing"
"time"
)
func TestTalentSlotsRestoreOwnershipAndClearSlots(t *testing.T) {
f := newPresetFixture(t)
design := &gamedata.FieldSettingsDesign{TalentSlots: 3, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 9}}
if e := f.deck.AttachFieldSettings(design); e != nil {
t.Fatal(e)
}
save := req(1, wire.AppendVarint(nil, 2, 360), wire.AppendVarint(nil, 2, 0), wire.AppendVarint(nil, 2, 350))
if _, _, _, e := f.deck.Handle("/TalentSlotSave", save); e != nil {
t.Fatal(e)
}
for _, bad := range [][]uint64{{350, 350, 0}, {999, 0, 0}, {100, 0, 0}, {350}} {
var r []byte
for _, id := range bad {
r = wire.AppendVarint(r, 2, id)
}
if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(2, r)); e == nil {
t.Fatal("invalid slots accepted", bad)
}
}
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(3, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 200))); e == nil {
t.Fatal("wrong talent caster accepted")
}
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 100))); e != nil {
t.Fatal(e)
}
reopened, e := OpenStore(f.storage, f.seed, f.deck.presetDesign)
if e != nil {
t.Fatal(e)
}
reopened.characters = f.characters
if e = reopened.AttachFieldSettings(design); e != nil {
t.Fatal(e)
}
ids, e := reopened.TalentCharacterIDs()
if e != nil || ids[0] != 360 || ids[1] != 0 || ids[2] != 350 {
t.Fatal(ids, e)
}
on, caster, e := reopened.AutoReviveSettings()
if e != nil || !on || caster != 100 {
t.Fatal(on, caster, e)
}
_, b, _, e := reopened.Handle("/DeckInfo", req(5))
if e != nil {
t.Fatal(e)
}
n := 0
if err := wire.Walk(b, func(f wire.Field) error {
if f.Number == 2 {
n++
}
return nil
}); err != nil {
t.Fatal(err)
}
if n != 3 {
t.Fatal("DeckInfo missing empty quick slot", n)
}
}
func TestCharmFieldAndTalentSlotsExpireWithoutPermanentOwnership(t *testing.T) {
f := newPresetFixture(t)
base := f.characters.RawAll()
index := player.CharmCharacterIndexBase + 11
install := func(expiry uint64) {
m := stateio.NewMemory()
inv, e := player.OpenInventory(m, &player.Starter{Version: f.seed.Version})
if e != nil {
t.Fatal(e)
}
chars := append(append([]player.Character(nil), base...), player.Character{InvenIndex: index, ID: 9010, Level: 1, CostumeID: 90101, UseCostume: 0, ExpiryTime: expiry})
s, e := player.OpenCharacterStore(m, chars, inv, "", "")
if e != nil {
t.Fatal(e)
}
f.deck.characters = s
}
install(uint64(time.Now().Add(time.Hour).UnixMilli()))
d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9010: 10}, CharacterTemporaryPack: map[uint64]int{9010: 99}}
if err := f.deck.AttachFieldSettingsPack(func() (int, error) { return 4, nil }); err != nil {
t.Fatal(err)
}
if e := f.deck.AttachFieldSettings(d); e != nil {
t.Fatal(e)
}
if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(1, wire.AppendVarint(nil, 2, 9010))); e != nil {
t.Fatal(e)
}
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(2, triple(1, index, 0))); e != nil {
t.Fatal(e)
}
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(3, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, index))); e == nil {
t.Fatal("charm recovery caster accepted")
}
install(uint64(time.Now().Add(-time.Second).UnixMilli()))
ids, e := f.deck.TalentCharacterIDs()
if e != nil || ids[0] != 0 {
t.Fatal("expired charm quick slot leaked", ids, e)
}
if len(f.deck.CurrentFieldDeck()) != 0 {
t.Fatal("expired charm field party leaked")
}
}
func TestTemporaryFieldAndTalentSlotsFollowPack(t *testing.T) {
f := newPresetFixture(t)
c := player.Character{InvenIndex: player.StoryCharacterIndexBase + 99, ID: 9001, Level: 1, CostumeID: 90011, UseCostume: 9999}
if e := f.characters.EnsureStoryCharacters([]player.Character{c}); e != nil {
t.Fatal(e)
}
pack := 4
d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9001: 9}, CharacterTemporaryPack: map[uint64]int{9001: 4}}
if err := f.deck.AttachFieldSettingsPack(func() (int, error) { return pack, nil }); err != nil {
t.Fatal(err)
}
if e := f.deck.AttachFieldSettings(d); e != nil {
t.Fatal(e)
}
if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(1, wire.AppendVarint(nil, 2, c.ID))); e != nil {
t.Fatal(e)
}
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(2, triple(1, c.InvenIndex, c.UseCostume))); e != nil {
t.Fatal(e)
}
pack = 5
ids, e := f.deck.TalentCharacterIDs()
if e != nil || ids[0] != 0 {
t.Fatal("temporary quick slot leaked", ids, e)
}
if len(f.deck.CurrentFieldDeck()) != 0 {
t.Fatal("temporary field party leaked")
}
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(3, triple(1, c.InvenIndex, c.UseCostume))); e == nil {
t.Fatal("foreign-pack temporary party accepted")
}
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4)); e != nil {
t.Fatal("inactive quick slots blocked unrelated setting", e)
}
}
func TestMalformedSettingsDoNotPersist(t *testing.T) {
f := newPresetFixture(t)
d := &gamedata.FieldSettingsDesign{TalentSlots: 2, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 9}}
if e := f.deck.AttachFieldSettings(d); e != nil {
t.Fatal(e)
}
for _, bad := range [][]byte{req(0, wire.AppendVarint(nil, 2, 350)), req(1, wire.AppendBytes(nil, 2, []byte{0x80})), req(2, wire.AppendFixed64(nil, 2, 350)), req(^uint64(0), wire.AppendVarint(nil, 2, 350)), req(3, wire.AppendVarint(nil, 2, ^uint64(0)), wire.AppendVarint(nil, 2, 0))} {
if _, _, _, e := f.deck.Handle("/TalentSlotSave", bad); e == nil {
t.Fatal("malformed talent request accepted")
}
if _, ok, e := f.storage.LoadEntry("deck", "field_settings", "state"); e != nil || ok {
t.Fatal("invalid talent request wrote settings", e)
}
}
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 2, 2))); e == nil {
t.Fatal("invalid protobuf bool accepted")
}
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(5, triple(2, 100, 1001))); e == nil {
t.Fatal("field gap accepted")
}
}
-888
View File
@@ -1,888 +0,0 @@
package deck
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type Preset struct {
Name string `json:"name"`
ResourceID uint64 `json:"resource_id"`
ResourceColor uint64 `json:"resource_color"`
Slot uint64 `json:"slot"`
Decks []PresetDeck `json:"decks"`
Blesses []PresetBless `json:"blesses"`
}
type PresetDeck struct {
Deck DeckEntry `json:"deck"`
CostumeIndex uint64 `json:"costume_index"`
Equipment []PresetEquipmentItem `json:"equipment"`
Team uint64 `json:"team"`
}
type PresetEquipmentItem struct {
Type uint64 `json:"type"`
Index uint64 `json:"index"`
}
type PresetBless struct {
DeckType uint64 `json:"deck_type"`
IDs []uint64 `json:"ids"`
}
type CostumeSetting struct {
CharacterIndex uint64 `json:"character_index"`
Sequence []CostumeSettingItem `json:"sequence"`
BattleMode uint64 `json:"battle_mode"`
MonsterID uint64 `json:"monster_id"`
}
type CostumeSettingItem struct {
CostumeIndex int64 `json:"costume_index"`
BurstLevel uint64 `json:"burst_level"`
}
func (s *Store) AttachPresetRuntime(wallet *player.Wallet, characters *player.CharacterStore, equipment *player.EquipmentInventory, collection *player.CollectionStore) error {
if wallet == nil || characters == nil || equipment == nil || collection == nil {
return errors.New("deck: incomplete preset runtime")
}
s.mu.Lock()
defer s.mu.Unlock()
s.wallet, s.characters, s.equipment, s.collection = wallet, characters, equipment, collection
return s.validatePresetOwnershipLocked()
}
func (s *Store) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" || id == s.sessionID {
return
}
s.sessionID = id
s.replies = map[string]deckReply{}
}
func (s *Store) PresetSlotCount() uint64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.presetSlots
}
func (s *Store) loadPresetEntries() error {
rawConfig, found, err := s.storage.LoadEntry("deck", "preset_config", "slots")
if err != nil {
return err
}
if found {
if err := json.Unmarshal(rawConfig, &s.presetSlots); err != nil || s.presetSlots < s.presetDesign.BaseCount || s.presetSlots > s.presetDesign.Maximum {
return errors.New("deck: invalid preset slot configuration")
}
}
raw, err := s.storage.ListEntries("deck", "presets")
if err != nil {
return err
}
for key, payload := range raw {
slot, err := strconv.ParseUint(key, 10, 64)
if err != nil || key != strconv.FormatUint(slot, 10) {
return fmt.Errorf("deck: invalid preset key %q", key)
}
var preset Preset
if err := json.Unmarshal(payload, &preset); err != nil || preset.Slot != slot {
return fmt.Errorf("deck: invalid preset %q", key)
}
if err := s.validatePresetShape(preset, s.presetSlots); err != nil {
return fmt.Errorf("deck: invalid preset %q: %w", key, err)
}
s.presets[slot] = preset
}
rawSettings, err := s.storage.ListEntries("deck", "costume_settings")
if err != nil {
return err
}
for key, payload := range rawSettings {
index, err := strconv.ParseUint(key, 10, 64)
if err != nil || index == 0 || key != strconv.FormatUint(index, 10) {
return fmt.Errorf("deck: invalid costume setting key %q", key)
}
var setting CostumeSetting
if err := json.Unmarshal(payload, &setting); err != nil || setting.CharacterIndex != index {
return fmt.Errorf("deck: invalid costume setting %q", key)
}
s.costumeSettings[index] = setting
}
return nil
}
func (s *Store) validatePresetOwnershipLocked() error {
if s.characters == nil || s.collection == nil || s.equipment == nil {
return nil
}
for _, preset := range s.presets {
if err := s.validatePresetOwnedLocked(preset); err != nil {
return fmt.Errorf("deck: saved preset %d: %w", preset.Slot, err)
}
}
for _, setting := range s.costumeSettings {
if _, found := s.characters.Find(setting.CharacterIndex); !found {
return fmt.Errorf("deck: costume setting references unknown character %d", setting.CharacterIndex)
}
for _, item := range setting.Sequence {
if item.CostumeIndex > 0 {
if _, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex)); !found {
return fmt.Errorf("deck: costume setting references unknown costume %d", item.CostumeIndex)
}
}
}
}
return nil
}
func (s *Store) validatePresetShape(p Preset, slotCount uint64) error {
if p.Slot >= slotCount || (p.ResourceID != 0 && !s.presetDesign.Icons[p.ResourceID]) || p.ResourceColor > 5 || !validPresetName(p.Name) || len(p.Decks) > 5 {
return errors.New("invalid metadata or deck count")
}
characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{}
for _, deck := range p.Decks {
if deck.Deck.CharacterInvenIndex == 0 || deck.Deck.CostumeInvenIndex > 11 || deck.Deck.Slot == 0 || deck.Deck.Slot > 5 || deck.Team != 0 ||
characters[deck.Deck.CharacterInvenIndex] || positions[deck.Deck.CostumeInvenIndex] || sequences[deck.Deck.Slot] {
return errors.New("invalid deck entry")
}
characters[deck.Deck.CharacterInvenIndex] = true
positions[deck.Deck.CostumeInvenIndex] = true
sequences[deck.Deck.Slot] = true
if len(deck.Equipment) != 5 {
return errors.New("preset deck requires five equipment slots")
}
seen := map[uint64]bool{}
for _, item := range deck.Equipment {
if item.Type >= 5 || seen[item.Type] {
return errors.New("invalid preset equipment slot")
}
seen[item.Type] = true
}
}
return nil
}
func validPresetName(value string) bool {
if !utf8.ValidString(value) || utf8.RuneCountInString(value) < 1 || utf8.RuneCountInString(value) > 16 || strings.Contains(value, "<") || strings.Contains(value, ">") {
return false
}
for _, r := range value {
if unicode.IsControl(r) {
return false
}
}
return true
}
func (s *Store) validatePresetOwnedLocked(p Preset) error {
if err := s.validatePresetShape(p, s.presetSlots); err != nil {
return err
}
ownedEquipment := make(map[uint64]player.Equipment)
for _, item := range s.equipment.All() {
ownedEquipment[item.InvenIndex] = item
}
seenEquipment := map[uint64]bool{}
for _, deck := range p.Decks {
if _, found := s.characters.Find(deck.Deck.CharacterInvenIndex); !found {
return fmt.Errorf("unknown character %d", deck.Deck.CharacterInvenIndex)
}
if deck.CostumeIndex != 0 {
costume, found := s.collection.CostumeByIndex(deck.CostumeIndex)
if !found || costume.UseChar != deck.Deck.CharacterInvenIndex {
return fmt.Errorf("unknown costume %d", deck.CostumeIndex)
}
}
for _, reference := range deck.Equipment {
if reference.Index == 0 {
continue
}
if seenEquipment[reference.Index] || ownedEquipment[reference.Index].InvenIndex == 0 {
return fmt.Errorf("invalid or repeated equipment %d", reference.Index)
}
seenEquipment[reference.Index] = true
}
binding := player.PresetEquipmentBinding{CharacterIndex: deck.Deck.CharacterInvenIndex, Equipment: make([]uint64, 5)}
for _, reference := range deck.Equipment {
binding.Equipment[reference.Type] = reference.Index
}
if err := s.equipment.ValidatePresetEquipment([]player.PresetEquipmentBinding{binding}); err != nil {
return err
}
}
return nil
}
func decodePreset(data []byte) (Preset, error) {
var p Preset
name, _, err := wire.Bytes(data, 1)
if err != nil {
return p, err
}
p.Name = string(name)
p.ResourceID, _, err = wire.Varint(data, 2)
if err != nil {
return p, err
}
p.ResourceColor, _, err = wire.Varint(data, 3)
if err != nil {
return p, err
}
p.Slot, _, err = wire.Varint(data, 4)
if err != nil {
return p, err
}
err = wire.Walk(data, func(field wire.Field) error {
if field.Type != 2 {
return nil
}
switch field.Number {
case 5:
deck, err := decodePresetDeck(field.Value)
if err != nil {
return err
}
p.Decks = append(p.Decks, deck)
case 6:
bless, err := decodePresetBless(field.Value)
if err != nil {
return err
}
p.Blesses = append(p.Blesses, bless)
}
return nil
})
return p, err
}
func decodePresetDeck(data []byte) (PresetDeck, error) {
var result PresetDeck
base, found, err := wire.Bytes(data, 1)
if err != nil || !found {
return result, errors.New("deck: preset missing deck base")
}
character, ok, err := wire.Varint(base, 1)
if err != nil || !ok || character == 0 {
return result, errors.New("deck: invalid preset character")
}
position, _, err := wire.Varint(base, 2)
if err != nil {
return result, err
}
sequence, ok, err := wire.Varint(base, 3)
if err != nil || !ok || sequence == 0 {
return result, errors.New("deck: invalid preset sequence")
}
result.Deck = DeckEntry{CharacterInvenIndex: character, CostumeInvenIndex: position, Slot: sequence}
result.CostumeIndex, _, err = wire.Varint(data, 2)
if err != nil {
return result, err
}
result.Team, _, err = wire.Varint(data, 4)
if err != nil {
return result, err
}
err = wire.Walk(data, func(field wire.Field) error {
if field.Number != 3 {
return nil
}
if field.Type != 2 {
return errors.New("deck: invalid preset equipment")
}
typeID, _, err := wire.Varint(field.Value, 1)
if err != nil {
return err
}
index, _, err := wire.Varint(field.Value, 2)
if err != nil {
return err
}
result.Equipment = append(result.Equipment, PresetEquipmentItem{Type: typeID, Index: index})
return nil
})
sort.Slice(result.Equipment, func(i, j int) bool { return result.Equipment[i].Type < result.Equipment[j].Type })
return result, err
}
func decodePresetBless(data []byte) (PresetBless, error) {
var result PresetBless
result.DeckType, _, _ = wire.Varint(data, 1)
err := wire.Walk(data, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
values, err := repeatedUint64(field)
if err != nil {
return err
}
result.IDs = append(result.IDs, values...)
return nil
})
return result, err
}
func repeatedUint64(field wire.Field) ([]uint64, error) {
if field.Type == 0 {
value, count := binary.Uvarint(field.Value)
if count <= 0 {
return nil, wire.ErrMalformed
}
return []uint64{value}, nil
}
if field.Type != 2 {
return nil, wire.ErrMalformed
}
var out []uint64
for offset := 0; offset < len(field.Value); {
value, count := binary.Uvarint(field.Value[offset:])
if count <= 0 {
return nil, wire.ErrMalformed
}
out = append(out, value)
offset += count
}
return out, nil
}
func presetWire(p Preset) []byte {
var out []byte
if p.Name != "" {
out = wire.AppendString(out, 1, p.Name)
}
if p.ResourceID != 0 {
out = wire.AppendVarint(out, 2, p.ResourceID)
}
if p.ResourceColor != 0 {
out = wire.AppendVarint(out, 3, p.ResourceColor)
}
if p.Slot != 0 {
out = wire.AppendVarint(out, 4, p.Slot)
}
for _, deck := range p.Decks {
out = wire.AppendBytes(out, 5, presetDeckWire(deck))
}
for _, bless := range p.Blesses {
var b []byte
if bless.DeckType != 0 {
b = wire.AppendVarint(b, 1, bless.DeckType)
}
for _, id := range bless.IDs {
b = wire.AppendVarint(b, 2, id)
}
out = wire.AppendBytes(out, 6, b)
}
return out
}
func presetDeckWire(deck PresetDeck) []byte {
base := wire.AppendVarint(nil, 1, deck.Deck.CharacterInvenIndex)
if deck.Deck.CostumeInvenIndex != 0 {
base = wire.AppendVarint(base, 2, deck.Deck.CostumeInvenIndex)
}
base = wire.AppendVarint(base, 3, deck.Deck.Slot)
out := wire.AppendBytes(nil, 1, base)
if deck.CostumeIndex != 0 {
out = wire.AppendVarint(out, 2, deck.CostumeIndex)
}
for _, item := range deck.Equipment {
var b []byte
if item.Type != 0 {
b = wire.AppendVarint(b, 1, item.Type)
}
if item.Index != 0 {
b = wire.AppendVarint(b, 2, item.Index)
}
out = wire.AppendBytes(out, 3, b)
}
if deck.Team != 0 {
out = wire.AppendVarint(out, 4, deck.Team)
}
return out
}
func (s *Store) presetCacheKey(kind string, seq uint64) string {
return kind + ":" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
}
func (s *Store) persistPresetLocked(p Preset) error {
payload, err := json.Marshal(p)
if err != nil {
return err
}
core, err := s.corePayloadLocked()
if err != nil {
return err
}
return s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "presets", Key: strconv.FormatUint(p.Slot, 10), Payload: payload}})
}
func (s *Store) corePayloadLocked() ([]byte, error) {
payload, err := json.MarshalIndent(s.state, "", " ")
if err != nil {
return nil, err
}
return append(payload, '\n'), nil
}
func (s *Store) cachedReplyLocked(kind string, seq uint64) (deckReply, bool) {
reply, found := s.replies[s.presetCacheKey(kind, seq)]
if found {
reply.body = append([]byte(nil), reply.body...)
}
return reply, found
}
func (s *Store) rememberReplyLocked(kind string, seq uint64, code int, body []byte) {
s.replies[s.presetCacheKey(kind, seq)] = deckReply{code: code, body: append([]byte(nil), body...)}
}
func requestSequence(request []byte) (uint64, error) {
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, errors.New("deck: invalid request sequence")
}
return seq, nil
}
func (s *Store) handlePresetInfo(request []byte) (int, []byte, bool, error) {
if _, err := requestSequence(request); err != nil {
return 0, nil, true, err
}
s.mu.RLock()
defer s.mu.RUnlock()
slots := make([]uint64, 0, len(s.presets))
for slot := range s.presets {
if slot < s.presetSlots {
slots = append(slots, slot)
}
}
slices.Sort(slots)
var response []byte
for _, slot := range slots {
response = wire.AppendBytes(response, 1, presetWire(s.presets[slot]))
}
return 178, response, true, nil
}
func (s *Store) handlePresetSave(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
raw, found, err := wire.Bytes(request, 2)
if err != nil || !found {
return 0, nil, true, errors.New("deck: PresetSave missing preset")
}
preset, err := decodePreset(raw)
if err != nil {
return 0, nil, true, fmt.Errorf("deck: decode preset: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("save", seq); found {
return reply.code, reply.body, true, nil
}
if s.characters == nil || s.collection == nil || s.equipment == nil {
return 0, nil, true, errors.New("deck: preset runtime unavailable")
}
if err := s.validatePresetOwnedLocked(preset); err != nil {
return 0, nil, true, fmt.Errorf("deck: invalid preset: %w", err)
}
if err := s.persistPresetLocked(preset); err != nil {
return 0, nil, true, fmt.Errorf("deck: persist preset: %w", err)
}
s.presets[preset.Slot] = preset
s.rememberReplyLocked("save", seq, 179, nil)
return 179, nil, true, nil
}
func (s *Store) handlePresetAddSlot(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
count, found, err := wire.Varint(request, 2)
if err != nil || !found || count == 0 {
return 0, nil, true, errors.New("deck: PresetAddSlot invalid count")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("add-slot", seq); found {
return reply.code, reply.body, true, nil
}
if s.wallet == nil || s.presetDesign.Validate() != nil {
return 0, nil, true, errors.New("deck: preset wallet/design unavailable")
}
if s.presetSlots > s.presetDesign.Maximum || count > s.presetDesign.Maximum-s.presetSlots {
return 0, nil, true, errors.New("deck: preset slot limit exceeded")
}
if count > ^uint64(0)/s.presetDesign.Price {
return 0, nil, true, errors.New("deck: preset slot price overflow")
}
identity := "preset-slot:" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
var spendErr error
switch s.presetDesign.PriceType {
case 4:
_, spendErr = s.wallet.SpendGoldOnce(identity, count*s.presetDesign.Price)
case 3:
_, spendErr = s.wallet.SpendFreeJewelryOnce(identity, count*s.presetDesign.Price)
case 2:
_, spendErr = s.wallet.SpendJewelryOnce(identity, count*s.presetDesign.Price)
case 12:
_, spendErr = s.wallet.SpendCatalystOnce(identity, count*s.presetDesign.Price)
}
if err := spendErr; err != nil {
return 0, nil, true, fmt.Errorf("deck: buy preset slot: %w", err)
}
next := s.presetSlots + count
payload, err := json.Marshal(next)
if err != nil {
return 0, nil, true, err
}
core, err := s.corePayloadLocked()
if err != nil {
return 0, nil, true, err
}
if err := s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "preset_config", Key: "slots", Payload: payload}}); err != nil {
return 0, nil, true, fmt.Errorf("deck: persist preset slots: %w", err)
}
s.presetSlots = next
s.rememberReplyLocked("add-slot", seq, 180, nil)
return 180, nil, true, nil
}
func (s *Store) handlePresetInfoChange(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
nameBytes, _, err := wire.Bytes(request, 2)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset name")
}
resourceID, _, err := wire.Varint(request, 3)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset icon")
}
color, _, err := wire.Varint(request, 4)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset color")
}
// Ordinary preset slots are zero-based. Proto3 omits slot=0, so an absent
// field 5 is the first slot rather than a malformed request.
slot, _, err := wire.Varint(request, 5)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset slot")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("info-change", seq); found {
return reply.code, reply.body, true, nil
}
preset, exists := s.presets[slot]
if !exists {
preset = Preset{Slot: slot, Decks: []PresetDeck{}, Blesses: []PresetBless{}}
}
preset.Name, preset.ResourceID, preset.ResourceColor = string(nameBytes), resourceID, color
if err := s.validatePresetShape(preset, s.presetSlots); err != nil {
return 0, nil, true, fmt.Errorf("deck: invalid preset metadata: %w", err)
}
if err := s.persistPresetLocked(preset); err != nil {
return 0, nil, true, err
}
s.presets[slot] = preset
s.rememberReplyLocked("info-change", seq, 278, nil)
return 278, nil, true, nil
}
func (s *Store) handlePresetDelete(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
var slots []uint64
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
values, err := repeatedUint64(field)
if err != nil {
return err
}
slots = append(slots, values...)
return nil
}); err != nil || len(slots) == 0 {
return 0, nil, true, errors.New("deck: invalid preset delete slots")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("delete", seq); found {
return reply.code, reply.body, true, nil
}
seen := make(map[uint64]bool, len(slots))
changes := make([]stateio.EntryMutation, 0, len(slots))
for _, slot := range slots {
if slot >= s.presetSlots || seen[slot] {
return 0, nil, true, errors.New("deck: invalid or duplicate preset delete slot")
}
seen[slot] = true
changes = append(changes, stateio.EntryMutation{Bucket: "presets", Key: strconv.FormatUint(slot, 10), Delete: true})
}
core, err := s.corePayloadLocked()
if err != nil {
return 0, nil, true, err
}
if err := s.storage.SaveWithEntries("deck", core, changes); err != nil {
return 0, nil, true, err
}
for slot := range seen {
delete(s.presets, slot)
}
// 2.35.10 contains the request/response classes but no PacketCode enum
// member. Code zero is the same compatibility fallback used for other
// unnumbered local endpoints; it must not be treated as an official value.
s.rememberReplyLocked("delete", seq, 0, nil)
return 0, nil, true, nil
}
func (s *Store) handlePresetUse(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
// PresetUse is also zero-based and slot=0 is omitted by proto3.
slot, _, err := wire.Varint(request, 2)
if err != nil {
return 0, nil, true, errors.New("deck: PresetUse missing slot")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("use", seq); found {
return reply.code, reply.body, true, nil
}
preset, found := s.presets[slot]
if !found || len(preset.Decks) == 0 {
return 0, nil, true, errors.New("deck: PresetUse references an empty slot")
}
if s.characters == nil || s.collection == nil || s.equipment == nil {
return 0, nil, true, errors.New("deck: preset runtime unavailable")
}
if err := s.validatePresetOwnedLocked(preset); err != nil {
return 0, nil, true, fmt.Errorf("deck: stale preset: %w", err)
}
deckEntries := make([]DeckEntry, 0, len(preset.Decks))
assignments := make(map[uint64]uint64, len(preset.Decks))
bindings := make([]player.PresetEquipmentBinding, 0, len(preset.Decks))
for _, entry := range preset.Decks {
deckEntries = append(deckEntries, entry.Deck)
assignments[entry.Deck.CharacterInvenIndex] = entry.CostumeIndex
equipment := make([]uint64, 5)
for _, item := range entry.Equipment {
equipment[item.Type] = item.Index
}
bindings = append(bindings, player.PresetEquipmentBinding{CharacterIndex: entry.Deck.CharacterInvenIndex, Equipment: equipment})
}
if _, err := s.characters.ApplyPresetCostumes(assignments); err != nil {
return 0, nil, true, fmt.Errorf("deck: apply preset costumes: %w", err)
}
affected, err := s.equipment.ApplyPresetEquipment(bindings)
if err != nil {
return 0, nil, true, fmt.Errorf("deck: apply preset equipment: %w", err)
}
next := clone(s.state)
next.Deck = append([]DeckEntry(nil), deckEntries...)
maps.Copy(next.Costumes, assignments)
if err := s.commit(next); err != nil {
return 0, nil, true, fmt.Errorf("deck: persist applied preset: %w", err)
}
var response []byte
for _, entry := range deckEntries {
base := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex)
if entry.CostumeInvenIndex != 0 {
base = wire.AppendVarint(base, 2, entry.CostumeInvenIndex)
}
base = wire.AppendVarint(base, 3, entry.Slot)
response = wire.AppendBytes(response, 1, base)
}
characterSet := make(map[uint64]bool, len(assignments)+len(affected))
for index := range assignments {
characterSet[index] = true
}
for _, character := range affected {
characterSet[character.InvenIndex] = true
}
indices := make([]uint64, 0, len(characterSet))
for index := range characterSet {
indices = append(indices, index)
}
slices.Sort(indices)
for _, index := range indices {
if character, found := s.characters.Find(index); found {
response = wire.AppendBytes(response, 2, player.CharacterWire(character))
}
}
for _, binding := range bindings {
info := wire.AppendVarint(nil, 1, binding.CharacterIndex)
for _, index := range binding.Equipment {
info = wire.AppendVarint(info, 2, index)
}
response = wire.AppendBytes(response, 3, info)
}
s.rememberReplyLocked("use", seq, 409, response)
return 409, response, true, nil
}
func costumeSettingWire(setting CostumeSetting) []byte {
out := wire.AppendVarint(nil, 1, setting.CharacterIndex)
for _, item := range setting.Sequence {
entry := wire.AppendVarint(nil, 1, uint64(item.CostumeIndex))
if item.BurstLevel != 0 {
entry = wire.AppendVarint(entry, 2, item.BurstLevel)
}
out = wire.AppendBytes(out, 2, entry)
}
if setting.BattleMode != 0 {
out = wire.AppendVarint(out, 3, setting.BattleMode)
}
if setting.MonsterID != 0 {
out = wire.AppendVarint(out, 4, setting.MonsterID)
}
return out
}
func decodeCostumeSetting(data []byte) (CostumeSetting, error) {
var result CostumeSetting
character, found, err := wire.Varint(data, 1)
if err != nil || !found || character == 0 {
return result, errors.New("deck: costume setting missing character")
}
result.CharacterIndex = character
result.BattleMode, _, err = wire.Varint(data, 3)
if err != nil {
return result, err
}
result.MonsterID, _, err = wire.Varint(data, 4)
if err != nil {
return result, err
}
err = wire.Walk(data, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
if field.Type != 2 {
return errors.New("deck: invalid costume setting sequence")
}
raw, found, err := wire.Varint(field.Value, 1)
if err != nil || !found {
return errors.New("deck: costume setting entry missing costume")
}
burst, _, err := wire.Varint(field.Value, 2)
if err != nil {
return err
}
result.Sequence = append(result.Sequence, CostumeSettingItem{CostumeIndex: int64(raw), BurstLevel: burst})
return nil
})
return result, err
}
func (s *Store) validateCostumeSettingLocked(setting CostumeSetting) error {
if s.characters == nil || s.collection == nil {
return errors.New("deck: costume setting runtime unavailable")
}
if setting.BattleMode != 0 || setting.MonsterID != 0 {
return errors.New("deck: ordinary costume setting requires normal battle mode")
}
if _, found := s.characters.Find(setting.CharacterIndex); !found {
return fmt.Errorf("deck: unknown costume setting character %d", setting.CharacterIndex)
}
for _, item := range setting.Sequence {
if item.CostumeIndex <= 0 {
if item.CostumeIndex != 0 && item.CostumeIndex != -1 {
return fmt.Errorf("deck: invalid costume setting sentinel %d", item.CostumeIndex)
}
continue
}
costume, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex))
if !found || costume.UseChar != setting.CharacterIndex {
return fmt.Errorf("deck: costume %d does not belong to character %d", item.CostumeIndex, setting.CharacterIndex)
}
if item.BurstLevel > costume.BurstLevel {
return fmt.Errorf("deck: costume %d burst level exceeds owned level", item.CostumeIndex)
}
}
return nil
}
func (s *Store) handleCostumeSettingInfo(request []byte) (int, []byte, bool, error) {
if _, err := requestSequence(request); err != nil {
return 0, nil, true, err
}
s.mu.RLock()
defer s.mu.RUnlock()
indices := make([]uint64, 0, len(s.costumeSettings))
for index := range s.costumeSettings {
indices = append(indices, index)
}
slices.Sort(indices)
var response []byte
for _, index := range indices {
response = wire.AppendBytes(response, 1, costumeSettingWire(s.costumeSettings[index]))
}
return 397, response, true, nil
}
func (s *Store) handleCostumeSettingSave(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
raw, found, err := wire.Bytes(request, 2)
if err != nil || !found {
return 0, nil, true, errors.New("deck: DeckCostumeSettingSave missing setting")
}
setting, err := decodeCostumeSetting(raw)
if err != nil {
return 0, nil, true, err
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("costume-setting-save", seq); found {
return reply.code, reply.body, true, nil
}
if err := s.validateCostumeSettingLocked(setting); err != nil {
return 0, nil, true, err
}
payload, err := json.Marshal(setting)
if err != nil {
return 0, nil, true, err
}
change := stateio.EntryMutation{Bucket: "costume_settings", Key: strconv.FormatUint(setting.CharacterIndex, 10), Payload: payload}
core, err := s.corePayloadLocked()
if err != nil {
return 0, nil, true, err
}
if err := s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{change}); err != nil {
return 0, nil, true, err
}
s.costumeSettings[setting.CharacterIndex] = setting
s.rememberReplyLocked("costume-setting-save", seq, 398, nil)
return 398, nil, true, nil
}
-380
View File
@@ -1,380 +0,0 @@
package deck
import (
"bd2server/internal/server/gamedata"
"reflect"
"sort"
"testing"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestPresetChangedDesignControlsBaseLimitAndCurrency(t *testing.T) {
f := newPresetFixture(t)
design := gamedata.PresetDesign{BaseCount: 2, Maximum: 3, PriceType: 3, Price: 17}
d, err := OpenStore(f.storage, f.seed, design)
if err != nil {
t.Fatal(err)
}
w, err := player.OpenWallet(stateio.NewMemory(), player.Currency{FreeJewelry: 100})
if err != nil {
t.Fatal(err)
}
d.wallet = w
d.BeginSession("changed-design")
if d.PresetSlotCount() != 2 {
t.Fatal("base count ignored")
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)
if _, _, _, err := d.Handle("/PresetAddSlot", request); err != nil {
t.Fatal(err)
}
if d.PresetSlotCount() != 3 || w.Snapshot().FreeJewelry != 83 || w.Snapshot().Gold != 0 {
t.Fatal("changed price/currency ignored")
}
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1)
if _, _, _, err := d.Handle("/PresetAddSlot", request); err == nil {
t.Fatal("changed limit ignored")
}
if w.Snapshot().FreeJewelry != 83 {
t.Fatal("invalid purchase charged")
}
}
type presetFixture struct {
storage *stateio.Memory
seed Seed
deck *Store
wallet *player.Wallet
characters *player.CharacterStore
equipment *player.EquipmentInventory
collection *player.CollectionStore
weapon player.Equipment
}
func newPresetFixture(t *testing.T) *presetFixture {
t.Helper()
storage := stateio.NewMemory()
seed, err := LoadSeed("../../../seed/v2_35_10/decks.json")
if err != nil {
t.Fatal(err)
}
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(storage, []player.Costume{
{InvenIndex: 1001, ID: 3501, UseChar: 100, BurstLevel: 3},
{InvenIndex: 2001, ID: 3601, UseChar: 200, BurstLevel: 1},
})
if err != nil {
t.Fatal(err)
}
characters, err := player.OpenCharacterStore(storage, []player.Character{
{InvenIndex: 100, ID: 350, Level: 20, UseCostume: 1001, CostumeID: 3501},
{InvenIndex: 200, ID: 360, Level: 20, UseCostume: 2001, CostumeID: 3601},
}, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err := characters.AttachCollection(collection); err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
if err := equipment.AttachSlots(map[uint64]uint64{10: 0, 11: 1, 12: 2, 13: 3, 14: 4}); err != nil {
t.Fatal(err)
}
if err := equipment.AttachCharacters(characters); err != nil {
t.Fatal(err)
}
weapon, err := equipment.GrantOnce("preset-test-weapon", 10)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{Gold: 20000})
if err != nil {
t.Fatal(err)
}
decks, err := OpenStore(storage, seed, testPresetDesign)
if err != nil {
t.Fatal(err)
}
if err := decks.AttachPresetRuntime(wallet, characters, equipment, collection); err != nil {
t.Fatal(err)
}
decks.BeginSession("preset-test")
return &presetFixture{storage: storage, seed: seed, deck: decks, wallet: wallet, characters: characters, equipment: equipment, collection: collection, weapon: weapon}
}
func presetRequest(slot, character, costume, equipment uint64, name string) []byte {
base := wire.AppendVarint(nil, 1, character)
base = wire.AppendVarint(base, 2, 0)
base = wire.AppendVarint(base, 3, 1)
deck := wire.AppendBytes(nil, 1, base)
deck = wire.AppendVarint(deck, 2, costume)
for equipmentType := range uint64(5) {
entry := wire.AppendVarint(nil, 1, equipmentType)
if equipmentType == 0 {
entry = wire.AppendVarint(entry, 2, equipment)
}
deck = wire.AppendBytes(deck, 3, entry)
}
preset := wire.AppendString(nil, 1, name)
preset = wire.AppendVarint(preset, 2, 1)
preset = wire.AppendVarint(preset, 4, slot)
preset = wire.AppendBytes(preset, 5, deck)
return wire.AppendBytes(nil, 2, preset)
}
func TestPresetSaveInfoMetadataDeleteAndRestart(t *testing.T) {
f := newPresetFixture(t)
for seq, slot := range []uint64{2, 0} {
request := req(uint64(seq+1), presetRequest(slot, 100, 1001, f.weapon.InvenIndex, "编队"))
if code, _, handled, err := f.deck.Handle("/PresetSave", request); err != nil || !handled || code != 179 {
t.Fatalf("save slot %d code=%d handled=%v err=%v", slot, code, handled, err)
}
}
code, response, handled, err := f.deck.Handle("/PresetInfo", req(10))
if err != nil || !handled || code != 178 {
t.Fatalf("info code=%d handled=%v err=%v", code, handled, err)
}
var slots []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 {
slot, _, err := wire.Varint(field.Value, 4)
if err != nil {
return err
}
slots = append(slots, slot)
}
return nil
}); err != nil || !reflect.DeepEqual(slots, []uint64{0, 2}) {
t.Fatalf("ordered slots=%v err=%v", slots, err)
}
change := wire.AppendString(nil, 2, "主力队")
change = wire.AppendVarint(change, 3, 21)
change = wire.AppendVarint(change, 4, 5)
// slot=0 is absent on the real proto3 wire.
if code, _, _, err := f.deck.Handle("/PresetInfoChange", req(11, change)); err != nil || code != 278 {
t.Fatalf("metadata change code=%d err=%v", code, err)
}
if got := f.deck.presets[0]; got.Name != "主力队" || got.ResourceID != 21 || got.ResourceColor != 5 || len(got.Decks) != 1 {
t.Fatalf("metadata change lost content: %+v", got)
}
restarted, err := OpenStore(f.storage, f.seed, testPresetDesign)
if err != nil {
t.Fatal(err)
}
if len(restarted.presets) != 2 || restarted.presets[0].Name != "主力队" {
t.Fatalf("restarted presets=%+v", restarted.presets)
}
restarted.BeginSession("restart")
deleteRequest := req(12, wire.AppendVarint(nil, 2, 0), wire.AppendVarint(nil, 2, 2))
if code, _, handled, err := restarted.Handle("/PresetDelete", deleteRequest); err != nil || !handled || code != 0 {
t.Fatalf("delete code=%d handled=%v err=%v", code, handled, err)
}
if len(restarted.presets) != 0 || restarted.presetSlots != 5 {
t.Fatalf("delete changed slots/content: slots=%d presets=%+v", restarted.presetSlots, restarted.presets)
}
}
func TestPresetMetadataCanCreateEmptySlot(t *testing.T) {
f := newPresetFixture(t)
change := wire.AppendString(nil, 2, "备用")
change = wire.AppendVarint(change, 3, 2)
change = wire.AppendVarint(change, 4, 1)
change = wire.AppendVarint(change, 5, 4)
if code, _, _, err := f.deck.Handle("/PresetInfoChange", req(1, change)); err != nil || code != 278 {
t.Fatalf("upsert code=%d err=%v", code, err)
}
if got := f.deck.presets[4]; got.Name != "备用" || len(got.Decks) != 0 {
t.Fatalf("empty metadata preset=%+v", got)
}
}
func TestPresetAddSlotChargesOncePersistsAndCaps(t *testing.T) {
f := newPresetFixture(t)
add := req(20, wire.AppendVarint(nil, 2, 1))
for attempt := range 2 {
if code, _, _, err := f.deck.Handle("/PresetAddSlot", add); err != nil || code != 180 {
t.Fatalf("add attempt %d code=%d err=%v", attempt, code, err)
}
}
if f.deck.PresetSlotCount() != 6 || f.wallet.Snapshot().Gold != 18000 {
t.Fatalf("slots=%d gold=%d", f.deck.PresetSlotCount(), f.wallet.Snapshot().Gold)
}
restarted, err := OpenStore(f.storage, f.seed, testPresetDesign)
if err != nil {
t.Fatal(err)
}
if restarted.PresetSlotCount() != 6 {
t.Fatalf("restarted slots=%d", restarted.PresetSlotCount())
}
if err := restarted.AttachPresetRuntime(f.wallet, f.characters, f.equipment, f.collection); err != nil {
t.Fatal(err)
}
restarted.BeginSession("cap")
if _, _, _, err := restarted.Handle("/PresetAddSlot", req(21, wire.AppendVarint(nil, 2, 6))); err != nil {
t.Fatalf("buy remaining slots: %v", err)
}
if _, _, _, err := restarted.Handle("/PresetAddSlot", req(22, wire.AppendVarint(nil, 2, 1))); err == nil {
t.Fatal("accepted slot beyond maximum")
}
}
func TestPresetUseAppliesDeckCostumeEquipmentWithoutChangingFieldDeck(t *testing.T) {
f := newPresetFixture(t)
wantField := append([]FieldEntry(nil), f.deck.state.FieldDeck...)
if _, _, _, err := f.deck.Handle("/PresetSave", req(1, presetRequest(0, 100, 1001, f.weapon.InvenIndex, "应用"))); err != nil {
t.Fatal(err)
}
code, response, handled, err := f.deck.Handle("/PresetUse", req(2, wire.AppendVarint(nil, 3, 999999999)))
if err != nil || !handled || code != 409 {
t.Fatalf("use code=%d handled=%v err=%v", code, handled, err)
}
if len(f.deck.state.Deck) != 1 || f.deck.state.Deck[0].CharacterInvenIndex != 100 || !reflect.DeepEqual(f.deck.state.FieldDeck, wantField) {
t.Fatalf("deck=%+v field changed=%v", f.deck.state.Deck, !reflect.DeepEqual(f.deck.state.FieldDeck, wantField))
}
owned := f.equipment.All()
if len(owned) != 1 || owned[0].UseChar != 100 {
t.Fatalf("equipment=%+v", owned)
}
var deckCount, characterCount int
var equipmentValues []uint64
if err := wire.Walk(response, func(field wire.Field) error {
switch field.Number {
case 1:
deckCount++
case 2:
characterCount++
case 3:
return wire.Walk(field.Value, func(nested wire.Field) error {
if nested.Number == 2 {
values, err := repeatedUint64(nested)
equipmentValues = append(equipmentValues, values...)
return err
}
return nil
})
}
return nil
}); err != nil {
t.Fatal(err)
}
if deckCount != 1 || characterCount == 0 || !reflect.DeepEqual(equipmentValues, []uint64{f.weapon.InvenIndex, 0, 0, 0, 0}) {
t.Fatalf("response deck=%d chars=%d equipment=%v", deckCount, characterCount, equipmentValues)
}
// The client-supplied power is deliberately ignored; replay returns the
// exact same response and does not repeat cross-domain state transitions.
if replayCode, replay, _, replayErr := f.deck.Handle("/PresetUse", req(2)); replayErr != nil || replayCode != 409 || !reflect.DeepEqual(replay, response) {
t.Fatalf("replay code=%d equal=%v err=%v", replayCode, reflect.DeepEqual(replay, response), replayErr)
}
}
func TestPresetSaveRejectsForgedOwnershipAndEquipmentSlot(t *testing.T) {
f := newPresetFixture(t)
tests := []struct {
name string
character uint64
costume uint64
equipment uint64
}{
{name: "character", character: 999, costume: 1001, equipment: f.weapon.InvenIndex},
{name: "costume owner", character: 100, costume: 2001, equipment: f.weapon.InvenIndex},
{name: "equipment", character: 100, costume: 1001, equipment: 999999},
}
for seq, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, _, err := f.deck.Handle("/PresetSave", req(uint64(seq+1), presetRequest(0, test.character, test.costume, test.equipment, "伪造"))); err == nil {
t.Fatal("forged preset accepted")
}
})
}
if len(f.deck.presets) != 0 {
t.Fatalf("rejected save mutated presets=%+v", f.deck.presets)
}
}
func TestDeckCostumeSettingSentinelsClearAndRestart(t *testing.T) {
f := newPresetFixture(t)
setting := wire.AppendVarint(nil, 1, 100)
for _, item := range []struct {
index int64
burst uint64
}{{1001, 3}, {-1, 0}, {0, 0}} {
entry := wire.AppendVarint(nil, 1, uint64(item.index))
entry = wire.AppendVarint(entry, 2, item.burst)
setting = wire.AppendBytes(setting, 2, entry)
}
if code, _, _, err := f.deck.Handle("/DeckCostumeSettingSave", req(1, wire.AppendBytes(nil, 2, setting))); err != nil || code != 398 {
t.Fatalf("save setting code=%d err=%v", code, err)
}
restarted, err := OpenStore(f.storage, f.seed, testPresetDesign)
if err != nil {
t.Fatal(err)
}
if err := restarted.AttachPresetRuntime(f.wallet, f.characters, f.equipment, f.collection); err != nil {
t.Fatal(err)
}
restarted.BeginSession("costume-restart")
code, response, _, err := restarted.Handle("/DeckCostumeSettingInfo", req(2))
if err != nil || code != 397 {
t.Fatalf("info setting code=%d err=%v", code, err)
}
var got []int64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 1 {
return nil
}
return wire.Walk(field.Value, func(nested wire.Field) error {
if nested.Number == 2 {
value, _, err := wire.Varint(nested.Value, 1)
got = append(got, int64(value))
return err
}
return nil
})
}); err != nil || !reflect.DeepEqual(got, []int64{1001, -1, 0}) {
t.Fatalf("setting sequence=%v err=%v", got, err)
}
clear := wire.AppendVarint(nil, 1, 100)
if _, _, _, err := restarted.Handle("/DeckCostumeSettingSave", req(3, wire.AppendBytes(nil, 2, clear))); err != nil {
t.Fatal(err)
}
if len(restarted.costumeSettings[100].Sequence) != 0 {
t.Fatalf("setting was not cleared: %+v", restarted.costumeSettings[100])
}
}
func TestCostumeSettingInfoIsSorted(t *testing.T) {
f := newPresetFixture(t)
for seq, character := range []uint64{200, 100} {
setting := wire.AppendVarint(nil, 1, character)
if _, _, _, err := f.deck.Handle("/DeckCostumeSettingSave", req(uint64(seq+1), wire.AppendBytes(nil, 2, setting))); err != nil {
t.Fatal(err)
}
}
_, response, _, err := f.deck.Handle("/DeckCostumeSettingInfo", req(8))
if err != nil {
t.Fatal(err)
}
var indices []uint64
_ = wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 {
index, _, _ := wire.Varint(field.Value, 1)
indices = append(indices, index)
}
return nil
})
if !sort.SliceIsSorted(indices, func(i, j int) bool { return indices[i] < indices[j] }) || !reflect.DeepEqual(indices, []uint64{100, 200}) {
t.Fatalf("setting order=%v", indices)
}
}
-24
View File
@@ -1,24 +0,0 @@
package deck
import "bd2server/internal/server/player"
func (s *Store) CurrentFieldDeck() []FieldEntry {
s.mu.RLock()
defer s.mu.RUnlock()
return s.visibleFieldDeckLocked()
}
func (s *Store) visibleFieldDeckLocked() []FieldEntry {
var out []FieldEntry
for _, entry := range s.state.FieldDeck {
if s.characters != nil {
c, ok := s.characters.Find(entry.CharacterInvenIndex)
if !ok || player.IsStoryCharacter(c) && !s.temporaryAllowed(c) {
continue
}
}
entry.Slot = uint64(len(out) + 1)
out = append(out, entry)
}
return out
}
-139
View File
@@ -1,139 +0,0 @@
package deck
import (
"encoding/binary"
"errors"
"fmt"
"math"
"slices"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
func (s *Store) ConfigureWaypoints(load func(uint64) (gamedata.WaypointPack, error), validate func(uint64, bool) error) error {
if load == nil || validate == nil {
return errors.New("deck: incomplete waypoint runtime")
}
s.mu.Lock()
defer s.mu.Unlock()
s.waypointDesign = load
s.waypointPack = validate
return nil
}
func (s *Store) ActivatedWaypoint(pack, id uint64) bool {
s.mu.RLock()
defer s.mu.RUnlock()
return hasWaypoint(s.state.Waypoints[pack], id)
}
func hasWaypoint(points []uint64, id uint64) bool {
return slices.Contains(points, id)
}
func validWaypointState(packs map[uint64][]uint64) error {
for pack, points := range packs {
if pack == 0 || pack > math.MaxInt32 || points == nil {
return errors.New("deck: invalid saved waypoint pack")
}
seen := map[uint64]bool{}
for _, id := range points {
if id == 0 || id > math.MaxInt32 || seen[id] {
return errors.New("deck: invalid saved waypoint activation")
}
seen[id] = true
}
}
return nil
}
func (s *Store) handleWaypoint(path string, req []byte) (int, []byte, bool, error) {
seq, err := requestSequence(req)
if err != nil {
return 0, nil, true, err
}
pack, found, err := wire.Varint(req, 2)
if err != nil || !found || pack == 0 || pack > math.MaxInt32 {
return 0, nil, true, errors.New("deck: invalid waypoint pack")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.waypointDesign == nil || s.waypointPack == nil {
return 0, nil, true, errors.New("deck: waypoint runtime unavailable")
}
if err = s.waypointPack(pack, path == "/WaypointUse"); err != nil {
return 0, nil, true, err
}
design, err := s.waypointDesign(pack)
if err != nil {
return 0, nil, true, err
}
if path == "/WaypointInfo" {
ids := append([]uint64(nil), s.state.Waypoints[pack]...)
slices.Sort(ids)
var packed []byte
for _, id := range ids {
if _, known := design.Points[id]; !known {
return 0, nil, true, fmt.Errorf("deck: saved waypoint %d absent from pack%d", id, pack)
}
packed = binary.AppendUvarint(packed, id)
}
if len(packed) == 0 {
return 31, nil, true, nil
}
return 31, wire.AppendBytes(nil, 1, packed), true, nil
}
id, found, err := wire.Varint(req, 3)
if err != nil || !found || id == 0 || id > math.MaxInt32 {
return 0, nil, true, errors.New("deck: invalid waypoint")
}
if _, known := design.Points[id]; !known {
return 0, nil, true, errors.New("deck: unknown waypoint")
}
if path == "/WaypointSave" {
if hasWaypoint(s.state.Waypoints[pack], id) {
return 32, nil, true, nil
}
next := clone(s.state)
next.Waypoints[pack] = append(next.Waypoints[pack], id)
err = s.commit(next)
return 32, nil, true, err
}
end, found, err := wire.Varint(req, 4)
if err != nil || !found || end == 0 || end > math.MaxInt32 || end == id {
return 0, nil, true, errors.New("deck: invalid waypoint destination")
}
target, known := design.Points[end]
if !known || target.MapID == 0 || !hasWaypoint(s.state.Waypoints[pack], end) || !hasWaypoint(s.state.Waypoints[pack], id) {
return 0, nil, true, errors.New("deck: waypoint is not activated")
}
moves, found, err := wire.Varint(req, 5)
if err != nil || !found || moves != 1 {
return 0, nil, true, errors.New("deck: invalid waypoint move count")
}
if reply, ok := s.cachedReplyLocked("waypoint-use", seq); ok {
return reply.code, reply.body, true, nil
}
if design.PriceUnit != 0 {
if s.wallet == nil || s.sessionID == "" {
return 0, nil, true, errors.New("deck: waypoint wallet session unavailable")
}
identity := fmt.Sprintf("waypoint:%s:%d", s.sessionID, seq)
switch design.PriceType {
case 4:
_, err = s.wallet.SpendGoldOnce(identity, design.PriceUnit)
case 3:
_, err = s.wallet.SpendFreeJewelryOnce(identity, design.PriceUnit)
case 2:
_, err = s.wallet.SpendJewelryOnce(identity, design.PriceUnit)
default:
err = errors.New("deck: unsupported waypoint currency")
}
if err != nil {
return 0, nil, true, err
}
}
// The client performs its warp and sends SaveUserPosition with scene coordinates.
s.rememberReplyLocked("waypoint-use", seq, 33, nil)
return 33, nil, true, nil
}
-127
View File
@@ -1,127 +0,0 @@
package deck
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"errors"
"testing"
)
func waypointFixture(t *testing.T) (*Store, *stateio.Memory) {
t.Helper()
memory := stateio.NewMemory()
seededStore := seeded(t)
s, err := OpenStore(memory, Seed{Version: seededStore.state.Version, FieldDeck: seededStore.state.FieldDeck, FieldCharControlDeckType: seededStore.state.FieldCharControlDeckType})
if err != nil {
t.Fatal(err)
}
configureWaypointFixture(t, s)
return s, memory
}
func configureWaypointFixture(t *testing.T, s *Store) {
t.Helper()
if err := s.ConfigureWaypoints(func(uint64) (gamedata.WaypointPack, error) {
return gamedata.WaypointPack{Points: map[uint64]gamedata.Waypoint{1: {ID: 1, MapID: 10}, 2: {ID: 2, MapID: 20}}, PriceType: 4, PriceUnit: 7}, nil
}, func(pack uint64, _ bool) error {
if pack == 3 {
return errors.New("locked pack")
}
return nil
}); err != nil {
t.Fatal(err)
}
}
func waypointReq(seq, pack, start, end uint64) []byte {
body := wire.AppendVarint(nil, 2, pack)
if start != 0 {
body = wire.AppendVarint(body, 3, start)
}
if end != 0 {
body = wire.AppendVarint(body, 4, end)
body = wire.AppendVarint(body, 5, 1)
}
return req(seq, body)
}
func TestWaypointActivationPersistsAllIDsAndInfoScopesPack(t *testing.T) {
s, memory := waypointFixture(t)
for _, r := range []struct{ seq, pack, id uint64 }{{1, 1, 2}, {2, 1, 1}, {3, 2, 2}, {4, 1, 1}} {
if _, _, _, err := s.Handle("/WaypointSave", waypointReq(r.seq, r.pack, r.id, 0)); err != nil {
t.Fatal(err)
}
}
reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType})
if err != nil {
t.Fatal(err)
}
configureWaypointFixture(t, reopened)
code, body, _, err := reopened.Handle("/WaypointInfo", waypointReq(5, 1, 0, 0))
if err != nil || code != 31 || !bytes.Equal(body, []byte{10, 2, 1, 2}) {
t.Fatalf("pack1 info %d %x %v", code, body, err)
}
_, body, _, err = reopened.Handle("/WaypointInfo", waypointReq(6, 2, 0, 0))
if err != nil || !bytes.Equal(body, []byte{10, 1, 2}) {
t.Fatalf("pack2 info %x %v", body, err)
}
if len(reopened.state.Waypoints[1]) != 2 {
t.Fatal("duplicate activation persisted")
}
}
func TestWaypointTravelChargesOnceAndRejectsInvalidBeforeSpending(t *testing.T) {
s, memory := waypointFixture(t)
wallet, err := player.OpenWallet(memory, player.Currency{Gold: 14})
if err != nil {
t.Fatal(err)
}
s.wallet = wallet
s.BeginSession("test-session")
for i := uint64(1); i <= 2; i++ {
if _, _, _, err = s.Handle("/WaypointSave", waypointReq(i, 1, i, 0)); err != nil {
t.Fatal(err)
}
}
for _, request := range [][]byte{waypointReq(3, 3, 1, 2), waypointReq(4, 1, 1, 99), waypointReq(5, 2, 1, 2)} {
if _, _, _, err = s.Handle("/WaypointUse", request); err == nil {
t.Fatal("invalid travel accepted")
}
if wallet.Snapshot().Gold != 14 {
t.Fatal("invalid travel charged")
}
}
valid := waypointReq(6, 1, 1, 2)
for range 2 {
code, _, _, err := s.Handle("/WaypointUse", valid)
if err != nil || code != 33 {
t.Fatalf("travel %d %v", code, err)
}
}
if wallet.Snapshot().Gold != 7 {
t.Fatal("repeat travel double charged")
}
reopened, err := OpenStore(memory, Seed{Version: s.state.Version, FieldDeck: s.state.FieldDeck, FieldCharControlDeckType: s.state.FieldCharControlDeckType})
if err != nil {
t.Fatal(err)
}
configureWaypointFixture(t, reopened)
reopened.wallet = wallet
reopened.BeginSession("test-session")
if _, _, _, err = reopened.Handle("/WaypointUse", valid); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 7 {
t.Fatal("restart retry charged")
}
if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(7, 1, 2, 1)); err != nil {
t.Fatal(err)
}
if _, _, _, err = reopened.Handle("/WaypointUse", waypointReq(8, 1, 1, 2)); err == nil {
t.Fatal("insufficient balance accepted")
}
if wallet.Snapshot().Gold != 0 {
t.Fatal("failed debit changed wallet")
}
}

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