Generate equipment loot atomically, grant random boxes and prevent repeat claims. Read gacha pools, pity, products, tickets, missions, presets, chapter rewards and inventory costs from GameData; extend both collaboration UR ticket pools. Sync owned recipes, configure startup state and calendars in versioned seeds, and validate unlock tickets and revival talents.
378 lines
14 KiB
Go
378 lines
14 KiB
Go
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
|
|
}()
|