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.
1988 lines
79 KiB
Go
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
|
|
}
|