refactor(all): reorganize packages, establish player ownership and isolate accounts
This commit is contained in:
@@ -1,42 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type testAchievementExperience struct {
|
||||
value uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *testAchievementExperience) AchievementExperience() (uint64, error) { return s.value, s.err }
|
||||
|
||||
func TestLoginReadsPersistedAchievementExperienceEachTime(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 12, 999)}
|
||||
source := &testAchievementExperience{value: 7}
|
||||
if err := seed.AttachAchievementExperience(source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []uint64{7, 16, 0} {
|
||||
source.value = value
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(response, 1)
|
||||
exp, found, err := wire.Varint(user, 12)
|
||||
if err != nil || !found || exp != value {
|
||||
t.Fatalf("exp=%d want=%d err=%v", exp, value, err)
|
||||
}
|
||||
}
|
||||
for _, invalid := range []testAchievementExperience{{value: math.MaxInt32 + 1}, {err: errors.New("read failed")}} {
|
||||
*source = invalid
|
||||
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("invalid experience accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AutoReviveSettingsProvider interface{ AutoReviveSettings() (bool, uint64, error) }
|
||||
|
||||
func (s *LoginSeed) AttachAutoReviveSettings(p AutoReviveSettingsProvider) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("account: nil automatic recovery settings")
|
||||
}
|
||||
s.autoReviveSettings = p
|
||||
return nil
|
||||
}
|
||||
func (s *LoginSeed) projectAutoRevive(user []byte) ([]byte, error) {
|
||||
if s.autoReviveSettings == nil {
|
||||
return user, nil
|
||||
}
|
||||
on, index, e := s.autoReviveSettings.AutoReviveSettings()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if index > 9223372036854775807 {
|
||||
return nil, fmt.Errorf("account: automatic recovery caster overflow")
|
||||
}
|
||||
n := uint64(0)
|
||||
if on {
|
||||
n = 1
|
||||
}
|
||||
user, _, e = wire.ReplaceVarint(user, 49, n)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
user, _, e = wire.ReplaceVarint(user, 50, index)
|
||||
return user, e
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type autoSettingsFake struct {
|
||||
on bool
|
||||
index uint64
|
||||
}
|
||||
|
||||
func (p *autoSettingsFake) AutoReviveSettings() (bool, uint64, error) { return p.on, p.index, nil }
|
||||
func TestLoginProjectsCurrentRecoverySettingsEachTime(t *testing.T) {
|
||||
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 42)}
|
||||
p := &autoSettingsFake{true, 199}
|
||||
if e := s.AttachAutoReviveSettings(p); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, on := range []bool{true, false} {
|
||||
p.on = on
|
||||
if !on {
|
||||
p.index = 0
|
||||
}
|
||||
b, e := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
user, _, _ := wire.Bytes(b, 1)
|
||||
v, _, _ := wire.Varint(user, 49)
|
||||
index, _, _ := wire.Varint(user, 50)
|
||||
if (v != 0) != on || index != p.index {
|
||||
t.Fatal("seed value leaked", v, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type friendshipAPFixture struct {
|
||||
remaining uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *friendshipAPFixture) FriendshipAP() (uint64, error) { return f.remaining, f.err }
|
||||
|
||||
func TestLoginReadsCurrentFriendshipAPInsteadOfSeed(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
|
||||
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 69, 3)
|
||||
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 70, 99)
|
||||
provider := &friendshipAPFixture{remaining: 2}
|
||||
if err := seed.AttachFriendshipAP(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, remaining := range []uint64{2, 0, 3} {
|
||||
provider.remaining = remaining
|
||||
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(body, 1)
|
||||
free, _, _ := wire.Varint(user, 69)
|
||||
stack, _, _ := wire.Varint(user, 70)
|
||||
if free != remaining || stack != 0 {
|
||||
t.Fatalf("friendship points free=%d stack=%d, want %d/0", free, stack, remaining)
|
||||
}
|
||||
}
|
||||
provider.err = errors.New("unavailable")
|
||||
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("login ignored friendship state error")
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type lastPackFixture struct {
|
||||
pack uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *lastPackFixture) LastPlayedPackID() (uint64, error) { return f.pack, f.err }
|
||||
|
||||
func TestLoginUsesSavedPackAndPreservesSeedOnlyForNewAccount(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 4, 21)}
|
||||
provider := &lastPackFixture{}
|
||||
if err := seed.AttachLastPlayedPack(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct{ saved, want uint64 }{{0, 21}, {3001, 3001}, {22, 22}} {
|
||||
provider.pack = tc.saved
|
||||
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(body, 1)
|
||||
pack, _, _ := wire.Varint(user, 4)
|
||||
if pack != tc.want {
|
||||
t.Fatalf("saved=%d login pack=%d want=%d", tc.saved, pack, tc.want)
|
||||
}
|
||||
}
|
||||
provider.err = errors.New("position unavailable")
|
||||
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("ignored saved position error")
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type mutableFirstGachaStatus struct{ completed bool }
|
||||
|
||||
func (p *mutableFirstGachaStatus) FirstGachaCompleted() bool { return p.completed }
|
||||
|
||||
func TestLoginReadsFirstGachaCompletionAtEachLogin(t *testing.T) {
|
||||
// An old immutable seed value must be replaced by authoritative account
|
||||
// state, then immediately change on the next login after confirmation.
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 27, 1)}
|
||||
status := &mutableFirstGachaStatus{}
|
||||
if err := seed.AttachFirstGacha(status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, completed := range []bool{false, true, false} {
|
||||
status.completed = completed
|
||||
body, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(body, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("user found=%v err=%v", found, err)
|
||||
}
|
||||
value, _, err := wire.Varint(user, 27)
|
||||
if err != nil || (value == 1) != completed {
|
||||
t.Fatalf("first gacha=%d want completed=%v err=%v", value, completed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsMissingFirstGachaProvider(t *testing.T) {
|
||||
if err := (&LoginSeed{}).AttachFirstGacha(nil); err == nil {
|
||||
t.Fatal("missing provider accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/cryptox"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestEncodeUsesFreshLocalKey(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 42)
|
||||
user = wire.AppendString(user, 2, "Guest_42")
|
||||
user = wire.AppendVarint(user, 5, 100)
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: user}
|
||||
const local = "0123456789abcdef0123456789abcdef"
|
||||
body, err := seed.Encode(local, time.UnixMilli(1234))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
PacketCode int `json:"packetCode"`
|
||||
Length int `json:"length"`
|
||||
Data string `json:"data"`
|
||||
ServerNowTime int64 `json:"serverNowTime"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.PacketCode != 11 || envelope.ServerNowTime != 1234 {
|
||||
t.Fatalf("envelope=%+v", envelope)
|
||||
}
|
||||
proto, err := cryptox.DecryptBase64Payload(envelope.Data, cryptox.Key())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
responseUser, found, err := wire.Bytes(proto, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("response UserInfo: found=%v err=%v", found, err)
|
||||
}
|
||||
gotKey, found, err := wire.Bytes(responseUser, 3)
|
||||
if err != nil || !found || string(gotKey) != local {
|
||||
t.Fatalf("local key=%q found=%v err=%v", gotKey, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginValidatesEncryptedRequest(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
|
||||
if _, err := seed.Login([]byte("not protobuf"), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("Login accepted invalid protobuf request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsSeedWithUserKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "bad.json")
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 11, UserInfo: wire.AppendString(nil, 3, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}
|
||||
if err := seed.Write(path); err == nil {
|
||||
t.Fatal("Write accepted user_key")
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"version":"2.35.10","packet_code":11,"user_info_base64":"GgF4"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("Load accepted user_key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckedInSeedBuildsLoginWithoutCapture(t *testing.T) {
|
||||
seed, err := Load(filepath.Join("..", "..", "..", "seed", "v2_35_10", "login_user.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proto, err := seed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(proto, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing UserInfo: found=%v err=%v", found, err)
|
||||
}
|
||||
key, found, err := wire.Bytes(user, 3)
|
||||
if err != nil || !found || string(key) != "0123456789abcdef0123456789abcdef" {
|
||||
t.Fatalf("unexpected generated key: %q found=%v err=%v", key, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
type loginCurrencyFixture struct{}
|
||||
|
||||
func (loginCurrencyFixture) Currencies() (uint64, uint64, uint64, uint64) {
|
||||
return 1, 2, 3, 4
|
||||
}
|
||||
func (loginCurrencyFixture) EquipmentMileageBalances() (uint64, uint64) { return 17, 845 }
|
||||
|
||||
func TestLoginRestoresEquipmentMileageFromCurrencyProvider(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
|
||||
if err := seed.AttachCurrencies(loginCurrencyFixture{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing login user: %v", err)
|
||||
}
|
||||
if mileage, found, err := wire.Varint(user, 67); err != nil || !found || mileage != 17 {
|
||||
t.Fatalf("equipment mileage=%d found=%v err=%v", mileage, found, err)
|
||||
}
|
||||
if gauge, found, err := wire.Varint(user, 68); err != nil || !found || gauge != 845 {
|
||||
t.Fatalf("equipment mileage gauge=%d found=%v err=%v", gauge, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
type loginPurchaseCountFixture struct {
|
||||
infos [][]byte
|
||||
}
|
||||
|
||||
func (f *loginPurchaseCountFixture) PurchaseCountDBInfos() [][]byte { return f.infos }
|
||||
|
||||
func TestLoginReplacesSeedPurchaseCountsFromProvider(t *testing.T) {
|
||||
stale := wire.AppendVarint(nil, 1, 999)
|
||||
userTemplate := wire.AppendVarint(nil, 1, 1)
|
||||
userTemplate = wire.AppendBytes(userTemplate, 26, stale)
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
|
||||
|
||||
current := wire.AppendVarint(nil, 1, 1100001)
|
||||
current = wire.AppendVarint(current, 2, 9100033)
|
||||
current = wire.AppendVarint(current, 4, 1)
|
||||
provider := &loginPurchaseCountFixture{infos: [][]byte{current}}
|
||||
if err := seed.AttachPurchaseCounts(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
login := func() []byte {
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing login user: found=%v err=%v", found, err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
counts := byteFields(login(), 26)
|
||||
if len(counts) != 1 || string(counts[0]) != string(current) {
|
||||
t.Fatalf("purchase counts=%x want=%x", counts, current)
|
||||
}
|
||||
|
||||
// The provider is consulted on every LoginUser response. An empty current
|
||||
// state must also remove any stale count captured in the seed template.
|
||||
provider.infos = nil
|
||||
if counts = byteFields(login(), 26); len(counts) != 0 {
|
||||
t.Fatalf("empty current state retained purchase counts: %x", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachPurchaseCountsRejectsNil(t *testing.T) {
|
||||
seed := &LoginSeed{}
|
||||
if err := seed.AttachPurchaseCounts(nil); err == nil {
|
||||
t.Fatal("AttachPurchaseCounts accepted nil provider")
|
||||
}
|
||||
}
|
||||
|
||||
type loginPresetSlotFixture struct{ count uint64 }
|
||||
|
||||
func (f *loginPresetSlotFixture) PresetSlotCount() uint64 { return f.count }
|
||||
|
||||
func TestLoginReplacesSeedPresetSlotFromProvider(t *testing.T) {
|
||||
userTemplate := wire.AppendVarint(nil, 1, 1)
|
||||
userTemplate = wire.AppendVarint(userTemplate, 28, 6)
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: userTemplate}
|
||||
provider := &loginPresetSlotFixture{count: 9}
|
||||
if err := seed.AttachPresetSlots(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
login := func() []byte {
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing login user: found=%v err=%v", found, err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 9 {
|
||||
t.Fatalf("preset slots=%d found=%v err=%v", count, found, err)
|
||||
}
|
||||
provider.count = 12
|
||||
if count, found, err := wire.Varint(login(), 28); err != nil || !found || count != 12 {
|
||||
t.Fatalf("updated preset slots=%d found=%v err=%v", count, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachPresetSlotsRejectsNil(t *testing.T) {
|
||||
seed := &LoginSeed{}
|
||||
if err := seed.AttachPresetSlots(nil); err == nil {
|
||||
t.Fatal("AttachPresetSlots accepted nil provider")
|
||||
}
|
||||
}
|
||||
|
||||
type loginInventorySlotFixture struct {
|
||||
items, storage, equipment, equipmentStorage uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *loginInventorySlotFixture) UserInventorySlots() (uint64, uint64, uint64, uint64, error) {
|
||||
return f.items, f.storage, f.equipment, f.equipmentStorage, f.err
|
||||
}
|
||||
|
||||
func TestLoginReplacesAllInventorySlotFieldsFromProvider(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 1)
|
||||
for field, value := range map[int]uint64{5: 100, 6: 100, 10: 500, 15: 100} {
|
||||
user = wire.AppendVarint(user, field, value)
|
||||
}
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
|
||||
provider := &loginInventorySlotFixture{items: 500, storage: 100, equipment: 2000, equipmentStorage: 100}
|
||||
if err := seed.AttachInventorySlots(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("missing UserInfo: %v", err)
|
||||
}
|
||||
for field, want := range map[int]uint64{5: 500, 6: 100, 10: 2000, 15: 100} {
|
||||
if got, found, err := wire.Varint(result, field); err != nil || !found || got != want {
|
||||
t.Fatalf("field %d=%d found=%t err=%v want=%d", field, got, found, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedInventorySlotsReadsUserInfoFields(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 1)
|
||||
for field, value := range map[int]uint64{5: 100, 6: 101, 10: 500, 15: 102} {
|
||||
user = wire.AppendVarint(user, field, value)
|
||||
}
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
|
||||
items, storage, equipment, equipmentStorage, err := seed.SeedInventorySlots()
|
||||
if err != nil || items != 100 || storage != 101 || equipment != 500 || equipmentStorage != 102 {
|
||||
t.Fatalf("slots=%d/%d/%d/%d err=%v", items, storage, equipment, equipmentStorage, err)
|
||||
}
|
||||
}
|
||||
|
||||
func byteFields(data []byte, number int) [][]byte {
|
||||
var result [][]byte
|
||||
_ = wire.Walk(data, func(field wire.Field) error {
|
||||
if field.Number == number && field.Type == 2 {
|
||||
result = append(result, append([]byte(nil), field.Value...))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/wire"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoginPortraitUsesSQLiteSelectionAfterRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
r, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed, err := deck.LoadSeed("../../../seed/v2_35_10/decks.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := deck.OpenStore(r, seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3601)
|
||||
if _, _, _, err = d.Handle("/UserPortraitChange", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
d, err = deck.OpenStore(r, seed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 14, 3501)}
|
||||
if err = s.AttachPortrait(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(body, 1)
|
||||
portrait, _, _ := wire.Varint(user, 14)
|
||||
if portrait != 3601 {
|
||||
t.Fatalf("reconnected portrait%d, want saved3601", portrait)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type rewardStateSource struct {
|
||||
claimed, free, bonus uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *rewardStateSource) LevelRewardCount() (uint64, error) { return p.claimed, p.err }
|
||||
func (p *rewardStateSource) HuntingAP() (uint64, uint64, error) { return p.free, p.bonus, p.err }
|
||||
|
||||
func TestLoginReadsMutableRewardAndHuntingState(t *testing.T) {
|
||||
user := wire.AppendVarint(nil, 1, 42)
|
||||
for _, field := range []int{13, 20, 21} {
|
||||
user = wire.AppendVarint(user, field, 999)
|
||||
}
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: user}
|
||||
p := &rewardStateSource{claimed: 2, free: 40, bonus: 7}
|
||||
if err := seed.AttachLevelReward(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := seed.AttachHuntingAP(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, state := range []rewardStateSource{*p, {claimed: 3, free: 20, bonus: 0}, {}} {
|
||||
*p = state
|
||||
response, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, _, _ := wire.Bytes(response, 1)
|
||||
for field, want := range map[int]uint64{13: p.claimed, 20: p.free, 21: p.bonus} {
|
||||
got, _, err := wire.Varint(actual, field)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("field %d=%d want %d: %v", field, got, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bad := range []rewardStateSource{{claimed: math.MaxInt32 + 1}, {free: math.MaxInt32 + 1}, {bonus: math.MaxInt32 + 1}, {err: errors.New("storage failed")}} {
|
||||
*p = bad
|
||||
if _, err := seed.Login(nil, []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("invalid state accepted")
|
||||
}
|
||||
}
|
||||
if err := seed.AttachLevelReward(nil); err == nil {
|
||||
t.Fatal("nil reward provider accepted")
|
||||
}
|
||||
if err := seed.AttachHuntingAP(nil); err == nil {
|
||||
t.Fatal("nil AP provider accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
var _ stateio.AtomicEntryStore = (*Repository)(nil)
|
||||
|
||||
// SaveWithEntries writes a domain's bounded core and its changed entry rows
|
||||
// together. Calls inside a request join that transaction; direct calls create
|
||||
// their own transaction so neither half can become visible alone.
|
||||
func (r *Repository) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
|
||||
if domain == "" {
|
||||
return fmt.Errorf("accountstate: empty domain name")
|
||||
}
|
||||
r.activeMu.RLock()
|
||||
if r.active != nil {
|
||||
err := saveWithEntries(r.active, domain, core, changes)
|
||||
r.activeMu.RUnlock()
|
||||
return err
|
||||
}
|
||||
r.activeMu.RUnlock()
|
||||
tx, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := saveWithEntries(tx, domain, core, changes); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func saveWithEntries(tx *Tx, domain string, core []byte, changes []stateio.EntryMutation) error {
|
||||
if core != nil {
|
||||
if _, err := tx.Save(domain, core); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Delete {
|
||||
if _, err := tx.DeleteEntry(domain, change.Bucket, change.Key); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := tx.PutEntry(domain, change.Bucket, change.Key, change.Payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
func TestSaveWithEntriesAtomicAndEntryOnly(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
change := stateio.EntryMutation{Bucket: "granted", Key: "quest:1", Payload: []byte("true")}
|
||||
if err := r.SaveWithEntries("wallet", []byte(`{"gold":10}`), []stateio.EntryMutation{change}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core, generation, found, err := r.LoadContext(context.Background(), "wallet")
|
||||
if err != nil || !found || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
|
||||
t.Fatalf("core=%q generation=%d found=%t err=%v", core, generation, found, err)
|
||||
}
|
||||
if err := r.SaveWithEntries("wallet", nil, []stateio.EntryMutation{{Bucket: "granted", Key: "quest:2", Payload: []byte("true")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.SaveWithEntries("wallet", []byte(`{"gold":99}`), []stateio.EntryMutation{{Bucket: "granted", Key: ""}}); err == nil {
|
||||
t.Fatal("accepted invalid entry mutation")
|
||||
}
|
||||
core, generation, _, err = r.LoadContext(context.Background(), "wallet")
|
||||
if err != nil || generation != 1 || !bytes.Equal(core, []byte(`{"gold":10}`)) {
|
||||
t.Fatalf("entry-only/failed write changed core: %q generation %d, %v", core, generation, err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.SaveWithEntries("wallet", []byte(`{"gold":20}`), []stateio.EntryMutation{{Bucket: "granted", Key: "quest:3", Payload: []byte("true")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err == nil {
|
||||
t.Fatal("dirty rollback did not fail stop")
|
||||
}
|
||||
var count int
|
||||
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry WHERE domain_name='wallet' AND bucket='granted'`).Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("entries after rollback=%d: %v", count, err)
|
||||
}
|
||||
if err := r.db.QueryRow(`SELECT generation FROM domain_state WHERE name='wallet'`).Scan(&generation); err != nil || generation != 1 {
|
||||
t.Fatalf("generation after rollback=%d: %v", generation, err)
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitializationMarkerMakesSchemaOnlyRollbackRetryable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
|
||||
t.Fatalf("initial state=%d err=%v", state, err)
|
||||
}
|
||||
operation, err := repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Save("wallet", []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.MarkInitializationComplete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := operation.Rollback(); err == nil {
|
||||
t.Fatal("dirty rollback did not require repository reopen")
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repository, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
if repository.IsNew() {
|
||||
t.Fatal("schema-only retry incorrectly depends on file creation")
|
||||
}
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationPending {
|
||||
t.Fatalf("post-rollback state=%d err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializationMarkerCommitsCompleteAccountExactlyOnce(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operation, err := repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, domain := range []string{"mail", "wallet"} {
|
||||
if err := repository.Save(domain, []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repository.MarkInitializationComplete(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := operation.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repository, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err != nil || state != InitializationComplete {
|
||||
t.Fatalf("committed state=%d err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializationStateRejectsPartialAccount(t *testing.T) {
|
||||
repository, err := Open(filepath.Join(t.TempDir(), "state.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
if err := repository.Save("mail", []byte(`{"version":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state, err := repository.InitializationState("mail", "wallet"); err == nil || state != InitializationCorrupt {
|
||||
t.Fatalf("partial state=%d err=%v", state, err)
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEntriesShareRequestTransaction(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.SaveContext(context.Background(), "collection", []byte("unchanged")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.PutEntry("collection", "grants", "draw:1", []byte(`{"id":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.PutEntry("collection", "applied", "draw:1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("wallet", []byte("charged")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte(`{"id":1}`)) {
|
||||
t.Fatalf("read own entry write: %q, %t, %v", got, found, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "collection", []byte("unchanged"), 1)
|
||||
requireState(t, r, "wallet", []byte("charged"), 1)
|
||||
entries, err := r.ListEntries("collection", "grants")
|
||||
if err != nil || len(entries) != 1 || !bytes.Equal(entries["draw:1"], []byte(`{"id":1}`)) {
|
||||
t.Fatalf("grant entries: %#v, %v", entries, err)
|
||||
}
|
||||
if got, found, err := r.LoadEntry("collection", "applied", "draw:1"); err != nil || !found || len(got) != 0 {
|
||||
t.Fatalf("empty marker: %q, %t, %v", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryRollbackAndDelete(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.PutEntry("collection", "grants", "draw:1", []byte("before")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.PutEntry("collection", "grants", "draw:1", []byte("after")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.PutEntry("collection", "grants", "draw:2", []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || !found || !bytes.Equal(got, []byte("before")) {
|
||||
t.Fatalf("rolled-back replacement: %q, %t, %v", got, found, err)
|
||||
}
|
||||
if _, found, err := r.LoadEntry("collection", "grants", "draw:2"); err != nil || found {
|
||||
t.Fatalf("rolled-back insertion: %t, %v", found, err)
|
||||
}
|
||||
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || !deleted {
|
||||
t.Fatalf("delete existing entry: %t, %v", deleted, err)
|
||||
}
|
||||
if deleted, err := r.DeleteEntry("collection", "grants", "draw:1"); err != nil || deleted {
|
||||
t.Fatalf("delete missing entry: %t, %v", deleted, err)
|
||||
}
|
||||
if _, found, err := r.LoadEntry("collection", "grants", "draw:1"); err != nil || found {
|
||||
t.Fatalf("deleted entry: %t, %v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryWriteMarksRequestRollbackDirty(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.PutEntry("collection", "grants", "draw:1", []byte("pending")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err == nil {
|
||||
t.Fatal("dirty entry rollback did not require restart")
|
||||
}
|
||||
if err := r.Check(); err == nil {
|
||||
t.Fatal("repository accepted requests after dirty entry rollback")
|
||||
}
|
||||
var count int
|
||||
if err := r.db.QueryRow(`SELECT COUNT(*) FROM domain_entry`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("entry survived rollback: count %d, error %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryScopeAndClosedTransaction(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.PutEntry("", "grants", "key", nil); err == nil {
|
||||
t.Fatal("accepted empty domain")
|
||||
}
|
||||
if err := r.PutEntry("collection", "", "key", nil); err == nil {
|
||||
t.Fatal("accepted empty bucket")
|
||||
}
|
||||
if err := r.PutEntry("collection", "grants", "", nil); err == nil {
|
||||
t.Fatal("accepted empty key")
|
||||
}
|
||||
tx, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.PutEntry("collection", "grants", "key", nil); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("put after commit: %v", err)
|
||||
}
|
||||
if _, err := tx.ListEntries("collection", "grants"); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("list after commit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldSchemaIsRejectedWithoutMutation(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.SaveContext(context.Background(), "collection", []byte("legacy snapshot")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.db.Exec(`UPDATE metadata SET value = '0' WHERE key = 'schema_version'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("opened unsupported schema version")
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
var version string
|
||||
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "0" {
|
||||
t.Fatalf("changed unsupported version %q: %v", version, err)
|
||||
}
|
||||
var entryTables int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 0 {
|
||||
t.Fatalf("created table during failed open: %d, %v", entryTables, err)
|
||||
}
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func openTestRepository(t *testing.T) (*Repository, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
r, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
return r, path
|
||||
}
|
||||
|
||||
func requireState(t *testing.T, r *Repository, name string, want []byte, wantGeneration int64) {
|
||||
t.Helper()
|
||||
got, generation, found, err := r.LoadContext(context.Background(), name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || !bytes.Equal(got, want) || generation != wantGeneration {
|
||||
t.Fatalf("%s: got (%q, %d, %t), want (%q, %d, true)", name, got, generation, found, want, wantGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionAtomicityAndGeneration(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
ctx := context.Background()
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"wallet", "deck", "progress"} {
|
||||
if generation, err := tx.Save(name, []byte(name)); err != nil || generation != 1 {
|
||||
t.Fatalf("save %s: generation %d, error %v", name, generation, err)
|
||||
}
|
||||
}
|
||||
if generation, err := tx.Save("wallet", []byte("updated")); err != nil || generation != 2 {
|
||||
t.Fatalf("second save: generation %d, error %v", generation, err)
|
||||
}
|
||||
if data, generation, found, err := tx.Load("wallet"); err != nil || !found || !bytes.Equal(data, []byte("updated")) || generation != 2 {
|
||||
t.Fatalf("read own write: %q, %d, %t, %v", data, generation, found, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := tx.Load("wallet"); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("load after commit: %v", err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("updated"), 2)
|
||||
requireState(t, r, "deck", []byte("deck"), 1)
|
||||
requireState(t, r, "progress", []byte("progress"), 1)
|
||||
}
|
||||
|
||||
func TestRollbackAndReopen(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
ctx := context.Background()
|
||||
if _, err := r.SaveContext(ctx, "wallet", []byte("before")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Save("wallet", []byte("after")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tx.Save("deck", []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("before"), 1)
|
||||
if _, _, found, err := r.LoadContext(ctx, "deck"); err != nil || found {
|
||||
t.Fatalf("rolled-back domain: found=%t err=%v", found, err)
|
||||
}
|
||||
if _, err := r.SaveContext(ctx, "wallet", []byte("committed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := reopened.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
requireState(t, reopened, "wallet", []byte("committed"), 2)
|
||||
var mode string
|
||||
if err := reopened.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" {
|
||||
t.Fatalf("journal mode %q: %v", mode, err)
|
||||
}
|
||||
var synchronous int
|
||||
if err := reopened.db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil || synchronous != 2 {
|
||||
t.Fatalf("synchronous %d: %v", synchronous, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentTransactionsSerialize(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
first, err := r.Begin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entered := make(chan struct{})
|
||||
finished := make(chan error, 1)
|
||||
go func() {
|
||||
close(entered)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
finished <- err
|
||||
return
|
||||
}
|
||||
_, err = tx.Save("wallet", []byte("second"))
|
||||
if err == nil {
|
||||
err = tx.Commit()
|
||||
} else {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
finished <- err
|
||||
}()
|
||||
<-entered
|
||||
select {
|
||||
case err := <-finished:
|
||||
t.Fatalf("second transaction finished before first released connection: %v", err)
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
if _, err := first.Save("wallet", []byte("first")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := first.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-finished; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("second"), 2)
|
||||
}
|
||||
|
||||
func TestOperationRoutesStoreWritesAcrossDomains(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.Save("wallet", []byte("before")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("wallet", []byte("after")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("deck", []byte("new")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if data, err := r.Load("wallet"); err != nil || !bytes.Equal(data, []byte("after")) {
|
||||
t.Fatalf("read active write: %q, %v", data, err)
|
||||
}
|
||||
if err := op.Rollback(); err == nil {
|
||||
t.Fatal("dirty request rollback did not require restart")
|
||||
}
|
||||
if err := r.Check(); err == nil {
|
||||
t.Fatal("repository accepted requests after dirty rollback")
|
||||
}
|
||||
if _, err := r.BeginOperation(); err == nil {
|
||||
t.Fatal("began request after dirty rollback")
|
||||
}
|
||||
// The durable rows are rolled back even though domain memory now needs reload.
|
||||
var payload []byte
|
||||
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'wallet'`).Scan(&payload); err != nil || !bytes.Equal(payload, []byte("before")) {
|
||||
t.Fatalf("wallet after rollback: %q, %v", payload, err)
|
||||
}
|
||||
if err := r.db.QueryRow(`SELECT payload FROM domain_state WHERE name = 'deck'`).Scan(&payload); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("deck after rollback: %q, %v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationCommitAndCleanRollback(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err = r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("wallet", []byte("committed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Save("deck", []byte("committed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "wallet", []byte("committed"), 1)
|
||||
requireState(t, r, "deck", []byte("committed"), 1)
|
||||
}
|
||||
|
||||
func TestSecondRepositoryIsRejectedUntilWriterCloses(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
first, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second, err := Open(path); !errors.Is(err, ErrWriterLocked) {
|
||||
if second != nil {
|
||||
_ = second.Close()
|
||||
}
|
||||
t.Fatalf("second Open error=%v, want ErrWriterLocked", err)
|
||||
}
|
||||
var epoch int64
|
||||
if err := first.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key = 'writer_epoch'`).Scan(&epoch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if epoch != first.writerEpoch {
|
||||
t.Fatalf("rejected Open advanced writer epoch to %d, want %d", epoch, first.writerEpoch)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open after writer Close: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := second.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func TestWriterEpochStillFencesStaleRepositoryBeforeMutation(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`UPDATE metadata SET value = CAST(value AS INTEGER) + 1 WHERE key = 'writer_epoch'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.BeginOperation(); !errors.Is(err, ErrFenced) {
|
||||
t.Fatalf("stale writer error=%v, want ErrFenced", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaVersionRejected(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`UPDATE metadata SET value = '4' WHERE key = 'schema_version'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("opened unknown schema version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopulatedDatabaseWithoutSchemaVersionRejected(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if err := r.Save("wallet", []byte("existing")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.db.Exec(`DELETE FROM metadata WHERE key = 'schema_version'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("stamped current schema onto populated unversioned database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentVersionWithMissingTableRejected(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`DROP TABLE domain_entry`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reopened, err := Open(path); err == nil {
|
||||
_ = reopened.Close()
|
||||
t.Fatal("recreated a missing table in an existing current-version database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireDomainsRejectsPartialAccount(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if err := r.RequireDomains("wallet"); err == nil {
|
||||
t.Fatal("accepted empty database as a complete account")
|
||||
}
|
||||
if err := r.Save("wallet", []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.RequireDomains("wallet"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.RequireDomains("wallet", "items"); err == nil {
|
||||
t.Fatal("accepted a missing account domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidNamesAndEmptyBlob(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
ctx := context.Background()
|
||||
if _, err := r.SaveContext(ctx, "", []byte("bad")); err == nil {
|
||||
t.Fatal("empty name accepted")
|
||||
}
|
||||
if _, err := r.SaveContext(ctx, "empty", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireState(t, r, "empty", []byte{}, 1)
|
||||
}
|
||||
|
||||
func BenchmarkRequestTransaction(b *testing.B) {
|
||||
path := filepath.Join(b.TempDir(), "state.db")
|
||||
r, err := Open(path)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil {
|
||||
b.Error(err)
|
||||
}
|
||||
}()
|
||||
ctx := context.Background()
|
||||
payload := bytes.Repeat([]byte("x"), 4096)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tx, err := r.Begin(ctx)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"wallet", "deck", "progress"} {
|
||||
if _, err := tx.Save(name, payload); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func createV1Database(t *testing.T, path string, domain, payload string) {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := createV1(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domain != "" {
|
||||
if _, err := tx.Exec(`INSERT INTO domain_state(name, payload, generation) VALUES (?, ?, 1)`, domain, []byte(payload)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreshDatabaseRunsEveryMigration(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
version, err := r.SchemaVersion()
|
||||
if err != nil || version != schemaVersion {
|
||||
t.Fatalf("schema version %d, error %v", version, err)
|
||||
}
|
||||
var entryTables int
|
||||
if err := r.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'domain_entry'`).Scan(&entryTables); err != nil || entryTables != 1 {
|
||||
t.Fatalf("domain_entry tables %d, error %v", entryTables, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV1ToV2IsRepeatSafe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for range 2 {
|
||||
if err := migrateV1ToV2(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := validateSchemaTables(tx, 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationV2ToV3IsRepeatSafe(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := migrateV1ToV2(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := migrateV2ToV3(context.Background(), tx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var epoch string
|
||||
if err := tx.QueryRow(`SELECT value FROM metadata WHERE key='writer_epoch'`).Scan(&epoch); err != nil || epoch != "0" {
|
||||
t.Fatalf("writer epoch=%q err=%v", epoch, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenMigratesV1ToCurrent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
r, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
version, err := r.SchemaVersion()
|
||||
if err != nil || version != schemaVersion {
|
||||
t.Fatalf("schema version %d, error %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationsRejectMissingAndNonAdjacentSteps(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
steps []migration
|
||||
want string
|
||||
}{
|
||||
{name: "missing", want: "missing migration 1->2"},
|
||||
{name: "skip", steps: []migration{{from: 1, to: 3, up: func(context.Context, *sql.Tx) error { return nil }}}, want: "not adjacent"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
err = runMigrations(context.Background(), tx, 1, 3, test.steps)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationFailureRollsBackMigrationAndVersion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "progress", `{"quests":{"0:1":{"QuestID":1,"PackID":0}},"cleared_quests":{}}`)
|
||||
if r, err := Open(path); err == nil {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
t.Fatal("opened state rejected by final validation")
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
var version string
|
||||
if err := db.QueryRow(`SELECT value FROM metadata WHERE key = 'schema_version'`).Scan(&version); err != nil || version != "1" {
|
||||
t.Fatalf("schema version %q after rollback: %v", version, err)
|
||||
}
|
||||
var generationColumns int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('domain_entry') WHERE name = 'generation'`).Scan(&generationColumns); err != nil || generationColumns != 0 {
|
||||
t.Fatalf("migration column survived rollback: %d, %v", generationColumns, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationFailureRollsBackEarlierStepWork(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
createV1Database(t, path, "", "")
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
steps := []migration{{from: 1, to: 2, up: func(ctx context.Context, tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, `CREATE TABLE partial_write(value INTEGER)`); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("injected migration failure")
|
||||
}}}
|
||||
if err := runMigrations(context.Background(), tx, 1, 2, steps); err == nil {
|
||||
t.Fatal("migration unexpectedly succeeded")
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tables int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'partial_write'`).Scan(&tables); err != nil || tables != 0 {
|
||||
t.Fatalf("partial migration survived rollback: %d, %v", tables, err)
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartingPackPolicyPersistsAndRejectsChanges(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
if _, err := r.LockStartingPack(21, true); err == nil {
|
||||
t.Fatal("accepted policy outside startup operation")
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := r.LockStartingPack(21, true); err != nil || got != 21 {
|
||||
t.Fatalf("lock=%d: %v", got, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
op, err = r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := r.LockStartingPack(21, false); err != nil || got != 21 {
|
||||
t.Fatalf("reopened lock=%d: %v", got, err)
|
||||
}
|
||||
if _, err := r.LockStartingPack(1, true); err == nil {
|
||||
t.Fatal("accepted changed policy")
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw string
|
||||
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); err != nil || raw != "21" {
|
||||
t.Fatalf("policy=%q: %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartingPackPolicyRollbackAllowsAnotherChoice(t *testing.T) {
|
||||
r, path := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.LockStartingPack(21, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw string
|
||||
if err := r.db.QueryRow(`SELECT value FROM metadata WHERE key=?`, startingPackMetadataKey).Scan(&raw); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("rolled back policy survived: %q, %v", raw, err)
|
||||
}
|
||||
if err := r.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err = Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := r.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
op, err = r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := r.LockStartingPack(1, true); err != nil || got != 1 {
|
||||
t.Fatalf("retry lock=%d: %v", got, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartingPackPolicyMissingExistingRejectsInitialization(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.SaveContext(context.Background(), "progress", []byte("{}")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = op.Rollback() }()
|
||||
for _, initialize := range []bool{false, true} {
|
||||
if _, err := r.LockStartingPack(1, initialize); err == nil {
|
||||
t.Fatalf("accepted missing existing policy initialize=%v", initialize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartingPackPolicyRejectsInvalidValues(t *testing.T) {
|
||||
for _, raw := range []string{"0", "2", "abc", "01", "21 "} {
|
||||
t.Run(raw, func(t *testing.T) {
|
||||
r, _ := openTestRepository(t)
|
||||
if _, err := r.db.Exec(`INSERT INTO metadata(key,value) VALUES(?,?)`, startingPackMetadataKey, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = op.Rollback() }()
|
||||
if _, err := r.LockStartingPack(1, false); err == nil {
|
||||
t.Fatal("accepted invalid stored value")
|
||||
}
|
||||
})
|
||||
}
|
||||
r, _ := openTestRepository(t)
|
||||
op, err := r.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = op.Rollback() }()
|
||||
for _, configured := range []int{0, 2, -1} {
|
||||
if _, err := r.LockStartingPack(configured, true); err == nil {
|
||||
t.Fatalf("accepted invalid configured value %d", configured)
|
||||
}
|
||||
}
|
||||
if _, err := r.LockStartingPack(1, false); err == nil {
|
||||
t.Fatal("initialized empty DB without authorization")
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package accountstate
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func validValidationSnapshot() validationSnapshot {
|
||||
return validationSnapshot{
|
||||
formatVersion: 1, clientVersion: "2.35.10", gameDataVersion: "20260924000000",
|
||||
itemNextIndex: 11, equipmentNextIndex: 21, characterNextIndex: 31, costumeNextIndex: 41,
|
||||
itemIndices: []uint64{10}, equipmentIndices: []uint64{20}, characterIndices: []uint64{30}, costumeIndices: []uint64{40},
|
||||
collectionCharacterIndices: []uint64{30},
|
||||
equipmentGrants: []namedIndex{{identity: "quest:test", index: 20}},
|
||||
equipmentUsers: []namedIndex{{identity: "20", index: 30}},
|
||||
quests: []questKey{{pack: 1, quest: 2}}, cleared: []questKey{{pack: 1, quest: 2}},
|
||||
granted: map[string]bool{"mail:test": true},
|
||||
itemGrants: []indexedGrant{{identity: "mail:test", indices: []uint64{9}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimedCompanionIdentityIsSeparateFromCollectionAllocator(t *testing.T) {
|
||||
s := validValidationSnapshot()
|
||||
companion := uint64(1<<59) + 100
|
||||
s.characterIndices = append(s.characterIndices, companion)
|
||||
s.equipmentUsers[0].index = companion
|
||||
if p := validateSnapshot(s); len(p) != 0 {
|
||||
t.Fatalf("temporary owner rejected: %+v", p)
|
||||
}
|
||||
s.collectionCharacterIndices = append(s.collectionCharacterIndices, s.characterNextIndex)
|
||||
if !slices.Contains(problemCodes(validateSnapshot(s)), "collection.next_character_index") {
|
||||
t.Fatal("permanent allocator invariant lost")
|
||||
}
|
||||
}
|
||||
|
||||
func problemCodes(problems []Problem) []string {
|
||||
codes := make([]string, 0, len(problems))
|
||||
for _, problem := range problems {
|
||||
codes = append(codes, problem.Code)
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func TestValidateSnapshotAcceptsConsumedGrantedItem(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
// Index 9 is intentionally absent from itemIndices: grant_items is an
|
||||
// issuance ledger and remains valid after the stack is consumed.
|
||||
if problems := validateSnapshot(snapshot); len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %#v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSnapshotAcceptsDismantledGrantedEquipment(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
snapshot.equipmentIndices = nil
|
||||
// Index 20 was issued below next_index and its grant marker must survive
|
||||
// dismantling so a retried grant cannot create a duplicate instance.
|
||||
if problems := validateSnapshot(snapshot); len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %#v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSnapshotPortsEveryExternalValidatorRule(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
snapshot.formatVersion = 2
|
||||
snapshot.clientVersion = ""
|
||||
snapshot.gameDataVersion = ""
|
||||
snapshot.itemIndices = []uint64{10, 10}
|
||||
snapshot.equipmentIndices = []uint64{20, 20}
|
||||
snapshot.characterIndices = []uint64{30, 30}
|
||||
snapshot.costumeIndices = []uint64{40, 40}
|
||||
snapshot.itemNextIndex = 10
|
||||
snapshot.equipmentNextIndex = 20
|
||||
snapshot.characterNextIndex = 30
|
||||
snapshot.costumeNextIndex = 40
|
||||
snapshot.equipmentGrants = []namedIndex{{identity: "missing", index: 999}}
|
||||
snapshot.equipmentUsers = []namedIndex{{identity: "20", index: 999}}
|
||||
snapshot.quests = []questKey{{}, {pack: 2, quest: 3}, {pack: 2, quest: 3}}
|
||||
snapshot.cleared = []questKey{{pack: 1}, {pack: 4, quest: 5}, {pack: 4, quest: 5}}
|
||||
snapshot.granted = map[string]bool{}
|
||||
snapshot.itemGrants = []indexedGrant{{identity: "unmarked", indices: []uint64{0, 10, 10}}}
|
||||
|
||||
want := []string{
|
||||
"snapshot.format_version", "snapshot.client_version", "snapshot.game_data_version",
|
||||
"inventory.duplicate_index", "equipment.duplicate_index", "characters.duplicate_index", "costumes.duplicate_index",
|
||||
"inventory.next_index", "equipment.next_index", "collection.next_character_index", "collection.next_costume_index",
|
||||
"equipment.grant_missing_equipment", "equipment.unknown_user",
|
||||
"progress.invalid_quest_key", "progress.invalid_cleared_key",
|
||||
"progress.duplicate_quest_key", "progress.duplicate_cleared_key",
|
||||
"inventory.grant_without_marker", "inventory.grant_invalid_index", "inventory.grant_invalid_index", "inventory.grant_invalid_index",
|
||||
"inventory.grant_duplicate_index",
|
||||
}
|
||||
got := problemCodes(validateSnapshot(snapshot))
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("problem codes:\n got %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSnapshotGrantAndOwnershipRules(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
edit func(*validationSnapshot)
|
||||
code string
|
||||
}{
|
||||
{name: "unissued item index", edit: func(s *validationSnapshot) { s.itemGrants[0].indices = []uint64{s.itemNextIndex} }, code: "inventory.grant_invalid_index"},
|
||||
{name: "missing equipment", edit: func(s *validationSnapshot) { s.equipmentGrants[0].index = 999 }, code: "equipment.grant_missing_equipment"},
|
||||
{name: "unknown equipped character", edit: func(s *validationSnapshot) { s.equipmentUsers[0].index = 999 }, code: "equipment.unknown_user"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
snapshot := validValidationSnapshot()
|
||||
test.edit(&snapshot)
|
||||
if got := problemCodes(validateSnapshot(snapshot)); !slices.Contains(got, test.code) {
|
||||
t.Fatalf("codes %v do not contain %s", got, test.code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gameconfig"
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/events/calendar"
|
||||
"bd2server/internal/server/gateway/authconfig"
|
||||
"bd2server/internal/server/gateway/bootstrap"
|
||||
"bd2server/internal/server/platform/versionconfig"
|
||||
"bd2server/internal/server/resources/policy"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type configuration struct {
|
||||
logLevel string
|
||||
logColor string
|
||||
versionConfigPath string
|
||||
authConfigPath string
|
||||
resourceConfigPath string
|
||||
gameConfigPath string
|
||||
listen string
|
||||
dataDir string
|
||||
gameDataVersion string
|
||||
gameDataOrigin string
|
||||
accountSeed string
|
||||
playerSeed string
|
||||
readonlySeed string
|
||||
mailSeed string
|
||||
mailGrantSpool string
|
||||
stateDirectory string
|
||||
deckSeed string
|
||||
worldSeed string
|
||||
devToolsConfig string
|
||||
versions versionconfig.Config
|
||||
calendars *calendar.Set
|
||||
gameRules gameconfig.Config
|
||||
authentication authconfig.Config
|
||||
authRuntime authconfig.Runtime
|
||||
publicResources resourcepolicy.Public
|
||||
bootstrap bootstrap.Config
|
||||
gameData string
|
||||
verifiedGameData gamedata.Result
|
||||
}
|
||||
|
||||
func loadConfiguration(args []string) (result *configuration, loadErr error) {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
logLevel := fs.String("log-level", "", "log threshold: trace, debug, info, warn, error (default BD2_LOG_LEVEL or info)")
|
||||
logColor := fs.String("log-color", "", "level colors: auto, always, never (default BD2_LOG_COLOR or auto)")
|
||||
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
|
||||
authConfigPath := fs.String("authentication-config", "", "authentication.json override for development")
|
||||
resourceConfigPath := fs.String("resource-config", "", "resources.json override for development")
|
||||
gameConfigPath := fs.String("game-config", "", "game.json server gameplay configuration override")
|
||||
listen := fs.String("listen", "127.0.0.1:8080", "local listen address")
|
||||
dataDir := fs.String("data-dir", "", "server data directory (defaults beside the executable)")
|
||||
gameDataVersion := fs.String("game-data-version", "", "validated GameData version (defaults to versions.json)")
|
||||
gameDataOrigin := fs.String("game-data-origin", resourcepolicy.OfficialGameDataURL, "official GameData repair source override for development")
|
||||
accountSeed := fs.String("account-seed", "", "versioned local account seed")
|
||||
playerSeed := fs.String("player-seed", "", "versioned starter inventory and characters")
|
||||
readonlySeed := fs.String("readonly-seed", "", "versioned static protocol defaults")
|
||||
mailSeed := fs.String("mail-seed", "", "versioned starter mailbox")
|
||||
mailGrantSpool := fs.String("mail-grant-spool", "", "optional local JSON spool for idempotent dynamic system mail")
|
||||
stateDirectory := fs.String("state-dir", "", "player and shared state directory")
|
||||
deckSeed := fs.String("deck-seed", "", "versioned starter deck")
|
||||
worldSeed := fs.String("world-seed", "", "versioned starter world")
|
||||
devToolsConfig := fs.String("dev-tools-config", "", "development-tool settings JSON (defaults to DATA_DIR/dev-tools.json)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ConfigureLogging(os.Stderr, *logLevel, *logColor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var versions versionconfig.Config
|
||||
var err error
|
||||
if *versionConfigPath == "" {
|
||||
versions, err = versionconfig.Find()
|
||||
} else {
|
||||
versions, err = versionconfig.Load(*versionConfigPath)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *gameDataVersion == "" {
|
||||
*gameDataVersion = versions.GameDataVersion
|
||||
} else {
|
||||
// Preserve the development override as part of the effective process
|
||||
// configuration so state snapshots describe the GameData actually used.
|
||||
versions.GameDataVersion = *gameDataVersion
|
||||
if err := versions.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("effective version config: %w", err)
|
||||
}
|
||||
}
|
||||
versionconfig.Use(versions)
|
||||
calendarDirectory := versions.Resolve("schedules")
|
||||
calendars, err := calendar.LoadDirectory(calendarDirectory, versions.GameVersion, versions.GameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load project calendars: %w", err)
|
||||
}
|
||||
if calendars.RegularService == nil || calendars.MonsterHunt == nil || len(calendars.MonsterHunt.Seasons) == 0 {
|
||||
return nil, errors.New("project calendars require regular content and monster hunt schedules")
|
||||
}
|
||||
slog.Info("project calendars loaded", "directory", calendarDirectory, "revisions", calendars.Revisions, "events", len(calendars.Events))
|
||||
if *gameConfigPath == "" {
|
||||
*gameConfigPath, err = gameconfig.BesideExecutable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
gameRules, err := gameconfig.Load(*gameConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *authConfigPath == "" {
|
||||
*authConfigPath, err = authconfig.BesideExecutable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
authentication, err := authconfig.Load(*authConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authRuntime, err := authentication.ResolveEnvironment()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if result == nil {
|
||||
clear(authRuntime.MasterKey)
|
||||
}
|
||||
}()
|
||||
if *resourceConfigPath == "" {
|
||||
*resourceConfigPath, err = resourcepolicy.BesideExecutable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resources, err := resourcepolicy.Load(*resourceConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if *dataDir == "" {
|
||||
executable, executableErr := os.Executable()
|
||||
if executableErr != nil {
|
||||
return nil, fmt.Errorf("resolve server data directory: %w", executableErr)
|
||||
}
|
||||
*dataDir = filepath.Join(filepath.Dir(executable), "data")
|
||||
}
|
||||
*dataDir, err = filepath.Abs(filepath.Clean(*dataDir))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve server data directory: %w", err)
|
||||
}
|
||||
*devToolsConfig = resolveDevelopmentSettingsPath(*dataDir, *devToolsConfig)
|
||||
gameData := filepath.Join(*dataDir, "resources", "GameData")
|
||||
if *stateDirectory == "" {
|
||||
*stateDirectory = filepath.Join(*dataDir, "state")
|
||||
}
|
||||
|
||||
seedRoot := versions.Resolve(versions.SeedDirectory)
|
||||
for target, name := range map[*string]string{
|
||||
accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json",
|
||||
mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json",
|
||||
} {
|
||||
if *target == "" {
|
||||
*target = filepath.Join(seedRoot, name)
|
||||
}
|
||||
}
|
||||
clientOrigin := "http://" + *listen
|
||||
if authentication.Mode == "oauth" {
|
||||
clientOrigin = strings.TrimSuffix(authentication.PublicURL, "/")
|
||||
}
|
||||
base := clientOrigin + "/game/"
|
||||
publicResources := resources.Public(versions.BundleVersion, *gameDataVersion)
|
||||
cfg := bootstrap.Config{
|
||||
BaseURL: base,
|
||||
CDNURL: publicResources.ServerDataURL,
|
||||
Version: versions.GameVersion,
|
||||
BundleVer: versions.BundleVersion,
|
||||
GameDataURL: publicResources.GameDataURL,
|
||||
GameDataVer: *gameDataVersion,
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
verifiedGameData, downloaded, err := gamedata.Ensure(context.Background(), nil, gameData, *gameDataVersion, *gameDataOrigin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refuse to advertise unavailable or unverified GameData: %w", err)
|
||||
}
|
||||
if downloaded {
|
||||
slog.Info("repaired GameData from official CDN", "archive", verifiedGameData.ArchivePath, "entries", verifiedGameData.EntryCount)
|
||||
}
|
||||
if err := calendars.ValidateDesign(gameData, *gameDataVersion); err != nil {
|
||||
return nil, fmt.Errorf("validate project calendar GameData references: %w", err)
|
||||
}
|
||||
return &configuration{
|
||||
logLevel: *logLevel,
|
||||
logColor: *logColor,
|
||||
versionConfigPath: *versionConfigPath,
|
||||
authConfigPath: *authConfigPath,
|
||||
resourceConfigPath: *resourceConfigPath,
|
||||
gameConfigPath: *gameConfigPath,
|
||||
listen: *listen,
|
||||
dataDir: *dataDir,
|
||||
gameDataVersion: *gameDataVersion,
|
||||
gameDataOrigin: *gameDataOrigin,
|
||||
accountSeed: *accountSeed,
|
||||
playerSeed: *playerSeed,
|
||||
readonlySeed: *readonlySeed,
|
||||
mailSeed: *mailSeed,
|
||||
mailGrantSpool: *mailGrantSpool,
|
||||
stateDirectory: *stateDirectory,
|
||||
deckSeed: *deckSeed,
|
||||
worldSeed: *worldSeed,
|
||||
devToolsConfig: *devToolsConfig,
|
||||
versions: versions, calendars: calendars, gameRules: gameRules, authentication: authentication, authRuntime: authRuntime, publicResources: publicResources, bootstrap: cfg, gameData: gameData, verifiedGameData: verifiedGameData,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type designCatalog struct {
|
||||
cashCatalog *commerce.Catalog
|
||||
source *gamedata.Source
|
||||
world *gamedata.WorldRules
|
||||
fieldReset gamedata.FieldResetSchedule
|
||||
fieldBuffs map[uint64]gamedata.FieldBuffDesign
|
||||
recovery *gamedata.PackRecoveryPolicy
|
||||
researchCharacters map[uint64]bool
|
||||
overwhelmSky []gamedata.SkyWayOverwhelmRule
|
||||
eventPlay *gamedata.EventPlayCatalog
|
||||
monsterHunt *monsterhunt.Rules
|
||||
presetDesign *gamedata.PresetDesign
|
||||
recipeDesign *gamedata.CookingRecipeDesign
|
||||
randomBoxes *gamedata.RandomBoxDesign
|
||||
itemStacks *gamedata.ItemStackDesign
|
||||
slotDesign *gamedata.InventorySlotDesign
|
||||
contentTickets *gamedata.GachaContentTicketDesign
|
||||
missionDesign *gamedata.MissionDesign
|
||||
levelDesign *gamedata.AchievementLevelDesign
|
||||
equipmentSlots map[uint64]uint64
|
||||
equipmentUpgrade *gamedata.EquipmentUpgradeDesign
|
||||
equipmentCraft *gamedata.EquipmentCraftDesign
|
||||
talentGrowth *gamedata.TalentGrowthDesign
|
||||
equipmentSmelting *gamedata.EquipmentSmeltingDesign
|
||||
equipmentOptionReroll *gamedata.EquipmentOptionRerollDesign
|
||||
infiniteGacha *gamedata.InfiniteGachaDesign
|
||||
fieldSettingsDesign *gamedata.FieldSettingsDesign
|
||||
pictorialDesign *gamedata.PictorialDesign
|
||||
equipmentStatDesign *gamedata.EquipmentStatDesign
|
||||
charAwakeDesign *gamedata.CharAwakeDesign
|
||||
immortalDesign *gamedata.ImmortalDesign
|
||||
costumePotentialDesign *gamedata.CostumePotentialDesign
|
||||
costumeBurstDesign *gamedata.CostumeBurstDesign
|
||||
friendshipDesign gamedata.FriendshipDesign
|
||||
contentOpeningDesign *gamedata.ContentOpeningDesign
|
||||
huntingAPDesign gamedata.HuntingAPDesign
|
||||
rewardGraph *gamedata.RewardGraph
|
||||
rewardEquipment *gamedata.EquipmentGachaCatalog
|
||||
rewardCostumes *gamedata.RegularGachaCatalog
|
||||
talentUseDesign *gamedata.TalentUseDesign
|
||||
dispatchDesign map[uint64]gamedata.TalentDispatchDesign
|
||||
itemCraftDesign *gamedata.ItemCraftDesign
|
||||
npcShopDesign gamedata.NPCShopDesign
|
||||
commissionDesign *gamedata.TodayQuestCatalog
|
||||
prestigeCatalog *gamedata.PrestigeSkinCatalog
|
||||
ownedEventItems map[uint64]map[uint64]bool
|
||||
avatarRewards *gamedata.AvatarRewardDesign
|
||||
buffDesign map[uint64]gamedata.PictorialBuffStat
|
||||
eventAPCaps map[uint64]uint64
|
||||
eventAPReset gamedata.HuntingAPDesign
|
||||
cashDesign *gamedata.CashCatalog
|
||||
cashEntitlementDesign *gamedata.CashEntitlementDesign
|
||||
cashRewards *gamedata.CashRewardResolver
|
||||
cashMailTemplates map[uint64]bool
|
||||
clearPackageDesign *gamedata.ClearPackageCatalog
|
||||
cashBonusDesign *gamedata.CashBonusCatalog
|
||||
eventTasksDesign *gamedata.EventTasksDesign
|
||||
loginPassDesign *gamedata.LoginPassCatalog
|
||||
eventExchangeDesign *gamedata.EventExchangeCatalog
|
||||
eventBattleChallenges gamedata.EventBattleChallenges
|
||||
eventActionsDesign *gamedata.EventActionsDesign
|
||||
miniContent *gamedata.MiniContentDesign
|
||||
recruitDesign gamedata.RecruitDesign
|
||||
foodDesign *gamedata.FoodDesign
|
||||
achievementCounterDesign *gamedata.AchievementCounterDesign
|
||||
achievementGrades gamedata.GameplayAchievementGrades
|
||||
regularGacha *gamedata.RegularGachaCatalog
|
||||
equipmentGacha *gamedata.EquipmentGachaCatalog
|
||||
limitedCostumes *gamedata.LimitedCostumeCatalog
|
||||
firstGacha *gamedata.FirstGachaDesign
|
||||
}
|
||||
|
||||
func loadDesign(c *configuration, seeds *seedCatalog) (*designCatalog, error) {
|
||||
gameData, gameDataVersion := c.gameData, c.gameDataVersion
|
||||
calendars, gameRules := c.calendars, c.gameRules
|
||||
d := &designCatalog{source: gamedata.NewSource(gameData, gameDataVersion)}
|
||||
var err error
|
||||
gachaSchedule := calendars.GachaSeed
|
||||
var scheduleGroupIDs, stepUpGroupIDs []uint64
|
||||
for _, window := range gachaSchedule.Schedules {
|
||||
scheduleGroupIDs = append(scheduleGroupIDs, window.GroupID)
|
||||
}
|
||||
for _, window := range gachaSchedule.StepUps {
|
||||
stepUpGroupIDs = append(stepUpGroupIDs, window.GroupID)
|
||||
}
|
||||
d.regularGacha, d.equipmentGacha, err = gamedata.LoadActiveGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs, stepUpGroupIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load active gacha GameData: %w", err)
|
||||
}
|
||||
if gameRules.Gacha.IncludeCollaborationURWeapons {
|
||||
if err := d.equipmentGacha.IncludeCollaborationURWeapons(gameData, gameDataVersion); err != nil {
|
||||
return nil, fmt.Errorf("apply collaboration UR weapon game rule: %w", err)
|
||||
}
|
||||
}
|
||||
d.presetDesign, err = gamedata.LoadPresetDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load party preset GameData: %w", err)
|
||||
}
|
||||
d.recipeDesign, err = gamedata.LoadCookingRecipeDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cooking recipes: %w", err)
|
||||
}
|
||||
d.randomBoxes, err = gamedata.LoadRandomBoxDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load deterministic random-box GameData: %w", err)
|
||||
}
|
||||
d.itemStacks, err = gamedata.LoadItemStackDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load item stack GameData: %w", err)
|
||||
}
|
||||
d.slotDesign, err = gamedata.LoadInventorySlotDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load inventory slot GameData: %w", err)
|
||||
}
|
||||
d.contentTickets, err = gamedata.LoadGachaContentTicketDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load gacha content tickets: %w", err)
|
||||
}
|
||||
d.missionDesign, err = gamedata.LoadMissionDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mission GameData: %w", err)
|
||||
}
|
||||
d.levelDesign, err = gamedata.LoadAchievementLevelDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load user level rewards: %w", err)
|
||||
}
|
||||
d.equipmentSlots, err = gamedata.LoadEquipmentSlots(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment slot GameData: %w", err)
|
||||
}
|
||||
d.equipmentUpgrade, err = gamedata.LoadEquipmentUpgradeDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment upgrade GameData: %w", err)
|
||||
}
|
||||
d.equipmentCraft, err = gamedata.LoadEquipmentCraftDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment crafting GameData: %w", err)
|
||||
}
|
||||
d.talentGrowth, err = gamedata.LoadTalentGrowthDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load talent growth GameData: %w", err)
|
||||
}
|
||||
d.equipmentSmelting, err = gamedata.LoadEquipmentSmeltingDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment smelting GameData: %w", err)
|
||||
}
|
||||
d.equipmentOptionReroll, err = gamedata.LoadEquipmentOptionRerollDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment option reroll GameData: %w", err)
|
||||
}
|
||||
d.infiniteGacha, err = gamedata.LoadInfiniteGachaForSchedules(gameData, gameDataVersion, scheduleGroupIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load infinite gacha GameData: %w", err)
|
||||
}
|
||||
d.fieldSettingsDesign, err = gamedata.LoadFieldSettingsDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load field character settings: %w", err)
|
||||
}
|
||||
d.pictorialDesign, err = gamedata.LoadPictorialDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load pictorial GameData: %w", err)
|
||||
}
|
||||
d.equipmentStatDesign, err = gamedata.LoadEquipmentStatDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load equipment stat GameData: %w", err)
|
||||
}
|
||||
d.charAwakeDesign, err = gamedata.LoadCharAwakeDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load character awakening GameData: %w", err)
|
||||
}
|
||||
d.immortalDesign, err = gamedata.LoadImmortalDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load immortal talent GameData: %w", err)
|
||||
}
|
||||
d.costumePotentialDesign, err = gamedata.LoadCostumePotentialDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load costume potential GameData: %w", err)
|
||||
}
|
||||
d.costumeBurstDesign, err = gamedata.LoadCostumeBurstDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load costume burst GameData: %w", err)
|
||||
}
|
||||
d.friendshipDesign, err = gamedata.LoadFriendshipDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load friendship GameData: %w", err)
|
||||
}
|
||||
d.contentOpeningDesign, err = gamedata.LoadContentOpeningDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load content opening GameData: %w", err)
|
||||
}
|
||||
d.huntingAPDesign, err = gamedata.LoadHuntingAPDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load hunting AP reset: %w", err)
|
||||
}
|
||||
d.rewardGraph, err = gamedata.LoadRewardGraph(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event reward graph: %w", err)
|
||||
}
|
||||
d.rewardEquipment, err = gamedata.LoadRewardEquipmentCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load reward equipment: %w", err)
|
||||
}
|
||||
d.rewardCostumes, err = gamedata.LoadRewardCostumeCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load reward costumes: %w", err)
|
||||
}
|
||||
d.talentUseDesign, err = gamedata.LoadTalentUseDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load field talent skills: %w", err)
|
||||
}
|
||||
d.dispatchDesign, err = gamedata.LoadTalentDispatchDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load talent dispatch design: %w", err)
|
||||
}
|
||||
d.itemCraftDesign, err = gamedata.LoadItemCraftDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load item crafting design: %w", err)
|
||||
}
|
||||
d.npcShopDesign, err = gamedata.LoadNPCShopDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load NPC shop design: %w", err)
|
||||
}
|
||||
d.commissionDesign, err = gamedata.LoadTodayQuests(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load NPC commission design: %w", err)
|
||||
}
|
||||
d.prestigeCatalog, err = gamedata.LoadPrestigeSkinCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load reward prestige skins: %w", err)
|
||||
}
|
||||
d.ownedEventItems, err = gamedata.LoadOwnedEventItemDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event inventory design: %w", err)
|
||||
}
|
||||
d.avatarRewards, err = gamedata.LoadAvatarRewardDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load avatar rewards: %w", err)
|
||||
}
|
||||
d.buffDesign, err = gamedata.LoadBuffRewardDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load permanent buff rewards: %w", err)
|
||||
}
|
||||
d.eventAPCaps, d.eventAPReset, err = gamedata.LoadEventAPDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event AP reset: %w", err)
|
||||
}
|
||||
d.cashDesign, err = gamedata.LoadCashCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash products: %w", err)
|
||||
}
|
||||
d.cashEntitlementDesign, err = gamedata.LoadCashEntitlementDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash entitlement design: %w", err)
|
||||
}
|
||||
d.cashRewards, err = gamedata.LoadCashRewardResolver(gameData, gameDataVersion, d.rewardGraph)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash product rewards: %w", err)
|
||||
}
|
||||
d.cashMailTemplates, err = gamedata.LoadCashMailTemplates(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash mail templates: %w", err)
|
||||
}
|
||||
d.clearPackageDesign, err = gamedata.LoadClearPackageCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load clear-package rewards: %w", err)
|
||||
}
|
||||
d.cashBonusDesign, err = gamedata.LoadCashBonusCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load cash bonus design: %w", err)
|
||||
}
|
||||
d.eventTasksDesign, err = gamedata.LoadEventTasksDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event tasks design: %w", err)
|
||||
}
|
||||
d.loginPassDesign, err = gamedata.LoadLoginPassCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load login-pass rewards: %w", err)
|
||||
}
|
||||
d.eventExchangeDesign, err = gamedata.LoadEventExchangeCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event exchange design: %w", err)
|
||||
}
|
||||
d.eventBattleChallenges, err = gamedata.LoadEventBattleChallenges(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event battle challenges: %w", err)
|
||||
}
|
||||
d.eventActionsDesign, err = gamedata.LoadEventActionsDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load event action design: %w", err)
|
||||
}
|
||||
d.miniContent, err = gamedata.LoadMiniContentDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mini event content: %w", err)
|
||||
}
|
||||
d.recruitDesign, err = gamedata.LoadRecruitDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load recruitment GameData: %w", err)
|
||||
}
|
||||
d.foodDesign, err = gamedata.LoadFoodDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load food GameData: %w", err)
|
||||
}
|
||||
d.achievementCounterDesign, err = gamedata.LoadAchievementCounterDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load achievement counters: %w", err)
|
||||
}
|
||||
d.achievementGrades, err = gamedata.LoadGameplayAchievementGrades(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load achievement gameplay grades: %w", err)
|
||||
}
|
||||
d.limitedCostumes, err = gamedata.LoadLimitedCostumes(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load limited costume GameData: %w", err)
|
||||
}
|
||||
d.firstGacha, err = gamedata.LoadFirstGacha(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load first gacha GameData: %w", err)
|
||||
}
|
||||
d.world, err = gamedata.LoadWorldRules(gameData, gameDataVersion, seeds.world.PlaceholderCostumeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.fieldReset, err = gamedata.LoadFieldResetSchedule(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.fieldBuffs, err = gamedata.LoadFieldBuffDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.recovery, err = gamedata.LoadPackRecoveryPolicy(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.researchCharacters, err = gamedata.LoadResearchCharacters(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.overwhelmSky, err = gamedata.LoadSkyWayOverwhelm(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.eventPlay, err = gamedata.LoadEventPlayCatalog(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presets, err := gamedata.LoadMonsterHuntPresetDesign(gameData, gameDataVersion)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seasons := monsterHuntSeasons(seeds.defaults)
|
||||
d.monsterHunt, err = monsterhunt.NewRules(seasons, seeds.defaults, presets, d.source.MonsterHunt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.cashCatalog, err = commerce.NewCatalog(c.versions.GameVersion, d.cashDesign, c.gameRules.Purchases)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
assets "bd2server/internal/server/domain/inventory"
|
||||
"bd2server/internal/server/domain/mail"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/roster/deck"
|
||||
"bd2server/internal/server/domain/world/progress"
|
||||
"bd2server/internal/server/storage/account"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) assets(ctx command.Context) error {
|
||||
var err error
|
||||
p.startingPackID, err = ctx.State.(*accountstate.CommandStore).LockStartingPack(p.options.gameRules.Story.StartPackID, p.initializeAccount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("server starting chapter policy: %w", err)
|
||||
}
|
||||
p.progressState, err = progress.OpenStore(ctx, p.scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.deckStateStore, err = deck.OpenStore(ctx, p.scope, p.deckSeed, *p.design.presetDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load deck state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachPresetSlots(p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach preset slots to login: %w", err)
|
||||
}
|
||||
if err := p.login.AttachPortrait(p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach persisted portrait to login: %w", err)
|
||||
}
|
||||
p.ownedItems, err = assets.OpenInventory(ctx, p.scope, p.starter.Items)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned inventory: %w", err)
|
||||
}
|
||||
if err := p.ownedItems.AttachItemStacks(p.design.itemStacks); err != nil {
|
||||
return err
|
||||
}
|
||||
p.recipeService, err = assets.NewRecipeService(p.design.recipeDesign, p.starter.CookingRecipes, p.ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load learned recipes: %w", err)
|
||||
}
|
||||
if err := p.ownedItems.AttachRandomBoxes(ctx, p.design.randomBoxes); err != nil {
|
||||
return fmt.Errorf("attach random-box GameData: %w", err)
|
||||
}
|
||||
gold, freeJewelry, jewelry, mileage, err := p.login.SeedCurrencies()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed currency: %w", err)
|
||||
}
|
||||
hopePowder, err := p.login.SeedHopePowder()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed hope powder: %w", err)
|
||||
}
|
||||
catalyst, err := p.login.SeedCatalyst()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed catalyst: %w", err)
|
||||
}
|
||||
equipMileage, equipMileageExchangeGage, err := p.login.SeedEquipmentMileage()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed equipment mileage: %w", err)
|
||||
}
|
||||
p.wallet, err = assets.OpenWallet(ctx, p.scope, assets.Currency{
|
||||
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Catalyst: catalyst, Mileage: mileage, HopePowder: hopePowder,
|
||||
EquipMileage: equipMileage, EquipMileageExchangeGage: equipMileageExchangeGage,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load wallet state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachCurrencies(p.wallet); err != nil {
|
||||
return fmt.Errorf("attach wallet to login: %w", err)
|
||||
}
|
||||
itemSlots, storageSlots, equipmentInventorySlots, equipmentStorageSlots, err := p.login.SeedInventorySlots()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read account seed inventory slots: %w", err)
|
||||
}
|
||||
p.inventorySlots, err = assets.OpenInventorySlots(ctx, p.scope, p.design.slotDesign, assets.InventorySlotCounts{
|
||||
Items: itemSlots, Storage: storageSlots, Equipment: equipmentInventorySlots, EquipmentStorage: equipmentStorageSlots,
|
||||
}, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load inventory slot state: %w", err)
|
||||
}
|
||||
p.inventorySlots.AttachDevelopmentSettings(p.options.devToolsConfig)
|
||||
if err := p.login.AttachInventorySlots(p.inventorySlots); err != nil {
|
||||
return fmt.Errorf("attach inventory slots to login: %w", err)
|
||||
}
|
||||
p.mailService, err = mail.OpenService(ctx, p.scope, p.mailbox, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mail state: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachContentTickets(ctx, p.design.contentTickets); err != nil {
|
||||
return fmt.Errorf("attach mailbox content tickets: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachSeedPath(ctx, filepath.Clean(p.options.mailSeed)); err != nil {
|
||||
return fmt.Errorf("watch mail seed: %w", err)
|
||||
}
|
||||
if p.options.mailGrantSpool != "" {
|
||||
if err := p.mailService.AttachGrantSpoolPath(ctx, p.options.mailGrantSpool); err != nil {
|
||||
return fmt.Errorf("attach mail grant spool: %w", err)
|
||||
}
|
||||
}
|
||||
p.missionService, err = missions.Open(ctx, p.scope, p.design.missionDesign, p.ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mission state: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachWallet(ctx, p.wallet); err != nil {
|
||||
return fmt.Errorf("attach mission wallet: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachUserLevelRewards(ctx, p.design.levelDesign); err != nil {
|
||||
return fmt.Errorf("attach user level rewards: %w", err)
|
||||
}
|
||||
if err := p.login.AttachLevelReward(p.missionService); err != nil {
|
||||
return fmt.Errorf("attach persisted user level reward: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachMail(ctx, p.mailService); err != nil {
|
||||
return fmt.Errorf("attach mission compensation mailbox: %w", err)
|
||||
}
|
||||
p.ownedEquipment, err = assets.OpenEquipmentInventory(ctx, p.scope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned equipment: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachSlots(ctx, p.design.equipmentSlots); err != nil {
|
||||
return fmt.Errorf("attach equipment slot GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachUpgrade(ctx, p.design.equipmentUpgrade, p.wallet, p.ownedItems); err != nil {
|
||||
return fmt.Errorf("attach equipment upgrade GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachCraft(ctx, p.design.equipmentCraft); err != nil {
|
||||
return fmt.Errorf("attach equipment crafting GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachSmelting(ctx, p.design.equipmentSmelting, p.wallet, p.ownedItems); err != nil {
|
||||
return fmt.Errorf("attach equipment smelting GameData: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachOptionReroll(ctx, p.design.equipmentOptionReroll, p.wallet, p.ownedItems); err != nil {
|
||||
return fmt.Errorf("attach equipment option reroll GameData: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/events/tasks"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) commerce(ctx command.Context) error {
|
||||
var err error
|
||||
p.cashCatalog = p.design.cashCatalog
|
||||
p.cashEconomy, err = commerce.NewEntitlementEconomy(ctx, p.gameplayStore, p.eventEconomy, p.design.cashRewards, p.ownedItems, p.design.cashEntitlementDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cash entitlements: %w", err)
|
||||
}
|
||||
p.cashEconomy.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
|
||||
if err := p.mailService.AttachCashRewards(ctx, p.cashEconomy, p.design.cashMailTemplates); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.cashEconomy.AttachCashMail(p.mailService); err != nil {
|
||||
return err
|
||||
}
|
||||
p.cashService, err = commerce.NewService(ctx, p.cashCatalog, p.gameplayStore, p.cashEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cash purchase state: %w", err)
|
||||
}
|
||||
p.clearPackages, err = commerce.NewClearPackages(ctx, p.gameplayStore, p.design.clearPackageDesign, p.cashEconomy, p.ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load clear-package claims: %w", err)
|
||||
}
|
||||
p.clearPackages.AttachProgress(p.worldService.CashPackagePackCleared, nil)
|
||||
p.cashService.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
|
||||
if err := p.cashService.AttachPackageRules(p.design.cashDesign.Packages); err != nil {
|
||||
return fmt.Errorf("attach cash package progression: %w", err)
|
||||
}
|
||||
if err := p.cashService.AttachShopSeed(ctx, p.seeds.defaults); err != nil {
|
||||
return fmt.Errorf("attach cash product availability: %w", err)
|
||||
}
|
||||
if err := p.cashService.AttachEventShopSchedules(p.design.cashDesign, p.options.calendars.Events); err != nil {
|
||||
return fmt.Errorf("attach event shop availability: %w", err)
|
||||
}
|
||||
p.cashBonuses, err = commerce.NewCashBonuses(ctx, p.gameplayStore, p.cashEconomy, p.cashService, p.design.cashBonusDesign, p.design.cashDesign.Packages)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cash bonus claims: %w", err)
|
||||
}
|
||||
p.cashService.AttachLegacyCounts(p.gachaService)
|
||||
cashSpecialProducts := []gamedata.CashProductKey{{GroupID: p.design.infiniteGacha.ProductGroupID, ProductID: p.design.infiniteGacha.ProductID, SaleGroup: p.design.infiniteGacha.SaleGroup}}
|
||||
for _, group := range p.design.regularGacha.Groups() {
|
||||
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
|
||||
cashSpecialProducts = append(cashSpecialProducts, gamedata.CashProductKey{GroupID: group.CashProductGroupID, ProductID: group.CashProductID, SaleGroup: group.CashSalesGroup})
|
||||
}
|
||||
}
|
||||
if err := p.cashService.AttachSpecialProducts(cashSpecialProducts); err != nil {
|
||||
return fmt.Errorf("attach special cash products: %w", err)
|
||||
}
|
||||
p.cashService.AttachDelegate(func(ctx command.Context, key gamedata.CashProductKey, request []byte) ([]byte, bool, error) {
|
||||
known := key.GroupID == p.design.infiniteGacha.ProductGroupID && key.ProductID == p.design.infiniteGacha.ProductID && key.SaleGroup == p.design.infiniteGacha.SaleGroup
|
||||
for _, group := range p.design.regularGacha.Groups() {
|
||||
if key.GroupID == group.CashProductGroupID && key.ProductID == group.CashProductID && key.SaleGroup == group.CashSalesGroup {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return nil, false, nil
|
||||
}
|
||||
_, response, handled, err := p.gachaService.Handle(ctx, "/CashShopBuy", request)
|
||||
if err != nil || !handled {
|
||||
return nil, handled, err
|
||||
}
|
||||
bundle, _, err := wire.Bytes(response, 1)
|
||||
return bundle, true, err
|
||||
})
|
||||
if err := p.login.AttachPurchaseCounts(p.cashService); err != nil {
|
||||
return fmt.Errorf("attach cash purchase counts: %w", err)
|
||||
}
|
||||
p.eventTasksService, err = eventtasks.Open(ctx, p.gameplayStore, p.design.eventTasksDesign, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event tasks state: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachAttendanceRewardEconomy(ctx, p.eventEconomy); err != nil {
|
||||
return fmt.Errorf("attach attendance mail rewards: %w", err)
|
||||
}
|
||||
p.eventTasksService.AttachAttendanceMail(p.mailService)
|
||||
newbieStep, _, err := wire.Varint(p.login.UserInfo, 39)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = p.eventTasksService.SetNewbieStep(ctx, newbieStep); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = p.login.AttachNewbieStep(p.eventTasksService); err != nil {
|
||||
return err
|
||||
}
|
||||
p.eventTasksService.AttachCashAuthorization(func(ctx command.Context, passID, buyType uint64) bool {
|
||||
for _, buy := range p.design.eventTasksDesign.PassBuys[passID] {
|
||||
if buy.Type == buyType && buy.CashID != 0 {
|
||||
return p.cashService.ConsumeEntitlement(ctx, gamedata.CashProductKey{GroupID: buy.CashGroup, ProductID: buy.CashID, SaleGroup: buy.CashSales})
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
p.eventTasksService.AttachAttendancePremium(func(ctx command.Context, ticket uint64) bool {
|
||||
for _, item := range p.ownedItems.All(ctx) {
|
||||
if item.Type == 19 && item.ID == ticket && item.Count > 0 && (item.ExpiryTime == 0 || item.ExpiryTime > uint64(time.Now().UnixMilli())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
p.loginPasses, err = commerce.NewLoginPasses(ctx, p.gameplayStore, p.design.loginPassDesign, p.cashEconomy, p.ownedItems, func(ctx command.Context, group uint64) bool {
|
||||
for _, pack := range p.design.cashDesign.Packages {
|
||||
if pack.PackageType == 7 && pack.ID == group && p.cashService.IsAvailable(ctx, gamedata.CashProductKey{GroupID: pack.GroupID, ProductID: pack.ID, SaleGroup: pack.SaleGroup}) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load login-pass progress: %w", err)
|
||||
}
|
||||
p.loginPasses.SetClock(time.Now, p.design.eventAPReset.ResetSeconds-9*3600)
|
||||
p.eventTasksService.AttachUnlockResolver(p.worldService.MissionsUnlocked)
|
||||
if err = p.missionService.AttachEventHandler(ctx, p.eventTasksService); err != nil {
|
||||
return fmt.Errorf("attach mission event handler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/events"
|
||||
"bd2server/internal/server/domain/events/actions"
|
||||
"bd2server/internal/server/domain/events/exchange"
|
||||
"bd2server/internal/server/domain/events/games"
|
||||
"bd2server/internal/server/domain/events/play"
|
||||
"bd2server/internal/server/domain/events/tasks"
|
||||
"bd2server/internal/server/domain/progression/achievements"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) events(ctx command.Context) error {
|
||||
var err error
|
||||
p.eventGamesService, err = eventgames.Open(ctx, p.gameplayStore, p.design.source.EventGame, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event games state: %w", err)
|
||||
}
|
||||
p.eventExchangeService, err = eventexchange.Open(ctx, p.gameplayStore, p.design.eventExchangeDesign, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event exchange state: %w", err)
|
||||
}
|
||||
p.boxService, err = events.OpenBoxes(p.gameplayStore, p.ownedItems, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load random box state: %w", err)
|
||||
}
|
||||
p.eventPlayService, err = eventplay.Open(ctx, p.gameplayStore, p.design.eventPlay, func(id uint64) (*gamedata.EventField, error) {
|
||||
return p.design.source.EventField(p.design.eventPlay, id)
|
||||
}, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event play state: %w", err)
|
||||
}
|
||||
p.eventPlayService.AttachBattleChallenges(p.design.eventBattleChallenges)
|
||||
p.eventPlayService.AttachHubCalendars(p.seeds.defaults)
|
||||
if err := p.eventPlayService.AttachFieldBindingsFile(filepath.Join(filepath.Dir(p.options.worldSeed), "event_field_bindings.json")); err != nil {
|
||||
return fmt.Errorf("attach hidden field bindings: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachEventFieldPacks(p.eventPlayService); err != nil {
|
||||
return fmt.Errorf("attach event field packs: %w", err)
|
||||
}
|
||||
p.battleService.AttachEventBattle(p.eventPlayService)
|
||||
p.eventActionsService, err = eventactions.Open(ctx, p.gameplayStore, p.design.eventActionsDesign, p.eventRegistry, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event action state: %w", err)
|
||||
}
|
||||
if err = p.eventActionsService.AttachMiniContent(ctx, p.eventPlayService, p.design.miniContent); err != nil {
|
||||
return fmt.Errorf("attach mini event content: %w", err)
|
||||
}
|
||||
p.eventActionsService.AttachFriendshipLevel(func(id uint64) uint64 {
|
||||
for _, entry := range p.collection.FriendshipEntries() {
|
||||
if entry.State != nil && entry.State.CostumeID == id {
|
||||
return entry.State.Level
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
p.eventActionsService.AttachChargeInfo(func(ctx command.Context) ([]byte, error) {
|
||||
rows, err := p.eventEconomy.ChargeInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
huntingRows, err := p.huntingService.APChargeInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(rows, huntingRows...), nil
|
||||
})
|
||||
p.eventActionsService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
|
||||
if err := p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.eventTasksService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
p.eventPlayService.AttachProgress(func(ctx command.Context, condition, sub, count uint64) error {
|
||||
return p.missionService.RecordEvent(ctx, condition, sub, count, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
p.eventTasksService.AttachAssociatedMissionGroup(func(schedule events.Schedule) uint64 {
|
||||
if group := p.eventActionsService.AssociatedMissionGroup(schedule); group != 0 {
|
||||
return group
|
||||
}
|
||||
group, err := p.eventPlayService.AssociatedMissionGroup(schedule)
|
||||
if err != nil {
|
||||
slog.Error("event mission design unavailable", "event_uid", schedule.UID, "event_id", schedule.ID, "error", err)
|
||||
}
|
||||
return group
|
||||
})
|
||||
p.battleService.AttachEventBattle(p.eventActionsService)
|
||||
p.battleService.AttachCurrentDifficulty(p.worldService.CurrentQuestDifficulty)
|
||||
p.battleService.AttachQuestBattleValidation(p.worldService.ValidateQuestBattle)
|
||||
p.battleService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
|
||||
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
|
||||
})
|
||||
if err := p.worldService.AttachBattleActive(p.battleService.Active); err != nil {
|
||||
return fmt.Errorf("attach world battle guard: %w", err)
|
||||
}
|
||||
|
||||
p.monsterHuntService, err = monsterhunt.Open(ctx, p.gameplayStore, p.design.monsterHunt, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load monster hunt state: %w", err)
|
||||
}
|
||||
if err := p.monsterHuntService.AttachPresetRuntime(ctx, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
|
||||
return fmt.Errorf("attach monster hunt preset ownership: %w", err)
|
||||
}
|
||||
if err := p.login.AttachMonsterHuntSlots(p.monsterHuntService); err != nil {
|
||||
return fmt.Errorf("attach monster hunt preset slots: %w", err)
|
||||
}
|
||||
p.battleService.AttachMonsterHunt(p.monsterHuntService)
|
||||
p.monsterHuntService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
|
||||
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
|
||||
})
|
||||
p.recruitService, err = roster.NewRecruitService(&p.design.recruitDesign, &p.design.recruitDesign, p.collection, p.ownedItems, p.wallet,
|
||||
func(ctx command.Context, npcID uint64) (uint64, error) {
|
||||
return p.worldService.ResolveRecruitNPC(ctx, npcID, p.design.source, &p.design.recruitDesign)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load recruitment service: %w", err)
|
||||
}
|
||||
p.battleService.AttachCommittedHealth(func(ctx command.Context, health map[uint64]uint64) error {
|
||||
for index, hp := range health {
|
||||
maximum, err := p.worldService.CharacterService().MaxHealth(ctx, index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid completed battle health for character %d: %w", index, err)
|
||||
}
|
||||
if hp > maximum {
|
||||
// Battle-only HP buffs are not persisted into field health.
|
||||
// This is our settlement policy, not an inferred provider rule.
|
||||
health[index] = maximum
|
||||
}
|
||||
}
|
||||
for index, hp := range health {
|
||||
if err := p.worldService.CharacterService().SetCurrentHealth(ctx, index, hp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
p.foodService, err = roster.OpenFoodService(ctx, p.scope, p.design.foodDesign, p.ownedItems, p.worldService.CharacterService())
|
||||
if err != nil {
|
||||
return fmt.Errorf("load food state: %w", err)
|
||||
}
|
||||
if err := p.foodService.AttachContext(ctx, p.worldService.CurrentPackID, p.battleService.Active); err != nil {
|
||||
return err
|
||||
}
|
||||
p.battleService.AttachMonsterWinMission(func(ctx command.Context) error {
|
||||
return p.missionService.CompleteSingleTargetEvent(ctx, missions.ConditionMonsterKill, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
p.battleService.AttachPictorialBuffs(func(ctx command.Context) ([]gamedata.PictorialBuffStat, error) {
|
||||
_, buffs, err := p.pictorialService.Snapshot(ctx)
|
||||
return buffs, err
|
||||
})
|
||||
p.achievementCounters, err = achievements.NewAchievementService(p.design.achievementCounterDesign, p.scope, p.missionService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load achievement counter state: %w", err)
|
||||
}
|
||||
if err := p.missionService.AttachAchievementProgress(ctx, p.achievementCounters); err != nil {
|
||||
return fmt.Errorf("attach achievement completion validation: %w", err)
|
||||
}
|
||||
p.commissionService.CompleteAchievement = func(ctx command.Context, identity string) error {
|
||||
_, err := p.achievementCounters.RecordEvent(ctx, identity, 17, 0, 1)
|
||||
return err
|
||||
}
|
||||
if err := p.login.AttachAchievementExperience(p.missionService); err != nil {
|
||||
return fmt.Errorf("attach persisted achievement experience: %w", err)
|
||||
}
|
||||
achievementProvider := p.worldService.GameplayAchievementProvider(ctx, p.design.achievementCounterDesign, p.design.achievementGrades)
|
||||
achievementProvider.StateVersion = p.stateRepository.ObservationVersion
|
||||
p.achievementObserver, err = achievements.NewGameplayAchievementObserver(p.achievementCounters, achievementProvider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize achievement gameplay observer: %w", err)
|
||||
}
|
||||
if err := p.achievementObserver.SyncRecordedHistory(ctx); err != nil {
|
||||
return fmt.Errorf("restore recorded achievement history: %w", err)
|
||||
}
|
||||
p.eventTasksService.AttachInventoryProvider(&eventtasks.InventoryProjection{Items: p.ownedItems, Equipment: p.ownedEquipment, Costumes: p.collection, StateVersion: p.stateRepository.ObservationVersion})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
accountstate "bd2server/internal/server/storage/account"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type commandReceipt struct {
|
||||
Digest string `json:"digest"`
|
||||
Responses []player.Response `json:"responses"`
|
||||
}
|
||||
|
||||
func (p *playerInstance) Execute(ctx context.Context, request player.Command) (reply player.Reply, result error) {
|
||||
if request.Identity.AccountID != p.accountID || p.assembly == nil {
|
||||
return reply, player.Failure{Cause: player.ErrUnavailable, RecoveryRequired: true}
|
||||
}
|
||||
started := time.Now()
|
||||
tx, err := p.repository.BeginCommand(ctx)
|
||||
reply.Timing.Begin = time.Since(started)
|
||||
if err != nil {
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true}
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
started := time.Now()
|
||||
result = errors.Join(result, tx.Rollback())
|
||||
reply.Timing.Rollback += time.Since(started)
|
||||
p.assembly = nil
|
||||
if result != nil {
|
||||
result = player.Failure{Cause: result, RecoveryRequired: true}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
identity := command.Context{Identity: request.Identity, Cancellation: request.Cancellation, State: tx}
|
||||
isLogin := len(request.Requests) == 1 && request.Requests[0].Path == "/LoginUser"
|
||||
receiptKey := request.Identity.SessionID + "/" + request.Identity.RequestID
|
||||
if !isLogin {
|
||||
raw, found, err := tx.LoadEntry("missions", "command_receipts", receiptKey)
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if found {
|
||||
var receipt commandReceipt
|
||||
if err := json.Unmarshal(raw, &receipt); err != nil {
|
||||
return reply, player.Failure{Cause: fmt.Errorf("invalid command receipt: %w", err), RecoveryRequired: true}
|
||||
}
|
||||
if receipt.Digest != hex.EncodeToString(request.Digest[:]) {
|
||||
finished = true
|
||||
if err := tx.Rollback(); err != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: errors.Join(player.ErrIdentityConflict, err), RecoveryRequired: true}
|
||||
}
|
||||
return reply, player.ErrIdentityConflict
|
||||
}
|
||||
reply.Responses = receipt.Responses
|
||||
finished = true
|
||||
if err := tx.Rollback(); err != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
assembly := p.assembly
|
||||
transientBefore := assembly.transientVersion()
|
||||
for _, item := range request.Requests {
|
||||
var response player.Response
|
||||
if isLogin {
|
||||
started := time.Now()
|
||||
err = assembly.beginLogin(identity)
|
||||
if err == nil {
|
||||
response.Body, err = assembly.login.Login(identity, item.Body, request.LoginSessionKey)
|
||||
response.PacketCode = 3
|
||||
}
|
||||
reply.Timing.Execute += time.Since(started)
|
||||
} else {
|
||||
started := time.Now()
|
||||
for _, observer := range assembly.observers {
|
||||
if err = observer.BeforeDispatch(identity, item.Path, item.Body); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
reply.Timing.Observer += time.Since(started)
|
||||
if err == nil {
|
||||
started = time.Now()
|
||||
response.PacketCode, response.Body, err = assembly.dispatch(identity, item.Path, item.Body)
|
||||
reply.Timing.Execute += time.Since(started)
|
||||
}
|
||||
if err == nil {
|
||||
started = time.Now()
|
||||
for _, observer := range assembly.observers {
|
||||
var notify []byte
|
||||
|
||||
notify, err = observer.AfterDispatch(identity, item.Path, item.Body, response.Body)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
response.Notification = append(response.Notification, notify...)
|
||||
}
|
||||
reply.Timing.Observer += time.Since(started)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
changed := tx.Dirty() || assembly.transientVersion() != transientBefore
|
||||
started := time.Now()
|
||||
rollbackErr := tx.Rollback()
|
||||
reply.Timing.Rollback += time.Since(started)
|
||||
finished = true
|
||||
if rollbackErr != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: errors.Join(err, rollbackErr), RecoveryRequired: true}
|
||||
}
|
||||
if !changed {
|
||||
return reply, err
|
||||
}
|
||||
p.assembly = nil
|
||||
if recoveryErr := p.Recover(context.WithoutCancel(ctx)); recoveryErr != nil {
|
||||
return reply, player.Failure{Cause: errors.Join(err, recoveryErr), RecoveryRequired: true}
|
||||
}
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true, AlreadyRecovered: true}
|
||||
}
|
||||
|
||||
reply.Responses = append(reply.Responses, response)
|
||||
}
|
||||
if tx.Dirty() && !isLogin {
|
||||
raw, err := json.Marshal(commandReceipt{Digest: hex.EncodeToString(request.Digest[:]), Responses: reply.Responses})
|
||||
if err != nil {
|
||||
return reply, err
|
||||
}
|
||||
if err := tx.PutEntry("missions", "command_receipts", receiptKey, raw); err != nil {
|
||||
return reply, err
|
||||
}
|
||||
}
|
||||
started = time.Now()
|
||||
err = tx.Commit()
|
||||
reply.Timing.Commit += time.Since(started)
|
||||
finished = true
|
||||
if err != nil {
|
||||
p.assembly = nil
|
||||
return reply, player.Failure{Cause: err, RecoveryRequired: true}
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func (p *playerAssembly) dispatch(ctx command.Context, path string, request []byte) (int, []byte, error) {
|
||||
for _, handler := range p.handlers {
|
||||
code, body, handled, err := handler.Handle(ctx, path, request)
|
||||
if handled || err != nil {
|
||||
return code, body, err
|
||||
}
|
||||
}
|
||||
return 0, nil, fmt.Errorf("packet not implemented: %s", path)
|
||||
}
|
||||
|
||||
func (p *playerAssembly) beginLogin(ctx command.Context) error {
|
||||
for _, observer := range p.observers {
|
||||
if hook, ok := observer.(interface{ BeginLogin(command.Context) }); ok {
|
||||
hook.BeginLogin(ctx)
|
||||
}
|
||||
}
|
||||
for _, handler := range p.handlers {
|
||||
if hook, ok := handler.(interface{ BeginLogin(command.Context) }); ok {
|
||||
hook.BeginLogin(ctx)
|
||||
}
|
||||
}
|
||||
return p.missionService.RecordLogin(ctx, p.worldService.MissionsUnlocked)
|
||||
}
|
||||
|
||||
func (p *playerInstance) Recover(ctx context.Context) error {
|
||||
if err := p.repository.Check(); err != nil {
|
||||
if errors.Is(err, accountstate.ErrFenced) {
|
||||
return err
|
||||
}
|
||||
if closeErr := p.repository.Close(); closeErr != nil {
|
||||
return errors.Join(err, closeErr)
|
||||
}
|
||||
path := filepath.Join(p.factory.options.stateDirectory, "accounts", accountDirectoryName(p.accountID), "state.db")
|
||||
repository, openErr := accountstate.Open(path)
|
||||
if openErr != nil {
|
||||
return errors.Join(err, openErr)
|
||||
}
|
||||
p.repository = repository
|
||||
}
|
||||
assembly, err := p.factory.assemble(ctx, p.accountID, p.repository)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.assembly = assembly
|
||||
slog.Warn("player state recovered from committed snapshot", "account_id", p.accountID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *playerInstance) Close() error {
|
||||
p.assembly = nil
|
||||
return p.repository.Close()
|
||||
}
|
||||
|
||||
func (p *playerAssembly) transientVersion() [4]uint64 {
|
||||
return [4]uint64{p.battleService.TransientVersion(), p.monsterHuntService.TransientVersion(), p.gachaService.TransientVersion(), p.worldService.TransientVersion()}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/battle"
|
||||
"bd2server/internal/server/domain/battle/hunting"
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/commerce/gacha"
|
||||
"bd2server/internal/server/domain/commerce/npcinn"
|
||||
"bd2server/internal/server/domain/commerce/npcshop"
|
||||
"bd2server/internal/server/domain/events"
|
||||
"bd2server/internal/server/domain/events/actions"
|
||||
"bd2server/internal/server/domain/events/exchange"
|
||||
"bd2server/internal/server/domain/events/games"
|
||||
"bd2server/internal/server/domain/events/play"
|
||||
"bd2server/internal/server/domain/events/tasks"
|
||||
assets "bd2server/internal/server/domain/inventory"
|
||||
"bd2server/internal/server/domain/mail"
|
||||
"bd2server/internal/server/domain/progression/achievements"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/progression/pictorial"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/roster/deck"
|
||||
"bd2server/internal/server/domain/world"
|
||||
"bd2server/internal/server/domain/world/progress"
|
||||
"bd2server/internal/server/domain/world/todayquest"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
loginprotocol "bd2server/internal/server/protocol/login"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"bd2server/internal/server/storage/account"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"bd2server/internal/server/storage/stateio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type PlayerFactory struct {
|
||||
options *configuration
|
||||
design *designCatalog
|
||||
seeds *seedCatalog
|
||||
profiles interface {
|
||||
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
|
||||
}
|
||||
}
|
||||
|
||||
type playerAssembly struct {
|
||||
worldSeed world.Seed
|
||||
mailbox *mail.Starter
|
||||
deckSeed deck.Seed
|
||||
*PlayerFactory
|
||||
progressState *progress.Store
|
||||
deckStateStore *deck.Store
|
||||
ownedItems *assets.Inventory
|
||||
recipeService *assets.RecipeService
|
||||
wallet *assets.Wallet
|
||||
inventorySlots *assets.InventorySlots
|
||||
mailService *mail.Service
|
||||
missionService *missions.Service
|
||||
ownedEquipment *assets.EquipmentInventory
|
||||
worldService *world.Service
|
||||
collection *roster.CollectionStore
|
||||
gachaService *gacha.Service
|
||||
pictorialService *pictorial.Service
|
||||
charAwakeService *roster.CharAwakeService
|
||||
costumePotentialService *roster.CostumePotentialService
|
||||
costumeBurstService *roster.CostumeBurstService
|
||||
friendshipService *roster.FriendshipService
|
||||
masterTitleService *roster.MasterTitleService
|
||||
battleService *battle.Service
|
||||
gameplayStore stateio.EntrySnapshotStore
|
||||
contentOpenService *assets.ContentOpenService
|
||||
huntingService *hunting.Service
|
||||
eventRegistry *events.Registry
|
||||
eventEconomy *events.Economy
|
||||
talentUseService *roster.TalentUseService
|
||||
dispatchService *roster.TalentDispatchService
|
||||
itemCraftService *roster.ItemCraftService
|
||||
innService *npcinn.Service
|
||||
npcShopService *npcshop.Service
|
||||
commissionService *todayquest.Service
|
||||
buffRewards *events.BuffRewards
|
||||
cashCatalog *commerce.Catalog
|
||||
cashEconomy *commerce.EntitlementEconomy
|
||||
cashService *commerce.Service
|
||||
clearPackages *commerce.ClearPackages
|
||||
cashBonuses *commerce.CashBonuses
|
||||
eventTasksService *eventtasks.Service
|
||||
loginPasses *commerce.LoginPasses
|
||||
eventGamesService *eventgames.Service
|
||||
eventExchangeService *eventexchange.Service
|
||||
boxService *events.BoxService
|
||||
eventPlayService *eventplay.Service
|
||||
eventActionsService *eventactions.Service
|
||||
monsterHuntService *monsterhunt.Service
|
||||
recruitService *roster.RecruitService
|
||||
foodService *roster.FoodService
|
||||
achievementCounters *achievements.AchievementService
|
||||
achievementObserver *achievements.GameplayAchievementObserver
|
||||
handlers []session.Handler
|
||||
observers []session.ResponseObserver
|
||||
stateRepository *accountstate.Repository
|
||||
scope stateio.RootStore
|
||||
startingPackID int
|
||||
initializeAccount bool
|
||||
login *loginprotocol.LoginSeed
|
||||
starter *roster.Starter
|
||||
}
|
||||
|
||||
type playerInstance struct {
|
||||
accountID string
|
||||
factory *PlayerFactory
|
||||
repository *accountstate.Repository
|
||||
assembly *playerAssembly
|
||||
}
|
||||
|
||||
func (f *PlayerFactory) open(accountID string) (instance *playerInstance, openErr error) {
|
||||
statePath := filepath.Join(f.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
|
||||
if err := os.MkdirAll(filepath.Dir(statePath), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository, err := accountstate.Open(statePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
openErr = fmt.Errorf("initialize player panic: %v", recovered)
|
||||
instance = nil
|
||||
}
|
||||
if openErr != nil {
|
||||
openErr = errors.Join(openErr, repository.Close())
|
||||
}
|
||||
}()
|
||||
assembly, err := f.assemble(context.Background(), accountID, repository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &playerInstance{accountID: accountID, factory: f, repository: repository, assembly: assembly}, nil
|
||||
}
|
||||
|
||||
func (f *PlayerFactory) assemble(ctx context.Context, accountID string, repository *accountstate.Repository) (_ *playerAssembly, openErr error) {
|
||||
|
||||
login := &loginprotocol.LoginSeed{Version: f.seeds.login.Version, PacketCode: f.seeds.login.PacketCode,
|
||||
UserInfo: slices.Clone(f.seeds.login.UserInfo), ResponseFields: slices.Clone(f.seeds.login.ResponseFields)}
|
||||
if f.profiles != nil {
|
||||
profile, err := f.profiles.GameIdentity(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
login.UserInfo, _, err = wire.ReplaceVarint(login.UserInfo, 1, uint64(profile.OwnerIndex))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
login.UserInfo, _, err = wire.ReplaceBytes(login.UserInfo, 2, []byte(profile.UserID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
starter, err := cloneSeed(f.seeds.starter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := cloneSeed(f.seeds.mailbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deckSeed, err := cloneSeed(&f.seeds.deck)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
worldSeed, err := cloneSeed(&f.seeds.world)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &playerAssembly{PlayerFactory: f, stateRepository: repository, login: login, starter: starter, mailbox: mailbox, deckSeed: *deckSeed, worldSeed: *worldSeed}
|
||||
accountDomains := []string{"characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"}
|
||||
initializationState, err := p.stateRepository.InitializationState(accountDomains...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reject incomplete account database: %w", err)
|
||||
}
|
||||
if initializationState == accountstate.InitializationCorrupt {
|
||||
return nil, errors.New("reject incomplete account database: corrupt initialization state")
|
||||
}
|
||||
p.initializeAccount = initializationState == accountstate.InitializationPending
|
||||
startupTransaction, err := p.stateRepository.BeginCommand(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin startup state transaction: %w", err)
|
||||
}
|
||||
startupCommitted := false
|
||||
defer func() {
|
||||
if startupCommitted {
|
||||
return
|
||||
}
|
||||
if rollbackErr := startupTransaction.Rollback(); rollbackErr != nil {
|
||||
openErr = errors.Join(openErr, rollbackErr)
|
||||
}
|
||||
}()
|
||||
|
||||
identity := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "startup", RequestID: "initialize"}, Cancellation: ctx, State: startupTransaction}
|
||||
for _, assemble := range []func(command.Context) error{p.assets, p.worldRoster, p.gameplay, p.commerce, p.events, p.session} {
|
||||
if err := assemble(identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := startupTransaction.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startupCommitted = true
|
||||
if err := repository.Check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/battle"
|
||||
"bd2server/internal/server/domain/battle/hunting"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce/npcinn"
|
||||
"bd2server/internal/server/domain/commerce/npcshop"
|
||||
"bd2server/internal/server/domain/events"
|
||||
assets "bd2server/internal/server/domain/inventory"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/world/todayquest"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"bd2server/internal/server/storage/stateio"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) gameplay(ctx command.Context) error {
|
||||
var err error
|
||||
p.battleService = battle.NewService(p.options.gameData, p.options.gameDataVersion, p.worldService.CurrentPackID)
|
||||
freeHuntingAP, bonusHuntingAP, err := p.login.SeedHuntingAP()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read initial hunting AP: %w", err)
|
||||
}
|
||||
p.gameplayStore = stateio.EntrySnapshotStore{Domain: "missions", Bucket: "gameplay"}
|
||||
if err := p.costumePotentialService.AttachConnectStore(p.gameplayStore); err != nil {
|
||||
return fmt.Errorf("attach costume potential connection state: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachFieldMonsterState(p.gameplayStore); err != nil {
|
||||
return fmt.Errorf("attach field monster state: %w", err)
|
||||
}
|
||||
p.battleService.AttachFieldMonsters(p.worldService)
|
||||
p.battleService.AttachFieldBuffConsume(p.worldService.ConsumeFieldBattleBuff)
|
||||
if err := p.worldService.AttachFieldBuffRuntime(p.design.fieldBuffs); err != nil {
|
||||
return fmt.Errorf("attach field monster damage: %w", err)
|
||||
}
|
||||
p.contentOpenService, err = assets.NewContentOpenService(ctx, p.design.contentOpeningDesign, p.ownedItems, p.gameplayStore, func() (uint64, error) {
|
||||
experience, err := p.missionService.AchievementExperience()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.design.levelDesign.Level(experience), nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("load content opening state: %w", err)
|
||||
}
|
||||
p.huntingService, err = hunting.Open(ctx, p.gameplayStore, p.design.source, p.ownedItems, p.wallet,
|
||||
p.worldService.CurrentPackID, freeHuntingAP, bonusHuntingAP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load hunting state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachHuntingAP(p.huntingService); err != nil {
|
||||
return fmt.Errorf("attach persisted hunting AP: %w", err)
|
||||
}
|
||||
if err = p.huntingService.AttachAPRefresh(p.design.huntingAPDesign); err != nil {
|
||||
return err
|
||||
}
|
||||
p.battleService.AttachHunting(p.huntingService)
|
||||
p.huntingService.AttachEligibility(p.worldService.HuntingEligibility)
|
||||
if err := p.worldService.AttachHuntingGround(p.huntingService); err != nil {
|
||||
return err
|
||||
}
|
||||
p.eventRegistry = events.NewRegistry()
|
||||
if err := p.eventRegistry.Replace(p.options.calendars.Events); err != nil {
|
||||
return err
|
||||
}
|
||||
initialEventCurrency := map[uint64]uint64{}
|
||||
for itemType, field := range events.AdditionalCurrencyFields {
|
||||
value, _, readErr := wire.Varint(p.login.UserInfo, field)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
initialEventCurrency[itemType] = value
|
||||
}
|
||||
p.eventEconomy, err = events.NewEconomy(ctx, p.gameplayStore, p.ownedItems, p.wallet, p.collection, p.ownedEquipment, p.design.rewardCostumes, p.design.rewardEquipment, p.design.rewardGraph, initialEventCurrency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load event economy: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachHuntingAP(p.huntingService)
|
||||
p.talentUseService, err = roster.NewTalentUseService(p.design.talentUseDesign, p.gameplayStore, p.worldService.CharacterService(), p.ownedItems, p.wallet, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load field talent state: %w", err)
|
||||
}
|
||||
p.talentUseService.AttachContext(ctx, p.worldService.TalentFieldContext)
|
||||
if err := p.worldService.AttachAutoRecoveryPolicy(p.design.recovery); err != nil {
|
||||
return fmt.Errorf("attach automatic recovery policy: %w", err)
|
||||
}
|
||||
p.deckStateStore.AttachAutoRecoveryAllowed(p.worldService.AutoRecoveryAllowed)
|
||||
p.deckStateStore.AttachAutoRecovery(p.talentUseService.AutoRecover)
|
||||
p.worldService.AttachTalentPackInfo(p.talentUseService.PackInfo)
|
||||
p.worldService.AttachOverwhelmAuthorization(p.talentUseService.ConsumeOverwhelm)
|
||||
p.worldService.AttachOverwhelmHunting(p.huntingService)
|
||||
if err := p.worldService.AttachOverwhelmDesign(p.design.source, p.design.overwhelmSky); err != nil {
|
||||
return fmt.Errorf("attach overwhelm design: %w", err)
|
||||
}
|
||||
p.talentUseService.AttachEffect(4, p.worldService.ApplyTalentFieldAbsorb)
|
||||
p.talentUseService.AttachEffect(20, p.worldService.ApplyTalentMonsterSummon)
|
||||
p.dispatchService, err = roster.OpenTalentDispatch(p.gameplayStore, p.design.dispatchDesign, p.eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load talent dispatch state: %w", err)
|
||||
}
|
||||
p.talentUseService.AttachEffect(18, p.dispatchService.Start)
|
||||
p.itemCraftService, err = roster.NewItemCraftService(p.design.itemCraftDesign, p.design.talentUseDesign, p.gameplayStore, p.ownedItems, p.worldService.CharacterService(), p.wallet, p.recipeService.Knows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load item crafting state: %w", err)
|
||||
}
|
||||
p.itemCraftService.AttachContext(ctx, func(ctx command.Context) (int, bool, error) {
|
||||
pack, err := p.worldService.CurrentPackID(ctx)
|
||||
return pack, p.battleService.Active(ctx), err
|
||||
})
|
||||
if err := p.worldService.ConfigureNPCRuntime(p.design.source, p.gameplayStore); err != nil {
|
||||
return fmt.Errorf("configure NPC world runtime: %w", err)
|
||||
}
|
||||
p.innService, err = npcinn.New(p.gameplayStore, p.worldService.CharacterService(), p.wallet, p.worldService.InnContext,
|
||||
func(ctx command.Context) (uint64, error) {
|
||||
experience, err := p.missionService.AchievementExperience()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.design.levelDesign.Level(experience), nil
|
||||
}, p.battleService.Active)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load inn recovery: %w", err)
|
||||
}
|
||||
p.npcShopService, err = npcshop.New(ctx, p.design.npcShopDesign, p.gameplayStore, p.eventEconomy, p.ownedItems, p.worldService.PackAvailable)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load NPC shop state: %w", err)
|
||||
}
|
||||
p.npcShopService.SetReputationSource(p.worldService.NPCShopReputation)
|
||||
p.npcShopService.SetTalentDiscountSource(p.talentUseService.ShopDiscount)
|
||||
p.commissionService, err = todayquest.Open(p.gameplayStore, p.design.commissionDesign, p.eventEconomy, p.ownedItems, p.worldService.CommissionPackUnlocked)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load NPC commission state: %w", err)
|
||||
}
|
||||
p.commissionService.CompleteReputation = p.worldService.CompleteNPCReputation
|
||||
if err := p.worldService.AttachTodayQuests(p.commissionService); err != nil {
|
||||
return fmt.Errorf("attach NPC commissions: %w", err)
|
||||
}
|
||||
if err = p.worldService.AttachResearchRuntime(p.design.source, p.design.researchCharacters, p.eventEconomy); err != nil {
|
||||
return fmt.Errorf("attach field research: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachPrestigeSkins(p.design.prestigeCatalog.Skins)
|
||||
p.eventEconomy.AttachPrestigePortrait(p.deckStateStore.PortraitCostume)
|
||||
if err := p.worldService.AttachPrestigeSelections(ctx, p.eventEconomy.PrestigeSkinSelections); err != nil {
|
||||
return fmt.Errorf("attach prestige skin selections: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachOwnedItemDesign(p.design.ownedEventItems)
|
||||
p.eventEconomy.AttachAvatarRewards(p.design.avatarRewards)
|
||||
p.buffRewards, err = events.OpenBuffRewards(ctx, p.gameplayStore, p.design.buffDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load permanent buff ownership: %w", err)
|
||||
}
|
||||
p.eventEconomy.AttachBuffRewards(p.buffRewards)
|
||||
p.pictorialService.AttachPermanentBuffs(p.buffRewards.SnapshotBuffs)
|
||||
if err = p.eventEconomy.AttachAPRefresh(p.design.eventAPCaps, p.design.eventAPReset); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = p.login.AttachAdditionalCurrencies(p.eventEconomy); err != nil {
|
||||
return err
|
||||
}
|
||||
p.huntingService.AttachRewards(func(ctx command.Context, identity string, rewards []gamedata.Reward) ([]byte, error) {
|
||||
return p.eventEconomy.Apply(ctx, identity, nil, rewards)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gameconfig"
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/events/calendar"
|
||||
"bd2server/internal/server/domain/identity"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/platform/versionconfig"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This integration uses the selected repository GameData and seeds. Missing
|
||||
// resources are a setup failure, never a silently skipped asset-isolation test.
|
||||
// Network fields are independently taken from the 2.35.10 client classes:
|
||||
// DeckSaveRequest/DeckDBInfo, EquipUseRequest, SaveUserPositionRequest,
|
||||
// MailOpenRequest/RewardDBInfoBundle, CostumeDBInfo and QuestUpdateRequest.
|
||||
func newIntegrationFactory(t *testing.T) (*PlayerFactory, []string) {
|
||||
t.Helper()
|
||||
versions, err := versionconfig.Find()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versionconfig.Use(versions)
|
||||
resources := versions.Resolve("data/resources/GameData")
|
||||
if _, err = gamedata.Validate(resources, versions.GameDataVersion); err != nil {
|
||||
t.Fatalf("selected GameData required; fetch repository resources before integration tests: %v", err)
|
||||
}
|
||||
calendars, err := calendar.LoadDirectory(versions.Resolve("schedules"), versions.GameVersion, versions.GameDataVersion)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = calendars.ValidateDesign(resources, versions.GameDataVersion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed := versions.Resolve(versions.SeedDirectory)
|
||||
root := t.TempDir()
|
||||
config := &configuration{versions: versions, calendars: calendars, gameRules: gameconfig.Default(), gameData: resources, gameDataVersion: versions.GameDataVersion, stateDirectory: filepath.Join(root, "state"), devToolsConfig: filepath.Join(root, "development.json"), accountSeed: filepath.Join(seed, "login_user.json"), playerSeed: filepath.Join(seed, "starter_player.json"), readonlySeed: filepath.Join(seed, "readonly.json"), mailSeed: filepath.Join(seed, "mail.json"), deckSeed: filepath.Join(seed, "decks.json"), worldSeed: filepath.Join(seed, "world.json")}
|
||||
if err = lockServerState(config.stateDirectory, config.gameRules.Story.StartPackID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seeds, err := loadSeeds(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design, err := loadDesign(config, seeds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := gamedata.CloseDatabaseCache(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = byte(i + 1)
|
||||
}
|
||||
profileStore, err := identitystore.Open(filepath.Join(root, "identity.db"), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := profileStore.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
registration, err := identity.New(identity.Config{Providers: map[string]string{"discord": "integration-client"}, DeviceTTL: time.Minute, AccessTTL: time.Hour, RefreshTTL: 24 * time.Hour}, profileStore)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts := []string{}
|
||||
for _, subject := range []string{"11111111", "22222222"} {
|
||||
device, err := registration.CreateDevice("discord", "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
authorization, err := registration.Start("discord", device.ID, device.StartTicket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = registration.CompleteDevice(authorization.ID, "discord", identity.ProviderIdentity{Issuer: "https://discord.com", Subject: subject}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := registration.Poll(device.ID, device.Secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := registration.ValidateAccess(result.Tokens.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts = append(accounts, id)
|
||||
}
|
||||
return &PlayerFactory{options: config, seeds: seeds, design: design, profiles: profileStore}, accounts
|
||||
}
|
||||
func appScalar(field int, value uint64) []byte {
|
||||
return binary.AppendUvarint(binary.AppendUvarint(nil, uint64(field<<3)), value)
|
||||
}
|
||||
func appMessage(field int, value []byte) []byte {
|
||||
p := binary.AppendUvarint(nil, uint64(field<<3|2))
|
||||
p = binary.AppendUvarint(p, uint64(len(value)))
|
||||
return append(p, value...)
|
||||
}
|
||||
func appRows(body []byte, wanted int) [][]byte {
|
||||
var rows [][]byte
|
||||
for len(body) > 0 {
|
||||
k, n := binary.Uvarint(body)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
body = body[n:]
|
||||
if k&7 == 0 {
|
||||
_, n = binary.Uvarint(body)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
body = body[n:]
|
||||
continue
|
||||
}
|
||||
if k&7 != 2 {
|
||||
return nil
|
||||
}
|
||||
size, n := binary.Uvarint(body)
|
||||
if n <= 0 || size > uint64(len(body)-n) {
|
||||
return nil
|
||||
}
|
||||
value := body[n : n+int(size)]
|
||||
if int(k>>3) == wanted {
|
||||
rows = append(rows, value)
|
||||
}
|
||||
body = body[n+int(size):]
|
||||
}
|
||||
return rows
|
||||
}
|
||||
func appValue(body []byte, wanted int) uint64 {
|
||||
for len(body) > 0 {
|
||||
k, n := binary.Uvarint(body)
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
body = body[n:]
|
||||
if k&7 == 0 {
|
||||
v, m := binary.Uvarint(body)
|
||||
if m <= 0 {
|
||||
return 0
|
||||
}
|
||||
if int(k>>3) == wanted {
|
||||
return v
|
||||
}
|
||||
body = body[m:]
|
||||
continue
|
||||
}
|
||||
if k&7 != 2 {
|
||||
return 0
|
||||
}
|
||||
size, m := binary.Uvarint(body)
|
||||
if m <= 0 || size > uint64(len(body)-m) {
|
||||
return 0
|
||||
}
|
||||
body = body[m+int(size):]
|
||||
}
|
||||
return 0
|
||||
}
|
||||
func appPacked(body []byte, field int) []uint64 {
|
||||
var values []uint64
|
||||
for _, row := range appRows(body, field) {
|
||||
for len(row) > 0 {
|
||||
v, n := binary.Uvarint(row)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
values = append(values, v)
|
||||
row = row[n:]
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
func appItemTotals(body []byte) map[[2]uint64]uint64 {
|
||||
totals := map[[2]uint64]uint64{}
|
||||
for _, row := range appRows(body, 1) {
|
||||
totals[[2]uint64{appValue(row, 3), appValue(row, 2)}] += appValue(row, 4)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
func TestTwoRealPlayerBundlesKeepEquipmentFormationAndWorldSeparate(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
aID, bID := accounts[0], accounts[1]
|
||||
instances := map[string]*playerInstance{}
|
||||
runtimes := map[string]*player.Runtime{}
|
||||
var equipmentID uint64
|
||||
ids := make([]uint64, 0, len(factory.design.equipmentSlots))
|
||||
for id, slot := range factory.design.equipmentSlots {
|
||||
if slot == 1 {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
if len(ids) == 0 {
|
||||
t.Fatal("real GameData has no weapon slot")
|
||||
}
|
||||
equipmentID = ids[0]
|
||||
equipmentIndex := map[string]uint64{}
|
||||
characterIndex := map[string]uint64{}
|
||||
for _, accountID := range accounts {
|
||||
instance, err := factory.open(accountID)
|
||||
if err != nil {
|
||||
t.Fatal("assemble actual account", accountID, err)
|
||||
}
|
||||
instances[accountID] = instance
|
||||
// Establish one owned weapon as an explicit test grant before the actor is
|
||||
// exposed. Both players may legitimately use equal inventory numbers.
|
||||
tx, err := instance.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := command.Context{Identity: command.Identity{AccountID: accountID, SessionID: "fixture", RequestID: "weapon"}, State: tx}
|
||||
equipment, err := instance.assembly.ownedEquipment.GrantOnce(ctx, "test-owned-weapon", equipmentID)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipmentIndex[accountID] = equipment.InvenIndex
|
||||
for _, c := range instance.assembly.worldService.CharacterService().RawAll() {
|
||||
if !roster.IsStoryCharacter(c) && !roster.IsCharmCharacter(c) && !roster.CharacterExpired(c, time.Now()) {
|
||||
characterIndex[accountID] = c.InvenIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
if characterIndex[accountID] == 0 {
|
||||
t.Fatal("seed has no permanent character")
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime, err := player.New(accountID, instance, player.Limits{CompletedReceipts: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimes[accountID] = runtime
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, runtime := range runtimes {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
if err := runtime.Close(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
makeCommand := func(accountID, identity string, requests []player.Request) player.Command {
|
||||
content := []byte{}
|
||||
for _, r := range requests {
|
||||
content = append(content, []byte(r.Path)...)
|
||||
content = append(content, r.Body...)
|
||||
}
|
||||
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "client-session", RequestID: identity}, Digest: sha256.Sum256(content), Requests: requests}
|
||||
}
|
||||
run := func(c player.Command) (player.Reply, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
future, err := runtimes[c.Identity.AccountID].Submit(ctx, c)
|
||||
if err != nil {
|
||||
return player.Reply{}, err
|
||||
}
|
||||
return future.Wait(ctx)
|
||||
}
|
||||
query := func(accountID, path string, sequence uint64) []byte {
|
||||
t.Helper()
|
||||
reply, err := run(makeCommand(accountID, fmt.Sprint("query-", path, "-", sequence), []player.Request{{Path: path, Body: appScalar(1, sequence)}}))
|
||||
if err != nil {
|
||||
t.Fatal(accountID, path, err)
|
||||
}
|
||||
return reply.Responses[0].Body
|
||||
}
|
||||
|
||||
// New-player ownership is instantiated separately even though versioned seeds
|
||||
// contain the same public design IDs. Mail claims must affect only the claimant.
|
||||
beforeBCostumes := query(bID, "/CostumeInfo", 100)
|
||||
beforeBItems := appItemTotals(query(bID, "/ItemInfo", 101))
|
||||
beforeAItems := appItemTotals(query(aID, "/ItemInfo", 102))
|
||||
beforeACostumes := query(aID, "/CostumeInfo", 103)
|
||||
if len(appRows(beforeACostumes, 1)) == 0 || len(appRows(beforeBCostumes, 1)) == 0 {
|
||||
t.Fatal("new player received no starter costume ownership")
|
||||
}
|
||||
owned := map[uint64]bool{}
|
||||
for _, row := range appRows(beforeACostumes, 1) {
|
||||
owned[appValue(row, 2)] = true
|
||||
}
|
||||
mailbox := query(aID, "/MailInfo", 104)
|
||||
var costumeMail uint64
|
||||
expectedCostumes := map[uint64]uint64{}
|
||||
for _, row := range appRows(mailbox, 1) {
|
||||
types, ids, counts := appPacked(row, 8), appPacked(row, 9), appPacked(row, 10)
|
||||
if len(types) != len(ids) || len(types) != len(counts) {
|
||||
t.Fatal("client mail reward arrays differ in length")
|
||||
}
|
||||
candidate := map[uint64]uint64{}
|
||||
valid := len(types) > 0
|
||||
for i, kind := range types {
|
||||
if kind != 11 || counts[i] != 6 || owned[ids[i]] {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
candidate[ids[i]] = counts[i] - 1
|
||||
}
|
||||
if valid {
|
||||
costumeMail = appValue(row, 1)
|
||||
expectedCostumes = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if costumeMail == 0 {
|
||||
t.Fatal("versioned new-player entitlement has no unowned six-copy costume mail")
|
||||
}
|
||||
claim := func(id, seq uint64) player.Command {
|
||||
return makeCommand(aID, fmt.Sprint("mail-claim-", seq), []player.Request{{Path: "/MailOpen", Body: append(appScalar(1, seq), appScalar(2, id)...)}})
|
||||
}
|
||||
costumeClaim := claim(costumeMail, 105)
|
||||
costumeReply, err := run(costumeClaim)
|
||||
if err != nil {
|
||||
t.Fatal("claim real costume entitlement", err)
|
||||
}
|
||||
bundles := appRows(costumeReply.Responses[0].Body, 1)
|
||||
if len(bundles) != 1 {
|
||||
t.Fatal("mail claim omitted reward bundle")
|
||||
}
|
||||
// During the introductory chapter CostumeInfo intentionally shows the story
|
||||
// roster. MailOpen's RewardDBInfoBundle is the client's ownership update.
|
||||
for id, level := range expectedCostumes {
|
||||
found := false
|
||||
for _, row := range appRows(bundles[0], 3) {
|
||||
if appValue(row, 2) == id {
|
||||
found = true
|
||||
if appValue(row, 3) != level {
|
||||
t.Fatal("six copies did not yield acquisition plus five enhancements", id, appValue(row, 3), level)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("costume reward message omitted listed costume", id)
|
||||
}
|
||||
}
|
||||
collection := instances[aID].assembly.collection.Costumes()
|
||||
for id, level := range expectedCostumes {
|
||||
found := false
|
||||
for _, entry := range collection {
|
||||
if entry.ID == id {
|
||||
found = true
|
||||
if entry.Level != level {
|
||||
t.Fatal("durable costume level differs from gift")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("costume ownership not committed", id)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(beforeBCostumes, query(bID, "/CostumeInfo", 107)) {
|
||||
t.Fatal("A costume entitlement granted to B")
|
||||
}
|
||||
var starterMail uint64
|
||||
expectedItems := map[[2]uint64]uint64{}
|
||||
for _, entry := range factory.seeds.mailbox.Mails {
|
||||
valid := len(entry.RewardTypes) > 0
|
||||
for i, kind := range entry.RewardTypes {
|
||||
if kind != 8 || entry.RewardIDs[i] == 0 {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
if valid && entry.ExpiresAt > uint64(time.Now().UnixMilli()) {
|
||||
starterMail = entry.MailID
|
||||
for i, kind := range entry.RewardTypes {
|
||||
expectedItems[[2]uint64{kind, entry.RewardIDs[i]}] += entry.RewardCounts[i]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if starterMail == 0 {
|
||||
t.Fatal("versioned starter mail requires a current resource gift")
|
||||
}
|
||||
resourceClaim := claim(starterMail, 108)
|
||||
if _, err := run(resourceClaim); err != nil {
|
||||
t.Fatal("claim versioned starter resources", err)
|
||||
}
|
||||
actualItems := appItemTotals(query(aID, "/ItemInfo", 109))
|
||||
for key, gift := range expectedItems {
|
||||
if actualItems[key] != beforeAItems[key]+gift {
|
||||
t.Fatal("mail claim did not deliver listed resource count", key, actualItems[key], gift)
|
||||
}
|
||||
}
|
||||
for key, old := range beforeBItems {
|
||||
if appItemTotals(query(bID, "/ItemInfo", 110))[key] != old {
|
||||
t.Fatal("starter mail changed unrelated player's inventory")
|
||||
}
|
||||
}
|
||||
// Memo is one item; query eviction forces the next claim retry to use SQL.
|
||||
if _, err := run(resourceClaim); err != nil {
|
||||
t.Fatal("retry starter claim", err)
|
||||
}
|
||||
afterRetry := appItemTotals(query(aID, "/ItemInfo", 111))
|
||||
for key, value := range actualItems {
|
||||
if afterRetry[key] != value {
|
||||
t.Fatal("starter claim retry issued duplicate resources")
|
||||
}
|
||||
}
|
||||
// QuestUpdate stores the client's counters without granting QuestClear's
|
||||
// GameData rewards. The selected starting quest is a genuine active quest.
|
||||
selection, selected := instances[aID].assembly.progressState.Selection(factory.options.gameRules.Story.StartPackID)
|
||||
if !selected || selection.QuestID <= 0 {
|
||||
t.Fatal("new-player active quest missing")
|
||||
}
|
||||
beforeGold := instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold
|
||||
questBody := append(appScalar(1, 120), appScalar(2, uint64(selection.QuestID))...)
|
||||
questBody = append(questBody, appScalar(3, uint64(factory.options.gameRules.Story.StartPackID))...)
|
||||
questBody = append(questBody, appScalar(4, 1)...)
|
||||
if _, err := run(makeCommand(aID, "quest-counter", []player.Request{{Path: "/QuestUpdate", Body: questBody}})); err != nil {
|
||||
t.Fatal("actual quest progress", err)
|
||||
}
|
||||
current, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
|
||||
if !found || len(current.Values) != 1 || current.Values[0] != 1 {
|
||||
t.Fatal("quest update did not retain player counter")
|
||||
}
|
||||
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
|
||||
t.Fatal("quest counter leaked to B")
|
||||
}
|
||||
if instances[aID].assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold {
|
||||
t.Fatal("quest update incorrectly granted clear reward")
|
||||
}
|
||||
beforeBDeck := query(bID, "/DeckInfo", 10)
|
||||
makeDeck := func(index, position, sequence uint64) []byte {
|
||||
entry := append(appScalar(1, index), appScalar(2, position)...)
|
||||
entry = append(entry, appScalar(3, 1)...)
|
||||
return append(appScalar(1, sequence), appMessage(2, entry)...)
|
||||
}
|
||||
equip := append(appScalar(1, 11), appScalar(2, equipmentIndex[aID])...)
|
||||
equip = append(equip, appScalar(3, characterIndex[aID])...)
|
||||
position := []byte(`{"MapId":1,"PlayerPosition":{"x":12,"y":3,"z":4},"ColleaguePositions":[]}`)
|
||||
save := append(appScalar(1, 13), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
|
||||
save = append(save, appMessage(3, position)...)
|
||||
changes := makeCommand(aID, "asset-batch", []player.Request{{Path: "/EquipUse", Body: equip}, {Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 5, 12)}, {Path: "/SaveUserPosition", Body: save}})
|
||||
if _, err := run(changes); err != nil {
|
||||
t.Fatal("actual asset batch", err)
|
||||
}
|
||||
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 20)) {
|
||||
t.Fatal("another account's formation changed")
|
||||
}
|
||||
bEquipment := appRows(query(bID, "/EquipInfo", 21), 1)
|
||||
if len(bEquipment) != 1 || appValue(bEquipment[0], 2) != 0 {
|
||||
t.Fatal("A equipped B's equal-index weapon")
|
||||
}
|
||||
aEquipment := appRows(query(aID, "/EquipInfo", 22), 1)
|
||||
if len(aEquipment) != 1 || appValue(aEquipment[0], 2) != characterIndex[aID] {
|
||||
t.Fatal("owner weapon not bound to owner's character")
|
||||
}
|
||||
aDeck := query(aID, "/DeckInfo", 23)
|
||||
entries := appRows(aDeck, 1)
|
||||
if len(entries) != 1 || appValue(entries[0], 1) != characterIndex[aID] || appValue(entries[0], 2) != 5 {
|
||||
t.Fatal("ordinary deck did not preserve selected battle-grid position")
|
||||
}
|
||||
if _, found := instances[bID].assembly.progressState.Position(); found {
|
||||
t.Fatal("world position leaked to unrelated account")
|
||||
}
|
||||
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
|
||||
t.Fatal("owner world position missing")
|
||||
}
|
||||
// Evict the small in-memory memo before replaying an asset operation. The
|
||||
// committed database receipt must preserve the exact response and ownership.
|
||||
replay, err := run(changes)
|
||||
if err != nil || len(replay.Responses) != 3 {
|
||||
t.Fatal("durable asset retry failed", err)
|
||||
}
|
||||
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 24)) {
|
||||
t.Fatal("retry modified formation")
|
||||
}
|
||||
// A later invalid equipment member must roll back an earlier valid deck
|
||||
// replacement in the same client batch and recover only this player's state.
|
||||
invalid := append(appScalar(1, 31), appScalar(2, ^uint64(0))...)
|
||||
invalid = append(invalid, appScalar(3, characterIndex[aID])...)
|
||||
failed := makeCommand(aID, "rejected-batch", []player.Request{{Path: "/DeckSave", Body: makeDeck(characterIndex[aID], 8, 30)}, {Path: "/EquipUse", Body: invalid}})
|
||||
if _, err = run(failed); err == nil {
|
||||
t.Fatal("invalid equipment accepted")
|
||||
}
|
||||
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 32)) {
|
||||
t.Fatal("failed batch published uncommitted formation")
|
||||
}
|
||||
if !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 33)) {
|
||||
t.Fatal("A recovery replaced B state")
|
||||
}
|
||||
// Unload and recreate real bundles from their separate SQLite databases.
|
||||
for _, accountID := range accounts {
|
||||
if err := runtimes[accountID].Close(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
instance, err := factory.open(accountID)
|
||||
if err != nil {
|
||||
t.Fatal("reopen account", err)
|
||||
}
|
||||
instances[accountID] = instance
|
||||
runtime, err := player.New(accountID, instance, player.Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimes[accountID] = runtime
|
||||
}
|
||||
if !bytes.Equal(aDeck, query(aID, "/DeckInfo", 40)) || !bytes.Equal(beforeBDeck, query(bID, "/DeckInfo", 41)) {
|
||||
t.Fatal("reopen combined player formations")
|
||||
}
|
||||
if _, found := instances[bID].assembly.progressState.Position(); found {
|
||||
t.Fatal("reopen mixed world progress")
|
||||
}
|
||||
if saved, found := instances[aID].assembly.progressState.Position(); !found || saved.Position.PlayerPosition.X != 12 {
|
||||
t.Fatal("owner progress lost on reopen")
|
||||
}
|
||||
retainedQuest, found := instances[aID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID)
|
||||
if !found || len(retainedQuest.Values) != 1 || retainedQuest.Values[0] != 1 {
|
||||
t.Fatal("owner task counter lost on reopen")
|
||||
}
|
||||
if other, found := instances[bID].assembly.progressState.QuestInPack(selection.QuestID, factory.options.gameRules.Story.StartPackID); found && len(other.Values) > 0 {
|
||||
t.Fatal("reopen mixed task counters")
|
||||
}
|
||||
for id, level := range expectedCostumes {
|
||||
found := false
|
||||
for _, entry := range instances[aID].assembly.collection.Costumes() {
|
||||
if entry.ID == id {
|
||||
found = true
|
||||
if entry.Level != level {
|
||||
t.Fatal("reopen changed costume enhancement")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("reopen lost claimed costume", id)
|
||||
}
|
||||
}
|
||||
bProfile, err := factory.profiles.GameIdentity(context.Background(), bID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aProfile, err := factory.profiles.GameIdentity(context.Background(), aID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aLogin := makeCommand(aID, "fresh-login-A", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
|
||||
aLogin.LoginSessionKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
bLogin := makeCommand(bID, "fresh-login-B", []player.Request{{Path: "/LoginUser", Body: appScalar(1, 121)}})
|
||||
bLogin.LoginSessionKey = []byte("fedcba9876543210fedcba9876543210")
|
||||
aReply, err := run(aLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bReply, err := run(bLogin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aUser, bUser := appRows(aReply.Responses[0].Body, 1), appRows(bReply.Responses[0].Body, 1)
|
||||
if len(aUser) != 1 || len(bUser) != 1 || appValue(aUser[0], 1) != uint64(aProfile.OwnerIndex) || appValue(bUser[0], 1) != uint64(bProfile.OwnerIndex) || appValue(aUser[0], 1) == appValue(bUser[0], 1) {
|
||||
t.Fatal("actual login responses share player numeric identity")
|
||||
}
|
||||
aUserID, bUserID := appRows(aUser[0], 2), appRows(bUser[0], 2)
|
||||
if len(aUserID) != 1 || len(bUserID) != 1 || string(aUserID[0]) != aProfile.UserID || string(bUserID[0]) != bProfile.UserID || bytes.Equal(aUserID[0], bUserID[0]) {
|
||||
t.Fatal("actual login responses share player string identity")
|
||||
}
|
||||
for _, instance := range instances {
|
||||
tx, err := instance.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err := tx.ListEntries("missions", "command_receipts")
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range records {
|
||||
if bytes.Contains(raw, aLogin.LoginSessionKey) || bytes.Contains(raw, bLogin.LoginSessionKey) {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal("ephemeral login encryption key persisted as command receipt")
|
||||
}
|
||||
}
|
||||
if err = tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/runtime/player"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
playerLoadWorkers = 4
|
||||
playerLoadQueue = 128
|
||||
maxResidentPlayers = 1024
|
||||
)
|
||||
|
||||
var errPlayerLoadCapacity = errors.New("player load capacity exhausted")
|
||||
|
||||
type playerSlot struct {
|
||||
ready chan struct{}
|
||||
closed chan struct{}
|
||||
runtime *player.Runtime
|
||||
err error
|
||||
leases int
|
||||
lastUsed time.Time
|
||||
retiring bool
|
||||
retiredErr error
|
||||
retireOnce sync.Once
|
||||
}
|
||||
|
||||
type playerLoad struct {
|
||||
accountID string
|
||||
slot *playerSlot
|
||||
}
|
||||
|
||||
type playerRegistry struct {
|
||||
factory *PlayerFactory
|
||||
mu sync.Mutex
|
||||
players map[string]*playerSlot
|
||||
closing bool
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
loadDone chan struct{}
|
||||
closeDone chan struct{}
|
||||
loads chan playerLoad
|
||||
closeErr error
|
||||
idle time.Duration
|
||||
}
|
||||
|
||||
func newPlayerRegistry(factory *PlayerFactory, idle time.Duration) *playerRegistry {
|
||||
r := &playerRegistry{factory: factory, players: make(map[string]*playerSlot), stop: make(chan struct{}), done: make(chan struct{}), loadDone: make(chan struct{}), closeDone: make(chan struct{}), loads: make(chan playerLoad, playerLoadQueue), idle: idle}
|
||||
var workers sync.WaitGroup
|
||||
for range playerLoadWorkers {
|
||||
workers.Go(func() {
|
||||
for load := range r.loads {
|
||||
r.load(load.accountID, load.slot)
|
||||
}
|
||||
})
|
||||
}
|
||||
go func() { workers.Wait(); close(r.loadDone) }()
|
||||
go r.sweep()
|
||||
return r
|
||||
}
|
||||
|
||||
func accountDirectoryName(accountID string) string {
|
||||
digest := sha256.Sum256([]byte(accountID))
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (r *playerRegistry) Acquire(ctx context.Context, accountID string) (*player.Runtime, func(), error) {
|
||||
if accountID == "" {
|
||||
return nil, nil, errors.New("player account identity is empty")
|
||||
}
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.closing {
|
||||
r.mu.Unlock()
|
||||
return nil, nil, player.ErrClosed
|
||||
}
|
||||
slot := r.players[accountID]
|
||||
if slot != nil && slot.retiring {
|
||||
if slot.retiredErr != nil {
|
||||
err := slot.retiredErr
|
||||
r.mu.Unlock()
|
||||
return nil, nil, errors.Join(player.ErrUnavailable, err)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-slot.closed:
|
||||
continue
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
if slot == nil {
|
||||
if len(r.players) >= maxResidentPlayers {
|
||||
r.mu.Unlock()
|
||||
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
|
||||
}
|
||||
slot = &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
|
||||
select {
|
||||
case r.loads <- playerLoad{accountID: accountID, slot: slot}:
|
||||
r.players[accountID] = slot
|
||||
default:
|
||||
r.mu.Unlock()
|
||||
return nil, nil, errors.Join(player.ErrMailboxFull, errPlayerLoadCapacity)
|
||||
}
|
||||
}
|
||||
slot.leases++
|
||||
r.mu.Unlock()
|
||||
var once sync.Once
|
||||
release := func() { once.Do(func() { r.mu.Lock(); slot.leases--; slot.lastUsed = time.Now(); r.mu.Unlock() }) }
|
||||
select {
|
||||
case <-slot.ready:
|
||||
r.mu.Lock()
|
||||
err := slot.err
|
||||
if ctx.Err() != nil {
|
||||
err = errors.Join(err, ctx.Err())
|
||||
}
|
||||
if r.closing {
|
||||
err = errors.Join(player.ErrClosed, err)
|
||||
}
|
||||
runtime := slot.runtime
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
release()
|
||||
return nil, nil, err
|
||||
}
|
||||
return runtime, release, nil
|
||||
case <-ctx.Done():
|
||||
release()
|
||||
return nil, nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) load(accountID string, slot *playerSlot) {
|
||||
r.mu.Lock()
|
||||
if r.closing {
|
||||
slot.err = player.ErrClosed
|
||||
close(slot.ready)
|
||||
delete(r.players, accountID)
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.mu.Unlock()
|
||||
runtime, err := r.open(accountID)
|
||||
r.mu.Lock()
|
||||
slot.runtime, slot.err = runtime, err
|
||||
close(slot.ready)
|
||||
if err != nil && r.players[accountID] == slot {
|
||||
delete(r.players, accountID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("player load failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) open(accountID string) (runtime *player.Runtime, err error) {
|
||||
defer func() {
|
||||
if value := recover(); value != nil {
|
||||
err = fmt.Errorf("load player panic: %v", value)
|
||||
}
|
||||
}()
|
||||
owner, err := r.factory.open(accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtime, err = player.New(accountID, owner, player.Limits{})
|
||||
if err != nil {
|
||||
err = errors.Join(err, owner.Close())
|
||||
}
|
||||
return runtime, err
|
||||
}
|
||||
|
||||
func (r *playerRegistry) sweep() {
|
||||
defer close(r.done)
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case now := <-ticker.C:
|
||||
r.expire(now)
|
||||
case <-r.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) expire(now time.Time) {
|
||||
r.mu.Lock()
|
||||
var expired []playerLoad
|
||||
if !r.closing {
|
||||
for accountID, slot := range r.players {
|
||||
select {
|
||||
case <-slot.ready:
|
||||
if !slot.retiring && slot.leases == 0 && now.Sub(slot.lastUsed) >= r.idle && slot.runtime != nil {
|
||||
slot.retiring = true
|
||||
expired = append(expired, playerLoad{accountID: accountID, slot: slot})
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, load := range expired {
|
||||
r.retire(load.accountID, load.slot)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) retire(accountID string, slot *playerSlot) {
|
||||
slot.retireOnce.Do(func() {
|
||||
err := slot.runtime.Close(context.Background())
|
||||
r.mu.Lock()
|
||||
slot.retiredErr = err
|
||||
if err == nil && r.players[accountID] == slot {
|
||||
delete(r.players, accountID)
|
||||
}
|
||||
r.closeErr = errors.Join(r.closeErr, err)
|
||||
close(slot.closed)
|
||||
r.mu.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("player close failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (r *playerRegistry) shutdown() {
|
||||
defer close(r.closeDone)
|
||||
<-r.loadDone
|
||||
<-r.done
|
||||
r.mu.Lock()
|
||||
var loaded []playerLoad
|
||||
for accountID, slot := range r.players {
|
||||
if slot.runtime != nil {
|
||||
slot.retiring = true
|
||||
loaded = append(loaded, playerLoad{accountID: accountID, slot: slot})
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, load := range loaded {
|
||||
r.retire(load.accountID, load.slot)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *playerRegistry) Close(ctx context.Context) error {
|
||||
r.mu.Lock()
|
||||
if !r.closing {
|
||||
r.closing = true
|
||||
close(r.stop)
|
||||
close(r.loads)
|
||||
go r.shutdown()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
select {
|
||||
case <-r.closeDone:
|
||||
return r.closeErr
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("player registry shutdown: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
accountstate "bd2server/internal/server/storage/account"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type gatedPlayerExecutor struct {
|
||||
owner *playerInstance
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
type gatedIdentityProfile struct {
|
||||
source interface {
|
||||
GameIdentity(context.Context, string) (identitystore.GameProfile, error)
|
||||
}
|
||||
accountID string
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (g *gatedIdentityProfile) GameIdentity(ctx context.Context, accountID string) (identitystore.GameProfile, error) {
|
||||
if accountID == g.accountID {
|
||||
g.once.Do(func() { close(g.entered); <-g.release })
|
||||
}
|
||||
return g.source.GameIdentity(ctx, accountID)
|
||||
}
|
||||
|
||||
func (g *gatedPlayerExecutor) Execute(ctx context.Context, c player.Command) (player.Reply, error) {
|
||||
g.once.Do(func() { close(g.entered); <-g.release })
|
||||
return g.owner.Execute(ctx, c)
|
||||
}
|
||||
func (g *gatedPlayerExecutor) Recover(ctx context.Context) error { return g.owner.Recover(ctx) }
|
||||
func (g *gatedPlayerExecutor) Close() error { err := g.owner.Close(); close(g.closed); return err }
|
||||
|
||||
func registryCommand(accountID, identity, path string, body []byte) player.Command {
|
||||
return player.Command{Identity: command.Identity{AccountID: accountID, SessionID: "registry-client", RequestID: identity}, Digest: sha256.Sum256(append([]byte(path), body...)), Requests: []player.Request{{Path: path, Body: body}}}
|
||||
}
|
||||
|
||||
func registryExecute(t *testing.T, runtime *player.Runtime, c player.Command) player.Reply {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
future, err := runtime.Submit(ctx, c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply, err := future.Wait(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func TestRegistryKeepsOnePlayerOwnerUntilAcceptedProgressDrains(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := &gatedPlayerExecutor{owner: owner, entered: make(chan struct{}), release: make(chan struct{}), closed: make(chan struct{})}
|
||||
old, err := player.New(accounts[0], gate, player.Limits{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := newPlayerRegistry(factory, time.Second)
|
||||
t.Cleanup(func() {
|
||||
if err := registry.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
select {
|
||||
case <-gate.release:
|
||||
default:
|
||||
close(gate.release)
|
||||
}
|
||||
}()
|
||||
ready := make(chan struct{})
|
||||
close(ready)
|
||||
registry.mu.Lock()
|
||||
registry.players[accounts[0]] = &playerSlot{ready: ready, closed: make(chan struct{}), runtime: old, lastUsed: time.Now()}
|
||||
registry.mu.Unlock()
|
||||
leased, release, err := registry.Acquire(context.Background(), accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry.expire(time.Now().Add(time.Hour))
|
||||
registry.mu.Lock()
|
||||
held := registry.players[accounts[0]]
|
||||
registry.mu.Unlock()
|
||||
if held == nil || held.retiring {
|
||||
t.Fatal("idle unload closed a leased player")
|
||||
}
|
||||
position := []byte(`{"MapId":1,"PlayerPosition":{"x":61,"y":2,"z":3},"ColleaguePositions":[]}`)
|
||||
body := append(appScalar(1, 7), appScalar(2, uint64(factory.options.gameRules.Story.StartPackID))...)
|
||||
body = append(body, appMessage(3, position)...)
|
||||
ctx, cancelWait := context.WithCancel(context.Background())
|
||||
future, err := leased.Submit(ctx, registryCommand(accounts[0], "save-position-before-idle", "/SaveUserPosition", body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("accepted progress did not enter owner")
|
||||
}
|
||||
cancelWait()
|
||||
release()
|
||||
release()
|
||||
expired := make(chan struct{})
|
||||
go func() { registry.expire(time.Now().Add(time.Hour)); close(expired) }()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
registry.mu.Lock()
|
||||
retiring := registry.players[accounts[0]].retiring
|
||||
registry.mu.Unlock()
|
||||
if retiring {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("idle player never entered retirement")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
// The same account must wait for the old writer's accepted save to settle.
|
||||
short, stop := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
if _, _, err := registry.Acquire(short, accounts[0]); !errors.Is(err, context.DeadlineExceeded) {
|
||||
stop()
|
||||
t.Fatalf("second writer opened during drain: %v", err)
|
||||
}
|
||||
stop()
|
||||
// Another account can load and process independently during A's retirement.
|
||||
type acquiredPlayer struct {
|
||||
runtime *player.Runtime
|
||||
release func()
|
||||
err error
|
||||
}
|
||||
loaded := make(chan acquiredPlayer, 8)
|
||||
var callers sync.WaitGroup
|
||||
for range cap(loaded) {
|
||||
callers.Go(func() {
|
||||
owner, release, err := registry.Acquire(context.Background(), accounts[1])
|
||||
loaded <- acquiredPlayer{owner, release, err}
|
||||
})
|
||||
}
|
||||
callers.Wait()
|
||||
close(loaded)
|
||||
var other *player.Runtime
|
||||
var releaseOther []func()
|
||||
defer func() {
|
||||
for _, release := range releaseOther {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
for acquired := range loaded {
|
||||
if acquired.err != nil {
|
||||
t.Fatal(acquired.err)
|
||||
}
|
||||
if other != nil && other != acquired.runtime {
|
||||
t.Fatal("simultaneous same-account loads created different owners")
|
||||
}
|
||||
other = acquired.runtime
|
||||
releaseOther = append(releaseOther, acquired.release)
|
||||
}
|
||||
registryExecute(t, other, registryCommand(accounts[1], "independent-equipment", "/EquipInfo", appScalar(1, 8)))
|
||||
close(gate.release)
|
||||
settlement, stop := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer stop()
|
||||
if _, err := future.Wait(settlement); err != nil {
|
||||
t.Fatal("cancelled caller lost accepted progress", err)
|
||||
}
|
||||
select {
|
||||
case <-expired:
|
||||
case <-settlement.Done():
|
||||
t.Fatal(settlement.Err())
|
||||
}
|
||||
select {
|
||||
case <-gate.closed:
|
||||
default:
|
||||
t.Fatal("retirement completed before writer close")
|
||||
}
|
||||
_, releaseReloaded, err := registry.Acquire(settlement, accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseReloaded()
|
||||
if err := registry.Close(settlement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := reopened.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
if saved, exists := reopened.assembly.progressState.Position(); !exists || saved.Position.PlayerPosition.X != 61 {
|
||||
t.Fatal("idle drain discarded accepted world progress")
|
||||
}
|
||||
otherReopened, err := factory.open(accounts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := otherReopened.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
if _, exists := otherReopened.assembly.progressState.Position(); exists {
|
||||
t.Fatal("world progress crossed account retirement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryShutdownClosesLoadingAndLeasedAccountWriters(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
gate := &gatedIdentityProfile{source: factory.profiles, accountID: accounts[0], entered: make(chan struct{}), release: make(chan struct{})}
|
||||
factory.profiles = gate
|
||||
registry := newPlayerRegistry(factory, time.Minute)
|
||||
t.Cleanup(func() {
|
||||
if err := registry.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
defer func() {
|
||||
select {
|
||||
case <-gate.release:
|
||||
default:
|
||||
close(gate.release)
|
||||
}
|
||||
}()
|
||||
registry.mu.Lock()
|
||||
slots := make([]*playerSlot, len(accounts))
|
||||
for i, accountID := range accounts {
|
||||
slot := &playerSlot{ready: make(chan struct{}), closed: make(chan struct{}), lastUsed: time.Now()}
|
||||
registry.players[accountID] = slot
|
||||
registry.loads <- playerLoad{accountID: accountID, slot: slot}
|
||||
slots[i] = slot
|
||||
}
|
||||
registry.mu.Unlock()
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("account did not begin real player loading")
|
||||
}
|
||||
// Another complete account remains leased when shutdown begins.
|
||||
ctx, stop := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer stop()
|
||||
leased, releaseLease, err := registry.Acquire(ctx, accounts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryExecute(t, leased, registryCommand(accounts[1], "query-before-close", "/EquipInfo", appScalar(1, 1)))
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if err := registry.Close(cancelled); err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(gate.release)
|
||||
if err := registry.Close(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releaseLease()
|
||||
for _, slot := range slots {
|
||||
select {
|
||||
case <-slot.ready:
|
||||
default:
|
||||
t.Fatal("shutdown forgot a loading account waiter")
|
||||
}
|
||||
}
|
||||
for _, accountID := range accounts {
|
||||
if _, _, err := registry.Acquire(ctx, accountID); !errors.Is(err, player.ErrClosed) {
|
||||
t.Fatalf("registry accepted after shutdown: %v", err)
|
||||
}
|
||||
path := filepath.Join(factory.options.stateDirectory, "accounts", accountDirectoryName(accountID), "state.db")
|
||||
repository, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal("shutdown left an account writer locked", err)
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/commerce/gacha"
|
||||
"bd2server/internal/server/domain/events"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
"bd2server/internal/server/protocol/defaults"
|
||||
scheduleadapter "bd2server/internal/server/protocol/schedule"
|
||||
"bd2server/internal/server/protocol/staticdata"
|
||||
"bd2server/internal/server/storage/account"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) session(ctx command.Context) error {
|
||||
p.handlers = []session.Handler{
|
||||
p.progressState,
|
||||
p.cashService,
|
||||
p.cashBonuses,
|
||||
p.clearPackages,
|
||||
commerce.PackInfoHandler{World: p.worldService, Claims: p.clearPackages},
|
||||
commerce.AttendanceHandler{Events: p.eventTasksService, Economy: p.cashEconomy, LoginPasses: p.loginPasses, Store: p.gameplayStore},
|
||||
p.eventRegistry,
|
||||
p.eventGamesService,
|
||||
p.eventExchangeService,
|
||||
p.boxService,
|
||||
p.eventPlayService,
|
||||
p.eventActionsService,
|
||||
p.npcShopService,
|
||||
p.innService,
|
||||
events.SkinHandler{Economy: p.eventEconomy},
|
||||
p.battleService,
|
||||
p.huntingService,
|
||||
p.monsterHuntService,
|
||||
p.worldService,
|
||||
p.worldService.CharacterService(),
|
||||
p.progressState,
|
||||
p.deckStateStore,
|
||||
p.ownedItems,
|
||||
p.ownedEquipment,
|
||||
p.inventorySlots,
|
||||
p.charAwakeService,
|
||||
p.costumePotentialService,
|
||||
p.costumeBurstService,
|
||||
p.friendshipService,
|
||||
p.contentOpenService,
|
||||
p.masterTitleService,
|
||||
p.recruitService,
|
||||
p.foodService,
|
||||
p.talentUseService,
|
||||
p.dispatchService,
|
||||
p.itemCraftService,
|
||||
p.recipeService,
|
||||
p.starter,
|
||||
p.mailService,
|
||||
p.gachaService,
|
||||
p.achievementCounters,
|
||||
p.missionService,
|
||||
p.eventTasksService,
|
||||
p.pictorialService,
|
||||
&scheduleadapter.Service{Schedule: p.options.calendars.RegularService},
|
||||
readonly.Service{Seed: p.seeds.defaults},
|
||||
feature.Service{},
|
||||
}
|
||||
p.observers = []session.ResponseObserver{p.achievementObserver, p.eventTasksService, p.mailService}
|
||||
featured := gacha.ActivePickupCostumes(p.design.regularGacha, p.options.calendars.GachaSeed, uint64(time.Now().UTC().UnixMilli()))
|
||||
limitedIDs := p.design.limitedCostumes.Excluding(featured)
|
||||
if len(limitedIDs) != 0 {
|
||||
if err := p.mailService.EnsureStarterLimitedCostumes(ctx, limitedIDs, time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("ensure account limited-costume entitlement: %w", err)
|
||||
}
|
||||
}
|
||||
prestigeIDs := p.design.prestigeCatalog.Giftable(func(key gamedata.CashProductKey) bool { return p.cashService.IsAvailable(ctx, key) })
|
||||
if len(prestigeIDs) != 0 {
|
||||
if err := p.mailService.EnsureStarterPrestigeSkins(ctx, prestigeIDs, time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("ensure account prestige-skin entitlement: %w", err)
|
||||
}
|
||||
}
|
||||
if p.initializeAccount {
|
||||
if err := ensureAccountStateInitialized(ctx,
|
||||
p.progressState, p.deckStateStore, p.ownedItems, p.ownedEquipment,
|
||||
p.worldService.CharacterService(), p.collection, p.wallet, p.inventorySlots, p.mailService, p.missionService,
|
||||
); err != nil {
|
||||
return fmt.Errorf("initialize complete account state generation: %w", err)
|
||||
}
|
||||
if err := p.worldService.EnsureInitialPackPurchase(ctx); err != nil {
|
||||
return fmt.Errorf("grant initial pack purchase rewards: %w", err)
|
||||
}
|
||||
if err := ctx.State.(*accountstate.CommandStore).MarkInitializationComplete(); err != nil {
|
||||
return fmt.Errorf("mark account initialization complete: %w", err)
|
||||
}
|
||||
}
|
||||
if err := p.masterTitleService.EnsurePersisted(ctx); err != nil {
|
||||
return fmt.Errorf("persist master title: %w", err)
|
||||
}
|
||||
problems, err := ctx.State.(*accountstate.CommandStore).Validate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("validate account state database: %w", err)
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
return stateProblemsError("account state database rejected", problems)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/battle"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Independently verified in selected 2.35.10 GameData using gamedata_db.py:
|
||||
// pack22 FieldMonsterTable id8 has phase group1; PhaseBattleTable group1 rows
|
||||
// id1/id2 use decks8/9. BattleEnter/Start/PhaseChange request fields are from
|
||||
// the original generated Proto.Net classes, not the server's response writer.
|
||||
func TestFailedBatchDiscardsRealBattlePhaseBeforeBattleCanRestart(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Select the verified pack as the battle's immutable scenario resolver.
|
||||
// The production battle service loads phases from real GameData; only this
|
||||
// scenario selection avoids progressing a full story campaign in this test.
|
||||
owner.assembly.battleService = battle.NewService(factory.options.gameData, factory.options.gameDataVersion, func(command.Context) (int, error) { return 22, nil })
|
||||
owner.assembly.handlers = []session.Handler{owner.assembly.battleService}
|
||||
runtime, err := player.New(accounts[0], owner, player.Limits{})
|
||||
if err != nil {
|
||||
_ = owner.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := runtime.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
run := func(c player.Command) (player.Reply, error) {
|
||||
future, err := runtime.Submit(ctx, c)
|
||||
if err != nil {
|
||||
return player.Reply{}, err
|
||||
}
|
||||
return future.Wait(ctx)
|
||||
}
|
||||
enter := append(appScalar(1, 1), appScalar(3, 8)...)
|
||||
enter = append(enter, appScalar(4, 8)...)
|
||||
enter = append(enter, appScalar(5, 1)...)
|
||||
if _, err := run(registryCommand(accounts[0], "enter-phase-battle", "/BattleEnter", enter)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := append(appScalar(1, 2), appScalar(2, 8)...)
|
||||
if _, err := run(registryCommand(accounts[0], "start-phase-one", "/BattleStart", start)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch := registryCommand(accounts[0], "phase-then-invalid-member", "/BattlePhaseChange", appScalar(1, 3))
|
||||
batch.Requests = append(batch.Requests, player.Request{Path: "/BattleStart", Body: append(appScalar(1, 4), appScalar(2, 999999)...)})
|
||||
batch.Digest[0] ^= 1
|
||||
if _, err := run(batch); err == nil {
|
||||
t.Fatal("invalid next phase start committed its preceding phase transition")
|
||||
}
|
||||
identity := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "registry-client", RequestID: "inspect-recovered-battle"}}
|
||||
if owner.assembly.battleService.Active(identity) {
|
||||
t.Fatal("recovered account retained uncommitted in-battle ownership")
|
||||
}
|
||||
if _, err := run(registryCommand(accounts[0], "invalid-before-enter", "/BattleStart", append(appScalar(1, 5), appScalar(2, 9)...))); err == nil {
|
||||
t.Fatal("post-recovery battle start bypassed required enter")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type accountStateInitializer interface {
|
||||
EnsurePersisted(command.Context) error
|
||||
}
|
||||
|
||||
func ensureAccountStateInitialized(ctx command.Context, stores ...accountStateInitializer) error {
|
||||
for _, store := range stores {
|
||||
if store == nil {
|
||||
return errors.New("nil account state initializer")
|
||||
}
|
||||
if err := store.EnsurePersisted(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce/gacha"
|
||||
"bd2server/internal/server/domain/progression/missions"
|
||||
"bd2server/internal/server/domain/progression/pictorial"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/world"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (p *playerAssembly) worldRoster(ctx command.Context) error {
|
||||
var err error
|
||||
p.worldService, err = world.New(ctx, p.worldSeed, p.design.world, p.design.source,
|
||||
p.scope, p.progressState, p.starter, p.ownedEquipment, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load world state: %w", err)
|
||||
}
|
||||
if err := p.worldService.ConfigureStartPack(ctx, p.startingPackID, p.initializeAccount); err != nil {
|
||||
return fmt.Errorf("configure account starting chapter: %w", err)
|
||||
}
|
||||
|
||||
if err := p.login.AttachLastPlayedPack(p.worldService); err != nil {
|
||||
return fmt.Errorf("attach persisted login destination: %w", err)
|
||||
}
|
||||
// Restore all earned seed ownership before validating persisted upgrades.
|
||||
// A quest costume is not a p.collection entry; attaching it after opening
|
||||
// p.collection would reject its otherwise valid burst ledger on restart.
|
||||
baseCostumes := append([]roster.Costume(nil), p.starter.Costumes...)
|
||||
if reward, earned := p.worldService.EarnedQuestCostume(); earned {
|
||||
baseCostumes = append(baseCostumes, reward)
|
||||
}
|
||||
p.collection, err = roster.OpenCollectionStore(ctx, p.scope, baseCostumes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned collection: %w", err)
|
||||
}
|
||||
p.gachaService, err = gacha.NewService(p.design.infiniteGacha, p.design.regularGacha, p.collection, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.login.AttachPurchaseCounts(p.gachaService); err != nil {
|
||||
return fmt.Errorf("attach cash purchase counts to login: %w", err)
|
||||
}
|
||||
if err := p.gachaService.AttachSchedule(ctx, p.options.calendars.GachaSeed); err != nil {
|
||||
return fmt.Errorf("attach gacha schedule: %w", err)
|
||||
}
|
||||
previewEventIndex, err := p.seeds.defaults.CashProductEventIndex(p.design.infiniteGacha.ProductGroupID, p.design.infiniteGacha.ProductID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load infinite preview event: %w", err)
|
||||
}
|
||||
if err := p.gachaService.AttachPreviewEventIndex(ctx, previewEventIndex); err != nil {
|
||||
return fmt.Errorf("attach infinite preview event: %w", err)
|
||||
}
|
||||
// The mapped client property is IsDoneFirstGachaPick. Its authoritative
|
||||
// local state is the explicit GachaSubType=3 completion marker.
|
||||
if err := p.login.AttachFirstGacha(p.gachaService); err != nil {
|
||||
return fmt.Errorf("attach first gacha status to login: %w", err)
|
||||
}
|
||||
if err := p.gachaService.AttachFirstGacha(ctx, p.design.firstGacha); err != nil {
|
||||
return fmt.Errorf("attach first gacha GameData: %w", err)
|
||||
}
|
||||
p.gachaService.AttachInventory(p.ownedItems)
|
||||
p.gachaService.AttachEquipmentGacha(p.design.equipmentGacha, p.ownedEquipment)
|
||||
p.gachaService.AttachDrawMission(func(ctx command.Context, count uint64) error {
|
||||
return p.missionService.RecordEvent(ctx, missions.ConditionGachaBuy, 0, count, p.worldService.MissionsUnlocked)
|
||||
})
|
||||
var permanentBaseCharacters []roster.Character
|
||||
for _, c := range p.worldService.CharacterService().RawAll() {
|
||||
if !roster.IsCharmCharacter(c) {
|
||||
permanentBaseCharacters = append(permanentBaseCharacters, c)
|
||||
}
|
||||
}
|
||||
if err := p.collection.BindBaseCharacters(ctx, permanentBaseCharacters); err != nil {
|
||||
return fmt.Errorf("bind base collection characters: %w", err)
|
||||
}
|
||||
if err := p.mailService.AttachCostumeRewards(ctx, p.collection, p.design.limitedCostumes); err != nil {
|
||||
return fmt.Errorf("attach limited costume mail rewards: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachCollection(ctx, p.collection); err != nil {
|
||||
return fmt.Errorf("attach gacha collection state: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachDecks(ctx, p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach world deck state: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachWaypointRuntime(ctx, p.design.source); err != nil {
|
||||
return fmt.Errorf("attach waypoint runtime: %w", err)
|
||||
}
|
||||
if err := p.worldService.AttachFieldObjectRuntime(p.design.source, p.design.fieldReset); err != nil {
|
||||
return fmt.Errorf("attach field object runtime: %w", err)
|
||||
}
|
||||
if err := p.ownedEquipment.AttachCharacters(ctx, p.worldService.CharacterService()); err != nil {
|
||||
return fmt.Errorf("attach equipment character state: %w", err)
|
||||
}
|
||||
if err := p.deckStateStore.AttachPresetRuntime(ctx, p.wallet, p.worldService.CharacterService(), p.ownedEquipment, p.collection); err != nil {
|
||||
return fmt.Errorf("attach ordinary preset runtime: %w", err)
|
||||
}
|
||||
if err := p.deckStateStore.AttachFieldSettingsPack(p.worldService.CurrentPackID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.deckStateStore.AttachFieldSettings(ctx, p.design.fieldSettingsDesign); err != nil {
|
||||
return fmt.Errorf("attach field character settings: %w", err)
|
||||
}
|
||||
if err := p.login.AttachAutoReviveSettings(p.deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach automatic revival settings: %w", err)
|
||||
}
|
||||
p.pictorialService = &pictorial.Service{Design: p.design.pictorialDesign, Owned: p.worldService}
|
||||
if err := p.ownedEquipment.AttachStatDesign(ctx, p.design.equipmentStatDesign); err != nil {
|
||||
return err
|
||||
}
|
||||
p.pictorialService.EquipmentContributions = func(ctx command.Context, character roster.Character) ([]gamedata.StatContribution, error) {
|
||||
return p.ownedEquipment.StatContributions(ctx, character.InvenIndex)
|
||||
}
|
||||
p.charAwakeService, err = roster.NewCharAwakeService(p.design.charAwakeDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.pictorialService.AwakeContributions = p.charAwakeService.Contributions
|
||||
if err := p.worldService.CharacterService().AttachMaxHealth(ctx, p.pictorialService.MaxHealth); err != nil {
|
||||
return fmt.Errorf("attach pictorial character stats: %w", err)
|
||||
}
|
||||
if err := p.worldService.CharacterService().AttachWallet(ctx, p.wallet); err != nil {
|
||||
return fmt.Errorf("attach character promotion wallet: %w", err)
|
||||
}
|
||||
if err := p.worldService.CharacterService().AttachTalentGrowth(ctx, p.design.talentGrowth); err != nil {
|
||||
return fmt.Errorf("attach character talent growth: %w", err)
|
||||
}
|
||||
if err := p.worldService.CharacterService().AttachImmortalDesign(ctx, p.design.immortalDesign); err != nil {
|
||||
return fmt.Errorf("attach immortal talent GameData: %w", err)
|
||||
}
|
||||
p.costumePotentialService, err = roster.NewCostumePotentialService(p.design.costumePotentialDesign, p.collection, p.worldService.CharacterService(), p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.pictorialService.PotentialContributions = p.costumePotentialService.Contributions
|
||||
p.costumeBurstService, err = roster.NewCostumeBurstService(p.design.costumeBurstDesign, p.collection, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.friendshipService, err = roster.NewFriendshipService(&p.design.friendshipDesign, p.design.charAwakeDesign, p.design.costumePotentialDesign, p.collection, p.ownedItems, p.wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load friendship state: %w", err)
|
||||
}
|
||||
if err := p.login.AttachFriendshipAP(p.friendshipService); err != nil {
|
||||
return err
|
||||
}
|
||||
accountName, found, err := wire.Bytes(p.login.UserInfo, 2)
|
||||
if err != nil || !found || len(accountName) == 0 {
|
||||
return errors.New("account seed requires its existing display name for master title")
|
||||
}
|
||||
p.masterTitleService, err = roster.OpenMasterTitleService(ctx, p.scope, string(accountName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load master title: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Preflight validates the same shared rules as Serve without opening player or authentication databases.
|
||||
func Preflight(args []string) (preflightErr error) {
|
||||
defer func() { preflightErr = errors.Join(preflightErr, gamedata.CloseDatabaseCache()) }()
|
||||
config, err := loadConfiguration(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(config.authRuntime.MasterKey)
|
||||
seeds, err := loadSeeds(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = loadDesign(config, seeds)
|
||||
return err
|
||||
}
|
||||
|
||||
func errorsVersionMismatch(want, login, player string) error {
|
||||
return fmt.Errorf("game version %s requires matching account and player seeds (got %s and %s)", want, login, player)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//go:build integration
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/world/progress"
|
||||
"bd2server/internal/server/runtime/player"
|
||||
"bytes"
|
||||
"context"
|
||||
"maps"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verified with gamedata_db.py in 20260923193640: pack1 monster2001 belongs
|
||||
// to side quest103 and deck301 grants gold350 (type4, id0); monster1 belongs
|
||||
// to main quest9 and decks1/200001 grant resource7 x2. These expected values
|
||||
// are independent of the resolver and reward loader under test.
|
||||
func TestQuestBattleUsesItsOwnQuestDifficultyAndRewardOwner(t *testing.T) {
|
||||
factory, accounts := newIntegrationFactory(t)
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
monster, deck, selected uint64
|
||||
difficulty, activeQuest int
|
||||
gold uint64
|
||||
}{
|
||||
{"normal-main", 1, 1, 1, 0, 9, 0},
|
||||
{"very-hard-main", 1, 1, 200001, 2, 9, 0},
|
||||
{"side-with-very-hard-main-selected", 2001, 301, 301, 2, 103, 350},
|
||||
} {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
owner, err := factory.open(accounts[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := owner.repository.BeginCommand(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := command.Context{Identity: command.Identity{AccountID: accounts[0], SessionID: "fixture"}, State: tx}
|
||||
state := owner.assembly.progressState
|
||||
if err := state.SetActivePackID(ctx, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := state.SelectQuest(ctx, 1, progress.QuestSelection{Difficulty: scenario.difficulty}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scenario.activeQuest == 103 {
|
||||
for _, id := range []int{101, 102} {
|
||||
if err := state.ClearQuest(ctx, id, 1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := state.AcceptQuest(ctx, 103, 1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
for id := 1; id < scenario.activeQuest; id++ {
|
||||
if err := state.ClearQuest(ctx, id, 1, scenario.difficulty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := owner.Recover(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime, err := player.New(accounts[0], owner, player.Limits{CompletedReceipts: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := runtime.Close(context.Background()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
run := func(identity, path string, body []byte) []byte {
|
||||
t.Helper()
|
||||
reply := registryExecute(t, runtime, registryCommand(accounts[0], scenario.name+identity, path, body))
|
||||
return reply.Responses[0].Body
|
||||
}
|
||||
beforeGold := owner.assembly.wallet.Snapshot(command.Context{}).Gold
|
||||
beforeItems := appItemTotals(run("items-before", "/ItemInfo", appScalar(1, 1)))
|
||||
invalid := append(appScalar(1, 99), appScalar(3, scenario.monster)...)
|
||||
invalid = append(invalid, appScalar(4, 303)...)
|
||||
invalid = append(invalid, appScalar(5, 1)...)
|
||||
future, err := runtime.Submit(context.Background(), registryCommand(accounts[0], scenario.name+"wrong-monster-deck", "/BattleEnter", invalid))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := future.Wait(context.Background()); err == nil {
|
||||
t.Fatal("quest battle accepted another monster's authored deck")
|
||||
}
|
||||
if owner.assembly.battleService.Active(command.Context{Identity: command.Identity{SessionID: "registry-client"}}) {
|
||||
t.Fatal("rejected deck installed an active battle")
|
||||
}
|
||||
if owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold || !maps.Equal(appItemTotals(run("items-rejected", "/ItemInfo", appScalar(1, 100))), beforeItems) {
|
||||
t.Fatal("rejected deck changed owned rewards")
|
||||
}
|
||||
enter := append(appScalar(1, 2), appScalar(3, scenario.monster)...)
|
||||
enter = append(enter, appScalar(4, scenario.deck)...)
|
||||
enter = append(enter, appScalar(5, 1)...)
|
||||
response := run("enter", "/BattleEnter", enter)
|
||||
if appValue(response, 2) != scenario.selected {
|
||||
t.Fatalf("client receives wrong enemy deck: %d, want %d", appValue(response, 2), scenario.selected)
|
||||
}
|
||||
run("start", "/BattleStart", append(appScalar(1, 3), appScalar(2, scenario.selected)...))
|
||||
end := append(appScalar(1, 4), appScalar(2, 1)...)
|
||||
response = run("end", "/BattleEnd", end)
|
||||
bundles := appRows(response, 5)
|
||||
if len(bundles) != 1 {
|
||||
t.Fatal("victory omitted client reward bundle")
|
||||
}
|
||||
rewards := appItemTotals(bundles[0])
|
||||
key, count := [2]uint64{8, 7}, uint64(2)
|
||||
if scenario.gold > 0 {
|
||||
key, count = [2]uint64{4, 0}, scenario.gold
|
||||
}
|
||||
if len(rewards) != 1 || rewards[key] != count {
|
||||
t.Fatalf("wrong authored victory reward: %v, want %v x%d", rewards, key, count)
|
||||
}
|
||||
if gold := owner.assembly.wallet.Snapshot(command.Context{}).Gold; gold-beforeGold != scenario.gold {
|
||||
t.Fatalf("gold did not settle in wallet: before=%d after=%d want delta=%d", beforeGold, gold, scenario.gold)
|
||||
}
|
||||
afterItems := appItemTotals(run("items-after", "/ItemInfo", appScalar(1, 5)))
|
||||
if scenario.gold > 0 {
|
||||
if afterItems[[2]uint64{4, 0}] != 0 {
|
||||
t.Fatal("wallet currency leaked into owned inventory")
|
||||
}
|
||||
} else if afterItems[key]-beforeItems[key] != 2 {
|
||||
t.Fatal("main battle resource did not settle in owned inventory")
|
||||
}
|
||||
// ItemInfo evicts the actor's single completed-command receipt. Retrying
|
||||
// BattleEnd must still be idempotent through the battle settlement.
|
||||
retry := run("end-retry", "/BattleEnd", end)
|
||||
if !bytes.Equal(response, retry) || owner.assembly.wallet.Snapshot(command.Context{}).Gold != beforeGold+scenario.gold {
|
||||
t.Fatal("settlement retry changed its response or credited gold twice")
|
||||
}
|
||||
if totals := appItemTotals(run("items-retry", "/ItemInfo", appScalar(1, 6))); !maps.Equal(totals, afterItems) {
|
||||
t.Fatal("settlement retry credited owned items twice")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/platform/versionconfig"
|
||||
"bd2server/internal/server/resources/fetch"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func Resources(args []string) error {
|
||||
if len(args) == 0 || args[0] != "fetch" {
|
||||
return errors.New("resources requires the fetch subcommand")
|
||||
}
|
||||
fs := flag.NewFlagSet("resources fetch", flag.ContinueOnError)
|
||||
versionConfigPath := fs.String("version-config", "", "repository versions.json override")
|
||||
output := fs.String("output", "", "resource mirror output directory (required)")
|
||||
platform := fs.String("platform", "StandaloneWindows64", "official ServerData platform")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if *output == "" {
|
||||
return errors.New("resources fetch requires --output")
|
||||
}
|
||||
var versions versionconfig.Config
|
||||
var err error
|
||||
if *versionConfigPath == "" {
|
||||
versions, err = versionconfig.Find()
|
||||
} else {
|
||||
versions, err = versionconfig.Load(*versionConfigPath)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
manifest, err := resourcefetch.Fetch(context.Background(), resourcefetch.Options{
|
||||
OutputRoot: *output, Platform: *platform, BundleVersion: versions.BundleVersion,
|
||||
GameDataVersion: versions.GameDataVersion,
|
||||
Progress: func(message string) { slog.Info(message) },
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("official resource mirror complete", "output", *output, "bundles", manifest.ServerData.Bundles, "bytes", manifest.ServerData.Bytes)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/domain/mail"
|
||||
"bd2server/internal/server/domain/roster"
|
||||
"bd2server/internal/server/domain/roster/deck"
|
||||
"bd2server/internal/server/domain/world"
|
||||
calendaradapter "bd2server/internal/server/protocol/calendar"
|
||||
loginprotocol "bd2server/internal/server/protocol/login"
|
||||
"bd2server/internal/server/protocol/staticdata"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type seedCatalog struct {
|
||||
login *loginprotocol.LoginSeed
|
||||
starter *roster.Starter
|
||||
defaults *readonly.Seed
|
||||
mailbox *mail.Starter
|
||||
deck deck.Seed
|
||||
world world.Seed
|
||||
}
|
||||
|
||||
func loadSeeds(c *configuration) (*seedCatalog, error) {
|
||||
login, err := loginprotocol.Load(c.accountSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
starter, err := roster.Load(c.playerSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if login.Version != c.versions.GameVersion || starter.Version != c.versions.GameVersion {
|
||||
return nil, errorsVersionMismatch(c.versions.GameVersion, login.Version, starter.Version)
|
||||
}
|
||||
defaults, err := readonly.Load(c.readonlySeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defaults, err = calendaradapter.ApplyStaticData(c.calendars, defaults)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := mail.Load(c.mailSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deckSeed, err := deck.LoadSeed(c.deckSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
worldSeed, err := world.LoadSeed(c.worldSeed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &seedCatalog{login: login, starter: starter, defaults: defaults, mailbox: mailbox, deck: deckSeed, world: worldSeed}, nil
|
||||
}
|
||||
|
||||
func cloneSeed[T any](source *T) (*T, error) {
|
||||
data, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result T
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/gateway/auth"
|
||||
"bd2server/internal/server/gateway/session"
|
||||
"bd2server/internal/server/gateway/transport"
|
||||
"bd2server/internal/server/platform/lifecycle"
|
||||
identitystore "bd2server/internal/server/storage/identity"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Serve(args []string) (serveErr error) {
|
||||
defer func() { serveErr = errors.Join(serveErr, gamedata.CloseDatabaseCache()) }()
|
||||
c, err := loadConfiguration(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer clear(c.authRuntime.MasterKey)
|
||||
seeds, err := loadSeeds(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
design, err := loadDesign(c, seeds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var authService *auth.Service
|
||||
var authStore *identitystore.Store
|
||||
if c.authentication.Mode == "oauth" {
|
||||
authStore, err = identitystore.Open(filepath.Join(c.stateDirectory, "auth.db"), c.authRuntime.MasterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open authentication database: %w", err)
|
||||
}
|
||||
defer func() { serveErr = errors.Join(serveErr, authStore.Close()) }()
|
||||
authService, err = auth.New(c.authRuntime, authStore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := lockServerState(c.stateDirectory, c.gameRules.Story.StartPackID); err != nil {
|
||||
return err
|
||||
}
|
||||
factory := &PlayerFactory{options: c, design: design, seeds: seeds}
|
||||
if authStore != nil {
|
||||
factory.profiles = authStore
|
||||
}
|
||||
registry := newPlayerRegistry(factory, 15*time.Minute)
|
||||
defer func() { serveErr = errors.Join(serveErr, registry.Close(context.Background())) }()
|
||||
var authenticator session.LoginAuthenticator
|
||||
if authService != nil {
|
||||
authenticator = authService
|
||||
}
|
||||
game, err := session.NewServer(registry, authenticator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dispatcher := transport.Bootstrap{Config: c.bootstrap}
|
||||
var authHandler http.Handler
|
||||
if authService != nil {
|
||||
authHandler = authService.Handler()
|
||||
}
|
||||
availability := lifecycle.NewGate()
|
||||
instanceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(instanceBytes); err != nil {
|
||||
return fmt.Errorf("create server instance identity: %w", err)
|
||||
}
|
||||
instanceID := hex.EncodeToString(instanceBytes)
|
||||
handler := transport.HTTP{
|
||||
Dispatcher: dispatcher, Raw: game, Authentication: c.authentication,
|
||||
AuthenticationHandler: authHandler, ResourcePolicy: c.publicResources,
|
||||
CommerceManifest: func() any { return design.cashCatalog.Manifest() }, Availability: availability, InstanceID: instanceID,
|
||||
}.Handler()
|
||||
server := &http.Server{
|
||||
Addr: c.listen,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 20 * time.Second,
|
||||
WriteTimeout: 20 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
slog.Info("BD2 server listening", "address", c.listen, "instance_id", instanceID, "server_version", c.versions.ServerVersion, "game_version", c.bootstrap.Version, "bundle", c.bootstrap.BundleVer, "resourceMode", c.publicResources.Mode, "gameData", c.verifiedGameData.ArchivePath, "gameDataEntries", c.verifiedGameData.EntryCount, "accountSeed", c.accountSeed)
|
||||
serveResult := make(chan error, 1)
|
||||
go func() { serveResult <- server.ListenAndServe() }()
|
||||
signals := make(chan os.Signal, 1)
|
||||
signal.Notify(signals, lifecycle.ShutdownSignals()...)
|
||||
defer signal.Stop(signals)
|
||||
select {
|
||||
case err := <-serveResult:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case received := <-signals:
|
||||
slog.Info("BD2 server draining", "signal", received.String())
|
||||
}
|
||||
availability.Drain()
|
||||
drainContext, cancelDrain := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancelDrain()
|
||||
if err := availability.Wait(drainContext); err != nil {
|
||||
slog.Warn("BD2 request drain timed out", "error", err)
|
||||
}
|
||||
if err := server.Shutdown(drainContext); err != nil {
|
||||
_ = server.Close()
|
||||
return fmt.Errorf("shutdown drained server: %w", err)
|
||||
}
|
||||
if err := <-serveResult; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
slog.Info("BD2 server stopped after drain")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/storage/stateio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type serverState struct {
|
||||
Version int `json:"version"`
|
||||
StartPackID int `json:"start_pack_id"`
|
||||
}
|
||||
|
||||
func lockServerState(directory string, configured int) error {
|
||||
if err := os.MkdirAll(directory, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, "state.db")); err == nil {
|
||||
return errors.New("single-account state layout requires explicit offline migration")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(directory, "server.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if entries, readErr := os.ReadDir(filepath.Join(directory, "accounts")); readErr == nil && len(entries) != 0 {
|
||||
return errors.New("server policy is missing for existing player databases; explicit repair required")
|
||||
} else if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return readErr
|
||||
}
|
||||
raw, err = json.Marshal(serverState{Version: 1, StartPackID: configured})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, writeErr := file.Write(raw)
|
||||
syncErr := file.Sync()
|
||||
closeErr := file.Close()
|
||||
return errors.Join(writeErr, syncErr, closeErr)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stateio.RequireExactJSONObject(raw, "version", "start_pack_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
var state serverState
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state.Version != 1 || (state.StartPackID != 1 && state.StartPackID != 21) {
|
||||
return errors.New("invalid permanent server story policy")
|
||||
}
|
||||
if state.StartPackID != configured {
|
||||
return fmt.Errorf("story.start_pack_id %d conflicts with permanent server policy %d", configured, state.StartPackID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
@@ -1,672 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/authconfig"
|
||||
)
|
||||
|
||||
const testNowUnix = int64(1_800_000_000)
|
||||
|
||||
func testService(t *testing.T) (*Service, *Store) {
|
||||
t.Helper()
|
||||
master := bytes.Repeat([]byte{0x42}, 32)
|
||||
store, err := Open(filepath.Join(t.TempDir(), "auth.db"), master)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = store.Close() })
|
||||
store.now = func() time.Time { return time.Unix(testNowUnix, 0) }
|
||||
public, _ := url.Parse("https://login.example.test")
|
||||
runtime := authconfig.Runtime{
|
||||
Config: authconfig.Config{
|
||||
Mode: "oauth",
|
||||
PublicURL: "https://login.example.test",
|
||||
Providers: map[string]authconfig.ProviderConfig{
|
||||
"discord": {ClientID: "discord-client", ClientSecretEnv: "DISCORD_SECRET"},
|
||||
"google": {ClientID: "google-client", ClientSecretEnv: "GOOGLE_SECRET"},
|
||||
},
|
||||
},
|
||||
PublicURLParsed: public,
|
||||
MasterKey: master,
|
||||
ProviderSecrets: map[string]string{"discord": "discord-secret", "google": "google-secret"},
|
||||
AccessTTL: 15 * time.Minute,
|
||||
RefreshTTL: 30 * 24 * time.Hour,
|
||||
DeviceTTL: 10 * time.Minute,
|
||||
}
|
||||
service, err := New(runtime, store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if service.config.MasterKey != nil {
|
||||
t.Fatal("service retained the authentication master key")
|
||||
}
|
||||
for i, value := range master {
|
||||
if value != 0 {
|
||||
t.Fatalf("master key byte %d was not cleared", i)
|
||||
}
|
||||
}
|
||||
return service, store
|
||||
}
|
||||
|
||||
func insertAuthorizingDevice(t *testing.T, store *Store, id, provider string) {
|
||||
t.Helper()
|
||||
_, err := store.db.Exec(`INSERT INTO devices(id,client_hash,secret_hash,start_hash,provider,status,created_at,expires_at)
|
||||
VALUES(?,?,?,?,?,'authorizing',?,?)`, id, store.digest("client-ip", "192.0.2.1"), store.digest("device-secret", "device-secret"), store.digest("start-ticket", "start-ticket"), provider, testNowUnix, testNowUnix+600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeAndPoll(t *testing.T, service *Service, store *Store, deviceID, provider, issuer, subject string) deviceResult {
|
||||
t.Helper()
|
||||
insertAuthorizingDevice(t, store, deviceID, provider)
|
||||
if err := service.completeDevice(deviceID, provider, providerIdentity{issuer: issuer, subject: subject}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/auth/device/"+deviceID+"/poll", nil)
|
||||
request.Header.Set("Authorization", "Device device-secret")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("poll status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var result deviceResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func postJSON(handler http.Handler, path string, value any) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(value)
|
||||
request := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func TestCompleteDeviceRequiresAuthorizingTransition(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
insertAuthorizingDevice(t, store, "already-consumed", "discord")
|
||||
if _, err := store.db.Exec(`UPDATE devices SET status='consumed' WHERE id='already-consumed'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := service.completeDevice("already-consumed", "discord", providerIdentity{issuer: "https://discord.com", subject: "123456789"})
|
||||
if !errors.Is(err, ErrConsumed) {
|
||||
t.Fatalf("completeDevice error=%v, want ErrConsumed", err)
|
||||
}
|
||||
for _, table := range []string{"accounts", "identities", "families", "access_tokens", "refresh_tokens"} {
|
||||
var count int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("%s has %d rows after rejected completion", table, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollConsumesEncryptedResultExactlyOnce(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
result := completeAndPoll(t, service, store, "poll-once", "discord", "https://discord.com", "123456789")
|
||||
if result.AccessToken == "" || result.RefreshToken == "" {
|
||||
t.Fatal("poll omitted issued tokens")
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/auth/device/poll-once/poll", nil)
|
||||
request.Header.Set("Authorization", "Device device-secret")
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusGone {
|
||||
t.Fatalf("second poll status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var status string
|
||||
var cipher []byte
|
||||
if err := store.db.QueryRow(`SELECT status,COALESCE(result_cipher,X'') FROM devices WHERE id='poll-once'`).Scan(&status, &cipher); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "consumed" || len(cipher) != 0 {
|
||||
t.Fatalf("device status=%q result bytes=%d", status, len(cipher))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRotationReplayRevokesFamily(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
first := completeAndPoll(t, service, store, "refresh-device", "discord", "https://discord.com", "123456789")
|
||||
handler := service.Handler()
|
||||
response := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "rotation-attempt-0001"})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("refresh status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var rotated deviceResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rotated.RefreshToken == "" || rotated.RefreshToken == first.RefreshToken || rotated.AccessToken == first.AccessToken {
|
||||
t.Fatal("refresh did not rotate both credentials")
|
||||
}
|
||||
if _, err := service.ValidateAccess(first.AccessToken); err == nil {
|
||||
t.Fatal("old access token survived refresh rotation")
|
||||
}
|
||||
if _, err := service.ValidateAccess(rotated.AccessToken); err != nil {
|
||||
t.Fatalf("new access token rejected: %v", err)
|
||||
}
|
||||
|
||||
replay := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "replay-attempt-000002"})
|
||||
if replay.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("replay status=%d body=%q", replay.Code, replay.Body.String())
|
||||
}
|
||||
if _, err := service.ValidateAccess(rotated.AccessToken); err == nil {
|
||||
t.Fatal("refresh replay did not revoke the token family")
|
||||
}
|
||||
next := postJSON(handler, "/auth/session/refresh", map[string]string{"refresh_token": rotated.RefreshToken, "attempt_id": "after-replay-attempt-3"})
|
||||
if next.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("family refresh after replay status=%d", next.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeInvalidatesAccessAndRefreshFamily(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
tokens := completeAndPoll(t, service, store, "revoke-device", "discord", "https://discord.com", "123456789")
|
||||
request := httptest.NewRequest(http.MethodPost, "/auth/session/revoke", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+tokens.AccessToken)
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("revoke status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if _, err := service.ValidateAccess(tokens.AccessToken); err == nil {
|
||||
t.Fatal("revoked access token remained valid")
|
||||
}
|
||||
refresh := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken, "attempt_id": "revoked-attempt-0001"})
|
||||
if refresh.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked refresh status=%d", refresh.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRetryWithSameAttemptReturnsCommittedRotation(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
first := completeAndPoll(t, service, store, "refresh-retry-device", "discord", "https://discord.com", "123456789")
|
||||
handler := service.Handler()
|
||||
request := map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "stable-attempt-000001"}
|
||||
response := postJSON(handler, "/auth/session/refresh", request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("first refresh status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var rotated deviceResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry := postJSON(handler, "/auth/session/refresh", request)
|
||||
if retry.Code != http.StatusOK {
|
||||
t.Fatalf("retry status=%d body=%q", retry.Code, retry.Body.String())
|
||||
}
|
||||
var replayed deviceResult
|
||||
if err := json.Unmarshal(retry.Body.Bytes(), &replayed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replayed.Provider != rotated.Provider || replayed.AccessToken != rotated.AccessToken || replayed.RefreshToken != rotated.RefreshToken {
|
||||
t.Fatalf("retry returned a different rotation: first=%+v retry=%+v", rotated, replayed)
|
||||
}
|
||||
if _, err := service.ValidateAccess(rotated.AccessToken); err != nil {
|
||||
t.Fatalf("idempotent retry revoked the family: %v", err)
|
||||
}
|
||||
var sealed []byte
|
||||
if err := store.db.QueryRow(`SELECT result_cipher FROM refresh_attempts`).Scan(&sealed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(sealed, []byte(rotated.AccessToken)) || bytes.Contains(sealed, []byte(rotated.RefreshToken)) {
|
||||
t.Fatal("refresh attempt result was stored outside AES-GCM ciphertext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRequiresStableAttemptID(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
tokens := completeAndPoll(t, service, store, "refresh-attempt-required", "discord", "https://discord.com", "123456789")
|
||||
response := postJSON(service.Handler(), "/auth/session/refresh", map[string]string{"refresh_token": tokens.RefreshToken})
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("missing attempt status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if _, err := service.ValidateAccess(tokens.AccessToken); err != nil {
|
||||
t.Fatalf("malformed refresh request changed credential family: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshCommittedAttemptReplaysAfterRequestTokenExpiry(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
first := completeAndPoll(t, service, store, "refresh-expiry-device", "discord", "https://discord.com", "123456789")
|
||||
request := map[string]string{"refresh_token": first.RefreshToken, "attempt_id": "expiry-replay-attempt-01"}
|
||||
response := postJSON(service.Handler(), "/auth/session/refresh", request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("first refresh status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var rotated deviceResult
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &rotated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.Exec(`UPDATE refresh_tokens SET expires_at=? WHERE token_hash=?`, testNowUnix-1, store.digest("refresh-token", first.RefreshToken)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.now = func() time.Time { return time.Unix(testNowUnix+int64((16*time.Minute).Seconds()), 0) }
|
||||
replay := postJSON(service.Handler(), "/auth/session/refresh", request)
|
||||
if replay.Code != http.StatusOK {
|
||||
t.Fatalf("expired request token replay status=%d body=%q", replay.Code, replay.Body.String())
|
||||
}
|
||||
var replayed deviceResult
|
||||
if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replayed.AccessToken != rotated.AccessToken || replayed.RefreshToken != rotated.RefreshToken {
|
||||
t.Fatalf("replay changed committed rotation: first=%+v replay=%+v", rotated, replayed)
|
||||
}
|
||||
if replayed.AccessExpiresIn != 0 {
|
||||
t.Fatalf("expired replay access TTL=%d, want 0", replayed.AccessExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSensitiveAuthenticationMaterialIsNotStoredInPlaintext(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
handler := service.Handler()
|
||||
created := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
|
||||
if created.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%q", created.Code, created.Body.String())
|
||||
}
|
||||
var device struct {
|
||||
ID string `json:"transaction_id"`
|
||||
Secret string `json:"device_secret"`
|
||||
StartURL string `json:"start_url"`
|
||||
}
|
||||
if err := json.Unmarshal(created.Body.Bytes(), &device); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startURL, _ := url.Parse(device.StartURL)
|
||||
ticket := startURL.Query().Get("ticket")
|
||||
start := httptest.NewRequest(http.MethodGet, startURL.RequestURI(), nil)
|
||||
started := httptest.NewRecorder()
|
||||
handler.ServeHTTP(started, start)
|
||||
if started.Code != http.StatusFound {
|
||||
t.Fatalf("start status=%d body=%q", started.Code, started.Body.String())
|
||||
}
|
||||
authorize, _ := url.Parse(started.Header().Get("Location"))
|
||||
state := authorize.Query().Get("state")
|
||||
var secretHash, startHash, stateHash, verifierCipher, nonceCipher []byte
|
||||
if err := store.db.QueryRow(`SELECT secret_hash,start_hash,state_hash,verifier_cipher,nonce_cipher FROM devices WHERE id=?`, device.ID).
|
||||
Scan(&secretHash, &startHash, &stateHash, &verifierCipher, &nonceCipher); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier, err := store.open(device.ID, "pkce", verifierCipher)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce, err := store.open(device.ID, "nonce", nonceCipher)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, pair := range map[string]struct{ stored, raw []byte }{
|
||||
"device secret": {secretHash, []byte(device.Secret)},
|
||||
"start ticket": {startHash, []byte(ticket)},
|
||||
"oauth state": {stateHash, []byte(state)},
|
||||
"pkce verifier": {verifierCipher, verifier},
|
||||
"oidc nonce": {nonceCipher, nonce},
|
||||
} {
|
||||
if bytes.Equal(pair.stored, pair.raw) || bytes.Contains(pair.stored, pair.raw) {
|
||||
t.Fatalf("%s was stored in plaintext", name)
|
||||
}
|
||||
}
|
||||
|
||||
const providerSubject = "987654321012345678"
|
||||
if err := service.completeDevice(device.ID, "discord", providerIdentity{issuer: "https://discord.com", subject: providerSubject}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var subjectHash, sealedResult []byte
|
||||
if err := store.db.QueryRow(`SELECT subject_hash FROM identities`).Scan(&subjectHash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT result_cipher FROM devices WHERE id=?`, device.ID).Scan(&sealedResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plainResult, err := store.open(device.ID, "result", sealedResult)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var issued deviceResult
|
||||
if err := json.Unmarshal(plainResult, &issued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(subjectHash, []byte(providerSubject)) {
|
||||
t.Fatal("provider subject was stored in plaintext")
|
||||
}
|
||||
for name, raw := range map[string]string{"access token": issued.AccessToken, "refresh token": issued.RefreshToken} {
|
||||
if bytes.Contains(sealedResult, []byte(raw)) {
|
||||
t.Fatalf("pending %s was stored outside AES-GCM ciphertext", name)
|
||||
}
|
||||
var count int
|
||||
table := "access_tokens"
|
||||
if name == "refresh token" {
|
||||
table = "refresh_tokens"
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE token_hash=?`, []byte(raw)).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("%s was stored in plaintext", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLimitsAndSecurityHeaders(t *testing.T) {
|
||||
service, _ := testService(t)
|
||||
handler := service.Handler()
|
||||
for name, body := range map[string]struct {
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
"trailing": {`{"provider":"discord"}{}`, http.StatusBadRequest},
|
||||
"oversize": {`{"provider":"discord","padding":"` + strings.Repeat("x", 17<<10) + `"}`, http.StatusRequestEntityTooLarge},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/auth/device", strings.NewReader(body.body))
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != body.want {
|
||||
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
for header, want := range map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
} {
|
||||
if got := response.Header().Get(header); got != want {
|
||||
t.Fatalf("%s=%q want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDeviceLimitsPendingTransactionsPerClient(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
handler := service.Handler()
|
||||
for i := range 5 {
|
||||
response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("create %d status=%d body=%q", i, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
response := postJSON(handler, "/auth/device", map[string]string{"provider": "discord"})
|
||||
if response.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("pending limit status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var count int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM devices`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 5 {
|
||||
t.Fatalf("device count=%d want 5", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeProviderJSONRejectsOversizeAndTrailingValues(t *testing.T) {
|
||||
var target map[string]any
|
||||
if err := decodeProviderJSON(strings.NewReader(`{"id":"1"}{}`), &target); err == nil {
|
||||
t.Fatal("accepted provider response with trailing JSON")
|
||||
}
|
||||
oversize := `{"padding":"` + strings.Repeat("x", 1<<20) + `"}`
|
||||
if err := decodeProviderJSON(strings.NewReader(oversize), &target); err == nil {
|
||||
t.Fatal("accepted oversized provider response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestLimiterIsBoundedAndExpiresWindows(t *testing.T) {
|
||||
limiter := requestLimiter{windows: make(map[string]limitWindow)}
|
||||
now := time.Unix(testNowUnix, 0)
|
||||
for i := range 4096 {
|
||||
if !limiter.allow(strconv.Itoa(i), now, time.Minute, 1) {
|
||||
t.Fatalf("rejected window %d before capacity", i)
|
||||
}
|
||||
}
|
||||
if limiter.allow("overflow", now, time.Minute, 1) {
|
||||
t.Fatal("accepted a limiter key beyond its bounded capacity")
|
||||
}
|
||||
if !limiter.allow("after-expiry", now.Add(time.Minute), time.Minute, 1) {
|
||||
t.Fatal("did not clean expired limiter windows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupRetainsUsedRefreshForReplayUntilFamilyExpiry(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
tokens := completeAndPoll(t, service, store, "cleanup-device", "discord", "https://discord.com", "123456789")
|
||||
if _, err := store.db.Exec(`UPDATE refresh_tokens SET used_at=? WHERE token_hash=?`, testNowUnix, store.digest("refresh-token", tokens.RefreshToken)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := store.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cleanupExpired(tx, testNowUnix+int64((29*24*time.Hour).Seconds())); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE used_at IS NOT NULL`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatal("used refresh token was removed before family expiry")
|
||||
}
|
||||
tx, err = store.db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cleanupExpired(tx, testNowUnix+int64((31*24*time.Hour).Seconds())); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("expired family refresh token was not cleaned")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
|
||||
func jsonResponse(status int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||
}
|
||||
|
||||
func TestProviderIdentityVerification(t *testing.T) {
|
||||
service, _ := testService(t)
|
||||
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch request.URL.Host + request.URL.Path {
|
||||
case "discord.com/api/v10/oauth2/token":
|
||||
return jsonResponse(http.StatusOK, `{"access_token":"provider-access"}`), nil
|
||||
case "discord.com/api/v10/users/@me":
|
||||
if request.Header.Get("Authorization") != "Bearer provider-access" {
|
||||
t.Fatal("Discord bearer token missing")
|
||||
}
|
||||
return jsonResponse(http.StatusOK, `{"id":"123456789"}`), nil
|
||||
case "oauth2.googleapis.com/token":
|
||||
return jsonResponse(http.StatusOK, `{"access_token":"provider-access","id_token":"signed-id-token"}`), nil
|
||||
case "oauth2.googleapis.com/tokeninfo":
|
||||
return jsonResponse(http.StatusOK, `{"iss":"https://accounts.google.com","aud":"google-client","sub":"google-subject","nonce":"expected-nonce","exp":"1900000000"}`), nil
|
||||
default:
|
||||
t.Fatalf("unexpected provider request %s", request.URL)
|
||||
return nil, nil
|
||||
}
|
||||
})}
|
||||
discord, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
|
||||
if err != nil || discord.issuer != "https://discord.com" || discord.subject != "123456789" {
|
||||
t.Fatalf("Discord identity=%+v err=%v", discord, err)
|
||||
}
|
||||
google, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "expected-nonce")
|
||||
if err != nil || google.issuer != "https://accounts.google.com" || google.subject != "google-subject" {
|
||||
t.Fatalf("Google identity=%+v err=%v", google, err)
|
||||
}
|
||||
if _, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "wrong-nonce"); err == nil {
|
||||
t.Fatal("Google identity accepted the wrong OIDC nonce")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFailureIsStructuredAndSanitized(t *testing.T) {
|
||||
service, _ := testService(t)
|
||||
secretDescription := "provider leaked secret sentinel"
|
||||
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(http.StatusUnauthorized, `{"error":"invalid_client","error_description":"`+secretDescription+`"}`), nil
|
||||
})}
|
||||
_, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
|
||||
var failure *providerFailure
|
||||
if !errors.As(err, &failure) {
|
||||
t.Fatalf("error %T does not expose provider failure", err)
|
||||
}
|
||||
if failure.Provider != "discord" || failure.Stage != "token_exchange" || failure.Reason != "http_rejected" ||
|
||||
failure.HTTPStatus != http.StatusUnauthorized || failure.OAuthError != "invalid_client" {
|
||||
t.Fatalf("failure=%+v", failure)
|
||||
}
|
||||
if strings.Contains(err.Error(), secretDescription) {
|
||||
t.Fatal("provider error description leaked through diagnostic error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderFailureRejectsUntrustedOAuthError(t *testing.T) {
|
||||
service, _ := testService(t)
|
||||
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(http.StatusBadRequest, `{"error":"access-token-sentinel"}`), nil
|
||||
})}
|
||||
_, err := service.exchangeIdentity(context.Background(), "discord", "code", "verifier", "nonce")
|
||||
var failure *providerFailure
|
||||
if !errors.As(err, &failure) || failure.OAuthError != "unknown" {
|
||||
t.Fatalf("failure=%+v err=%v", failure, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "access-token-sentinel") {
|
||||
t.Fatal("untrusted provider error leaked through diagnostic error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleProviderNetworkFailureDoesNotLeakIDTokenURL(t *testing.T) {
|
||||
service, _ := testService(t)
|
||||
idToken := "signed-id-token-sentinel"
|
||||
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
if request.URL.Path == "/token" {
|
||||
return jsonResponse(http.StatusOK, `{"access_token":"provider-access","id_token":"`+idToken+`"}`), nil
|
||||
}
|
||||
return nil, &url.Error{Op: "Get", URL: "https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken, Err: errors.New("transport sentinel")}
|
||||
})}
|
||||
_, err := service.exchangeIdentity(context.Background(), "google", "code", "verifier", "nonce")
|
||||
var failure *providerFailure
|
||||
if !errors.As(err, &failure) || failure.Provider != "google" || failure.Stage != "id_token_verify" || failure.Reason != "network_error" {
|
||||
t.Fatalf("failure=%+v err=%v", failure, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), idToken) || strings.Contains(err.Error(), "transport sentinel") {
|
||||
t.Fatal("Google ID token URL or transport details leaked through diagnostic error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackFailureClearsShortLivedOAuthMaterial(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
insertAuthorizingDevice(t, store, "failed-device", "discord")
|
||||
state := "failed-state"
|
||||
verifier, err := store.seal("failed-device", "pkce", []byte("verifier"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce, err := store.seal("failed-device", "nonce", []byte("nonce"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.db.Exec(`UPDATE devices SET state_hash=?,verifier_cipher=?,nonce_cipher=? WHERE id='failed-device'`, store.digest("oauth-state", state), verifier, nonce); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
return jsonResponse(http.StatusUnauthorized, `{"error":"invalid_client"}`), nil
|
||||
})}
|
||||
request := httptest.NewRequest(http.MethodGet, "/auth/discord/callback?code=failed-code&state="+url.QueryEscape(state), nil)
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadGateway || !strings.Contains(response.Body.String(), "server OAuth configuration is invalid") {
|
||||
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var status string
|
||||
var stateHash, verifierCipher, nonceCipher []byte
|
||||
if err := store.db.QueryRow(`SELECT status,COALESCE(state_hash,X''),COALESCE(verifier_cipher,X''),COALESCE(nonce_cipher,X'') FROM devices WHERE id='failed-device'`).Scan(&status, &stateHash, &verifierCipher, &nonceCipher); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "failed" || len(stateHash) != 0 || len(verifierCipher) != 0 || len(nonceCipher) != 0 {
|
||||
t.Fatalf("status=%q state=%d verifier=%d nonce=%d", status, len(stateHash), len(verifierCipher), len(nonceCipher))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderScopesUseLeastPrivilege(t *testing.T) {
|
||||
if got := providerScope("discord"); got != "identify" {
|
||||
t.Fatalf("Discord scope=%q, want identify", got)
|
||||
}
|
||||
if got := providerScope("google"); got != "openid" {
|
||||
t.Fatalf("Google scope=%q, want openid", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderErrorConsumesAuthorizationStateWithoutExchange(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
insertAuthorizingDevice(t, store, "cancelled-device", "discord")
|
||||
state := "cancelled-oauth-state"
|
||||
if _, err := store.db.Exec(`UPDATE devices SET state_hash=? WHERE id='cancelled-device'`, store.digest("oauth-state", state)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.client = &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
t.Fatal("provider error callback attempted a token exchange")
|
||||
return nil, nil
|
||||
})}
|
||||
request := httptest.NewRequest(http.MethodGet, "/auth/discord/callback?error=access_denied&state="+url.QueryEscape(state), nil)
|
||||
response := httptest.NewRecorder()
|
||||
service.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
var status, errorCode string
|
||||
var stateHash, verifier, nonce []byte
|
||||
if err := store.db.QueryRow(`SELECT status,error_code,COALESCE(state_hash,X''),COALESCE(verifier_cipher,X''),COALESCE(nonce_cipher,X'') FROM devices WHERE id='cancelled-device'`).
|
||||
Scan(&status, &errorCode, &stateHash, &verifier, &nonce); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "failed" || errorCode != "provider_cancelled" || len(stateHash) != 0 || len(verifier) != 0 || len(nonce) != 0 {
|
||||
t.Fatalf("cancelled device status=%q error=%q state=%d verifier=%d nonce=%d", status, errorCode, len(stateHash), len(verifier), len(nonce))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteDeviceRejectsMalformedProviderIdentity(t *testing.T) {
|
||||
service, store := testService(t)
|
||||
insertAuthorizingDevice(t, store, "bad-identity", "discord")
|
||||
if err := service.completeDevice("bad-identity", "discord", providerIdentity{issuer: "https://discord.com", subject: "not-a-snowflake"}); err == nil {
|
||||
t.Fatal("accepted malformed Discord identity")
|
||||
}
|
||||
var count int
|
||||
if err := store.db.QueryRow(`SELECT COUNT(*) FROM identities`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatal("malformed identity was persisted")
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoreRequiresAndClearsExactMasterKey(t *testing.T) {
|
||||
if _, err := Open(filepath.Join(t.TempDir(), "short.db"), make([]byte, 31)); err == nil {
|
||||
t.Fatal("store accepted a non-256-bit master key")
|
||||
}
|
||||
key := bytes.Repeat([]byte{0x7a}, 32)
|
||||
store, err := Open(filepath.Join(t.TempDir(), "auth.db"), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := store.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
for index, value := range key {
|
||||
if value != 0 {
|
||||
t.Fatalf("master key byte %d was retained by the caller buffer", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMigratesSchemaV1ToV2(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "auth.db")
|
||||
store, err := Open(path, bytes.Repeat([]byte{0x61}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`DROP TABLE refresh_attempts; UPDATE metadata SET value='1' WHERE key='schema_version'`); err != nil {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reopened, err := Open(path, bytes.Repeat([]byte{0x61}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := reopened.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
var version int
|
||||
if err := reopened.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != 2 {
|
||||
t.Fatalf("schema_version=%d, want 2", version)
|
||||
}
|
||||
var table string
|
||||
if err := reopened.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_attempts'`).Scan(&table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorePersistsExplicitSchemaVersionAndRejectsUnknownVersion(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "auth.db")
|
||||
store, err := Open(path, bytes.Repeat([]byte{0x35}, 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var version int
|
||||
if err := store.db.QueryRow(`SELECT CAST(value AS INTEGER) FROM metadata WHERE key='schema_version'`).Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != schemaVersion {
|
||||
t.Fatalf("schema_version=%d, want %d", version, schemaVersion)
|
||||
}
|
||||
if _, err := store.db.Exec(`UPDATE metadata SET value='999' WHERE key='schema_version'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Open(path, bytes.Repeat([]byte{0x35}, 32)); err == nil {
|
||||
t.Fatal("store accepted an unknown schema version")
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package authconfig
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), FileName)
|
||||
if err := os.WriteFile(path, []byte(`{"mode":"oauth","public_url":"https://example.com","master_key_env":"MASTER","providers":{"discord":{"client_id":"d","client_secret_env":"DS"},"google":{"client_id":"g","client_secret_env":"GS"}}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Mode != "oauth" || len(config.Providers) != 2 || config.Providers["discord"].ClientID != "d" || config.Providers["google"].ClientID != "g" {
|
||||
t.Fatalf("unexpected config: %+v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnsafePolicies(t *testing.T) {
|
||||
for name, config := range map[string]Config{
|
||||
"unknown mode": {Mode: "disabled"},
|
||||
"local providers": {Mode: "local", Providers: map[string]ProviderConfig{"discord": {ClientID: "d", ClientSecretEnv: "DS"}}},
|
||||
"empty oauth": {Mode: "oauth"},
|
||||
"unknown provider": {Mode: "oauth", PublicURL: "https://example.com", MasterKeyEnv: "MASTER", Providers: map[string]ProviderConfig{"github": {ClientID: "g", ClientSecretEnv: "GS"}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := config.Validate(); err == nil {
|
||||
t.Fatal("accepted invalid authentication policy")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEnvironmentKeepsSecretsOutOfPublicView(t *testing.T) {
|
||||
master := make([]byte, 32)
|
||||
for i := range master {
|
||||
master[i] = byte(i + 1)
|
||||
}
|
||||
t.Setenv("AUTH_MASTER", base64.StdEncoding.EncodeToString(master))
|
||||
t.Setenv("DISCORD_SECRET", "private-discord-secret")
|
||||
config := Config{
|
||||
Mode: "oauth",
|
||||
PublicURL: "https://example.com",
|
||||
MasterKeyEnv: "AUTH_MASTER",
|
||||
Providers: map[string]ProviderConfig{
|
||||
"discord": {ClientID: "public-client-id", ClientSecretEnv: "DISCORD_SECRET"},
|
||||
},
|
||||
}
|
||||
runtime, err := config.ResolveEnvironment()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(runtime.MasterKey) != 32 || runtime.ProviderSecrets["discord"] != "private-discord-secret" {
|
||||
t.Fatal("runtime did not resolve authentication secrets")
|
||||
}
|
||||
publicJSON, err := json.Marshal(config.Public())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"AUTH_MASTER", "DISCORD_SECRET", "private-discord-secret", "public-client-id"} {
|
||||
if strings.Contains(string(publicJSON), forbidden) {
|
||||
t.Fatalf("public authentication view leaked %q: %s", forbidden, publicJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnsafePublicURLsAndTTLs(t *testing.T) {
|
||||
base := Config{
|
||||
Mode: "oauth",
|
||||
PublicURL: "https://example.com",
|
||||
MasterKeyEnv: "MASTER",
|
||||
Providers: map[string]ProviderConfig{
|
||||
"discord": {ClientID: "d", ClientSecretEnv: "DS"},
|
||||
},
|
||||
}
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"http public": func(c *Config) { c.PublicURL = "http://example.com" },
|
||||
"path": func(c *Config) { c.PublicURL = "https://example.com/auth" },
|
||||
"query": func(c *Config) { c.PublicURL = "https://example.com?x=y" },
|
||||
"missing secret": func(c *Config) { c.Providers["discord"] = ProviderConfig{ClientID: "d"} },
|
||||
"invalid ttl": func(c *Config) { c.Session.AccessTTL = "0s" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
candidate := base
|
||||
candidate.Providers = map[string]ProviderConfig{"discord": base.Providers["discord"]}
|
||||
mutate(&candidate)
|
||||
if err := candidate.Validate(); err == nil {
|
||||
t.Fatal("accepted unsafe authentication configuration")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func request(seq uint64) []byte { return wire.AppendVarint(nil, 1, seq) }
|
||||
|
||||
func TestQuestBattleSelectsAndRetainsDifficultyDeck(t *testing.T) {
|
||||
s := NewService("design", "version", nil, func() (int, error) { return 1, nil })
|
||||
s.AttachCurrentDifficulty(func() (uint64, error) { return 2, nil })
|
||||
s.loadDifficultyDeck = func(_, _ string, pack int, deck, difficulty uint64) (uint64, error) {
|
||||
if pack != 1 || deck != 1 || difficulty != 2 {
|
||||
t.Fatalf("selection %d/%d/%d", pack, deck, difficulty)
|
||||
}
|
||||
return 200001, nil
|
||||
}
|
||||
s.loadPhases = func(_, _ string, pack int, monster, deck uint64) ([]gamedata.BattlePhase, error) {
|
||||
if pack != 1 || deck != 200001 {
|
||||
t.Fatalf("phase selection %d/%d", pack, deck)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 1), 5, 1)
|
||||
_, response, _, err := s.Handle("/BattleEnter", enter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deck, _, _ := wire.Varint(response, 2)
|
||||
if deck != 200001 {
|
||||
t.Fatalf("deck %d", deck)
|
||||
}
|
||||
start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 200001), 5, wire.AppendVarint(nil, 2, 101))
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, response, _, err = s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 200001))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deck, _, _ = wire.Varint(response, 3)
|
||||
if deck != 200001 {
|
||||
t.Fatalf("retry deck %d", deck)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalBattleLifecycle(t *testing.T) {
|
||||
s := &Service{}
|
||||
enter := request(1)
|
||||
enter = wire.AppendVarint(enter, 4, 1)
|
||||
enter = wire.AppendVarint(enter, 5, 1)
|
||||
code, response, ok, err := s.Handle("/BattleEnter", enter)
|
||||
if err != nil || !ok || code != 52 {
|
||||
t.Fatalf("enter: %d %v %v", code, ok, err)
|
||||
}
|
||||
engine, found, _ := wire.Varint(response, 6)
|
||||
if !found || engine != 1 {
|
||||
t.Fatalf("engine=%d/%v", engine, found)
|
||||
}
|
||||
red := wire.AppendVarint(nil, 1, 1)
|
||||
blue := wire.AppendVarint(nil, 1, 6)
|
||||
start := request(2)
|
||||
start = wire.AppendVarint(start, 2, 1)
|
||||
start = wire.AppendBytes(start, 4, red)
|
||||
start = wire.AppendBytes(start, 5, blue)
|
||||
code, response, _, err = s.Handle("/BattleStart", start)
|
||||
if err != nil || code != 14 {
|
||||
t.Fatalf("start: %d %v", code, err)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 1); !found {
|
||||
t.Fatal("red state not echoed")
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 2); !found {
|
||||
t.Fatal("blue state not echoed")
|
||||
}
|
||||
code, response, _, err = s.Handle("/BattleVerifyState", request(3))
|
||||
state, found, _ := wire.Varint(response, 1)
|
||||
if err != nil || code != 142 || !found || state != 3 {
|
||||
t.Fatalf("verify: code=%d state=%d/%v err=%v", code, state, found, err)
|
||||
}
|
||||
end := request(4)
|
||||
end = wire.AppendVarint(end, 2, 2)
|
||||
end = wire.AppendBytes(end, 3, blue)
|
||||
code, response, _, err = s.Handle("/BattleEnd", end)
|
||||
result, found, _ := wire.Varint(response, 1)
|
||||
if err != nil || code != 15 || !found || result != 2 {
|
||||
t.Fatalf("end: code=%d result=%d/%v err=%v", code, result, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleOrdering(t *testing.T) {
|
||||
s := &Service{}
|
||||
start := request(1)
|
||||
start = wire.AppendVarint(start, 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
|
||||
t.Fatal("start without enter accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleRetryRestoresFirstSubmittedBlueTeam(t *testing.T) {
|
||||
s := &Service{}
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 4), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := wire.AppendVarint(wire.AppendVarint(nil, 2, 101), 4, 513)
|
||||
second := wire.AppendVarint(wire.AppendVarint(nil, 2, 102), 4, 200)
|
||||
start := wire.AppendVarint(request(2), 2, 77)
|
||||
start = wire.AppendBytes(start, 5, first)
|
||||
start = wire.AppendBytes(start, 5, second)
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry := wire.AppendVarint(request(3), 2, 77)
|
||||
code, response, handled, err := s.Handle("/BattleRetry", retry)
|
||||
if err != nil || !handled || code != 58 {
|
||||
t.Fatalf("retry code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
index, found, err := wire.Varint(response, 3)
|
||||
if err != nil || !found || index != 77 {
|
||||
t.Fatalf("retry battle index=%d found=%v err=%v", index, found, err)
|
||||
}
|
||||
var restored [][]byte
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number == 2 {
|
||||
restored = append(restored, field.Value)
|
||||
}
|
||||
return nil
|
||||
}); err != nil || len(restored) != 2 || string(restored[0]) != string(first) || string(restored[1]) != string(second) {
|
||||
t.Fatalf("retry team=%x err=%v", restored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleEnterUsesSamePictorialSnapshotAsAllCharRefresh(t *testing.T) {
|
||||
s := &Service{}
|
||||
s.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) {
|
||||
return []gamedata.PictorialBuffStat{{StatType: 2, Value: .0175}, {StatType: 4, Value: .01}}, nil
|
||||
})
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(9), 4, 1), 5, 1)
|
||||
code, response, handled, err := s.Handle("/BattleEnter", enter)
|
||||
if err != nil || !handled || code != 52 {
|
||||
t.Fatalf("battle entry code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
var count int
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number == 4 {
|
||||
count++
|
||||
stat, found, err := wire.Varint(field.Value, 1)
|
||||
if err != nil || !found || (stat != 2 && stat != 4) {
|
||||
t.Fatalf("invalid battle buff stat=%d found=%v err=%v", stat, found, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil || count != 2 {
|
||||
t.Fatalf("battle buffs count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleVictoryLocksPackAtEnterForRewardsAndIdentity(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
currentPack := 22
|
||||
s := NewService("test-root", "test-version", inventory, func() (int, error) {
|
||||
return currentPack, nil
|
||||
})
|
||||
var loadedPack int
|
||||
var loadedDeck uint64
|
||||
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) { return nil, nil }
|
||||
s.loadRewards = func(_, _ string, packID int, deckID uint64) ([]gamedata.BattleReward, error) {
|
||||
loadedPack, loadedDeck = packID, deckID
|
||||
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 3}}, nil
|
||||
}
|
||||
|
||||
enter := wire.AppendVarint(request(1), 3, 7)
|
||||
enter = wire.AppendVarint(enter, 4, 9)
|
||||
enter = wire.AppendVarint(enter, 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A later world transition must not change the identity of an in-flight
|
||||
// battle; the pack is captured at BattleEnter.
|
||||
currentPack = 21
|
||||
end := wire.AppendVarint(request(2), 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadedPack != 22 || loadedDeck != 9 {
|
||||
t.Fatalf("reward lookup pack/deck=%d/%d, want 22/9", loadedPack, loadedDeck)
|
||||
}
|
||||
if got := inventory.GrantedItems("pack22:monster7:deck9"); len(got) != 1 || got[0].ID != 8 || got[0].Count != 3 {
|
||||
t.Fatalf("pack22 reward grant=%+v", got)
|
||||
}
|
||||
if got := inventory.GrantedItems("pack21:monster7:deck9"); len(got) != 0 {
|
||||
t.Fatalf("reward leaked into pack21 identity: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewGameSessionDiscardsUnfinishedBattleWithoutRewards(t *testing.T) {
|
||||
s := NewService("", "", nil, nil)
|
||||
s.BeginSession("session-a")
|
||||
enter := wire.AppendVarint(request(1), 4, 10)
|
||||
enter = wire.AppendVarint(enter, 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := wire.AppendVarint(request(2), 2, 99)
|
||||
start = wire.AppendBytes(start, 5, wire.AppendVarint(nil, 1, 123))
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.BeginSession("session-b")
|
||||
end := wire.AppendVarint(request(3), 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil {
|
||||
t.Fatal("new session resumed unfinished battle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleStateIsIsolatedPerGameSession(t *testing.T) {
|
||||
s := NewService("", "", nil, nil)
|
||||
enter := wire.AppendVarint(request(1), 4, 10)
|
||||
enter = wire.AppendVarint(enter, 5, 1)
|
||||
s.BeginSession("session-a")
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.BeginSession("session-b")
|
||||
end := wire.AppendVarint(request(2), 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil {
|
||||
t.Fatal("session-b observed session-a battle")
|
||||
}
|
||||
s.BeginSession("session-a")
|
||||
if _, _, _, err := s.Handle("/BattleEnd", end); err != nil {
|
||||
t.Fatalf("session-a lost its own battle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedCurrentSessionSelectionDoesNotEvictOtherBattles(t *testing.T) {
|
||||
service := NewService("", "", nil, nil)
|
||||
for index := range 1024 {
|
||||
service.BeginSession(fmt.Sprintf("session-%d", index))
|
||||
}
|
||||
service.BeginSession("session-1023")
|
||||
if len(service.states) != 1024 {
|
||||
t.Fatalf("reselecting current session changed cache size: %d", len(service.states))
|
||||
}
|
||||
if service.states["session-1023"] == nil {
|
||||
t.Fatal("reselecting current session evicted its battle state")
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type fakeEventBattle struct {
|
||||
enter, end string
|
||||
rejected bool
|
||||
}
|
||||
|
||||
func (*fakeEventBattle) HandlesBattle(mode uint64) bool { return mode == 17 }
|
||||
func (e *fakeEventBattle) EnterBattle(_ []byte, receipt string) ([]byte, error) {
|
||||
e.enter = receipt
|
||||
if e.rejected {
|
||||
return nil, fmt.Errorf("stage locked")
|
||||
}
|
||||
return wire.AppendVarint(nil, 1, 7), nil
|
||||
}
|
||||
func (e *fakeEventBattle) CompleteBattle(_ []byte, receipt string) ([]byte, error) {
|
||||
e.end = receipt
|
||||
return wire.AppendBytes(nil, 16, wire.AppendVarint(nil, 3, 77)), nil
|
||||
}
|
||||
|
||||
func TestEventBattleOwnsSettlementAndAvoidsWorldDeck(t *testing.T) {
|
||||
s := NewService("unused", "unused", nil, func() (int, error) { return 21, nil })
|
||||
s.BeginSession("event-session")
|
||||
e := &fakeEventBattle{}
|
||||
s.AttachEventBattle(e)
|
||||
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
|
||||
t.Fatal("event read ordinary world deck")
|
||||
return nil, nil
|
||||
}
|
||||
s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("event altered world health"); return nil })
|
||||
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 1), 4, 73), 5, 17)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(2), 2, 73)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, out, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
|
||||
if err != nil || code != 15 {
|
||||
t.Fatalf("settlement: %d %v", code, err)
|
||||
}
|
||||
if e.enter != "event-session:1" || e.end != e.enter {
|
||||
t.Fatalf("receipt %q/%q", e.enter, e.end)
|
||||
}
|
||||
row, _, _ := wire.Bytes(out, 16)
|
||||
if !bytes.Equal(row, wire.AppendVarint(nil, 3, 77)) {
|
||||
t.Fatal("event progress lost")
|
||||
}
|
||||
if s.Active() {
|
||||
t.Fatal("event battle remained active")
|
||||
}
|
||||
_, retry, _, err := s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 1))
|
||||
if err != nil || !bytes.Equal(retry, out) {
|
||||
t.Fatal("settlement retry lost reply")
|
||||
}
|
||||
if _, _, _, err = s.Handle("/BattleEnd", wire.AppendVarint(request(3), 2, 2)); err == nil {
|
||||
t.Fatal("changed settlement retry accepted")
|
||||
}
|
||||
e.rejected = true
|
||||
if _, _, _, err = s.Handle("/BattleEnter", enter); err == nil {
|
||||
t.Fatal("locked stage entered")
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFieldBuffConsumptionAfterValidationAndBeforeBattleActivation(t *testing.T) {
|
||||
s := NewService("", "", nil, nil)
|
||||
s.BeginSession("first-session")
|
||||
remaining := 3
|
||||
used := map[string]bool{}
|
||||
fail := false
|
||||
s.AttachFieldBuffConsume(func(identity string) error {
|
||||
if fail {
|
||||
return errors.New("field buff persistence failed")
|
||||
}
|
||||
if !used[identity] {
|
||||
remaining--
|
||||
used[identity] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", request(1)); err == nil || remaining != 3 {
|
||||
t.Fatal("invalid battle consumed buff")
|
||||
}
|
||||
fail = true
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
|
||||
t.Fatal("persistence failure activated battle")
|
||||
}
|
||||
fail = false
|
||||
for range 2 {
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if remaining != 2 {
|
||||
t.Fatal("same enter receipt consumed twice")
|
||||
}
|
||||
start := wire.AppendBytes(wire.AppendVarint(request(2), 2, 1), 5, wire.AppendVarint(nil, 1, 6))
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(3), 2, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if remaining != 2 {
|
||||
t.Fatal("retry consumed extra field buff")
|
||||
}
|
||||
s.BeginSession("second-session")
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil || remaining != 1 {
|
||||
t.Fatal("new battle did not consume once", err)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestBattleDefeatAndRunCommitZeroAndInjuredHealth(t *testing.T) {
|
||||
// EBattleResultType: RED_WIN=2, CONDITION_FAIL=3, RUN=4. A dead
|
||||
// CharDBInfo omits hp because proto3's default is zero; it must be committed
|
||||
// rather than ignored and replaced with an earlier full-health value.
|
||||
for _, result := range []uint64{2, 3, 4} {
|
||||
t.Run(strconv.FormatUint(result, 10), func(t *testing.T) {
|
||||
s := &Service{}
|
||||
calls := 0
|
||||
var saved map[uint64]uint64
|
||||
s.AttachCommittedHealth(func(health map[uint64]uint64) error { calls++; saved = health; return nil })
|
||||
s.BeginSession("settlement")
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := wire.AppendVarint(request(2), 2, 11)
|
||||
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 100))
|
||||
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 200), 4, 100))
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end := wire.AppendVarint(request(3), 2, result)
|
||||
end = wire.AppendBytes(end, 3, wire.AppendVarint(nil, 1, 100))
|
||||
end = wire.AppendBytes(end, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 200), 3, 21))
|
||||
_, response, _, err := s.Handle("/BattleEnd", end)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || len(saved) != 2 || saved[100] != 0 || saved[200] != 21 {
|
||||
t.Fatalf("result=%d saved health=%v calls=%d", result, saved, calls)
|
||||
}
|
||||
if err := wire.Walk(response, func(field wire.Field) error {
|
||||
if field.Number == 3 {
|
||||
index, _, _ := wire.Varint(field.Value, 1)
|
||||
hp, _, _ := wire.Varint(field.Value, 3)
|
||||
if hp != saved[index] {
|
||||
t.Fatal("response differs from settled HP")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleExit", request(4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatal("exit wrote health after settlement")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBattleHealthCommitsOnlyAtEndAndChecksParticipants(t *testing.T) {
|
||||
s := &Service{}
|
||||
calls := 0
|
||||
var health map[uint64]uint64
|
||||
s.AttachCommittedHealth(func(values map[uint64]uint64) error {
|
||||
calls++
|
||||
for index, hp := range values {
|
||||
values[index] = min(hp, 50)
|
||||
}
|
||||
health = values
|
||||
return nil
|
||||
})
|
||||
s.BeginSession("first")
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := wire.AppendVarint(request(2), 2, 11)
|
||||
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 60)
|
||||
start = wire.AppendBytes(start, 5, blue)
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 0 || !s.Active() {
|
||||
t.Fatal("round state was committed or battle was not active")
|
||||
}
|
||||
invalid := wire.AppendVarint(request(3), 2, 1)
|
||||
invalid = wire.AppendBytes(invalid, 3, wire.AppendVarint(nil, 1, 999))
|
||||
if _, _, _, err := s.Handle("/BattleEnd", invalid); err == nil || calls != 0 {
|
||||
t.Fatal("accepted health for a character outside the blue team")
|
||||
}
|
||||
result := wire.AppendVarint(request(4), 2, 1)
|
||||
character := wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 17)
|
||||
result = wire.AppendBytes(result, 3, character)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || health[100] != 17 || s.Active() {
|
||||
t.Fatalf("health=%v calls=%d active=%t", health, calls, s.Active())
|
||||
}
|
||||
// A second battle interrupted by a login never commits its round health.
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.BeginSession("reconnected")
|
||||
if calls != 1 || s.Active() {
|
||||
t.Fatal("reconnect persisted unfinished battle health")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = wire.AppendVarint(request(5), 2, 1)
|
||||
character = wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 999)
|
||||
result = wire.AppendBytes(result, 3, character)
|
||||
_, body, _, err := s.Handle("/BattleEnd", result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
returned, _, _ := wire.Bytes(body, 3)
|
||||
hp, _, _ := wire.Varint(returned, 3)
|
||||
if health[100] != 50 || hp != 50 {
|
||||
t.Fatalf("committed and returned HP differ: saved=%v response=%d", health, hp)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type huntingFixture struct {
|
||||
validationError error
|
||||
settlementError error
|
||||
pack int
|
||||
mode, monster, deck uint64
|
||||
receipt string
|
||||
settlements int
|
||||
}
|
||||
|
||||
func (h *huntingFixture) ValidateBattle(pack int, mode, monster, deck uint64) error {
|
||||
h.pack, h.mode, h.monster, h.deck = pack, mode, monster, deck
|
||||
return h.validationError
|
||||
}
|
||||
func (h *huntingFixture) CompleteBattle(pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) {
|
||||
h.pack, h.mode, h.monster, h.deck, h.receipt = pack, mode, monster, deck, receipt
|
||||
h.settlements++
|
||||
return wire.AppendVarint(nil, 2, 7), [][]byte{wire.AppendVarint(nil, 1, monster)}, h.settlementError
|
||||
}
|
||||
|
||||
func TestHuntingBattleUsesLockedEncounterAndSessionReceipt(t *testing.T) {
|
||||
pack := 1
|
||||
s := NewService("", "", nil, func() (int, error) { return pack, nil })
|
||||
h := &huntingFixture{}
|
||||
s.AttachHunting(h)
|
||||
s.BeginSession("login-A")
|
||||
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 3, 11), 4, 12), 5, huntingGroundMode)
|
||||
h.validationError = errors.New("locked difficulty")
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
|
||||
t.Fatal("invalid encounter accepted")
|
||||
}
|
||||
h.validationError = nil
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pack = 2
|
||||
h.settlementError = errors.New("not enough AP")
|
||||
end := wire.AppendVarint(request(2), 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() {
|
||||
t.Fatal("failed settlement ended battle")
|
||||
}
|
||||
h.settlementError = nil
|
||||
code, response, _, err := s.Handle("/BattleEnd", end)
|
||||
if err != nil || code != 15 {
|
||||
t.Fatalf("settle: %d %v", code, err)
|
||||
}
|
||||
if h.pack != 1 || h.mode != huntingGroundMode || h.monster != 11 || h.deck != 12 || h.receipt != "login-A:2" {
|
||||
t.Fatalf("settled wrong encounter: %+v", h)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 4); !found {
|
||||
t.Fatal("missing monster progress")
|
||||
}
|
||||
if bundle, found, _ := wire.Bytes(response, 5); !found || len(bundle) == 0 {
|
||||
t.Fatal("missing hunting reward")
|
||||
}
|
||||
settlements := h.settlements
|
||||
if _, retry, _, err := s.Handle("/BattleEnd", end); err != nil || !bytes.Equal(retry, response) || h.settlements != settlements {
|
||||
t.Fatal("completed battle retry settled twice or lost response")
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type monsterHuntFixture struct {
|
||||
enter, end []byte
|
||||
enterReceipt, endReceipt string
|
||||
err error
|
||||
}
|
||||
|
||||
func (h *monsterHuntFixture) EnterBattle(request []byte, receipt string) ([]byte, error) {
|
||||
h.enter, h.enterReceipt = append([]byte(nil), request...), receipt
|
||||
return wire.AppendBytes(nil, 5, wire.AppendVarint(nil, 1, 11)), h.err
|
||||
}
|
||||
func (h *monsterHuntFixture) CompleteBattle(request []byte, receipt string) ([]byte, error) {
|
||||
h.end, h.endReceipt = append([]byte(nil), request...), receipt
|
||||
return wire.AppendBytes(nil, 13, wire.AppendVarint(nil, 1, 11)), h.err
|
||||
}
|
||||
|
||||
func TestMonsterHuntBattleUsesSpecialRuntimeAndLeavesFieldHealthAlone(t *testing.T) {
|
||||
for _, mode := range []uint64{8, 24} {
|
||||
s := NewService("", "", nil, func() (int, error) { return 1, nil })
|
||||
h := &monsterHuntFixture{}
|
||||
s.AttachMonsterHunt(h)
|
||||
s.BeginSession("login-A")
|
||||
s.loadPhases = func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
|
||||
t.Fatal("monster hunt used pack phases")
|
||||
return nil, nil
|
||||
}
|
||||
s.AttachCommittedHealth(func(map[uint64]uint64) error { t.Fatal("monster hunt changed field HP"); return nil })
|
||||
s.AttachMonsterWinMission(func() error { t.Fatal("monster hunt awarded field monster mission"); return nil })
|
||||
s.AttachTutorialWin(func() error { t.Fatal("monster hunt awarded tutorial progress"); return nil })
|
||||
enter := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(request(1), 4, 12), 5, mode), 6, 91)
|
||||
h.err = errors.New("hunt unavailable")
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil || s.Active() {
|
||||
t.Fatal("rejected hunt entered")
|
||||
}
|
||||
h.err = nil
|
||||
code, response, _, err := s.Handle("/BattleEnter", enter)
|
||||
if err != nil || code != 52 {
|
||||
t.Fatalf("enter: %d %v", code, err)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 5); !found {
|
||||
t.Fatal("missing hunt user info at enter")
|
||||
}
|
||||
end := wire.AppendVarint(wire.AppendVarint(request(2), 2, 1), 7, 1234)
|
||||
h.err = errors.New("invalid hunt settlement")
|
||||
if _, _, _, err := s.Handle("/BattleEnd", end); err == nil || !s.Active() {
|
||||
t.Fatal("failed hunt settlement finished battle")
|
||||
}
|
||||
h.err = nil
|
||||
code, response, _, err = s.Handle("/BattleEnd", end)
|
||||
if err != nil || code != 15 || h.enterReceipt != "login-A:1" || h.endReceipt != h.enterReceipt {
|
||||
t.Fatalf("end: %d %v %+v", code, err, h)
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 13); !found {
|
||||
t.Fatal("missing hunt settlement progress")
|
||||
}
|
||||
if _, found, _ := wire.Bytes(response, 5); found {
|
||||
t.Fatal("hunt used ordinary pack reward bundle")
|
||||
}
|
||||
if s.Active() {
|
||||
t.Fatal("hunt battle still active")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
package battle
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func phaseService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
s := &Service{loadPhases: func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error) {
|
||||
return []gamedata.BattlePhase{{GroupID: 70, ID: 101, DeckID: 11}, {GroupID: 70, ID: 205, DeckID: 12}, {GroupID: 70, ID: 309, DeckID: 13}}, nil
|
||||
}}
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func phaseStart(t *testing.T, s *Service, seq, deck uint64, blue []byte) []byte {
|
||||
t.Helper()
|
||||
start := wire.AppendVarint(request(seq), 2, deck)
|
||||
start = wire.AppendBytes(start, 5, blue)
|
||||
code, reply, _, err := s.Handle("/BattleStart", start)
|
||||
if err != nil || code != 14 {
|
||||
t.Fatalf("start %d: code=%d err=%v", deck, code, err)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func TestPhaseLifecyclePreservesClientStateAndRejectsSkipping(t *testing.T) {
|
||||
s := phaseService(t)
|
||||
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 1234)
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(2)); err == nil {
|
||||
t.Fatal("phase advanced before start")
|
||||
}
|
||||
phaseStart(t, s, 3, 11, blue)
|
||||
commits := 0
|
||||
s.AttachCommittedHealth(func(map[uint64]uint64) error { commits++; return nil })
|
||||
win := wire.AppendVarint(request(4), 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
|
||||
t.Fatal("early victory accepted")
|
||||
}
|
||||
if commits != 0 {
|
||||
t.Fatal("early victory committed health")
|
||||
}
|
||||
code, reply, _, err := s.Handle("/BattlePhaseChange", request(5))
|
||||
if err != nil || code != 632 {
|
||||
t.Fatalf("phase response=%d %v", code, err)
|
||||
}
|
||||
for field, want := range map[int]uint64{1: 70, 2: 205, 8: 12} {
|
||||
got, found, err := wire.Varint(reply, field)
|
||||
if err != nil || !found || got != want {
|
||||
t.Fatalf("field %d = %d/%v: %v", field, got, found, err)
|
||||
}
|
||||
}
|
||||
if result, found, _ := wire.Bytes(reply, 3); found && len(result) != 0 {
|
||||
t.Fatal("fabricated verified battle result")
|
||||
}
|
||||
verify, _, _ := wire.Varint(reply, 7)
|
||||
if verify != 0 {
|
||||
t.Fatal("verification enabled without authoritative combat state")
|
||||
}
|
||||
_, replay, _, err := s.Handle("/BattlePhaseChange", request(5))
|
||||
if err != nil || !bytes.Equal(reply, replay) || s.stateLocked().phase != 1 {
|
||||
t.Fatal("replay advanced phase")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err == nil {
|
||||
t.Fatal("skipped unstarted phase")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleStart", wire.AppendVarint(request(7), 2, 13)); err == nil {
|
||||
t.Fatal("skipped deck accepted")
|
||||
}
|
||||
changedBlue := wire.AppendVarint(wire.AppendVarint(nil, 2, 400), 4, 600)
|
||||
response := phaseStart(t, s, 8, 12, changedBlue)
|
||||
got, _, _ := wire.Bytes(response, 2)
|
||||
if !bytes.Equal(got, changedBlue) || !bytes.Equal(s.stateLocked().initialBlue[0], blue) {
|
||||
t.Fatal("phase start reset player state or retry baseline")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(9)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
|
||||
t.Fatal("victory before final start")
|
||||
}
|
||||
phaseStart(t, s, 10, 13, changedBlue)
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(11)); err == nil {
|
||||
t.Fatal("advanced past final phase")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(12)); err == nil {
|
||||
t.Fatal("inactive phase request accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseRetryRestoresFirstDeckAndInitialBlue(t *testing.T) {
|
||||
s := phaseService(t)
|
||||
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 401), 4, 200)
|
||||
phaseStart(t, s, 2, 11, blue)
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phaseStart(t, s, 4, 12, wire.AppendVarint(nil, 4, 1))
|
||||
if _, _, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(5), 2, 11)); err == nil {
|
||||
t.Fatal("retry accepted wrong current deck")
|
||||
}
|
||||
code, response, _, err := s.Handle("/BattleRetry", wire.AppendVarint(request(6), 2, 12))
|
||||
index, _, _ := wire.Varint(response, 3)
|
||||
restored, _, _ := wire.Bytes(response, 2)
|
||||
if err != nil || code != 58 || index != 11 || !bytes.Equal(restored, blue) {
|
||||
t.Fatalf("retry: code=%d index=%d blue=%x err=%v", code, index, restored, err)
|
||||
}
|
||||
state := s.stateLocked()
|
||||
if state.phase != 0 || state.deck != 11 || state.phaseStarted || state.phaseReply != nil {
|
||||
t.Fatal("retry did not reset phase state")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(7)); err == nil {
|
||||
t.Fatal("retry advanced before start")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedPhaseStartDoesNotConsumeRound(t *testing.T) {
|
||||
s := phaseService(t)
|
||||
start := append(wire.AppendVarint(request(2), 2, 11), 0x2a, 0x80)
|
||||
if _, _, _, err := s.Handle("/BattleStart", start); err == nil {
|
||||
t.Fatal("malformed start accepted")
|
||||
}
|
||||
if state := s.stateLocked(); state.round != 0 || state.index != 0 || state.phaseStarted {
|
||||
t.Fatal("malformed request mutated battle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseEntryRejectsLaterDeckWithoutReplacingActiveBattle(t *testing.T) {
|
||||
s := phaseService(t)
|
||||
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(3), 4, 12), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err == nil {
|
||||
t.Fatal("entered a later phase directly")
|
||||
}
|
||||
state := s.stateLocked()
|
||||
if state.deck != 11 || state.index != 11 || !state.phaseStarted {
|
||||
t.Fatal("rejected enter replaced active battle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryBattleRejectsPhaseChange(t *testing.T) {
|
||||
s := &Service{}
|
||||
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 11), 5, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phaseStart(t, s, 2, 11, wire.AppendVarint(nil, 2, 400))
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(3)); err == nil {
|
||||
t.Fatal("ordinary battle accepted phase change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseVictoryRewardsOnlyFinalDeck(t *testing.T) {
|
||||
s := phaseService(t)
|
||||
var err error
|
||||
s.inventory, err = player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.gameDataRoot = "test-root"
|
||||
s.stateLocked().pack, s.stateLocked().monster = 77, 42
|
||||
loaded := uint64(0)
|
||||
s.loadRewards = func(_, _ string, pack int, deck uint64) ([]gamedata.BattleReward, error) {
|
||||
if pack != 77 {
|
||||
t.Fatalf("reward pack = %d", pack)
|
||||
}
|
||||
loaded = deck
|
||||
return []gamedata.BattleReward{{Type: 8, ID: 8, Count: 1}}, nil
|
||||
}
|
||||
blue := wire.AppendVarint(nil, 2, 400)
|
||||
phaseStart(t, s, 2, 11, blue)
|
||||
win := wire.AppendVarint(request(3), 2, 1)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", win); err == nil {
|
||||
t.Fatal("early win accepted")
|
||||
}
|
||||
if loaded != 0 {
|
||||
t.Fatal("early win loaded rewards")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(4)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phaseStart(t, s, 5, 12, blue)
|
||||
if _, _, _, err := s.Handle("/BattlePhaseChange", request(6)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
phaseStart(t, s, 7, 13, blue)
|
||||
if _, _, _, err := s.Handle("/BattleEnd", win); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded != 13 || len(s.inventory.GrantedItems("pack77:monster42:deck13")) != 1 {
|
||||
t.Fatalf("final reward deck=%d", loaded)
|
||||
}
|
||||
if len(s.inventory.GrantedItems("pack77:monster42:deck11")) != 0 {
|
||||
t.Fatal("granted first phase reward")
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestMaintenance(t *testing.T) {
|
||||
req := wire.AppendVarint(nil, 1, 2)
|
||||
req = wire.AppendVarint(req, 2, 8)
|
||||
response, err := Maintenance(versionconfig.Game(), versionconfig.Bundle(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
market, found, err := wire.Bytes(response, 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("market info missing: %v", err)
|
||||
}
|
||||
typ, _, _ := wire.Varint(market, 1)
|
||||
if typ != 4 {
|
||||
t.Fatalf("market type does not match response contract: %d", typ)
|
||||
}
|
||||
connect, _, _ := wire.Varint(response, 3)
|
||||
user, _, _ := wire.Varint(response, 4)
|
||||
if connect != 1 || user == 0 {
|
||||
t.Fatalf("client would enter update branch: connect=%d user=%d", connect, user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerInfoNoOfficialEndpoints(t *testing.T) {
|
||||
c := Config{BaseURL: "http://127.0.0.1:8080/game/", CDNURL: "http://127.0.0.1:8080/assets/ServerData", Version: versionconfig.Game(), BundleVer: versionconfig.Bundle()}
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := ServerInfo(c)
|
||||
info, _, _ := wire.Bytes(response, 1)
|
||||
address, _, _ := wire.Bytes(info, 2)
|
||||
if string(address) != c.BaseURL {
|
||||
t.Fatalf("game URL: %q", address)
|
||||
}
|
||||
if _, exists, _ := wire.Bytes(info, 8); exists {
|
||||
t.Fatal("unexpected GameData URL")
|
||||
}
|
||||
if v, _, _ := wire.Varint(ServerNowTime(time.UnixMilli(1234567)), 1); v != 1234567 {
|
||||
t.Fatalf("time: %d", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAcceptsHTTPSPublicOrigin(t *testing.T) {
|
||||
cfg := Config{
|
||||
BaseURL: "https://bd2.example.com/game/", CDNURL: "https://bd2.example.com/assets/ServerData",
|
||||
Version: "client", BundleVer: "bundle",
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("HTTPS self-hosted server config rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerInfoIncludesLocalGameData(t *testing.T) {
|
||||
c := Config{
|
||||
BaseURL: "http://127.0.0.1:8080/game/",
|
||||
CDNURL: "http://127.0.0.1:8080/assets/ServerData",
|
||||
Version: versionconfig.Game(), BundleVer: versionconfig.Bundle(),
|
||||
GameDataURL: "http://127.0.0.1:8080/assets/GameData",
|
||||
GameDataVer: "20260921140855",
|
||||
}
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, found, err := wire.Bytes(ServerInfo(c), 1)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("server info missing: %v", err)
|
||||
}
|
||||
url, _, _ := wire.Bytes(info, 8)
|
||||
version, _, _ := wire.Bytes(info, 9)
|
||||
if string(url) != c.GameDataURL || string(version) != c.GameDataVer {
|
||||
t.Fatalf("GameData mismatch: url=%q version=%q", url, version)
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const MaxFileSize = 8 * 1024 * 1024
|
||||
const maxRows = 100000
|
||||
const maxString = 64 * 1024
|
||||
const headerSize = 46
|
||||
|
||||
var magic = []byte{'B', 'D', '2', 'S', 'C', 'H', 0, 0}
|
||||
|
||||
type encoder struct {
|
||||
data []byte
|
||||
err error
|
||||
rows uint64
|
||||
}
|
||||
|
||||
func (e *encoder) u64(v uint64) {
|
||||
if e.room(8) {
|
||||
e.data = binary.LittleEndian.AppendUint64(e.data, v)
|
||||
}
|
||||
}
|
||||
func (e *encoder) u32(v uint32) {
|
||||
if e.room(4) {
|
||||
e.data = binary.LittleEndian.AppendUint32(e.data, v)
|
||||
}
|
||||
}
|
||||
func (e *encoder) b(v bool) {
|
||||
if !e.room(1) {
|
||||
return
|
||||
}
|
||||
if v {
|
||||
e.data = append(e.data, 1)
|
||||
} else {
|
||||
e.data = append(e.data, 0)
|
||||
}
|
||||
}
|
||||
func (e *encoder) str(v string) {
|
||||
if e.err != nil {
|
||||
return
|
||||
}
|
||||
if len(v) > maxString || !utf8.ValidString(v) {
|
||||
e.err = fmt.Errorf("calendar: invalid/oversized UTF8 string")
|
||||
return
|
||||
}
|
||||
if !e.room(4 + len(v)) {
|
||||
return
|
||||
}
|
||||
e.u32(uint32(len(v)))
|
||||
e.data = append(e.data, v...)
|
||||
}
|
||||
func (e *encoder) count(n int) {
|
||||
if e.err != nil {
|
||||
return
|
||||
}
|
||||
e.rows += uint64(n)
|
||||
if n > maxRows || e.rows > maxRows {
|
||||
e.err = fmt.Errorf("calendar: row limit exceeded")
|
||||
return
|
||||
}
|
||||
e.u32(uint32(n))
|
||||
}
|
||||
func (e *encoder) room(n int) bool {
|
||||
if e.err != nil {
|
||||
return false
|
||||
}
|
||||
if n > MaxFileSize-headerSize-len(e.data) {
|
||||
e.err = fmt.Errorf("calendar: file size limit exceeded")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type decoder struct {
|
||||
data []byte
|
||||
pos int
|
||||
err error
|
||||
rows uint64
|
||||
}
|
||||
|
||||
func (d *decoder) take(n int) []byte {
|
||||
if d.err != nil {
|
||||
return nil
|
||||
}
|
||||
if n < 0 || n > len(d.data)-d.pos {
|
||||
d.err = fmt.Errorf("calendar: truncated payload")
|
||||
return nil
|
||||
}
|
||||
v := d.data[d.pos : d.pos+n]
|
||||
d.pos += n
|
||||
return v
|
||||
}
|
||||
func (d *decoder) u64() uint64 {
|
||||
v := d.take(8)
|
||||
if len(v) != 8 {
|
||||
return 0
|
||||
}
|
||||
return binary.LittleEndian.Uint64(v)
|
||||
}
|
||||
func (d *decoder) u32() uint32 {
|
||||
v := d.take(4)
|
||||
if len(v) != 4 {
|
||||
return 0
|
||||
}
|
||||
return binary.LittleEndian.Uint32(v)
|
||||
}
|
||||
func (d *decoder) b() bool {
|
||||
v := d.take(1)
|
||||
if len(v) != 1 {
|
||||
return false
|
||||
}
|
||||
if v[0] > 1 {
|
||||
d.err = fmt.Errorf("calendar: invalid boolean/presence")
|
||||
}
|
||||
return v[0] == 1
|
||||
}
|
||||
func (d *decoder) str() string {
|
||||
n := d.u32()
|
||||
if n > maxString {
|
||||
d.err = fmt.Errorf("calendar: string limit exceeded")
|
||||
return ""
|
||||
}
|
||||
v := d.take(int(n))
|
||||
if !utf8.Valid(v) {
|
||||
d.err = fmt.Errorf("calendar: invalid UTF8")
|
||||
}
|
||||
return string(v)
|
||||
}
|
||||
func (d *decoder) count() int {
|
||||
n := d.u32()
|
||||
d.rows += uint64(n)
|
||||
if n > maxRows || d.rows > maxRows || uint64(n) > uint64(len(d.data)-d.pos) {
|
||||
d.err = fmt.Errorf("calendar: row count limit/truncation")
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
// MarshalBinary emits format 1's fixed-order record payload and checksum.
|
||||
func MarshalBinary(m Manifest) ([]byte, error) {
|
||||
if m.SchemaVersion != 1 {
|
||||
return nil, fmt.Errorf("calendar: unsupported schema")
|
||||
}
|
||||
e := &encoder{}
|
||||
e.manifest(m)
|
||||
if e.err != nil {
|
||||
return nil, e.err
|
||||
}
|
||||
if len(e.data) > MaxFileSize-headerSize {
|
||||
return nil, fmt.Errorf("calendar: file size limit exceeded")
|
||||
}
|
||||
out := append([]byte(nil), magic...)
|
||||
out = binary.LittleEndian.AppendUint16(out, 1)
|
||||
out = binary.LittleEndian.AppendUint32(out, uint32(len(e.data)))
|
||||
sum := sha256.Sum256(e.data)
|
||||
out = append(out, sum[:]...)
|
||||
return append(out, e.data...), nil
|
||||
}
|
||||
|
||||
// UnmarshalBinary rejects damaged, unsupported, oversized and trailing data.
|
||||
func UnmarshalBinary(raw []byte) (Manifest, error) {
|
||||
var m Manifest
|
||||
if len(raw) < headerSize || len(raw) > MaxFileSize {
|
||||
return m, fmt.Errorf("calendar: invalid file size")
|
||||
}
|
||||
if !bytes.Equal(raw[:8], magic) || binary.LittleEndian.Uint16(raw[8:10]) != 1 {
|
||||
return m, fmt.Errorf("calendar: unsupported magic/format")
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(raw[10:14])
|
||||
if uint64(n) != uint64(len(raw)-headerSize) {
|
||||
return m, fmt.Errorf("calendar: payload length mismatch")
|
||||
}
|
||||
payload := raw[headerSize:]
|
||||
sum := sha256.Sum256(payload)
|
||||
if !bytes.Equal(raw[14:46], sum[:]) {
|
||||
return m, fmt.Errorf("calendar: checksum mismatch")
|
||||
}
|
||||
d := &decoder{data: payload}
|
||||
m = d.manifest()
|
||||
if d.err != nil {
|
||||
return Manifest{}, d.err
|
||||
}
|
||||
if d.pos != len(payload) {
|
||||
return Manifest{}, fmt.Errorf("calendar: trailing payload")
|
||||
}
|
||||
m.SchemaVersion = 1
|
||||
return m, nil
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstalledBinaryFilesReencodeExactly(t *testing.T) {
|
||||
paths, e := filepath.Glob("../../../../schedules/*.bd2schedule")
|
||||
if e != nil || len(paths) != 6 {
|
||||
t.Fatalf("installed files=%d err=%v", len(paths), e)
|
||||
}
|
||||
for _, path := range paths {
|
||||
raw, e := os.ReadFile(path)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
m, e := UnmarshalBinary(raw)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
actual, e := MarshalBinary(m)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if !bytes.Equal(raw, actual) {
|
||||
t.Fatalf("canonical bytes changed %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestBinaryRejectsInvalidPresence(t *testing.T) {
|
||||
raw, e := MarshalBinary(baseManifest())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
d := &decoder{data: raw[headerSize:]}
|
||||
d.str()
|
||||
d.str()
|
||||
d.str()
|
||||
d.count()
|
||||
d.count()
|
||||
d.count()
|
||||
raw[headerSize+d.pos] = 2
|
||||
sum := sha256.Sum256(raw[headerSize:])
|
||||
copy(raw[14:46], sum[:])
|
||||
if _, e = UnmarshalBinary(raw); e == nil {
|
||||
t.Fatal("invalid optional presence accepted despite valid checksum")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBinaryRoundTripAllRecords(t *testing.T) {
|
||||
m := baseManifest()
|
||||
m.Events = []Event{{UID: 5, Type: 11, ID: 7, Start: "2026-01-01T00:00:00Z", End: "2026-02-01T00:00:00Z"}}
|
||||
m.Gacha = []Gacha{{GroupID: 2, FreeCountBonus: true}}
|
||||
m.StepUps = []Gacha{{GroupID: 3}}
|
||||
m.Regular = &Regular{Contents: []Content{{ID: 4, Current: Season{ID: 8, Error: true}, Next: Season{ID: 9, Return: true}}}}
|
||||
m.MonsterHunt = &MonsterHunt{Seasons: []Hunt{{HuntID: 6, CostumeBanIDs: []uint64{5}, BurstBanIDs: []uint64{7}, IndependentFlag: true}}, History: []HuntHistory{{Season: 5, HuntID: 6, Hidden: true}}}
|
||||
m.CashProducts = []CashProduct{{GroupID: 1, ProductID: 2, EventIndex: 3}}
|
||||
m.EventHubs = []EventHub{{HubID: 4, Settings: []HubSetting{{Slot: 5, EventUIDs: []uint64{6, 7}}}}}
|
||||
m.MiniGameHubs = []MiniGameHub{{Slot: 8, EventUID: 5}}
|
||||
raw, e := MarshalBinary(m)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
actual, e := UnmarshalBinary(raw)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if !reflect.DeepEqual(actual, m) {
|
||||
t.Fatalf("roundtrip changed records: %+v", actual)
|
||||
}
|
||||
}
|
||||
func TestBinaryRejectsCorruptionLimitsAndTrailing(t *testing.T) {
|
||||
raw, e := MarshalBinary(baseManifest())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for n := range len(raw) {
|
||||
if _, e = UnmarshalBinary(raw[:n]); e == nil {
|
||||
t.Fatalf("accepted truncation %d", n)
|
||||
}
|
||||
}
|
||||
for _, offset := range []int{0, 8, 10, 14, 46} {
|
||||
bad := append([]byte(nil), raw...)
|
||||
bad[offset] ^= 0xff
|
||||
if _, e = UnmarshalBinary(bad); e == nil {
|
||||
t.Fatalf("accepted corruption %d", offset)
|
||||
}
|
||||
}
|
||||
if _, e = UnmarshalBinary(append(raw, 0)); e == nil {
|
||||
t.Fatal("accepted trailing data")
|
||||
}
|
||||
m := baseManifest()
|
||||
m.Revision = string(make([]byte, maxString+1))
|
||||
if _, e = MarshalBinary(m); e == nil {
|
||||
t.Fatal("oversized string accepted")
|
||||
}
|
||||
m = baseManifest()
|
||||
m.Events = make([]Event, maxRows+1)
|
||||
if _, e = MarshalBinary(m); e == nil {
|
||||
t.Fatal("oversized count accepted")
|
||||
}
|
||||
bad := append([]byte(nil), raw...)
|
||||
binary.LittleEndian.PutUint32(bad[46:50], ^uint32(0))
|
||||
sum := sha256.Sum256(bad[46:])
|
||||
copy(bad[14:46], sum[:])
|
||||
if _, e = UnmarshalBinary(bad); e == nil {
|
||||
t.Fatal("overflow string length accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ValidateDesign checks playable identities against their domain's installed
|
||||
// GameData. Announcement-only types 3/6/14/15/16/18 have no local gameplay
|
||||
// design resolver and receive only the manifest's protocol/format validation.
|
||||
func (s *Set) ValidateDesign(root, version string) error {
|
||||
cash, e := gamedata.LoadCashCatalog(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
keys := map[gamedata.CashProductKey]bool{}
|
||||
for _, v := range cash.Products {
|
||||
keys[v.Key] = true
|
||||
}
|
||||
for _, v := range s.CashProducts {
|
||||
if !keys[gamedata.CashProductKey{GroupID: v.GroupID, ProductID: v.ProductID, SaleGroup: v.SaleGroup}] {
|
||||
return fmt.Errorf("calendar: cash product %d/%d/%d missing GameData", v.GroupID, v.ProductID, v.SaleGroup)
|
||||
}
|
||||
}
|
||||
if s.MonsterHunt != nil {
|
||||
ids, e := gamedata.LoadMonsterHuntIDs(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for _, v := range s.MonsterHunt.Seasons {
|
||||
if !ids[v.HuntID] {
|
||||
return fmt.Errorf("calendar: active/playable hunt %d missing GameData", v.HuntID)
|
||||
}
|
||||
}
|
||||
}
|
||||
play, e := gamedata.LoadEventPlayCatalog(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
tasks, e := gamedata.LoadEventTasksDesign(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
exchange, e := gamedata.LoadEventExchangeCatalog(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
actions, e := gamedata.LoadEventActionsDesign(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
packs, e := gamedata.LoadCalendarPackIDs(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for _, h := range s.EventHubs {
|
||||
if _, e = play.Row("PackEventHubTable", 14, h.HubID); e != nil {
|
||||
return fmt.Errorf("calendar: hub %d: %w", h.HubID, e)
|
||||
}
|
||||
}
|
||||
if err := s.validateMiniHubBindings(play); err != nil {
|
||||
return err
|
||||
}
|
||||
uid := map[uint64]events.Schedule{}
|
||||
for _, v := range s.Events {
|
||||
if v.UID != 0 {
|
||||
uid[v.UID] = v
|
||||
}
|
||||
}
|
||||
for _, h := range s.MiniGameHubs {
|
||||
v, ok := uid[h.EventUID]
|
||||
if !ok || v.Type != 11 {
|
||||
return fmt.Errorf("calendar: mini game slot %d references non-minigame event %d", h.Slot, h.EventUID)
|
||||
}
|
||||
if _, e := play.Row("PackEventMiniGameTable", 8, v.ID); e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
}
|
||||
for _, v := range s.Events {
|
||||
valid := true
|
||||
var err error
|
||||
switch v.Type {
|
||||
case 0:
|
||||
_, valid = tasks.Attendance[v.ID]
|
||||
case 1:
|
||||
valid = false
|
||||
for k := range tasks.LimitRewards {
|
||||
if k[0] == v.ID {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
case 4:
|
||||
_, valid = tasks.MissionGroups[v.ID]
|
||||
case 5:
|
||||
_, valid = tasks.Passes[v.ID]
|
||||
case 7:
|
||||
_, valid = exchange.Groups[v.ID]
|
||||
case 8:
|
||||
valid = packs[v.ID]
|
||||
case 9:
|
||||
_, err = play.Row("PackEventBattleGroupTable", 3, v.ID)
|
||||
case 10:
|
||||
_, err = play.Row("PackEventStoryGroupTable", 1, v.ID)
|
||||
case 11:
|
||||
_, err = play.Row("PackEventMiniGameTable", 8, v.ID)
|
||||
case 12, 13, 17, 19:
|
||||
_, err = gamedata.LoadEventGame(root, version, v.Type, v.ID)
|
||||
case 20:
|
||||
_, valid = actions.Row("TacticsBingoGroupTable", 3, v.ID)
|
||||
case 21:
|
||||
_, valid = actions.Row("FieldSpawnEventTable", 5, v.ID)
|
||||
case 22:
|
||||
_, valid = actions.Row("FireworksTable", 4, v.ID)
|
||||
case 23:
|
||||
_, valid = actions.Row("VotingEventTable", 6, v.ID)
|
||||
case 24:
|
||||
_, valid = actions.Row("FriendshipSpecialEpisodeTable", 5, v.ID)
|
||||
case 25:
|
||||
id := v.SubID
|
||||
if id == 0 {
|
||||
id = v.ID
|
||||
}
|
||||
_, valid = actions.Row("CafeteriaEventTable", 5, id)
|
||||
}
|
||||
if !valid || err != nil {
|
||||
return fmt.Errorf("calendar: event uid %d type %d id %d missing GameData: %v", v.UID, v.Type, v.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/readonly"
|
||||
"bd2server/internal/server/wire"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeManifest(t *testing.T, dir, name string, m Manifest) {
|
||||
t.Helper()
|
||||
raw, e := MarshalBinary(m)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = os.WriteFile(filepath.Join(dir, name), raw, 0600); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func baseManifest() Manifest {
|
||||
return Manifest{SchemaVersion: 1, Revision: "2026-10-05", GameVersion: "2.35.10", GameDataVersion: "2.35.10"}
|
||||
}
|
||||
func TestInstalledCalendarsContainAllRuntimeDomains(t *testing.T) {
|
||||
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(set.Events) != 54 || len(set.GachaSeed.Schedules) != 11 || len(set.GachaSeed.StepUps) != 2 || set.RegularService == nil || len(set.RegularService.Contents) != 9 || set.MonsterHunt == nil || len(set.MonsterHunt.History) != 80 || len(set.CashProducts) != 61 || len(set.EventHubs) != 4 || len(set.MiniGameHubs) != 6 {
|
||||
t.Fatalf("installed calendar omitted a domain: events=%d gacha=%d steps=%d cash=%d hubs=%d mini=%d", len(set.Events), len(set.GachaSeed.Schedules), len(set.GachaSeed.StepUps), len(set.CashProducts), len(set.EventHubs), len(set.MiniGameHubs))
|
||||
}
|
||||
var mooncake, chained *EventHub
|
||||
for i := range set.EventHubs {
|
||||
h := &set.EventHubs[i]
|
||||
if h.UID == 3 && h.HubID == 1003 {
|
||||
mooncake = h
|
||||
}
|
||||
if h.UID == 76 && h.HubID == 59 {
|
||||
chained = h
|
||||
}
|
||||
}
|
||||
if mooncake == nil || mooncake.PlayEnd != "2026-09-30T23:59:59.000Z" || len(mooncake.Settings) != 3 || mooncake.Settings[2].EventUIDs[0] != 10000034 {
|
||||
t.Fatal("published Mooncake Wishes calendar omitted its deferred board slot")
|
||||
}
|
||||
if chained == nil || len(chained.Settings) != 6 || chained.Settings[2].EventUIDs[0] != 80 {
|
||||
t.Fatal("published Chained Soldier hub omitted its event shop")
|
||||
}
|
||||
}
|
||||
func TestMultiFileCalendarAtomicDeterministicAndIndependentRevision(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := baseManifest()
|
||||
a.Revision = "a"
|
||||
a.Gacha = []Gacha{{GroupID: 9, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
|
||||
writeManifest(t, dir, "z.bd2schedule", a)
|
||||
b := baseManifest()
|
||||
b.Revision = "b"
|
||||
b.Gacha = []Gacha{{GroupID: 1, Start: "2027-01-01T09:00:00+09:00", End: "2027-01-02T09:00:00+09:00"}, {GroupID: 9, Start: "2026-10-02T00:00:00Z", End: "2026-10-03T00:00:00Z"}}
|
||||
writeManifest(t, dir, "a.bd2schedule", b)
|
||||
set, e := LoadDirectory(dir, "2.35.10", "2.35.10")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(set.GachaSeed.Schedules) != 3 || set.GachaSeed.Schedules[0].GroupID != 1 || set.Revisions[0] != "b" {
|
||||
t.Fatalf("unexpected merged set %+v", set)
|
||||
}
|
||||
b.Revision = "changed without game update"
|
||||
b.Gacha[0].End = "2027-01-03T09:00:00+09:00"
|
||||
writeManifest(t, dir, "a.bd2schedule", b)
|
||||
changed, e := LoadDirectory(dir, "2.35.10", "2.35.10")
|
||||
if e != nil || changed.GachaSeed.Schedules[0].EndTime == set.GachaSeed.Schedules[0].EndTime {
|
||||
t.Fatalf("calendar update ignored: %v", e)
|
||||
}
|
||||
if e = os.WriteFile(filepath.Join(dir, "broken.bd2schedule"), []byte("broken binary"), 0600); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if partial, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil || partial != nil {
|
||||
t.Fatal("bad file returned partial calendar")
|
||||
}
|
||||
}
|
||||
func TestStrictCalendarRejectsConflictsAndInvalidData(t *testing.T) {
|
||||
for _, mutate := range []func(*Manifest){func(m *Manifest) { m.GameDataVersion = "wrong" }, func(m *Manifest) { m.Gacha[0].Start = "2026-10-01" }, func(m *Manifest) { m.Gacha[0].End = m.Gacha[0].Start }, func(m *Manifest) { m.Gacha = append(m.Gacha, m.Gacha[0]) }, func(m *Manifest) { m.Gacha[0].GroupID = 1 << 32 }} {
|
||||
dir := t.TempDir()
|
||||
m := baseManifest()
|
||||
m.Gacha = []Gacha{{GroupID: 1, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
|
||||
mutate(&m)
|
||||
writeManifest(t, dir, "one.bd2schedule", m)
|
||||
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
|
||||
t.Fatal("invalid calendar accepted")
|
||||
}
|
||||
}
|
||||
dir := t.TempDir()
|
||||
m := baseManifest()
|
||||
m.Events = []Event{{UID: 77, Type: 12, ID: 3, Start: "2026-10-01T00:00:00Z", End: "2026-10-02T00:00:00Z"}}
|
||||
writeManifest(t, dir, "a.bd2schedule", m)
|
||||
writeManifest(t, dir, "b.bd2schedule", m)
|
||||
if _, e := LoadDirectory(dir, "2.35.10", "2.35.10"); e == nil {
|
||||
t.Fatal("duplicate UID across files accepted")
|
||||
}
|
||||
}
|
||||
func TestReadonlyCalendarReplacementPreservesStaticResponses(t *testing.T) {
|
||||
set := &Set{CashProducts: []CashProduct{{GroupID: 11, ProductID: 22, EventIndex: 99}}, MonsterHunt: &MonsterHunt{StartRegularSeason: 5, History: []HuntHistory{{Season: 78, HuntID: 73, Hidden: true}}}}
|
||||
old := &readonly.Seed{Version: "2.35.10", Responses: map[string]readonly.Response{"/Static": {PacketCode: 17, Fields: []readonly.Field{scalar(1, 10)}}, "/CashShopInfo": {PacketCode: 60, Fields: []readonly.Field{scalar(2, 1)}}}}
|
||||
result, e := set.ApplyReadonly(old)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(old.Responses["/CashShopInfo"].Fields) != 1 || result.Responses["/Static"].PacketCode != 17 {
|
||||
t.Fatal("source seed changed/static endpoint lost")
|
||||
}
|
||||
_, raw, handled, e := result.Handle("/CashShopInfo", wire.AppendVarint(nil, 1, 1))
|
||||
if e != nil || !handled {
|
||||
t.Fatal(e)
|
||||
}
|
||||
p, found, e := wire.Bytes(raw, 1)
|
||||
if e != nil || !found {
|
||||
t.Fatal("missing product")
|
||||
}
|
||||
id, _, _ := wire.Varint(p, 8)
|
||||
if id != 99 {
|
||||
t.Fatalf("event index=%d", id)
|
||||
}
|
||||
if _, found, _ = wire.Varint(raw, 2); found {
|
||||
t.Fatal("stale reset timestamp retained")
|
||||
}
|
||||
}
|
||||
func TestInstalledCalendarRealGameData(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
set, e := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = set.ValidateDesign(root, "20260923193640"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// validateMiniHubBindings rejects invalid published routes at startup instead
|
||||
// of letting a client open an incompatible prefab or use another activity UID.
|
||||
func (s *Set) validateMiniHubBindings(design *gamedata.EventPlayCatalog) error {
|
||||
uid := make(map[uint64]events.Schedule)
|
||||
for _, v := range s.Events {
|
||||
if v.UID != 0 {
|
||||
uid[v.UID] = v
|
||||
}
|
||||
}
|
||||
value := func(row []byte, field int) uint64 { v, _, _ := wire.Varint(row, field); return v }
|
||||
for _, hub := range s.EventHubs {
|
||||
table, err := design.Row("PackEventHubTable", 14, hub.HubID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value(table, 13) != 1 {
|
||||
continue
|
||||
}
|
||||
if hub.UID == 0 {
|
||||
return fmt.Errorf("calendar: mini hub %d requires a nonzero UID", hub.HubID)
|
||||
}
|
||||
start, err := timestamp(hub.Start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
playEnd, err := timestamp(hub.PlayEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
end, err := timestamp(hub.End)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, binding := range hub.Settings {
|
||||
var slot []byte
|
||||
for _, row := range design.Rows("PackEventListTable", 6, hub.HubID) {
|
||||
if value(row, 11) != binding.Slot {
|
||||
continue
|
||||
}
|
||||
if slot != nil {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d is ambiguous in GameData", hub.HubID, binding.Slot)
|
||||
}
|
||||
slot = row
|
||||
}
|
||||
if slot == nil {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d missing GameData", hub.HubID, binding.Slot)
|
||||
}
|
||||
contentType, contentID := value(slot, 9), value(slot, 7)
|
||||
if binding.ProgressType != contentType {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d content type %d, want %d", hub.HubID, binding.Slot, binding.ProgressType, contentType)
|
||||
}
|
||||
eventType, supported := gamedata.MiniHubEventType(contentType)
|
||||
// Mini stories and NPC quizzes have their own slot UID namespace,
|
||||
// distinct from Define_EventType (13 there means bingo). Their group
|
||||
// and availability derive from this static slot and the hub window.
|
||||
if contentType == 13 || contentType == 14 {
|
||||
if len(binding.EventUIDs) != 1 || binding.EventUIDs[0] == 0 {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d requires one local content UID", hub.HubID, binding.Slot)
|
||||
}
|
||||
if _, collision := uid[binding.EventUIDs[0]]; collision {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d content UID collides with global event", hub.HubID, binding.Slot)
|
||||
}
|
||||
if value(slot, 4) != 0 {
|
||||
return fmt.Errorf("calendar: mini hub %d story/quiz slot has invalid end type", hub.HubID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !supported {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d content type %d has no supported scheduled route", hub.HubID, binding.Slot, contentType)
|
||||
}
|
||||
endType := value(slot, 4)
|
||||
if endType > 1 {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d unsupported end type %d", hub.HubID, binding.Slot, endType)
|
||||
}
|
||||
matches := 0
|
||||
for _, id := range binding.EventUIDs {
|
||||
child, ok := uid[id]
|
||||
if !ok || child.Type != eventType || child.ID != contentID || child.SubID != 0 {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d references incompatible event UID %d", hub.HubID, binding.Slot, id)
|
||||
}
|
||||
childStart, childEnd := child.Start, child.End
|
||||
if childStart < int64(start) {
|
||||
childStart = int64(start)
|
||||
}
|
||||
if childEnd > int64(end) {
|
||||
childEnd = int64(end)
|
||||
}
|
||||
// Project policy: EndDateType=0 closes the slot at PlayEnd;
|
||||
// EndDateType=1 allows it through the final hub End window.
|
||||
if endType == 0 && childEnd > int64(playEnd) {
|
||||
childEnd = int64(playEnd)
|
||||
}
|
||||
if childStart < childEnd {
|
||||
matches++
|
||||
}
|
||||
}
|
||||
if matches != 1 {
|
||||
return fmt.Errorf("calendar: mini hub %d slot %d has %d schedules in its window, want one", hub.HubID, binding.Slot, matches)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestMiniHubPublishedRoutesValidateIndependentEnumsAndWindows(t *testing.T) {
|
||||
row := func(fields ...uint64) []byte {
|
||||
var b []byte
|
||||
for i := 0; i < len(fields); i += 2 {
|
||||
b = wire.AppendVarint(b, int(fields[i]), fields[i+1])
|
||||
}
|
||||
return b
|
||||
}
|
||||
start := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
format := func(d time.Duration) string { return start.Add(d).Format(time.RFC3339) }
|
||||
design := &gamedata.EventPlayCatalog{Tables: map[string][][]byte{
|
||||
"PackEventHubTable": {row(14, 7, 13, 1)},
|
||||
"PackEventListTable": {row(6, 7, 10, 42, 11, 5, 9, 12, 7, 99, 4, 1)},
|
||||
}}
|
||||
fresh := func() *Set {
|
||||
return &Set{
|
||||
Events: []events.Schedule{{UID: 21, Type: 19, ID: 99, Start: start.UnixMilli(), End: start.Add(48 * time.Hour).UnixMilli()}},
|
||||
EventHubs: []EventHub{{UID: 1, HubID: 7, Start: format(0), PlayEnd: format(24 * time.Hour), End: format(48 * time.Hour), Settings: []HubSetting{{Slot: 5, ProgressType: 12, EventUIDs: []uint64{21}}}}},
|
||||
}
|
||||
}
|
||||
if err := fresh().validateMiniHubBindings(design); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
change func(*Set)
|
||||
}{
|
||||
{"wrong content enum", func(s *Set) { s.EventHubs[0].Settings[0].ProgressType = 19 }},
|
||||
{"table id is not slot index", func(s *Set) { s.EventHubs[0].Settings[0].Slot = 42 }},
|
||||
{"missing UID", func(s *Set) { s.EventHubs[0].Settings[0].EventUIDs = []uint64{22} }},
|
||||
{"wrong event enum", func(s *Set) { s.Events[0].Type = 12 }},
|
||||
{"wrong design id", func(s *Set) { s.Events[0].ID = 7 }},
|
||||
{"wrong sub identity", func(s *Set) { s.Events[0].SubID = 7 }},
|
||||
{"no window overlap", func(s *Set) {
|
||||
s.Events[0].Start = start.Add(72 * time.Hour).UnixMilli()
|
||||
s.Events[0].End = start.Add(96 * time.Hour).UnixMilli()
|
||||
}},
|
||||
{"ambiguous schedule", func(s *Set) {
|
||||
other := s.Events[0]
|
||||
other.UID = 22
|
||||
s.Events = append(s.Events, other)
|
||||
s.EventHubs[0].Settings[0].EventUIDs = append(s.EventHubs[0].Settings[0].EventUIDs, 22)
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
s := fresh()
|
||||
test.change(s)
|
||||
if err := s.validateMiniHubBindings(design); err == nil {
|
||||
t.Fatal("invalid published route accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
// A future hub must validate without comparing its dates to the current clock.
|
||||
design.Tables["PackEventListTable"][0] = row(6, 7, 10, 42, 11, 5, 9, 13, 7, 99, 4, 1)
|
||||
s := fresh()
|
||||
s.EventHubs[0].Settings[0].ProgressType = 13
|
||||
if err := s.validateMiniHubBindings(design); err == nil {
|
||||
t.Fatal("quiz borrowed global bingo event type")
|
||||
}
|
||||
design.Tables["PackEventListTable"][0] = row(6, 7, 10, 42, 11, 11, 9, 13, 7, 99)
|
||||
s = fresh()
|
||||
s.EventHubs[0].Settings = []HubSetting{{Slot: 11, ProgressType: 13, EventUIDs: []uint64{10000032}}}
|
||||
if err := s.validateMiniHubBindings(design); err != nil {
|
||||
t.Fatalf("independent quiz content UID rejected: %v", err)
|
||||
}
|
||||
s.EventHubs[0].Settings[0].EventUIDs = []uint64{21}
|
||||
if err := s.validateMiniHubBindings(design); err == nil {
|
||||
t.Fatal("quiz content UID collided with global event")
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import "testing"
|
||||
|
||||
// Current-version publication facts were verified against the client schemas,
|
||||
// GameData identities and 2026-10-05 schedule comparison. No capture is read.
|
||||
func TestPublishedRegularAndGachaCalendarCompleteness(t *testing.T) {
|
||||
set, err := LoadDirectory("../../../../schedules", "2.35.10", "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[uint64][2]uint64{1: {163, 164}, 2: {58, 58}, 3: {131, 999999}, 4: {877, 999999}, 5: {30, 31}, 6: {27, 28}, 7: {15, 999999}, 8: {44, 45}, 9: {2, 999999}}
|
||||
if len(set.RegularService.Contents) != len(want) || set.RegularService.CalculateMilliseconds != 32400000 {
|
||||
t.Fatal("regular content calendar incomplete")
|
||||
}
|
||||
for _, c := range set.RegularService.Contents {
|
||||
if want[c.ID] != [2]uint64{c.Current.ID, c.Next.ID} {
|
||||
t.Fatalf("content %d stale seasons %d/%d", c.ID, c.Current.ID, c.Next.ID)
|
||||
}
|
||||
}
|
||||
current := map[uint64]bool{166: true, 30010: true, 30011: true, 206: true, 205: true, 208: true, 153: true, 72: true, 71: true, 207: true}
|
||||
for _, g := range set.GachaSeed.Schedules {
|
||||
if current[g.GroupID] {
|
||||
delete(current, g.GroupID)
|
||||
if g.EndTime != 1791417599000 {
|
||||
t.Fatalf("gacha %d truncated window", g.GroupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(current) != 0 {
|
||||
t.Fatalf("missing published gacha groups %v", current)
|
||||
}
|
||||
if len(set.GachaSeed.StepUps) != 2 {
|
||||
t.Fatal("step-up schedule omitted")
|
||||
}
|
||||
for _, g := range set.GachaSeed.StepUps {
|
||||
if g.GroupID != 29 && g.GroupID != 30 || g.EndTime != 1791417599000 {
|
||||
t.Fatalf("step-up schedule incorrect %+v", g)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package calendar
|
||||
|
||||
import "bd2server/internal/server/schedule"
|
||||
|
||||
func (e *encoder) manifest(v Manifest) {
|
||||
e.str(v.Revision)
|
||||
e.str(v.GameVersion)
|
||||
e.str(v.GameDataVersion)
|
||||
e.count(len(v.Events))
|
||||
for _, item := range v.Events {
|
||||
e.event(item)
|
||||
}
|
||||
e.count(len(v.Gacha))
|
||||
for _, item := range v.Gacha {
|
||||
e.gacha(item)
|
||||
}
|
||||
e.count(len(v.StepUps))
|
||||
for _, item := range v.StepUps {
|
||||
e.gacha(item)
|
||||
}
|
||||
e.b(v.Regular != nil)
|
||||
if v.Regular != nil {
|
||||
e.regular(*v.Regular)
|
||||
}
|
||||
e.b(v.MonsterHunt != nil)
|
||||
if v.MonsterHunt != nil {
|
||||
e.monsterhunt(*v.MonsterHunt)
|
||||
}
|
||||
e.count(len(v.CashProducts))
|
||||
for _, item := range v.CashProducts {
|
||||
e.cashproduct(item)
|
||||
}
|
||||
e.count(len(v.EventHubs))
|
||||
for _, item := range v.EventHubs {
|
||||
e.eventhub(item)
|
||||
}
|
||||
e.count(len(v.MiniGameHubs))
|
||||
for _, item := range v.MiniGameHubs {
|
||||
e.minigamehub(item)
|
||||
}
|
||||
}
|
||||
func (d *decoder) manifest() Manifest {
|
||||
var v Manifest
|
||||
v.Revision = d.str()
|
||||
v.GameVersion = d.str()
|
||||
v.GameDataVersion = d.str()
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.Events = append(v.Events, d.event())
|
||||
}
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.Gacha = append(v.Gacha, d.gacha())
|
||||
}
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.StepUps = append(v.StepUps, d.gacha())
|
||||
}
|
||||
if d.b() {
|
||||
item := d.regular()
|
||||
v.Regular = &item
|
||||
}
|
||||
if d.b() {
|
||||
item := d.monsterhunt()
|
||||
v.MonsterHunt = &item
|
||||
}
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.CashProducts = append(v.CashProducts, d.cashproduct())
|
||||
}
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.EventHubs = append(v.EventHubs, d.eventhub())
|
||||
}
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.MiniGameHubs = append(v.MiniGameHubs, d.minigamehub())
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (e *encoder) event(v Event) {
|
||||
e.u64(v.UID)
|
||||
e.u64(v.Type)
|
||||
e.u64(v.ID)
|
||||
e.u64(v.SubID)
|
||||
e.str(v.Start)
|
||||
e.str(v.End)
|
||||
}
|
||||
func (d *decoder) event() Event {
|
||||
var v Event
|
||||
v.UID = d.u64()
|
||||
v.Type = d.u64()
|
||||
v.ID = d.u64()
|
||||
v.SubID = d.u64()
|
||||
v.Start = d.str()
|
||||
v.End = d.str()
|
||||
return v
|
||||
}
|
||||
func (e *encoder) gacha(v Gacha) {
|
||||
e.u64(v.GroupID)
|
||||
e.str(v.Start)
|
||||
e.str(v.End)
|
||||
e.b(v.FreeCountBonus)
|
||||
e.b(v.CashCountBonus)
|
||||
}
|
||||
func (d *decoder) gacha() Gacha {
|
||||
var v Gacha
|
||||
v.GroupID = d.u64()
|
||||
v.Start = d.str()
|
||||
v.End = d.str()
|
||||
v.FreeCountBonus = d.b()
|
||||
v.CashCountBonus = d.b()
|
||||
return v
|
||||
}
|
||||
func (e *encoder) season(v Season) {
|
||||
e.u64(v.ID)
|
||||
e.str(v.Start)
|
||||
e.str(v.End)
|
||||
e.u64(v.RankRewardGroupID)
|
||||
e.b(v.Error)
|
||||
e.b(v.Return)
|
||||
}
|
||||
func (d *decoder) season() Season {
|
||||
var v Season
|
||||
v.ID = d.u64()
|
||||
v.Start = d.str()
|
||||
v.End = d.str()
|
||||
v.RankRewardGroupID = d.u64()
|
||||
v.Error = d.b()
|
||||
v.Return = d.b()
|
||||
return v
|
||||
}
|
||||
func (e *encoder) content(v Content) {
|
||||
e.u64(v.ID)
|
||||
e.season(v.Current)
|
||||
e.season(v.Next)
|
||||
}
|
||||
func (d *decoder) content() Content {
|
||||
var v Content
|
||||
v.ID = d.u64()
|
||||
v.Current = d.season()
|
||||
v.Next = d.season()
|
||||
return v
|
||||
}
|
||||
func (e *encoder) regular(v Regular) {
|
||||
e.u64(v.CalculateMilliseconds)
|
||||
e.count(len(v.Contents))
|
||||
for _, item := range v.Contents {
|
||||
e.content(item)
|
||||
}
|
||||
e.count(len(v.Regular))
|
||||
for _, item := range v.Regular {
|
||||
e.regularseason(item)
|
||||
}
|
||||
}
|
||||
func (d *decoder) regular() Regular {
|
||||
var v Regular
|
||||
v.CalculateMilliseconds = d.u64()
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.Contents = append(v.Contents, d.content())
|
||||
}
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.Regular = append(v.Regular, d.regularseason())
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (e *encoder) hunt(v Hunt) {
|
||||
e.season(v.Season)
|
||||
e.u64(v.HuntID)
|
||||
e.u64(v.InfoOpenDay)
|
||||
e.str(v.CalculateEndAt)
|
||||
e.b(v.ErrorFlag)
|
||||
e.b(v.IndependentFlag)
|
||||
e.u64(v.RankRewardGroupID)
|
||||
e.count(len(v.CostumeBanIDs))
|
||||
for _, item := range v.CostumeBanIDs {
|
||||
e.u64(item)
|
||||
}
|
||||
e.count(len(v.BurstBanIDs))
|
||||
for _, item := range v.BurstBanIDs {
|
||||
e.u64(item)
|
||||
}
|
||||
}
|
||||
func (d *decoder) hunt() Hunt {
|
||||
var v Hunt
|
||||
v.Season = d.season()
|
||||
v.HuntID = d.u64()
|
||||
v.InfoOpenDay = d.u64()
|
||||
v.CalculateEndAt = d.str()
|
||||
v.ErrorFlag = d.b()
|
||||
v.IndependentFlag = d.b()
|
||||
v.RankRewardGroupID = d.u64()
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.CostumeBanIDs = append(v.CostumeBanIDs, d.u64())
|
||||
}
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.BurstBanIDs = append(v.BurstBanIDs, d.u64())
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (e *encoder) hunthistory(v HuntHistory) {
|
||||
e.u64(v.Season)
|
||||
e.u64(v.HuntID)
|
||||
e.b(v.ErrorFlag)
|
||||
e.b(v.Hidden)
|
||||
}
|
||||
func (d *decoder) hunthistory() HuntHistory {
|
||||
var v HuntHistory
|
||||
v.Season = d.u64()
|
||||
v.HuntID = d.u64()
|
||||
v.ErrorFlag = d.b()
|
||||
v.Hidden = d.b()
|
||||
return v
|
||||
}
|
||||
func (e *encoder) monsterhunt(v MonsterHunt) {
|
||||
e.count(len(v.Seasons))
|
||||
for _, item := range v.Seasons {
|
||||
e.hunt(item)
|
||||
}
|
||||
e.u64(v.StartRegularSeason)
|
||||
e.count(len(v.History))
|
||||
for _, item := range v.History {
|
||||
e.hunthistory(item)
|
||||
}
|
||||
}
|
||||
func (d *decoder) monsterhunt() MonsterHunt {
|
||||
var v MonsterHunt
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.Seasons = append(v.Seasons, d.hunt())
|
||||
}
|
||||
v.StartRegularSeason = d.u64()
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.History = append(v.History, d.hunthistory())
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (e *encoder) cashproduct(v CashProduct) {
|
||||
e.u64(v.GroupID)
|
||||
e.u64(v.ProductID)
|
||||
e.u64(v.SaleGroup)
|
||||
e.str(v.Start)
|
||||
e.str(v.End)
|
||||
e.u64(v.EndDelayMinutes)
|
||||
e.u64(v.EventIndex)
|
||||
}
|
||||
func (d *decoder) cashproduct() CashProduct {
|
||||
var v CashProduct
|
||||
v.GroupID = d.u64()
|
||||
v.ProductID = d.u64()
|
||||
v.SaleGroup = d.u64()
|
||||
v.Start = d.str()
|
||||
v.End = d.str()
|
||||
v.EndDelayMinutes = d.u64()
|
||||
v.EventIndex = d.u64()
|
||||
return v
|
||||
}
|
||||
func (e *encoder) hubsetting(v HubSetting) {
|
||||
e.u64(v.Slot)
|
||||
e.u64(v.ProgressType)
|
||||
e.count(len(v.EventUIDs))
|
||||
for _, item := range v.EventUIDs {
|
||||
e.u64(item)
|
||||
}
|
||||
}
|
||||
func (d *decoder) hubsetting() HubSetting {
|
||||
var v HubSetting
|
||||
v.Slot = d.u64()
|
||||
v.ProgressType = d.u64()
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.EventUIDs = append(v.EventUIDs, d.u64())
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (e *encoder) eventhub(v EventHub) {
|
||||
e.u64(v.UID)
|
||||
e.u64(v.HubID)
|
||||
e.str(v.Start)
|
||||
e.str(v.PlayEnd)
|
||||
e.str(v.End)
|
||||
e.count(len(v.Settings))
|
||||
for _, item := range v.Settings {
|
||||
e.hubsetting(item)
|
||||
}
|
||||
}
|
||||
func (d *decoder) eventhub() EventHub {
|
||||
var v EventHub
|
||||
v.UID = d.u64()
|
||||
v.HubID = d.u64()
|
||||
v.Start = d.str()
|
||||
v.PlayEnd = d.str()
|
||||
v.End = d.str()
|
||||
for n := d.count(); n > 0 && d.err == nil; n-- {
|
||||
v.Settings = append(v.Settings, d.hubsetting())
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (e *encoder) minigamehub(v MiniGameHub) {
|
||||
e.u64(v.Slot)
|
||||
e.u64(v.EventUID)
|
||||
e.u64(v.ProgressType)
|
||||
}
|
||||
func (d *decoder) minigamehub() MiniGameHub {
|
||||
var v MiniGameHub
|
||||
v.Slot = d.u64()
|
||||
v.EventUID = d.u64()
|
||||
v.ProgressType = d.u64()
|
||||
return v
|
||||
}
|
||||
func (e *encoder) regularseason(v schedule.RegularSeason) {
|
||||
e.u64(v.ContentID)
|
||||
e.u64(v.Season)
|
||||
}
|
||||
func (d *decoder) regularseason() schedule.RegularSeason {
|
||||
var v schedule.RegularSeason
|
||||
v.ContentID = d.u64()
|
||||
v.Season = d.u64()
|
||||
return v
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type attendanceHandler interface {
|
||||
Handle(string, []byte) (int, []byte, bool, error)
|
||||
}
|
||||
|
||||
// AttendanceHandler preserves the original event progress response and adds
|
||||
// subscription grants in extension fields understood by the commerce plugin.
|
||||
// All operations execute inside the transport's account transaction.
|
||||
type AttendanceHandler struct {
|
||||
Events attendanceHandler
|
||||
Economy *EntitlementEconomy
|
||||
LoginPasses *LoginPasses
|
||||
Store stateio.Store
|
||||
}
|
||||
|
||||
type attendanceReceipt struct {
|
||||
Digest string `json:"digest"`
|
||||
Bundle []byte `json:"bundle"`
|
||||
}
|
||||
|
||||
func (h AttendanceHandler) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
return h.HandleSession(path, request, "")
|
||||
}
|
||||
|
||||
func (h AttendanceHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
|
||||
if path != "/Attendance" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
if session == "" || h.Events == nil || h.Economy == nil || h.Store == nil {
|
||||
return 0, nil, true, fmt.Errorf("commerce: attendance dependencies/session unavailable")
|
||||
}
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
return 0, nil, true, fmt.Errorf("commerce: missing attendance sequence")
|
||||
}
|
||||
code, response, handled, err := h.Events.Handle(path, request)
|
||||
if err != nil || !handled {
|
||||
return code, response, handled, err
|
||||
}
|
||||
// The event handler may already have granted ordinary attendance rewards.
|
||||
// The client accepts exactly one reward envelope, so combine every grant in
|
||||
// execution order under this wrapper's replay identity.
|
||||
response, eventBundle, err := takeAttendanceRewardEnvelope(response)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
key := fmt.Sprintf("commerce_attendance:%x:%d", sha256.Sum256([]byte(session)), seq)
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||
var receipt attendanceReceipt
|
||||
previous, err := h.Store.Load(key)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
var loginBundle []byte
|
||||
if h.LoginPasses != nil {
|
||||
var infos [][]byte
|
||||
loginBundle, infos, err = h.LoginPasses.ClaimAndInfo(key)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
for _, info := range infos {
|
||||
response = wire.AppendBytes(response, 6, info)
|
||||
}
|
||||
}
|
||||
if previous != nil {
|
||||
if err = json.Unmarshal(previous, &receipt); err != nil || receipt.Digest != digest {
|
||||
return code, nil, true, fmt.Errorf("commerce: conflicting attendance replay")
|
||||
}
|
||||
} else {
|
||||
receipt.Digest = digest
|
||||
subscriptionBundle, err := h.Economy.ClaimSubscriptions(key)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
// Preserve events -> login pass -> subscription execution order and all
|
||||
// repeated reward entries in the single client envelope.
|
||||
receipt.Bundle = append(receipt.Bundle, eventBundle...)
|
||||
receipt.Bundle = append(receipt.Bundle, loginBundle...)
|
||||
receipt.Bundle = append(receipt.Bundle, subscriptionBundle...)
|
||||
raw, err := json.Marshal(receipt)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
if err = h.Store.Save(key, raw); err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
}
|
||||
response, err = h.Economy.MergeAttendance(response)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
if len(receipt.Bundle) != 0 {
|
||||
response = wire.AppendBytes(response, 1001, receipt.Bundle)
|
||||
response = wire.AppendString(response, 1002, key)
|
||||
}
|
||||
return code, response, true, nil
|
||||
}
|
||||
|
||||
// Strip the child envelope before adding the combined one. Keep native and
|
||||
// unrelated unknown fields byte-for-byte; reject ambiguous child envelopes
|
||||
// rather than returning a response the client would silently ignore.
|
||||
func takeAttendanceRewardEnvelope(response []byte) ([]byte, []byte, error) {
|
||||
var native, bundle []byte
|
||||
var receipt string
|
||||
var hasBundle, hasReceipt bool
|
||||
err := wire.Walk(response, func(f wire.Field) error {
|
||||
switch f.Number {
|
||||
case 1001:
|
||||
if f.Type != 2 || hasBundle {
|
||||
return fmt.Errorf("commerce: ambiguous attendance reward bundle")
|
||||
}
|
||||
hasBundle = true
|
||||
bundle = append([]byte(nil), f.Value...)
|
||||
case 1002:
|
||||
if f.Type != 2 || hasReceipt {
|
||||
return fmt.Errorf("commerce: ambiguous attendance reward receipt")
|
||||
}
|
||||
hasReceipt = true
|
||||
receipt = string(f.Value)
|
||||
default:
|
||||
native = append(native, response[f.Start:f.End]...)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if hasBundle != hasReceipt || hasReceipt && (receipt == "" || len(receipt) > 1024) {
|
||||
return nil, nil, fmt.Errorf("commerce: incomplete attendance reward envelope")
|
||||
}
|
||||
return native, bundle, nil
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/events"
|
||||
"bd2server/internal/server/eventtasks"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/mail"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type originalAttendance struct{}
|
||||
|
||||
func (originalAttendance) Handle(path string, _ []byte) (int, []byte, bool, error) {
|
||||
return 0, wire.AppendVarint(nil, 55, 123), path == "/Attendance", nil
|
||||
}
|
||||
|
||||
type attendanceBalances struct {
|
||||
balance uint64
|
||||
calls int
|
||||
}
|
||||
|
||||
func (e *attendanceBalances) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
var bundle []byte
|
||||
for _, r := range rewards {
|
||||
e.balance += r.Count
|
||||
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(player.Item{Type: r.Type, ID: r.ID, Count: r.Count}))
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func TestAttendanceMailsOrdinaryRewardsAndCombinesLoginPassAndSubscription(t *testing.T) {
|
||||
e, items, _, _, now := entitlementFixture(t)
|
||||
// eventtasks uses the production clock; align the commerce fixture with it
|
||||
// while buying the subscription on the previous reset day.
|
||||
*now = time.Now().UTC().Add(-24 * time.Hour)
|
||||
balances := &attendanceBalances{}
|
||||
e.base = balances
|
||||
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
registry := events.NewRegistry()
|
||||
if err := registry.Replace([]events.Schedule{{UID: 17, Type: 0, ID: 1, Start: 1, End: now.Add(time.Hour).UnixMilli()}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.EventTasksDesign{
|
||||
Attendance: map[uint64]gamedata.EventAttendance{1: {ID: 1, Group: 10}},
|
||||
AttendanceRewards: map[uint64][]gamedata.EventAttendanceReward{10: {{Group: 10, ID: 1, Day: 1, Basic: gamedata.Reward{Type: 3, Count: 2}}}},
|
||||
}
|
||||
tasks, err := eventtasks.Open(e.store, design, registry, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks.SetSession("combined-session")
|
||||
mailWallet, err := player.OpenWallet(e.store, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailbox, err := mail.OpenService(e.store, &mail.Starter{Version: "2.35.10", MailCount: 1}, items, mailWallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mailbox.AttachAttendanceRewardEconomy(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tasks.AttachAttendanceMail(mailbox)
|
||||
passes, err := NewLoginPasses(e.store, &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
|
||||
20: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}}},
|
||||
}}, e, items, func(uint64) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passes.SetClock(func() time.Time { return *now }, 0)
|
||||
h := AttendanceHandler{Events: tasks, Economy: e, LoginPasses: passes, Store: e.store}
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if balances.calls != 3 || balances.balance != 17 {
|
||||
t.Fatalf("wrong combined grant: calls=%d balance=%d", balances.calls, balances.balance)
|
||||
}
|
||||
counts := map[int]int{}
|
||||
if err := wire.Walk(response, func(f wire.Field) error { counts[f.Number]++; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if counts[1001] != 1 || counts[1002] != 1 || counts[5] != 1 || counts[6] != 1 {
|
||||
t.Fatalf("client requires a single combined envelope and native notices: %v", counts)
|
||||
}
|
||||
bundle, _, _ := wire.Bytes(response, 1001)
|
||||
var snapshots []uint64
|
||||
if err := wire.Walk(bundle, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
count, _, err := wire.Varint(f.Value, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots = append(snapshots, count)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snapshots) != 2 || snapshots[0] != 11 || snapshots[1] != 5 {
|
||||
t.Fatalf("combined reward entries lost or reordered: %v", snapshots)
|
||||
}
|
||||
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil || !bytes.Equal(response, replay) || balances.calls != 3 {
|
||||
t.Fatal("combined retry changed rewards", err)
|
||||
}
|
||||
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
|
||||
if err != nil || balances.calls != 3 {
|
||||
t.Fatal("fresh request granted again", err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 1001); ok {
|
||||
t.Fatal("fresh request replays old reward envelope")
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 5); ok {
|
||||
t.Fatal("fresh request replays old attendance stamps")
|
||||
}
|
||||
_, mailInfo, _, err := mailbox.Handle("/MailInfo", wire.AppendVarint(nil, 1, 3))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailCount := 0
|
||||
var mailID uint64
|
||||
if err := wire.Walk(mailInfo, func(f wire.Field) error {
|
||||
if f.Number == 1 {
|
||||
mailCount++
|
||||
mailID, _, _ = wire.Varint(f.Value, 1)
|
||||
}
|
||||
return nil
|
||||
}); err != nil || mailCount != 1 {
|
||||
t.Fatalf("ordinary attendance did not issue exactly one mail: count=%d err=%v", mailCount, err)
|
||||
}
|
||||
open := wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, mailID)
|
||||
if _, _, _, err := mailbox.Handle("/MailOpen", open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if balances.calls != 4 || balances.balance != 19 {
|
||||
t.Fatalf("ordinary reward must arrive only after opening mail: calls=%d balance=%d", balances.calls, balances.balance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceRejectsAmbiguousChildRewardEnvelope(t *testing.T) {
|
||||
native := wire.AppendVarint(nil, 55, 123)
|
||||
bundle := wire.AppendBytes(native, 1001, wire.AppendVarint(nil, 7, 1))
|
||||
valid := wire.AppendString(bundle, 1002, "event-receipt")
|
||||
stripped, rewards, err := takeAttendanceRewardEnvelope(valid)
|
||||
if err != nil || !bytes.Equal(stripped, native) || len(rewards) == 0 {
|
||||
t.Fatal("valid envelope was not preserved", err)
|
||||
}
|
||||
for _, malformed := range [][]byte{
|
||||
bundle,
|
||||
wire.AppendString(native, 1002, "event-receipt"),
|
||||
wire.AppendBytes(valid, 1001, nil),
|
||||
wire.AppendString(valid, 1002, "second"),
|
||||
wire.AppendString(bundle, 1002, ""),
|
||||
wire.AppendVarint(native, 1001, 1),
|
||||
} {
|
||||
if _, _, err := takeAttendanceRewardEnvelope(malformed); err == nil {
|
||||
t.Fatalf("ambiguous envelope accepted: %x", malformed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceExtensionReplaysRewardAndPreservesNativeResponse(t *testing.T) {
|
||||
e, _, base, _, now := entitlementFixture(t)
|
||||
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, Store: e.store}
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
_, response, handled, err := h.HandleSession("/Attendance", request, "session")
|
||||
if err != nil || !handled {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok, _ := wire.Varint(response, 55); !ok || v != 123 {
|
||||
t.Fatal("native response lost")
|
||||
}
|
||||
bundle, ok, err := wire.Bytes(response, 1001)
|
||||
if err != nil || !ok || len(bundle) == 0 {
|
||||
t.Fatal("reward extension missing")
|
||||
}
|
||||
if receipt, ok, _ := wire.Bytes(response, 1002); !ok || len(receipt) == 0 {
|
||||
t.Fatal("receipt extension missing")
|
||||
}
|
||||
calls := base.Calls
|
||||
_, replay, _, err := h.HandleSession("/Attendance", request, "session")
|
||||
if err != nil || !bytes.Equal(response, replay) || base.Calls != calls {
|
||||
t.Fatal("retry lost reward or granted twice", err)
|
||||
}
|
||||
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "session")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 1001); ok || base.Calls != calls {
|
||||
t.Fatal("same day duplicate grant")
|
||||
}
|
||||
_, _, _, err = h.HandleSession("/Attendance", append(request, wire.AppendVarint(nil, 2, 1)...), "session")
|
||||
if err == nil {
|
||||
t.Fatal("conflicting sequence accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttendanceCombinesLoginPassAndSubscriptionWithoutDuplicateClaims(t *testing.T) {
|
||||
e, items, base, _, now := entitlementFixture(t)
|
||||
if _, err := e.Apply("subscription", nil, []gamedata.Reward{{Type: 19, ID: 38, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*now = now.Add(24 * time.Hour)
|
||||
design := &gamedata.LoginPassCatalog{Groups: map[uint64][]gamedata.LoginPassReward{
|
||||
10: {{ID: 1, TicketID: 77, Free: gamedata.Reward{Type: 3, Count: 11}, Premium: gamedata.Reward{Type: 3, Count: 22}}},
|
||||
}}
|
||||
passes, err := NewLoginPasses(e.store, design, e, items, func(group uint64) bool { return group == 10 })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
passes.SetClock(func() time.Time { return *now }, 0)
|
||||
h := AttendanceHandler{Events: originalAttendance{}, Economy: e, LoginPasses: passes, Store: e.store}
|
||||
calls := base.Calls
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
_, response, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil || base.Calls != calls+2 {
|
||||
t.Fatalf("daily claims not combined: calls=%d err=%v", base.Calls-calls, err)
|
||||
}
|
||||
info, ok, err := wire.Bytes(response, 6)
|
||||
if err != nil || !ok {
|
||||
t.Fatal("login-pass metadata lost", err)
|
||||
}
|
||||
if group, _, _ := wire.Varint(info, 1); group != 10 {
|
||||
t.Fatal("wrong login-pass group")
|
||||
}
|
||||
bundle, ok, err := wire.Bytes(response, 1001)
|
||||
if err != nil || !ok || len(bundle) == 0 {
|
||||
t.Fatal("combined reward envelope missing", err)
|
||||
}
|
||||
_, replay, _, err := h.HandleSession("/Attendance", request, "combined-session")
|
||||
if err != nil || !bytes.Equal(replay, response) || base.Calls != calls+2 {
|
||||
t.Fatal("retry changed the envelope or repeated claims", err)
|
||||
}
|
||||
_, next, _, err := h.HandleSession("/Attendance", wire.AppendVarint(nil, 1, 2), "combined-session")
|
||||
if err != nil || base.Calls != calls+2 {
|
||||
t.Fatal("fresh request claimed the same day again", err)
|
||||
}
|
||||
if _, ok, _ := wire.Bytes(next, 1001); ok {
|
||||
t.Fatal("fresh request replayed old rewards")
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
package commerce
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type bonusGroup struct{ ProductGroup, ContentsGroup uint64 }
|
||||
type bonusPurchases interface {
|
||||
LifetimePurchaseTotal([]gamedata.CashProductKey) (uint64, error)
|
||||
}
|
||||
type bonusState struct {
|
||||
Claims map[string]bool `json:"claims"`
|
||||
Receipts map[string]purchaseReceipt `json:"receipts"`
|
||||
}
|
||||
type CashBonuses struct {
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
economy Economy
|
||||
counts bonusPurchases
|
||||
groups map[bonusGroup][]gamedata.CashProductKey
|
||||
rewards map[bonusGroup][]gamedata.CashBonusReward
|
||||
ordered []bonusGroup
|
||||
claimIDs map[string]bool
|
||||
}
|
||||
|
||||
func bonusClaimID(group bonusGroup, id uint64) string {
|
||||
return fmt.Sprintf("%d:%d:%d", group.ProductGroup, group.ContentsGroup, id)
|
||||
}
|
||||
|
||||
func NewCashBonuses(store stateio.Store, economy Economy, counts bonusPurchases, design *gamedata.CashBonusCatalog, packages []gamedata.CashPackageDesign) (*CashBonuses, error) {
|
||||
if store == nil || economy == nil || counts == nil || design == nil {
|
||||
return nil, fmt.Errorf("commerce: missing cash bonus dependency")
|
||||
}
|
||||
s := &CashBonuses{store: store, economy: economy, counts: counts, groups: map[bonusGroup][]gamedata.CashProductKey{}, rewards: map[bonusGroup][]gamedata.CashBonusReward{}, claimIDs: map[string]bool{}}
|
||||
seen := map[gamedata.CashProductKey]bool{}
|
||||
for _, p := range packages {
|
||||
// EPackages.BonusBundleGroup=8; unrelated packages can reuse a contents ID.
|
||||
if p.PackageType != 8 || len(design.Groups[p.ContentsGroupID]) == 0 {
|
||||
continue
|
||||
}
|
||||
k := gamedata.CashProductKey{GroupID: p.GroupID, ProductID: p.ID, SaleGroup: p.SaleGroup}
|
||||
if seen[k] {
|
||||
return nil, fmt.Errorf("commerce: duplicate cash bonus product %+v", k)
|
||||
}
|
||||
seen[k] = true
|
||||
group := bonusGroup{p.GroupID, p.ContentsGroupID}
|
||||
s.groups[group] = append(s.groups[group], k)
|
||||
s.rewards[group] = design.Groups[p.ContentsGroupID]
|
||||
}
|
||||
for group, rows := range s.rewards {
|
||||
s.ordered = append(s.ordered, group)
|
||||
for _, row := range rows {
|
||||
s.claimIDs[bonusClaimID(group, row.ID)] = true
|
||||
}
|
||||
}
|
||||
sort.Slice(s.ordered, func(i, j int) bool {
|
||||
if s.ordered[i].ProductGroup != s.ordered[j].ProductGroup {
|
||||
return s.ordered[i].ProductGroup < s.ordered[j].ProductGroup
|
||||
}
|
||||
return s.ordered[i].ContentsGroup < s.ordered[j].ContentsGroup
|
||||
})
|
||||
_, err := s.load()
|
||||
return s, err
|
||||
}
|
||||
|
||||
func (s *CashBonuses) load() (bonusState, error) {
|
||||
v := bonusState{Claims: map[string]bool{}, Receipts: map[string]purchaseReceipt{}}
|
||||
raw, err := s.store.Load("commerce_cash_bonuses")
|
||||
if err != nil || raw == nil {
|
||||
return v, err
|
||||
}
|
||||
if err = stateio.RequireExactJSONObject(raw, "claims", "receipts"); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if err = json.Unmarshal(raw, &v); err != nil {
|
||||
return v, err
|
||||
}
|
||||
if v.Claims == nil || v.Receipts == nil {
|
||||
return v, fmt.Errorf("commerce: malformed cash bonus state")
|
||||
}
|
||||
for id, claimed := range v.Claims {
|
||||
if !claimed || !s.claimIDs[id] {
|
||||
return v, fmt.Errorf("commerce: invalid cash bonus claim %s", id)
|
||||
}
|
||||
}
|
||||
for id, r := range v.Receipts {
|
||||
digest, err := hex.DecodeString(r.Digest)
|
||||
if id == "" || err != nil || len(digest) != sha256.Size || len(r.Response) == 0 {
|
||||
return v, fmt.Errorf("commerce: invalid cash bonus receipt")
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (s *CashBonuses) rewarded(v bonusState, group bonusGroup, field int) []byte {
|
||||
var out []byte
|
||||
for _, row := range s.rewards[group] {
|
||||
if v.Claims[bonusClaimID(group, row.ID)] {
|
||||
out = wire.AppendVarint(out, field, row.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *CashBonuses) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
return s.HandleSession(path, request, "")
|
||||
}
|
||||
|
||||
func (s *CashBonuses) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) {
|
||||
code := 588
|
||||
if path == "/CashBonusReward" {
|
||||
code = 589
|
||||
} else if path != "/CashBonusInfo" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
seq, present, err := wire.Varint(request, 1)
|
||||
if err != nil || !present || seq == 0 || seq > math.MaxInt32 {
|
||||
return code, nil, true, fmt.Errorf("commerce: invalid cash bonus sequence")
|
||||
}
|
||||
if err = wire.Walk(request, func(wire.Field) error { return nil }); err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
if path == "/CashBonusInfo" {
|
||||
var out []byte
|
||||
for _, group := range s.ordered {
|
||||
n, err := s.counts.LifetimePurchaseTotal(s.groups[group])
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
// Preserve the native empty response for groups with no purchases.
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
b := wire.AppendVarint(nil, 1, group.ProductGroup)
|
||||
b = wire.AppendVarint(b, 2, group.ContentsGroup)
|
||||
b = wire.AppendVarint(b, 3, n)
|
||||
b = append(b, s.rewarded(v, group, 4)...)
|
||||
out = wire.AppendBytes(out, 1, b)
|
||||
}
|
||||
return code, out, true, nil
|
||||
}
|
||||
if session == "" {
|
||||
return code, nil, true, fmt.Errorf("commerce: cash bonus session unavailable")
|
||||
}
|
||||
identity := fmt.Sprintf("cash-bonus:%x:%d", sha256.Sum256([]byte(session)), seq)
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||
if receipt, ok := v.Receipts[identity]; ok {
|
||||
if receipt.Digest != digest {
|
||||
return code, nil, true, fmt.Errorf("commerce: conflicting cash bonus replay")
|
||||
}
|
||||
return code, append([]byte(nil), receipt.Response...), true, nil
|
||||
}
|
||||
groupID, _, err := wire.Varint(request, 2)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
contents, _, err := wire.Varint(request, 3)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
id, _, err := wire.Varint(request, 4)
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
group := bonusGroup{groupID, contents}
|
||||
var chosen *gamedata.CashBonusReward
|
||||
for _, row := range s.rewards[group] {
|
||||
if row.ID == id {
|
||||
copy := row
|
||||
chosen = ©
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return code, nil, true, fmt.Errorf("commerce: unknown cash bonus group=%d contents=%d bonus=%d", groupID, contents, id)
|
||||
}
|
||||
n, err := s.counts.LifetimePurchaseTotal(s.groups[group])
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
if n < chosen.RequireCount {
|
||||
return code, nil, true, fmt.Errorf("commerce: cash bonus purchase threshold not reached group=%d contents=%d bonus=%d count=%d require=%d", groupID, contents, id, n, chosen.RequireCount)
|
||||
}
|
||||
claimID := bonusClaimID(group, id)
|
||||
var bundle []byte
|
||||
if !v.Claims[claimID] {
|
||||
bundle, err = s.economy.Apply("cash-bonus-grant:"+claimID, nil, []gamedata.Reward{chosen.Reward})
|
||||
if err != nil {
|
||||
return code, nil, true, err
|
||||
}
|
||||
v.Claims[claimID] = true
|
||||
}
|
||||
response := wire.AppendBytes(nil, 1, bundle)
|
||||
response = append(response, s.rewarded(v, group, 2)...)
|
||||
v.Receipts[identity] = purchaseReceipt{Digest: digest, Response: response}
|
||||
raw, err := json.Marshal(v)
|
||||
if err == nil {
|
||||
err = s.store.Save("commerce_cash_bonuses", raw)
|
||||
}
|
||||
return code, response, true, err
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user