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.
155 lines
6.5 KiB
Go
155 lines
6.5 KiB
Go
package gacha
|
|
|
|
import (
|
|
"bytes"
|
|
"testing"
|
|
|
|
"bd2server/internal/server/gamedata"
|
|
"bd2server/internal/server/player"
|
|
"bd2server/internal/server/stateio"
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
func TestFirstGachaPreviewConfirmAndReplay(t *testing.T) {
|
|
characters := make(map[uint64]gamedata.CharacterDesign)
|
|
equipmentDesign := make(map[uint64]gamedata.EquipmentDesign)
|
|
program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 1, DropType: 1}
|
|
for i := uint64(0); i < 5; i++ {
|
|
costumeID := 61001 + i
|
|
equipmentID := 71001 + i
|
|
characters[costumeID] = fixtureCharacter(6100+i, 100+i)
|
|
equipmentDesign[equipmentID] = gamedata.EquipmentDesign{ID: equipmentID, Grade: 4}
|
|
program.Entries = append(program.Entries,
|
|
gamedata.FirstGachaRewardEntry{ItemType: 11, ItemID: costumeID, Count: 1, Weight: 1},
|
|
gamedata.FirstGachaRewardEntry{ItemType: 10, ItemID: equipmentID, Count: 1, Weight: 1})
|
|
}
|
|
first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, equipmentDesign)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{61001}, characters)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
|
|
1: {ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}},
|
|
}, characters)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
storage := stateio.NewMemory()
|
|
collection, err := player.OpenCollectionStore(storage, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wallet, err := player.OpenWallet(storage, player.Currency{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
equipment, err := player.OpenEquipmentInventory(storage)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := NewService(infinite, regular, collection, wallet)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.AttachFirstGacha(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment)
|
|
service.BeginSession("first-session")
|
|
|
|
previewRequest := wire.AppendVarint(wire.AppendVarint(nil, 1, 11), 2, 20)
|
|
code, preview, handled, err := service.Handle("/GachaBuyPreview", previewRequest)
|
|
if err != nil || !handled || code != 175 {
|
|
t.Fatalf("preview code=%d handled=%v err=%v", code, handled, err)
|
|
}
|
|
bundle, found, err := wire.Bytes(preview, 1)
|
|
if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 {
|
|
t.Fatalf("preview costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err)
|
|
}
|
|
if len(collection.Costumes()) != 0 || len(equipment.All()) != 0 || service.FirstGachaCompleted() {
|
|
t.Fatal("preview changed authoritative ownership")
|
|
}
|
|
_, replayPreview, _, err := service.Handle("/GachaBuyPreview", previewRequest)
|
|
if err != nil || !bytes.Equal(preview, replayPreview) {
|
|
t.Fatalf("same preview request was not idempotent: err=%v", err)
|
|
}
|
|
// session.Server activates every SessionAware handler before every request.
|
|
// Re-activating the same login between preview and confirmation must not
|
|
// discard the server-owned result selected by the player.
|
|
service.BeginSession("first-session")
|
|
|
|
buyRequest := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 20), 3, 1)
|
|
code, response, handled, err := service.Handle("/GachaBuy", buyRequest)
|
|
if err != nil || !handled || code != 146 {
|
|
t.Fatalf("confirm code=%d handled=%v err=%v", code, handled, err)
|
|
}
|
|
bundle, found, err = wire.Bytes(response, 1)
|
|
if err != nil || !found || countFields(bundle, 3) != 5 || countFields(bundle, 4) != 5 {
|
|
t.Fatalf("confirm costumes=%d equipment=%d found=%v err=%v", countFields(bundle, 3), countFields(bundle, 4), found, err)
|
|
}
|
|
if !service.FirstGachaCompleted() || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 {
|
|
t.Fatalf("confirmed state completed=%v costumes=%d equipment=%d", service.FirstGachaCompleted(), len(collection.Costumes()), len(equipment.All()))
|
|
}
|
|
if user := collection.GachaUser(2); user.TotalBuyCount != 10 || user.Point != 0 {
|
|
t.Fatalf("first gacha accounting=%+v", user)
|
|
}
|
|
for i, costume := range collection.Costumes() {
|
|
if costume.SortID != uint64(i*2) {
|
|
t.Fatalf("costume %d sort=%d want=%d", costume.ID, costume.SortID, i*2)
|
|
}
|
|
}
|
|
_, replay, _, err := service.Handle("/GachaBuy", buyRequest)
|
|
if err != nil || !bytes.Equal(response, replay) || len(collection.Costumes()) != 5 || len(equipment.All()) != 5 {
|
|
t.Fatalf("confirm replay mutated state: costumes=%d equipment=%d err=%v", len(collection.Costumes()), len(equipment.All()), err)
|
|
}
|
|
service.BeginSession("second-session")
|
|
if _, _, _, err := service.Handle("/GachaBuyPreview", wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20)); err == nil {
|
|
t.Fatal("completed first gacha reopened in a new session")
|
|
}
|
|
}
|
|
|
|
func TestFirstGachaRejectsConfirmationWithoutPreview(t *testing.T) {
|
|
// The full happy-path test proves attachment. This assertion locks down the
|
|
// security boundary: a forged buy cannot skip the server-owned reroll.
|
|
service := newFirstGachaTestService(t)
|
|
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 20), 3, 1)
|
|
if _, _, handled, err := service.Handle("/GachaBuy", request); err == nil || !handled {
|
|
t.Fatalf("direct confirmation handled=%v err=%v", handled, err)
|
|
}
|
|
}
|
|
|
|
func newFirstGachaTestService(t *testing.T) *Service {
|
|
t.Helper()
|
|
characters := map[uint64]gamedata.CharacterDesign{61001: fixtureCharacter(6100, 100)}
|
|
program := &gamedata.FirstGachaRewardGroup{ID: 20, DropCount: 10, Entries: []gamedata.FirstGachaRewardEntry{{ItemType: 11, ItemID: 61001, Count: 1, Weight: 1}}}
|
|
first, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 2, GachaSubType: 3, TenTimeGachaID: 20}, 20, 10, program, characters, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{61001}, characters)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}}}, characters)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
storage := stateio.NewMemory()
|
|
collection, _ := player.OpenCollectionStore(storage, nil)
|
|
wallet, _ := player.OpenWallet(storage, player.Currency{})
|
|
equipment, _ := player.OpenEquipmentInventory(storage)
|
|
service, err := NewService(infinite, regular, collection, wallet)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.AttachFirstGacha(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service.AttachEquipmentGacha(first.EquipmentCatalog(), equipment)
|
|
service.BeginSession("first-no-preview")
|
|
return service
|
|
}
|