Files
bd2/go/internal/server/deck/deck_test.go
T
Flechazo 5b5a219dc2 feat(server): add special loot and data-driven gameplay rules
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.
2026-10-04 19:15:59 +08:00

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
}()