Files
bd2/go/internal/server/gacha/gacha_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

1988 lines
79 KiB
Go

package gacha
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
const version = "20260923193640"
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{10001, 135, 205, 121}, []uint64{29})
if err != nil {
t.Fatal(err)
}
equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{10002, 9, 133, 206})
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)
}
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
granted, err := inventory.GrantOnce("ticket", []gamedata.BattleReward{{Type: 8, ID: 1104, Count: 20}})
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)
}
service.AttachInventory(inventory)
service.AttachEquipmentGacha(equipmentCatalog, equipment)
service.BeginSession("ticket-only")
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 71200001)
request = wire.AppendVarint(request, 3, 1)
ticket := granted[0]
ticket.Count = 10
request = wire.AppendBytes(request, 4, player.ItemWire(ticket))
code, response, handled, err := service.Handle("/GachaBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("ticket-only draw code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 4) != 10 {
t.Fatalf("ticket-only reward equipment=%d found=%v err=%v", countFields(bundle, 4), found, err)
}
if len(equipment.All()) != 10 {
t.Fatalf("ticket-only persisted equipment=%d", len(equipment.All()))
}
if user := collection.GachaUser(71200001); user != (player.GachaUserState{}) {
t.Fatalf("standalone draw invented schedule accounting: %+v", user)
}
remaining := inventory.All()
foundTicket := false
for _, item := range remaining {
if item.InvenIndex == ticket.InvenIndex {
foundTicket = true
if item.Count != 10 {
t.Fatalf("ticket count=%d want=10", item.Count)
}
}
}
if !foundTicket {
t.Fatal("remaining ticket stack missing")
}
_, replay, _, err := service.Handle("/GachaBuy", request)
if err != nil || string(replay) != string(response) || len(equipment.All()) != 10 {
t.Fatalf("ticket-only replay changed result: equipment=%d err=%v", len(equipment.All()), err)
}
}
func TestCostumeFixedStatesOmitDisabledThresholds(t *testing.T) {
result := gamedata.GachaFixedRoll{
CostumeGrade4Count: 7,
CostumeGrade5Count: 8,
CostumeGrade4Sort: 2,
CostumeGrade5Sort: 3,
}
fiveOnly := costumeFixedStates(gamedata.GachaFixedDesign{ID: 3, CostumeGrade5Count: 10}, result)
if len(fiveOnly) != 1 || fiveOnly[0].FixedID != 3 || fiveOnly[0].Type != 1 || fiveOnly[0].Count != 8 || fiveOnly[0].ApplySort != 3 {
t.Fatalf("five-only states=%+v", fiveOnly)
}
fourOnly := costumeFixedStates(gamedata.GachaFixedDesign{ID: 5, CostumeGrade4Count: 10}, result)
if len(fourOnly) != 1 || fourOnly[0].FixedID != 5 || fourOnly[0].Type != 0 || fourOnly[0].Count != 7 || fourOnly[0].ApplySort != 2 {
t.Fatalf("four-only states=%+v", fourOnly)
}
both := costumeFixedStates(gamedata.GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100}, result)
if len(both) != 2 || both[0].Type != 0 || both[1].Type != 1 {
t.Fatalf("two-threshold states=%+v", both)
}
}
func fixtureCharacter(id, hp uint64) gamedata.CharacterDesign {
return gamedata.CharacterDesign{
ID: id, HP: hp, CostumeMaxLevel: 5,
OverflowItemType: 20, OverflowItemCount: 2,
}
}
func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
60901: fixtureCharacter(6090, 253),
})
if err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
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: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
if err := service.AttachPreviewEventIndex(1171); err != nil {
t.Fatal(err)
}
schedule := &ScheduleSeed{
ClientVersion: "test",
Schedules: []ScheduleWindow{
{GroupID: 166, StartTime: 10, EndTime: 20},
{GroupID: infiniteScheduleGroupID, StartTime: 10, EndTime: 20},
{GroupID: paidTwelvePickGroupID, StartTime: 10, EndTime: 20},
{GroupID: 206, StartTime: 10, EndTime: 20},
},
StepUps: []ScheduleWindow{{GroupID: 29, StartTime: 10, EndTime: 20}},
}
if err := service.AttachSchedule(schedule); err != nil {
t.Fatal(err)
}
preview := wire.AppendVarint(nil, 1, 1)
preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID)
preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID)
code, response, ok, err := service.Handle("/GachaBuyPreview", preview)
if err != nil || !ok || code != 175 {
t.Fatalf("preview code=%d ok=%v err=%v", code, ok, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 3) != 10 {
t.Fatalf("preview bundle fields=%d found=%v err=%v", countFields(bundle, 3), found, err)
}
if len(collection.Characters()) != 0 {
t.Fatal("preview changed ownership")
}
eventIndex, locked := collection.PreviewLock()
if locked || eventIndex != 1171 {
t.Fatalf("new preview event=%d locked=%v", eventIndex, locked)
}
_, infoBeforeLock, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 2))
if got := collectScheduleWindows(t, infoBeforeLock, 1); !sameScheduleGroups(got, 166, infiniteScheduleGroupID, paidTwelvePickGroupID, 206) {
t.Fatalf("pre-purchase schedule groups=%v", scheduleGroupIDs(got))
}
if got := collectScheduleWindows(t, infoBeforeLock, 7); !sameScheduleGroups(got, 29) {
t.Fatalf("pre-purchase step-up groups=%v", scheduleGroupIDs(got))
}
previewState, found, parseErr := wire.Bytes(infoBeforeLock, 9)
gotEvent, eventFound, eventErr := wire.Varint(previewState, 1)
previewBundle, bundleFound, bundleErr := wire.Bytes(previewState, 8)
if err != nil || parseErr != nil || eventErr != nil || bundleErr != nil || !found || !eventFound || !bundleFound || gotEvent != eventIndex || countFields(previewBundle, 3) != 10 {
t.Fatalf("gacha info preview event=%d items=%d found=%v/%v/%v err=%v/%v/%v/%v", gotEvent, countFields(previewBundle, 3), found, eventFound, bundleFound, err, parseErr, eventErr, bundleErr)
}
lock := wire.AppendVarint(nil, 1, 2)
lock = wire.AppendVarint(lock, 2, eventIndex)
code, response, ok, err = service.Handle("/GachaBuyPreviewLock", lock)
if err != nil || !ok || code != 175 || len(response) != 0 {
t.Fatalf("preview lock code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err)
}
if got, locked := collection.PreviewLock(); !locked || got != eventIndex {
t.Fatalf("preview lock event=%d locked=%v", got, locked)
}
buy := wire.AppendVarint(nil, 1, 3)
buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID)
product = wire.AppendVarint(product, 3, 1)
buy = wire.AppendBytes(buy, 4, product)
code, response, ok, err = service.Handle("/CashShopBuy", buy)
if err != nil || !ok || code != 61 {
t.Fatalf("confirm code=%d ok=%v err=%v", code, ok, err)
}
if service.FirstGachaCompleted() {
t.Fatal("infinite product incorrectly completed the subtype-3 first pick")
}
reward, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(reward, 2) != 1 || countFields(reward, 3) != 1 || countFields(reward, 9) != 9 || countFields(reward, 8) != 4 {
t.Fatalf("reward chars=%d costumes=%d upgrades=%d exchanges=%d found=%v err=%v", countFields(reward, 2), countFields(reward, 3), countFields(reward, 9), countFields(reward, 8), found, err)
}
restored, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
if got := restored.Characters(); len(got) != 1 || got[0].ID != 6090 {
t.Fatalf("restored characters=%+v", got)
}
if got := restored.Costumes(); len(got) != 1 || got[0].ID != 60901 || got[0].Level != 5 {
t.Fatalf("restored costumes=%+v", got)
}
if got := wallet.Snapshot().Mileage; got != 8 {
t.Fatalf("mileage=%d want=8", got)
}
if got, locked := restored.PreviewLock(); !locked || got != eventIndex {
t.Fatalf("restored preview lock event=%d locked=%v", got, locked)
}
code, response, ok, err = service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 4))
postPurchaseSchedules := collectScheduleWindows(t, response, 1)
postPurchaseStepUps := collectScheduleWindows(t, response, 7)
if err != nil || !ok || code != 145 || countFields(response, 9) != 0 || !sameScheduleGroups(postPurchaseSchedules, 166, paidTwelvePickGroupID, 206) || !sameScheduleGroups(postPurchaseStepUps, 29) {
t.Fatalf("post-purchase info code=%d schedules=%v step-ups=%v preview=%d ok=%v err=%v", code, scheduleGroupIDs(postPurchaseSchedules), scheduleGroupIDs(postPurchaseStepUps), countFields(response, 9), ok, err)
}
if !sameScheduleGroups(schedule.Schedules, 166, infiniteScheduleGroupID, paidTwelvePickGroupID, 206) {
t.Fatalf("account filtering mutated public seed=%v", scheduleGroupIDs(schedule.Schedules))
}
code, response, ok, err = service.Handle("/CashShopPurchaseCountInfo", wire.AppendVarint(nil, 1, 5))
countInfo, found, parseErr := wire.Bytes(response, 1)
productGroup, groupFound, groupErr := wire.Varint(countInfo, 1)
productID, productFound, productErr := wire.Varint(countInfo, 2)
_, saleGroupFound, saleGroupErr := wire.Varint(countInfo, 3)
count, countFound, countErr := wire.Varint(countInfo, 4)
if err != nil || parseErr != nil || groupErr != nil || productErr != nil || saleGroupErr != nil || countErr != nil || !ok || code != 432 || countFields(response, 1) != 1 || !found || !groupFound || !productFound || saleGroupFound || !countFound || productGroup != fixtureInfiniteProductGroupID || productID != fixtureInfiniteProductID || count != 1 {
t.Fatalf("purchase count code=%d group=%d product=%d saleGroupFound=%v count=%d found=%v/%v/%v/%v ok=%v err=%v/%v/%v/%v/%v/%v", code, productGroup, productID, saleGroupFound, count, found, groupFound, productFound, countFound, ok, err, parseErr, groupErr, productErr, saleGroupErr, countErr)
}
if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err == nil {
t.Fatal("purchased infinite gacha accepted a new preview")
}
_, infoAfterRejectedPreview, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 6))
if err != nil || !sameScheduleGroups(collectScheduleWindows(t, infoAfterRejectedPreview, 1), 166, paidTwelvePickGroupID, 206) || countFields(infoAfterRejectedPreview, 9) != 0 {
t.Fatalf("rejected preview changed post-purchase availability: err=%v", err)
}
}
func sameScheduleGroups(windows []ScheduleWindow, want ...uint64) bool {
if len(windows) != len(want) {
return false
}
for i := range windows {
if windows[i].GroupID != want[i] {
return false
}
}
return true
}
func scheduleGroupIDs(windows []ScheduleWindow) []uint64 {
groups := make([]uint64, len(windows))
for i := range windows {
groups[i] = windows[i].GroupID
}
return groups
}
func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
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: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
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)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
if err := service.AttachPreviewEventIndex(1171); err != nil {
t.Fatal(err)
}
preview := wire.AppendVarint(nil, 1, 1)
preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID)
preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID)
if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil {
t.Fatal(err)
}
firstEvent, locked := collection.PreviewLock()
if locked || firstEvent != 1171 {
t.Fatalf("first preview event=%d locked=%v", firstEvent, locked)
}
buy := wire.AppendVarint(nil, 1, 2)
buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID)
product = wire.AppendVarint(product, 3, 1)
buy = wire.AppendBytes(buy, 4, product)
if _, _, _, err := service.Handle("/CashShopBuy", buy); err == nil {
t.Fatal("unlocked preview was confirmed")
}
lock := wire.AppendVarint(nil, 1, 3)
lock = wire.AppendVarint(lock, 2, firstEvent)
if _, _, _, err := service.Handle("/GachaBuyPreviewLock", lock); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil {
t.Fatal(err)
}
if eventIndex, locked := collection.PreviewLock(); locked || eventIndex != firstEvent {
t.Fatalf("reroll event=%d locked=%v, want %d/unlocked", eventIndex, locked, firstEvent)
}
}
func TestRegularGachaBuySpendsWalletAndPersistsCostume(t *testing.T) {
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
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{FreeJewelry: 500})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 77)
request = wire.AppendVarint(request, 2, 10100084)
request = wire.AppendVarint(request, 3, 1)
code, response, ok, err := service.Handle("/GachaBuy", request)
if err != nil || !ok || code != 146 {
t.Fatalf("code=%d ok=%v err=%v", code, ok, err)
}
if wallet.Snapshot().FreeJewelry != 300 {
t.Fatalf("wallet=%+v", wallet.Snapshot())
}
bundle, found, _ := wire.Bytes(response, 1)
if !found || countFields(bundle, 2) != 1 || countFields(bundle, 3) != 1 {
t.Fatalf("bundle=%x", bundle)
}
if _, _, _, err := service.Handle("/GachaBuy", request); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().FreeJewelry != 300 || len(collection.Characters()) != 1 {
t.Fatalf("retry wallet=%+v chars=%d", wallet.Snapshot(), len(collection.Characters()))
}
}
func TestRegularGachaSequenceReuseAcrossLoginIsANewPurchase(t *testing.T) {
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
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{FreeJewelry: 400})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 10100084)
request = wire.AppendVarint(request, 3, 1)
service.BeginSession("login-a")
if _, _, _, err := service.Handle("/GachaBuy", request); err != nil {
t.Fatal(err)
}
// Same login + sequence is an HTTP retry.
if _, _, _, err := service.Handle("/GachaBuy", request); err != nil {
t.Fatal(err)
}
if got := wallet.Snapshot().FreeJewelry; got != 200 {
t.Fatalf("retry balance=%d want=200", got)
}
service.BeginSession("login-b")
if _, _, _, err := service.Handle("/GachaBuy", request); err != nil {
t.Fatal(err)
}
if got := wallet.Snapshot().FreeJewelry; got != 0 {
t.Fatalf("new-login balance=%d want=0", got)
}
if got := collection.Costumes(); len(got) != 1 || got[0].Level != 1 {
t.Fatalf("costumes=%+v", got)
}
}
func TestPaidRegularGachaBuySpendsPaidJewelry(t *testing.T) {
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
8100118: {ID: 8100118, Count: 10, PriceType: 2, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
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{Jewelry: 5000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 88)
request = wire.AppendVarint(request, 2, 8100118)
// GB_NORMAL uses GachaTable.PriceType to distinguish paid (2) from free
// jewelry (3); it is not itself the paid/free currency selector.
request = wire.AppendVarint(request, 3, 1)
code, response, ok, err := service.Handle("/GachaBuy", request)
if err != nil || !ok || code != 146 {
t.Fatalf("code=%d ok=%v err=%v", code, ok, err)
}
if wallet.Snapshot().Jewelry != 3000 || wallet.Snapshot().FreeJewelry != 0 {
t.Fatalf("wallet=%+v", wallet.Snapshot())
}
bundle, found, _ := wire.Bytes(response, 1)
if !found || countFields(bundle, 3) != 1 || countFields(bundle, 9) != 9 || countFields(bundle, 8) != 4 {
t.Fatalf("bundle=%x", bundle)
}
}
func TestPriceType19IsRejectedWithoutChargingDiamonds(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
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: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
regular.Gachas[9100037] = gamedata.RegularGacha{ID: 9100037, Count: 10, PriceType: 19, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 9999, Jewelry: 9999})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 9100037)
request = wire.AppendVarint(request, 3, 1)
if _, _, handled, err := service.Handle("/GachaBuy", request); !handled || err == nil {
t.Fatalf("priceType19 handled=%v err=%v", handled, err)
}
if got := wallet.Snapshot(); got.FreeJewelry != 9999 || got.Jewelry != 9999 {
t.Fatalf("unsupported purchase charged wallet: %+v", got)
}
if len(collection.Characters()) != 0 || len(collection.Costumes()) != 0 {
t.Fatal("unsupported purchase granted collection items")
}
}
func TestUnverifiedGachaRPCsRemainUnhandled(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
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: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: character})
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)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
for _, path := range []string{"/GachaLog"} {
if code, response, handled, err := service.Handle(path, request); err != nil || handled || code != 0 || response != nil {
t.Fatalf("%s code=%d bytes=%x handled=%v err=%v", path, code, response, handled, err)
}
}
}
func newMultiBuyTestService(t *testing.T, storage stateio.Store) *Service {
t.Helper()
character := fixtureCharacter(6090, 253)
characters := map[uint64]gamedata.CharacterDesign{60901: character}
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
11: {ID: 11, Count: 1, FreeCountDay: 2, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
12: {ID: 12, Count: 1, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
13: {ID: 13, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
for _, group := range []gamedata.GachaGroupDesign{{ID: 1, OneTimeGachaID: 11, TenTimeGachaID: 13, PointCount: 1}, {ID: 2, OneTimeGachaID: 12, PointCount: 1}} {
if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
}
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 1000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("multi-login")
service.now = func() time.Time { return time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) }
service.schedule = &ScheduleSeed{Schedules: []ScheduleWindow{{GroupID: 1, StartTime: 1, EndTime: 2000000000000}, {GroupID: 2, StartTime: 1, EndTime: 2000000000000}}}
return service
}
func multiBuyRequest(seq uint64, packed bool, ids ...uint64) []byte {
request := wire.AppendVarint(nil, 1, seq)
var values []byte
for _, id := range ids {
if packed {
values = binary.AppendUvarint(values, id)
} else {
request = wire.AppendVarint(request, 2, id)
}
}
if packed {
request = wire.AppendBytes(request, 2, values)
}
return request
}
func multiFixedSnapshots(t *testing.T, response []byte, wantIDs []uint64) [][4]uint64 {
t.Helper()
index := 0
snapshots := make([][4]uint64, 0, len(wantIDs))
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 1 || field.Type != 2 || index >= len(wantIDs) {
return errors.New("unexpected multi-buy result")
}
id, found, err := wire.Varint(field.Value, 1)
if err != nil || !found || id != wantIDs[index] {
return fmt.Errorf("result %d id=%d found=%v want=%d", index, id, found, wantIDs[index])
}
point, found, err := wire.Varint(field.Value, 3)
if err != nil || !found || point != 1 {
return fmt.Errorf("result %d point=%d found=%v", index, point, found)
}
seen := [4]bool{}
counts := [4]uint64{}
if err := wire.Walk(field.Value, func(resultField wire.Field) error {
if resultField.Number != 4 {
return nil
}
fixedID, fixedFound, err := wire.Varint(resultField.Value, 1)
if err != nil || !fixedFound || fixedID != 1 {
return fmt.Errorf("result %d fixed id=%d found=%v", index, fixedID, fixedFound)
}
fixedType, _, err := wire.Varint(resultField.Value, 2)
if err != nil || fixedType >= uint64(len(seen)) || seen[fixedType] {
return fmt.Errorf("result %d invalid fixed type=%d", index, fixedType)
}
count, _, err := wire.Varint(resultField.Value, 3)
if err != nil {
return err
}
applySort, applyFound, err := wire.Varint(resultField.Value, 4)
if err != nil || !applyFound || applySort != ^uint64(0) {
return fmt.Errorf("result %d type=%d apply=%d found=%v", index, fixedType, applySort, applyFound)
}
seen[fixedType] = true
counts[fixedType] = count
return nil
}); err != nil {
return err
}
for fixedType, found := range seen {
if !found {
return fmt.Errorf("result %d missing fixed type %d", index, fixedType)
}
}
snapshots = append(snapshots, counts)
index++
return nil
}); err != nil {
t.Fatal(err)
}
if index != len(wantIDs) {
t.Fatalf("results=%d want=%d", index, len(wantIDs))
}
return snapshots
}
func TestGachaMultiBuyPreservesOrderRepeatedIDsAndRestartReplay(t *testing.T) {
storage := stateio.NewMemory()
service := newMultiBuyTestService(t, storage)
request := multiBuyRequest(10, true, 12, 11, 11)
code, response, handled, err := service.Handle("/GachaMultiBuy", request)
if err != nil || code != 146 || !handled {
t.Fatalf("multi code=%d handled=%v err=%v", code, handled, err)
}
var gotIDs []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 1 || field.Type != 2 {
return errors.New("invalid result")
}
id, _, err := wire.Varint(field.Value, 1)
if err != nil {
return err
}
gotIDs = append(gotIDs, id)
bundle, found, err := wire.Bytes(field.Value, 2)
if err != nil || !found || (countFields(bundle, 3)+countFields(bundle, 9)) != 1 {
return errors.New("missing costume/upgrade")
}
point, _, err := wire.Varint(field.Value, 3)
if err != nil || point != 1 {
return errors.New("missing point")
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(gotIDs) != 3 || gotIDs[0] != 12 || gotIDs[1] != 11 || gotIDs[2] != 11 {
t.Fatalf("order=%v", gotIDs)
}
if service.wallet.Snapshot().FreeJewelry != 1000 {
t.Fatal("free batch charged currency")
}
if owned := service.collection.Costumes(); len(owned) != 1 || owned[0].Level != 2 {
t.Fatalf("owned=%+v", owned)
}
if service.collection.GachaDailyCount("2026-09-30", 1, 0) != 2 || service.collection.GachaUser(1).Point != 2 {
t.Fatal("repeated IDs did not make independent draws")
}
service = newMultiBuyTestService(t, storage)
_, replay, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, false, 12, 11, 11))
if err != nil || !bytes.Equal(response, replay) {
t.Fatalf("restart/unpacked replay differs: %v", err)
}
if service.collection.Costumes()[0].Level != 2 || service.collection.GachaUser(1).Point != 2 {
t.Fatal("replay mutated rewards")
}
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(10, true, 11, 12, 11)); err == nil {
t.Fatal("same sequence changed ordered request")
}
service.BeginSession("next-login")
if _, _, _, err := service.Handle("/GachaMultiBuy", request); err == nil {
t.Fatal("new login bypassed exhausted daily limits")
}
service.now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }
if _, _, _, err := service.Handle("/GachaMultiBuy", request); err != nil {
t.Fatalf("next UTC reset did not restore allowance: %v", err)
}
}
func TestGachaMultiBuyInvalidOrExcessBatchDoesNotGrantAnySlot(t *testing.T) {
for name, ids := range map[string][]uint64{"unknown": {11, 999}, "excess": {11, 11, 11}, "ten-pull": {11, 13}, "zero": {11, 0}, "negative-int32": {11, ^uint64(0)}, "empty": {}} {
t.Run(name, func(t *testing.T) {
service := newMultiBuyTestService(t, stateio.NewMemory())
code, _, handled, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, ids...))
if code != 146 || !handled || err == nil {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
if len(service.collection.Costumes()) != 0 || service.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || service.collection.GachaUser(1).Point != 0 {
t.Fatal("invalid batch partly granted")
}
})
}
service := newMultiBuyTestService(t, stateio.NewMemory())
service.schedule.Schedules[1].EndTime = 2
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil || len(service.collection.Costumes()) != 0 {
t.Fatal("closed schedule partly granted")
}
}
func TestGachaMultiBuyUsesGameDefaultBonusAllowanceAndRepeatedIDs(t *testing.T) {
service := newMultiBuyTestService(t, stateio.NewMemory())
if err := service.regular.SetGachaEventAddFreeCount(1); err != nil {
t.Fatal(err)
}
service.schedule.Schedules[0].FreeCountBonus = true
request := multiBuyRequest(1, true, 11, 11, 11)
if code, response, handled, err := service.Handle("/GachaMultiBuy", request); err != nil || !handled || code != 146 || countFields(response, 1) != 3 {
t.Fatalf("bonus multi code=%d results=%d handled=%v err=%v", code, countFields(response, 1), handled, err)
}
if got := service.collection.GachaDailyCount("2026-09-30", 1, 0); got != 3 {
t.Fatalf("bonus daily count=%d want=3", got)
}
service.BeginSession("bonus-next-login")
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(2, true, 11)); err == nil {
t.Fatal("bonus allowance accepted a fourth draw")
}
}
func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
service := newMultiBuyTestService(t, repo)
if err := service.collection.EnsurePersisted(); err != nil {
t.Fatal(err)
}
service.AttachDrawMission(func(uint64) error { return errors.New("injected mission failure after reward") })
operation, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/GachaMultiBuy", multiBuyRequest(1, true, 11, 12)); err == nil {
t.Fatal("expected failure")
}
// A dirty rollback intentionally fail-stops domain memory. Reopen below
// verifies the disk state and prevents stale in-memory objects being reused.
if err := operation.Rollback(); err == nil || repo.Check() == nil {
t.Fatal("dirty rollback must fail-stop the current repository")
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
reopened := newMultiBuyTestService(t, repo)
if len(reopened.collection.Costumes()) != 0 || reopened.collection.GachaDailyCount("2026-09-30", 1, 0) != 0 || reopened.collection.GachaUser(1).Point != 0 {
t.Fatal("failed request persisted a partial batch")
}
if _, found, _ := reopened.collection.GachaBatchResponse("multi-gacha:session:multi-login:seq:1", "ignored"); found {
t.Fatal("failed request stored completion")
}
}
func TestGachaMultiBuyCurrent23510CostumeAndEquipmentPools(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "resources", "GameData")
const version = "20260923193640"
if _, err := os.Stat(filepath.Join(root, version, "release", "common-dbdata.bin")); os.IsNotExist(err) {
t.Skip("installed 2.35.10 GameData archive is unavailable")
}
storage := stateio.NewMemory()
service := newMultiBuyTestService(t, storage)
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{71, 166, 205, 207}, nil)
if err != nil {
t.Fatal(err)
}
if bonus := regular.GachaEventAddFreeCount(); bonus != 1 {
t.Fatalf("2.35.10 GachaEventAddFreeCount=%d want=1", bonus)
}
equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{72, 153, 206, 208})
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
service.regular = regular
service.AttachEquipmentGacha(equipmentCatalog, equipment)
service.schedule.Schedules = nil
for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} {
service.schedule.Schedules = append(service.schedule.Schedules, ScheduleWindow{GroupID: groupID, StartTime: 1, EndTime: 2000000000000})
}
// Exact ordered ID list captured from the official 2.35.10 client at
// 2026-10-01 00:44:17. Each result must carry the complete fixed-id 1
// snapshot, including the untouched equipment counters.
ids := []uint64{10100146, 10100145, 10100040, 10100113}
request := multiBuyRequest(98, true, ids...)
code, response, handled, err := service.Handle("/GachaMultiBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("installed mixed batch: code=%d handled=%v err=%v", code, handled, err)
}
if countFields(response, 1) != 4 || len(equipment.All()) != 0 {
t.Fatalf("results=%d equipment=%d", countFields(response, 1), len(equipment.All()))
}
costumeSnapshots := multiFixedSnapshots(t, response, ids)
for index, snapshot := range costumeSnapshots {
if snapshot[2] != 0 || snapshot[3] != 0 {
t.Fatalf("costume result %d changed equipment counters: %v", index, snapshot)
}
}
wantCostumeCounters := costumeSnapshots[len(costumeSnapshots)-1]
equipmentIDs := []uint64{20100034, 20100070, 20100082, 20100083}
equipmentRequest := multiBuyRequest(99, true, equipmentIDs...)
_, equipmentResponse, _, err := service.Handle("/GachaMultiBuy", equipmentRequest)
if err != nil || countFields(equipmentResponse, 1) != 4 || len(equipment.All()) != 4 {
t.Fatalf("equipment results=%d equipment=%d err=%v", countFields(equipmentResponse, 1), len(equipment.All()), err)
}
equipmentSnapshots := multiFixedSnapshots(t, equipmentResponse, equipmentIDs)
for index, snapshot := range equipmentSnapshots {
if snapshot[0] != wantCostumeCounters[0] || snapshot[1] != wantCostumeCounters[1] {
t.Fatalf("equipment result %d lost costume counters: got=%v want=%v", index, snapshot, wantCostumeCounters)
}
}
if service.wallet.Snapshot().FreeJewelry != 1000 {
t.Fatal("installed free batch spent diamonds")
}
for _, groupID := range []uint64{71, 166, 205, 207, 72, 153, 206, 208} {
if service.collection.GachaDailyCount("2026-09-30", groupID, 0) != 1 || service.collection.GachaUser(groupID).Point != 1 {
t.Fatalf("missing daily allowance/point group=%d", groupID)
}
}
if states := service.collection.GachaFixedStates(); len(states) != 4 {
t.Fatalf("missing four costume/equipment fixed types: %+v", states)
}
_, replay, _, err := service.Handle("/GachaMultiBuy", request)
if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 4 {
t.Fatalf("installed replay changed equipment/rewards: %v", err)
}
}
func TestEquipmentPointExchangeUsesGameDataAndIsIdempotent(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "data", "resources", "GameData")
const version = "20260923193640"
if _, err := os.Stat(filepath.Join(root, version, "release", "common-dbdata.bin")); os.IsNotExist(err) {
t.Skip("installed 2.35.10 GameData archive is unavailable")
}
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{71}, nil)
if err != nil {
t.Fatal(err)
}
equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{208})
if err != nil {
t.Fatal(err)
}
group, found := equipmentCatalog.Group(208)
if !found || group.PickUpExchangeCost != 200 || group.PickUpItemID != 943213 {
t.Fatalf("equipment group 208=%+v found=%v", group, found)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{FreeJewelry: 1234})
if err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
if _, err := collection.GrantEquipmentPurchase("seed-equipment-points", 200, player.GachaPurchase{
Group: gamedata.GachaGroupDesign{ID: 208, PointCount: 1},
}); err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.AttachEquipmentGacha(equipmentCatalog, equipment)
service.BeginSession("equipment-exchange-login")
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, 208)
code, response, handled, err := service.Handle("/GachaPointExchange", request)
if err != nil || !handled || code != 147 {
t.Fatalf("equipment exchange code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 4) != 1 {
t.Fatalf("equipment exchange bundle=%x found=%v err=%v", bundle, found, err)
}
entries := equipment.All()
if len(entries) != 1 || entries[0].ID != 943213 || len(entries[0].Rank) != 3 || entries[0].InvenIndex == 0 {
t.Fatalf("equipment exchange entry=%+v", entries)
}
if user := collection.GachaUser(208); user.Point != 0 || user.ExchangeItemCount != 1 || user.TotalBuyCount != 0 {
t.Fatalf("equipment exchange user=%+v", user)
}
if got := wallet.Snapshot().FreeJewelry; got != 1234 {
t.Fatalf("equipment exchange changed wallet=%d", got)
}
_, replay, _, err := service.Handle("/GachaPointExchange", request)
if err != nil || !bytes.Equal(response, replay) || len(equipment.All()) != 1 {
t.Fatalf("equipment exchange replay changed result: equipment=%d err=%v", len(equipment.All()), err)
}
invalid := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 78), 2, 208), 3, 943213)
if _, _, _, err := service.Handle("/GachaPointExchange", invalid); err == nil || len(equipment.All()) != 1 {
t.Fatalf("equipment exchange accepted invalid selection: equipment=%d err=%v", len(equipment.All()), err)
}
}
func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
const stepUpGroupID = 29
stepUpGachaIDs := []uint64{8100118, 8100119, 8100120, 8100121}
character := fixtureCharacter(6090, 253)
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
gachas := make(map[uint64]gamedata.RegularGacha, len(stepUpGachaIDs))
for _, id := range stepUpGachaIDs {
gachas[id] = gamedata.RegularGacha{ID: id, Count: 1, PriceType: 2, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}
}
regular, err := fixtureRegularCatalog(gachas, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
steps := gamedata.GachaStepUpDesign{ID: stepUpGroupID}
for i, id := range stepUpGachaIDs {
steps.Steps = append(steps.Steps, gamedata.GachaStepDesign{Step: uint64(i + 1), GroupID: uint64(20118 + i), GachaID: id, FixedID: 5})
}
if err := regular.AddStepUpDesign(steps); 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{Jewelry: 1000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("first-login")
for i, id := range stepUpGachaIDs {
request := wire.AppendVarint(nil, 1, uint64(i+1))
request = wire.AppendVarint(request, 2, id)
request = wire.AppendVarint(request, 3, 1)
if _, _, _, err := service.Handle("/GachaBuy", request); err != nil {
t.Fatalf("step %d: %v", i+1, err)
}
}
if got := collection.StepUpProgress(stepUpGroupID); got != 4 {
t.Fatalf("progress=%d want=4", got)
}
restored, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
restarted, err := NewService(design, regular, restored, wallet)
if err != nil {
t.Fatal(err)
}
restarted.BeginSession("second-login")
if err := restarted.AttachSchedule(&ScheduleSeed{ClientVersion: "test", Schedules: []ScheduleWindow{{GroupID: 1, StartTime: 1, EndTime: 2}}, StepUps: []ScheduleWindow{{GroupID: stepUpGroupID, StartTime: 1, EndTime: 2}}}); err != nil {
t.Fatal(err)
}
_, info, _, err := restarted.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 1))
if err != nil {
t.Fatal(err)
}
if countFields(info, 7) != 1 || countFields(info, 8) != 1 {
t.Fatalf("step-up schedule/user count=%d/%d info=%x", countFields(info, 7), countFields(info, 8), info)
}
user, found, err := wire.Bytes(info, 8)
if err != nil || !found {
t.Fatalf("step user found=%v err=%v", found, err)
}
group, _, _ := wire.Varint(user, 1)
completed, _, _ := wire.Varint(user, 2)
if group != stepUpGroupID || completed != 4 {
t.Fatalf("step user group=%d completed=%d", group, completed)
}
balance := wallet.Snapshot().Jewelry
rebuy := wire.AppendVarint(nil, 1, 2)
rebuy = wire.AppendVarint(rebuy, 2, stepUpGachaIDs[0])
rebuy = wire.AppendVarint(rebuy, 3, 1)
if _, _, _, err := restarted.Handle("/GachaBuy", rebuy); err == nil {
t.Fatal("completed step-up accepted another purchase")
}
if got := wallet.Snapshot().Jewelry; got != balance {
t.Fatalf("rejected purchase changed wallet: %d -> %d", balance, got)
}
}
func TestInstalledStepUpGroups29And30UseGenericIndependentProgress(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
}
const version = "20260923193640"
design, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, nil, []uint64{29, 30})
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{Jewelry: 20000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
sequence := uint64(100)
for _, groupID := range []uint64{29, 30} {
stepUp, ok := regular.StepUp(groupID)
if !ok || len(stepUp.Steps) != 4 {
t.Fatalf("step-up group %d=%+v ok=%v", groupID, stepUp, ok)
}
sequence++
wrong := wire.AppendVarint(nil, 1, sequence)
wrong = wire.AppendVarint(wrong, 2, stepUp.Steps[0].GachaID)
wrong = wire.AppendVarint(wrong, 3, 2)
before := wallet.Snapshot().Jewelry
if _, _, handled, err := service.Handle("/GachaBuy", wrong); !handled || err == nil {
t.Fatalf("step-up group %d accepted GB_CASH handled=%v err=%v", groupID, handled, err)
}
if got := wallet.Snapshot().Jewelry; got != before {
t.Fatalf("rejected GB_CASH changed jewelry %d -> %d", before, got)
}
for _, step := range stepUp.Steps {
sequence++
request := wire.AppendVarint(nil, 1, sequence)
request = wire.AppendVarint(request, 2, step.GachaID)
request = wire.AppendVarint(request, 3, 1)
code, _, handled, err := service.Handle("/GachaBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("group=%d step=%d gacha=%d code=%d handled=%v err=%v", groupID, step.Step, step.GachaID, code, handled, err)
}
if got := collection.StepUpProgress(groupID); got != step.Step {
t.Fatalf("group=%d progress=%d want=%d", groupID, got, step.Step)
}
}
}
if collection.StepUpProgress(29) != 4 || collection.StepUpProgress(30) != 4 {
t.Fatalf("independent progress 29=%d 30=%d", collection.StepUpProgress(29), collection.StepUpProgress(30))
}
for _, groupID := range []uint64{29, 30} {
stepUp, _ := regular.StepUp(groupID)
for _, step := range stepUp.Steps {
user := collection.GachaUser(step.GroupID)
if user.TotalBuyCount != 10 || user.OneCashPickCount != 0 || user.TenCashPickCount != 0 {
t.Fatalf("step-up group %d child %d counters=%+v", groupID, step.GroupID, user)
}
}
}
if got := wallet.Snapshot().Jewelry; got != 10000 {
t.Fatalf("two step-up groups left jewelry=%d want=10000", got)
}
}
func TestRegularTenPullContainsTenClientDisplayEntriesAtMaxLevel(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
11000084: {ID: 11000084, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: character})
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{FreeJewelry: 2000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 11000084)
request = wire.AppendVarint(request, 3, 1)
_, response, _, err := service.Handle("/GachaBuy", request)
if err != nil {
t.Fatal(err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("reward bundle found=%v err=%v", found, err)
}
// Gacha result builds a slot from CostumeInfo (field 3) or
// ItemAutoUpgradeInfo (field 9). With one new costume followed by nine
// duplicates, all ten pulls must therefore be represented by 1 + 9.
if got := countFields(bundle, 3) + countFields(bundle, 9); got != 10 {
t.Fatalf("client display entries=%d costumes=%d upgrades=%d bundle=%x", got, countFields(bundle, 3), countFields(bundle, 9), bundle)
}
if countFields(bundle, 8) != 4 {
t.Fatalf("overflow exchanges=%d want=4", countFields(bundle, 8))
}
if got := collection.Costumes()[0].Level; got != 5 {
t.Fatalf("costume level=%d want=5", got)
}
grant, ok := collection.Grant("regular-gacha:11000084:seq:1")
if !ok || len(grant.Upgrades) != 9 || len(grant.Exchanges) != 4 {
t.Fatalf("grant=%+v", grant)
}
for _, exchange := range grant.Exchanges {
matched := false
for _, upgrade := range grant.Upgrades {
if upgrade.CostumeID == exchange.OriginalItemID && upgrade.SortID == exchange.SortID && upgrade.Before == 5 && upgrade.After == 5 {
matched = true
break
}
}
if !matched {
t.Fatalf("exchange has no display upgrade: %+v grant=%+v", exchange, grant)
}
}
}
func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
pool := []gamedata.WeightedCostume{
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
11000145: {Grades: map[uint64]uint64{60901: 5}, ID: 11000145, Count: 10, PriceType: 3, Price: 2000, Pool: pool},
}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: 205, FixedID: 1, PointCount: 1, PickUpExchangeCost: 200, PickUpCostumeID: 60901, TenTimeGachaID: 11000145}, gamedata.GachaFixedDesign{ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}); 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{FreeJewelry: 2000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("point-test-login")
buy := wire.AppendVarint(nil, 1, 41)
buy = wire.AppendVarint(buy, 2, 11000145)
buy = wire.AppendVarint(buy, 3, 1)
_, buyResponse, _, err := service.Handle("/GachaBuy", buy)
if err != nil {
t.Fatal(err)
}
if points, _, _ := wire.Varint(buyResponse, 2); points != 10 {
t.Fatalf("buy points=%d want=10", points)
}
if user := collection.GachaUser(205); user.Point != 10 || user.TotalBuyCount != 10 {
t.Fatalf("gacha user=%+v", user)
}
exchange := wire.AppendVarint(nil, 1, 42)
exchange = wire.AppendVarint(exchange, 2, 205)
exchange = wire.AppendVarint(exchange, 3, 5)
for i := 0; i < 2; i++ {
code, response, ok, err := service.Handle("/GachaPointManualExchange", exchange)
if err != nil || !ok || code != 188 {
t.Fatalf("exchange %d code=%d ok=%v err=%v", i, code, ok, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 10) != 1 {
t.Fatalf("exchange reward bundle=%x found=%v err=%v", bundle, found, err)
}
view, _, _ := wire.Bytes(bundle, 10)
itemType, _, _ := wire.Varint(view, 2)
count, _, _ := wire.Varint(view, 3)
if itemType != 22 || count != 5 {
t.Fatalf("hope powder view type=%d count=%d", itemType, count)
}
}
if user := collection.GachaUser(205); user.Point != 5 || user.ExchangeMileageCount != 5 {
t.Fatalf("point exchange user=%+v", user)
}
if got := wallet.Snapshot().HopePowder; got != 5 {
t.Fatalf("hope powder=%d want=5", got)
}
restored, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
if user := restored.GachaUser(205); user.Point != 5 || user.ExchangeMileageCount != 5 {
t.Fatalf("restored gacha user=%+v", user)
}
if fixed := restored.GachaFixedStates(); len(fixed) != 2 || fixed[0].ApplySort != -1 || fixed[1].ApplySort != -1 {
t.Fatalf("restored guaranteed counters=%+v", fixed)
}
restarted, err := NewService(design, regular, restored, wallet)
if err != nil {
t.Fatal(err)
}
restarted.BeginSession("new-login")
_, info, _, err := restarted.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 43))
if err != nil || countFields(info, 2) != 1 || countFields(info, 4) != 2 {
t.Fatalf("gacha info users=%d fixed=%d err=%v", countFields(info, 2), countFields(info, 4), err)
}
}
func TestCashShopPurchaseCountStartsEmpty(t *testing.T) {
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
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: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
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)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 99)
code, response, ok, err := service.Handle("/CashShopPurchaseCountInfo", request)
if err != nil || !ok || code != 432 || len(response) != 0 {
t.Fatalf("code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err)
}
}
func TestTwelvePickSelectionSavePersistsAndReturnsInGachaInfo(t *testing.T) {
fiveStars := make([]uint64, 12)
characters := make(map[uint64]gamedata.CharacterDesign, 14)
for i := range fiveStars {
fiveStars[i] = uint64(5001 + i)
characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100)
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, 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: 5001, Weight: 1}}}}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, SelectCount: 12}, gamedata.GachaFixedDesign{}); 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)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 100)
for slot, itemID := range fiveStars {
entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID)
if slot != 0 {
entry = wire.AppendVarint(entry, 2, uint64(slot))
}
entry = wire.AppendVarint(entry, 3, itemID)
request = wire.AppendBytes(request, 2, entry)
}
code, response, ok, err := service.Handle("/GachaSelectionSave", request)
if err != nil || !ok || code != 198 || len(response) != 0 {
t.Fatalf("save code=%d bytes=%d ok=%v err=%v", code, len(response), ok, err)
}
restored, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
if got := restored.GachaSelections(paidTwelvePickGroupID); len(got) != 12 || got[0].ItemID != fiveStars[0] || got[11].Slot != 11 {
t.Fatalf("restored selections=%+v", got)
}
code, response, ok, err = service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 101))
if err != nil || !ok || code != 145 || countFields(response, 5) != 12 {
t.Fatalf("info code=%d selections=%d ok=%v err=%v", code, countFields(response, 5), ok, err)
}
}
func TestDailyFreeAndPaidSingleDrawsResetByUTCDate(t *testing.T) {
characters := map[uint64]gamedata.CharacterDesign{5001: fixtureCharacter(500, 100)}
design, err := fixtureInfiniteGachaDesign(10, []uint64{5001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
101: {ID: 101, Count: 1, DailyPayGachaCount: 1, DailyPayGachaPriceCount: 90, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: 205, PointCount: 1, OneTimeGachaID: 101}, gamedata.GachaFixedDesign{}); 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{Jewelry: 1000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 24, 12, 0, 0, 0, time.UTC)
service.now = func() time.Time { return now }
service.schedule = &ScheduleSeed{Schedules: []ScheduleWindow{{GroupID: 205, StartTime: 1, EndTime: 2000000000000}}}
buy := func(seq, buyType uint64) error {
request := wire.AppendVarint(nil, 1, seq)
request = wire.AppendVarint(request, 2, 101)
if buyType != 0 {
request = wire.AppendVarint(request, 3, buyType)
}
code, _, handled, buyErr := service.Handle("/GachaBuy", request)
if code != 146 || !handled {
t.Fatalf("buy seq=%d type=%d code=%d handled=%v", seq, buyType, code, handled)
}
return buyErr
}
if err := buy(1, 0); err != nil {
t.Fatal(err)
}
if err := buy(1, 0); err != nil { // transport retry is idempotent
t.Fatal(err)
}
if err := buy(2, 0); err == nil {
t.Fatal("second daily free draw was accepted")
}
if err := buy(3, 2); err != nil {
t.Fatal(err)
}
if got := wallet.Snapshot().Jewelry; got != 910 {
t.Fatalf("paid daily jewelry=%d want=910", got)
}
if err := buy(4, 2); err == nil {
t.Fatal("second daily paid draw was accepted")
}
_, info, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(info, 2)
free, freeFound, freeErr := wire.Varint(user, 4)
paid, paidFound, paidErr := wire.Varint(user, 5)
if err != nil || freeErr != nil || paidErr != nil || !found || !freeFound || !paidFound || free != 1 || paid != 1 {
t.Fatalf("daily info free=%d paid=%d found=%v/%v/%v err=%v/%v/%v", free, paid, found, freeFound, paidFound, err, freeErr, paidErr)
}
now = now.Add(24 * time.Hour)
if err := buy(6, 0); err != nil {
t.Fatal(err)
}
if err := buy(7, 2); err != nil {
t.Fatal(err)
}
if got := wallet.Snapshot().Jewelry; got != 820 {
t.Fatalf("next-day paid jewelry=%d want=820", got)
}
}
func TestDailyResetKeyUsesOfficialUTCBoundaryNotHostMidnight(t *testing.T) {
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatal(err)
}
before := time.Date(2026, 9, 24, 23, 59, 59, 0, time.UTC)
after := before.Add(time.Second)
if before.In(shanghai).Day() != after.In(shanghai).Day() {
t.Fatal("fixture unexpectedly crosses Shanghai midnight")
}
if got := dailyResetKey(before); got != "2026-09-24" {
t.Fatalf("before key=%q", got)
}
if got := dailyResetKey(after); got != "2026-09-25" {
t.Fatalf("after key=%q", got)
}
// Shanghai midnight is 16:00 UTC and must not reset the official bucket.
localMidnight := time.Date(2026, 9, 25, 0, 0, 0, 0, shanghai)
if got := dailyResetKey(localMidnight.Add(-time.Second)); got != dailyResetKey(localMidnight) {
t.Fatalf("host midnight changed official key: %q -> %q", dailyResetKey(localMidnight.Add(-time.Second)), got)
}
}
func TestMoonriseSelectionCashProductAndOneTimeTicketDraw(t *testing.T) {
fiveStars := make([]uint64, 12)
characters := make(map[uint64]gamedata.CharacterDesign, 14)
choices := make([]gamedata.CostumeRewardEntry, 12)
for i := range fiveStars {
fiveStars[i] = uint64(5001 + i)
characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100)
choices[i] = gamedata.CostumeRewardEntry{ItemType: 11, ItemID: fiveStars[i], Count: 1, Weight: 1}
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
moonrise := gamedata.RegularGacha{ID: moonriseProductID, Count: 10, PriceType: moonriseTicketType, PriceID: moonriseTicketID, Price: 1, RewardGroup: &gamedata.CostumeRewardGroup{
ID: moonriseProductID, DropCount: 1, DropType: 1, Entries: []gamedata.CostumeRewardEntry{
{ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}},
{ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []gamedata.CostumeRewardEntry{
{ItemType: 11, ItemID: 4001, Count: 1, Weight: 1443},
{ItemType: 11, ItemID: 3001, Count: 1, Weight: 8557},
}}},
},
}}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, BuyLimitCount: 10, CashProductGroupID: moonriseProductGroupID, CashProductID: moonriseProductID, CashRewards: []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}}, TenTimeGachaID: moonriseProductID, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); 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)
}
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.AttachInventory(inventory)
if err := service.AttachSchedule(&ScheduleSeed{ClientVersion: "test", Schedules: []ScheduleWindow{{GroupID: paidTwelvePickGroupID, StartTime: 1, EndTime: 2}}, StepUps: []ScheduleWindow{{GroupID: 30, StartTime: 1, EndTime: 2}}}); err != nil {
t.Fatal(err)
}
product := wire.AppendVarint(nil, 1, moonriseProductID)
product = wire.AppendVarint(product, 3, 1)
buyProduct := wire.AppendVarint(nil, 1, 1)
buyProduct = wire.AppendVarint(buyProduct, 3, moonriseProductGroupID)
buyProduct = wire.AppendBytes(buyProduct, 4, product)
if code, _, handled, err := service.Handle("/CashShopBuy", buyProduct); err != nil || !handled || code != 61 {
t.Fatalf("cash product code=%d handled=%v err=%v", code, handled, err)
}
if !inventory.WasGranted(moonriseTicketGrant) {
t.Fatal("moonrise cash product purchase was not persisted")
}
if _, bought := collection.Grant(moonriseProductGrant); !bought {
t.Fatal("moonrise cash purchase marker was not persisted")
}
selection := wire.AppendVarint(nil, 1, 2)
for slot, itemID := range fiveStars {
entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID)
if slot != 0 {
entry = wire.AppendVarint(entry, 2, uint64(slot))
}
entry = wire.AppendVarint(entry, 3, itemID)
selection = wire.AppendBytes(selection, 2, entry)
}
if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil {
t.Fatal(err)
}
draw := wire.AppendVarint(nil, 1, 3)
draw = wire.AppendVarint(draw, 2, moonriseProductID)
draw = wire.AppendVarint(draw, 3, 3)
if code, response, handled, err := service.Handle("/GachaBuy", draw); err != nil || !handled || code != 146 || countFields(response, 4) != 3 {
t.Fatalf("moonrise draw code=%d handled=%v err=%v", code, handled, err)
}
grant, found := collection.Grant(moonriseDrawGrant)
if !found || len(grant.ViewCostumeIDs) != 0 {
// The completion grant is deliberately an empty marker; the concrete
// result remains keyed by the request identity for retry replay.
if !found {
t.Fatal("moonrise completion marker missing")
}
}
if got := collection.GachaUser(paidTwelvePickGroupID).TotalBuyCount; got != 10 {
t.Fatalf("moonrise total buy count=%d want=10", got)
}
if _, _, _, err := service.Handle("/GachaBuy", wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, moonriseProductID), 3, 3)); err == nil {
t.Fatal("second moonrise draw was accepted")
}
_, info, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5))
if err != nil || len(collectScheduleWindows(t, info, 1)) != 0 || !sameScheduleGroups(collectScheduleWindows(t, info, 7), 30) {
t.Fatalf("post-moonrise schedules=%v step=%v err=%v", scheduleGroupIDs(collectScheduleWindows(t, info, 1)), scheduleGroupIDs(collectScheduleWindows(t, info, 7)), err)
}
counts := service.PurchaseCountDBInfos()
if len(counts) != 1 {
t.Fatalf("purchase counts=%d want=1", len(counts))
}
group, _, _ := wire.Varint(counts[0], 1)
id, _, _ := wire.Varint(counts[0], 2)
if group != moonriseProductGroupID || id != moonriseProductID {
t.Fatalf("purchase count group=%d id=%d", group, id)
}
}
func TestSpecialSelectionUsesDifferentProductGroupTicketAndChoiceCount(t *testing.T) {
const (
moonriseProductID = 890001
moonriseProductGroupID = 770001
moonriseTicketID = 660003
paidTwelvePickGroupID = 550004
moonriseTicketType = 19
moonriseTicketGrant = "cash-product-reward:770001:890001:0"
moonriseProductGrant = "cash-product:770001:890001"
moonriseDrawGrant = "special-gacha:550004:890001"
)
fiveStars := make([]uint64, 4)
characters := make(map[uint64]gamedata.CharacterDesign, 14)
choices := make([]gamedata.CostumeRewardEntry, 4)
for i := range fiveStars {
fiveStars[i] = uint64(5001 + i)
characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100)
choices[i] = gamedata.CostumeRewardEntry{ItemType: 11, ItemID: fiveStars[i], Count: 1, Weight: 1}
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
moonrise := gamedata.RegularGacha{ID: moonriseProductID, Count: 10, PriceType: moonriseTicketType, PriceID: moonriseTicketID, Price: 1, RewardGroup: &gamedata.CostumeRewardGroup{
ID: moonriseProductID, DropCount: 1, DropType: 1, Entries: []gamedata.CostumeRewardEntry{
{ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}},
{ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []gamedata.CostumeRewardEntry{
{ItemType: 11, ItemID: 4001, Count: 1, Weight: 1443},
{ItemType: 11, ItemID: 3001, Count: 1, Weight: 8557},
}}},
},
}}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, BuyLimitCount: 10, CashProductGroupID: moonriseProductGroupID, CashProductID: moonriseProductID, CashRewards: []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}}, TenTimeGachaID: moonriseProductID, SelectCount: 4, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); 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)
}
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.AttachInventory(inventory)
if err := service.AttachSchedule(&ScheduleSeed{ClientVersion: "test", Schedules: []ScheduleWindow{{GroupID: paidTwelvePickGroupID, StartTime: 1, EndTime: 2}}, StepUps: []ScheduleWindow{{GroupID: 30, StartTime: 1, EndTime: 2}}}); err != nil {
t.Fatal(err)
}
product := wire.AppendVarint(nil, 1, moonriseProductID)
product = wire.AppendVarint(product, 3, 1)
buyProduct := wire.AppendVarint(nil, 1, 1)
buyProduct = wire.AppendVarint(buyProduct, 3, moonriseProductGroupID)
buyProduct = wire.AppendBytes(buyProduct, 4, product)
if code, _, handled, err := service.Handle("/CashShopBuy", buyProduct); err != nil || !handled || code != 61 {
t.Fatalf("cash product code=%d handled=%v err=%v", code, handled, err)
}
if !inventory.WasGranted(moonriseTicketGrant) {
t.Fatal("moonrise cash product purchase was not persisted")
}
if _, bought := collection.Grant(moonriseProductGrant); !bought {
t.Fatal("moonrise cash purchase marker was not persisted")
}
selection := wire.AppendVarint(nil, 1, 2)
for slot, itemID := range fiveStars {
entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID)
if slot != 0 {
entry = wire.AppendVarint(entry, 2, uint64(slot))
}
entry = wire.AppendVarint(entry, 3, itemID)
selection = wire.AppendBytes(selection, 2, entry)
}
if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil {
t.Fatal(err)
}
draw := wire.AppendVarint(nil, 1, 3)
draw = wire.AppendVarint(draw, 2, moonriseProductID)
draw = wire.AppendVarint(draw, 3, 3)
if code, response, handled, err := service.Handle("/GachaBuy", draw); err != nil || !handled || code != 146 || countFields(response, 4) != 3 {
t.Fatalf("moonrise draw code=%d handled=%v err=%v", code, handled, err)
}
grant, found := collection.Grant(moonriseDrawGrant)
if !found || len(grant.ViewCostumeIDs) != 0 {
// The completion grant is deliberately an empty marker; the concrete
// result remains keyed by the request identity for retry replay.
if !found {
t.Fatal("moonrise completion marker missing")
}
}
if got := collection.GachaUser(paidTwelvePickGroupID).TotalBuyCount; got != 10 {
t.Fatalf("moonrise total buy count=%d want=10", got)
}
if _, _, _, err := service.Handle("/GachaBuy", wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, moonriseProductID), 3, 3)); err == nil {
t.Fatal("second moonrise draw was accepted")
}
_, info, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5))
if err != nil || len(collectScheduleWindows(t, info, 1)) != 0 || !sameScheduleGroups(collectScheduleWindows(t, info, 7), 30) {
t.Fatalf("post-moonrise schedules=%v step=%v err=%v", scheduleGroupIDs(collectScheduleWindows(t, info, 1)), scheduleGroupIDs(collectScheduleWindows(t, info, 7)), err)
}
counts := service.PurchaseCountDBInfos()
if len(counts) != 1 {
t.Fatalf("purchase counts=%d want=1", len(counts))
}
group, _, _ := wire.Varint(counts[0], 1)
id, _, _ := wire.Varint(counts[0], 2)
if group != moonriseProductGroupID || id != moonriseProductID {
t.Fatalf("purchase count group=%d id=%d", group, id)
}
}
func TestSelectionChangeCountPersistsEnforcesLimitAndReturnsInGachaInfo(t *testing.T) {
fiveStars := []uint64{5001, 5002}
characters := map[uint64]gamedata.CharacterDesign{
5001: fixtureCharacter(500, 100),
5002: fixtureCharacter(501, 100),
4001: fixtureCharacter(400, 100),
3001: fixtureCharacter(300, 100),
}
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
101: {ID: 101, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
const groupID = 7001
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{
ID: groupID, PointCount: 1, TenTimeGachaID: 101, SelectCount: 1, SelectionChangeCount: 2,
}, gamedata.GachaFixedDesign{}); 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)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
save := func(seq, itemID uint64) error {
request := wire.AppendVarint(nil, 1, seq)
entry := wire.AppendVarint(nil, 1, groupID)
entry = wire.AppendVarint(entry, 3, itemID)
request = wire.AppendBytes(request, 2, entry)
code, response, handled, saveErr := service.Handle("/GachaSelectionSave", request)
if code != 198 || !handled || len(response) != 0 {
t.Fatalf("save code=%d handled=%v response=%x", code, handled, response)
}
return saveErr
}
if err := save(1, 5001); err != nil {
t.Fatal(err)
}
// Re-saving the exact same slots is not a selection change and must not
// consume the finite GameData allowance.
if err := save(2, 5001); err != nil {
t.Fatal(err)
}
if err := save(3, 5002); err != nil {
t.Fatal(err)
}
if err := save(4, 5001); err == nil {
t.Fatal("selection save exceeded GameData change limit")
}
_, info, handled, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5))
if err != nil || !handled || countFields(info, 6) != 1 {
t.Fatalf("info change fields=%d handled=%v err=%v", countFields(info, 6), handled, err)
}
change, found, err := wire.Bytes(info, 6)
gotGroup, groupFound, groupErr := wire.Varint(change, 1)
gotCount, countFound, countErr := wire.Varint(change, 2)
if err != nil || groupErr != nil || countErr != nil || !found || !groupFound || !countFound || gotGroup != groupID || gotCount != 2 {
t.Fatalf("change group=%d count=%d found=%v/%v/%v err=%v/%v/%v", gotGroup, gotCount, found, groupFound, countFound, err, groupErr, countErr)
}
restored, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
counts := restored.GachaSelectionChangeCounts()
if len(counts) != 1 || counts[0].GroupID != groupID || counts[0].Count != 2 {
t.Fatalf("restored change counts=%+v", counts)
}
}
func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing.T) {
fiveStars := make([]uint64, 12)
characters := make(map[uint64]gamedata.CharacterDesign, 14)
for i := range fiveStars {
fiveStars[i] = uint64(5001 + i)
characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100)
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
pool := []gamedata.WeightedCostume{
{Weight: 300, Children: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}},
{Weight: 1400, Children: []gamedata.WeightedCostume{{ID: 4001, Weight: 1}}},
{Weight: 8300, Children: []gamedata.WeightedCostume{{ID: 3001, Weight: 1}}},
}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
101: {ID: 101, Count: 10, PriceType: 3, Price: 2000, Pool: pool, TicketIDs: []uint64{1108, 1000}},
}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: twelvePickGroupID, FixedID: 8, PointCount: 1, TenTimeGachaID: 101, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1, IsSelectedFromPity: true}, gamedata.GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}); 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{FreeJewelry: 2000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("ordinary-12pick")
save := wire.AppendVarint(nil, 1, 1)
for slot, itemID := range fiveStars {
choice := wire.AppendVarint(nil, 1, twelvePickGroupID)
if slot != 0 {
choice = wire.AppendVarint(choice, 2, uint64(slot))
}
choice = wire.AppendVarint(choice, 3, itemID)
save = wire.AppendBytes(save, 2, choice)
}
if _, _, _, err := service.Handle("/GachaSelectionSave", save); err != nil {
t.Fatal(err)
}
buy := wire.AppendVarint(nil, 1, 2)
buy = wire.AppendVarint(buy, 2, 101)
buy = wire.AppendVarint(buy, 3, 1)
code, response, ok, err := service.Handle("/GachaBuy", buy)
if err != nil || !ok || code != 146 {
t.Fatalf("buy code=%d ok=%v err=%v", code, ok, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 3)+countFields(bundle, 9) != 10 {
t.Fatalf("bundle=%x found=%v err=%v", bundle, found, err)
}
if user := collection.GachaUser(twelvePickGroupID); user.Point != 10 || user.TotalBuyCount != 10 {
t.Fatalf("user=%+v", user)
}
fixed := collection.GachaFixedStates()
if len(fixed) != 2 || fixed[0].FixedID != 8 || fixed[1].FixedID != 8 {
t.Fatalf("fixed=%+v", fixed)
}
if wallet.Snapshot().FreeJewelry != 0 {
t.Fatalf("wallet=%+v", wallet.Snapshot())
}
}
func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
regular, _, err := gamedata.LoadActiveGachaForSchedules(root, "20260923193640", []uint64{1009}, nil)
if err != nil {
t.Fatal(err)
}
choices := regular.FiveStarIDs(31)
if len(choices) < 3 {
t.Fatalf("newbie selectable five-stars=%v", choices)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{Jewelry: 6000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("newbie-30")
selection := wire.AppendVarint(nil, 1, 1)
selected := map[uint64]bool{}
for slot, costumeID := range choices[:3] {
selected[costumeID] = true
entry := wire.AppendVarint(nil, 1, 1009)
if slot != 0 {
entry = wire.AppendVarint(entry, 2, uint64(slot))
}
entry = wire.AppendVarint(entry, 3, costumeID)
selection = wire.AppendBytes(selection, 2, entry)
}
if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil {
t.Fatal(err)
}
for draw := uint64(0); draw < 3; draw++ {
request := wire.AppendVarint(nil, 1, draw+2)
request = wire.AppendVarint(request, 2, 31)
request = wire.AppendVarint(request, 3, 1)
code, response, handled, err := service.Handle("/GachaBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("draw %d code=%d handled=%v err=%v", draw+1, code, handled, err)
}
point, _, err := wire.Varint(response, 2)
if err != nil || point != 0 {
t.Fatalf("draw %d point=%d err=%v", draw+1, point, err)
}
grant, ok := collection.Grant(service.requestIdentity(31, draw+2))
if !ok || len(grant.ViewCostumeIDs) != 10 {
t.Fatalf("grant=%+v", grant)
}
for _, sortID := range grant.SelectionApplySortIDs {
if sortID >= uint64(len(grant.ViewCostumeIDs)) || !selected[grant.ViewCostumeIDs[sortID]] {
t.Fatalf("invalid selected guarantee sort=%d grant=%+v", sortID, grant)
}
}
foundLast := false
for _, sortID := range grant.SelectionApplySortIDs {
if sortID == 9 {
foundLast = true
}
}
if !foundLast || !selected[grant.ViewCostumeIDs[9]] {
t.Fatalf("nonresetting newbie tenth-slot guarantee missing: %+v", grant)
}
}
user := collection.GachaUser(1009)
if user.TotalBuyCount != 30 || user.Point != 0 || wallet.Snapshot().Jewelry != 0 {
t.Fatalf("newbie state user=%+v wallet=%+v", user, wallet.Snapshot())
}
before := wallet.Snapshot()
fourth := wire.AppendVarint(nil, 1, 5)
fourth = wire.AppendVarint(fourth, 2, 31)
fourth = wire.AppendVarint(fourth, 3, 1)
if _, _, _, err := service.Handle("/GachaBuy", fourth); err == nil {
t.Fatal("newbie draw accepted after 30 pulls")
}
if wallet.Snapshot() != before || collection.GachaUser(1009).TotalBuyCount != 30 {
t.Fatalf("rejected fourth draw mutated state wallet=%+v user=%+v", wallet.Snapshot(), collection.GachaUser(1009))
}
}
func countFields(data []byte, number int) int {
count := 0
_ = wire.Walk(data, func(field wire.Field) error {
if field.Number == number {
count++
}
return nil
})
return count
}