feat: split client tooling and add OAuth server login

This commit is contained in:
2026-09-30 17:32:14 +08:00
parent 7cb5e36c2a
commit 2d054f6b72
230 changed files with 9875 additions and 853 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,158 @@
package gacha
import (
"path/filepath"
"testing"
"bd2server/internal/server/account"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "state.db")
repository, err := accountstate.Open(statePath)
if err != nil {
t.Fatal(err)
}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
60901: {ID: 6090, HP: 253, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2},
})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(
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: {ID: 6090, HP: 253, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2},
},
)
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(repository, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(repository, 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, gamedata.InfiniteGachaID)
preview = wire.AppendVarint(preview, 3, gamedata.InfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, gamedata.InfiniteProductID)
if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil {
t.Fatal(err)
}
lock := wire.AppendVarint(nil, 1, 2)
lock = wire.AppendVarint(lock, 2, 1171)
if _, _, _, err := service.Handle("/GachaBuyPreviewLock", lock); err != nil {
t.Fatal(err)
}
buy := wire.AppendVarint(nil, 1, 3)
buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID)
product = wire.AppendVarint(product, 3, 1)
buy = wire.AppendBytes(buy, 4, product)
if _, _, _, err := service.Handle("/CashShopBuy", buy); err != nil {
t.Fatal(err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
// Reopen the database and construct fresh stores/services so this assertion
// cannot pass from an in-memory grant left by the purchase call above.
repository, err = accountstate.Open(statePath)
if err != nil {
t.Fatal(err)
}
defer repository.Close()
collection, err = player.OpenCollectionStore(repository, nil)
if err != nil {
t.Fatal(err)
}
if _, found := collection.Grant(infiniteGrant); !found {
t.Fatal("SQLite-backed infinite cash-product grant was not restored")
}
wallet, err = player.OpenWallet(repository, player.Currency{})
if err != nil {
t.Fatal(err)
}
service, err = NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
stale := wire.AppendVarint(nil, 1, 999)
userTemplate := wire.AppendVarint(nil, 1, 42)
userTemplate = wire.AppendBytes(userTemplate, 26, stale)
loginSeed := &account.LoginSeed{Version: account.StateVersion(), PacketCode: 11, UserInfo: userTemplate}
if err := loginSeed.AttachPurchaseCounts(service); err != nil {
t.Fatal(err)
}
loginResponse, err := loginSeed.Login(wire.AppendVarint(nil, 1, 4), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, found, err := wire.Bytes(loginResponse, 1)
if err != nil || !found {
t.Fatalf("LoginUser missing UserDBInfo: found=%v err=%v", found, err)
}
loginCounts := purchaseCountFields(user, 26)
if len(loginCounts) != 1 {
t.Fatalf("LoginUser purchase counts=%d, want 1", len(loginCounts))
}
assertInfinitePurchaseCount(t, loginCounts[0])
code, rpcResponse, handled, err := service.Handle("/CashShopPurchaseCountInfo", wire.AppendVarint(nil, 1, 5))
if err != nil || !handled || code != 432 {
t.Fatalf("purchase-count RPC code=%d handled=%v err=%v", code, handled, err)
}
rpcCounts := purchaseCountFields(rpcResponse, 1)
if len(rpcCounts) != 1 || string(rpcCounts[0]) != string(loginCounts[0]) {
t.Fatalf("LoginUser/RPC purchase counts differ: login=%x rpc=%x", loginCounts, rpcCounts)
}
}
func assertInfinitePurchaseCount(t *testing.T, data []byte) {
t.Helper()
group, groupFound, groupErr := wire.Varint(data, 1)
id, idFound, idErr := wire.Varint(data, 2)
saleGroup, saleGroupFound, saleGroupErr := wire.Varint(data, 3)
count, countFound, countErr := wire.Varint(data, 4)
if groupErr != nil || idErr != nil || saleGroupErr != nil || countErr != nil ||
!groupFound || !idFound || saleGroupFound || !countFound ||
group != gamedata.InfiniteProductGroupID || id != gamedata.InfiniteProductID || saleGroup != 0 || count != 1 {
t.Fatalf("PurchaseCountDBInfo group=%d id=%d saleGroup=%d count=%d found=%v/%v/%v/%v errors=%v/%v/%v/%v",
group, id, saleGroup, count, groupFound, idFound, saleGroupFound, countFound,
groupErr, idErr, saleGroupErr, countErr)
}
}
func purchaseCountFields(data []byte, number int) [][]byte {
var result [][]byte
if err := wire.Walk(data, func(field wire.Field) error {
if field.Number == number && field.Type == 2 {
result = append(result, append([]byte(nil), field.Value...))
}
return nil
}); err != nil {
return nil
}
return result
}
@@ -0,0 +1,199 @@
package gacha
import (
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestGachaPointExchangeGrantsUpgradesOverflowsAndRetries(t *testing.T) {
const (
groupID = uint64(205)
gachaID = uint64(11000145)
rollID = uint64(60901)
pickupID = uint64(21201)
cost = uint64(200)
)
characters := map[uint64]gamedata.CharacterDesign{
rollID: {ID: 6090, HP: 253, CostumeMaxLevel: 100, OverflowItemType: 20, OverflowItemCount: 2},
pickupID: {ID: 2120, HP: 300, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2},
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{rollID}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
gachaID: {ID: gachaID, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
group := gamedata.GachaGroupDesign{
ID: groupID, GachaType: 1, PointCount: 140,
PickUpExchangeCost: cost, PickUpCostumeID: pickupID, TenTimeGachaID: gachaID,
}
if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
seed := make([]uint64, 10)
for i := range seed {
seed[i] = rollID
}
if _, err := collection.GrantRegularPurchase("point-seed", seed, regular, player.GachaPurchase{Group: group, BuyType: 1}); err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("point-costume-login")
var lastRequest, lastResponse []byte
for exchange := uint64(0); exchange < 7; exchange++ {
request := wire.AppendVarint(nil, 1, 100+exchange)
request = wire.AppendVarint(request, 2, groupID)
code, response, handled, err := service.Handle("/GachaPointExchange", request)
if err != nil || !handled || code != 147 {
t.Fatalf("exchange %d code=%d handled=%v err=%v", exchange, code, handled, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("exchange %d reward bundle found=%v err=%v", exchange, found, err)
}
switch {
case exchange == 0:
if countFields(bundle, 2) != 1 || countFields(bundle, 3) != 1 || countFields(bundle, 9) != 0 {
t.Fatalf("new pickup reward characters=%d costumes=%d upgrades=%d bundle=%x", countFields(bundle, 2), countFields(bundle, 3), countFields(bundle, 9), bundle)
}
case exchange < 6:
if countFields(bundle, 3) != 0 || countFields(bundle, 9) != 1 || countFields(bundle, 8) != 0 {
t.Fatalf("upgrade %d costumes=%d upgrades=%d exchanges=%d bundle=%x", exchange, countFields(bundle, 3), countFields(bundle, 9), countFields(bundle, 8), bundle)
}
default:
if countFields(bundle, 9) != 1 || countFields(bundle, 8) != 1 || countFields(bundle, 10) != 1 {
t.Fatalf("overflow upgrades=%d exchanges=%d repaid=%d bundle=%x", countFields(bundle, 9), countFields(bundle, 8), countFields(bundle, 10), bundle)
}
}
lastRequest, lastResponse = request, response
}
if user := collection.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 7 || user.ExchangeMileageCount != 0 {
t.Fatalf("gacha user after exchanges=%+v", user)
}
if got := wallet.Snapshot().Mileage; got != 2 {
t.Fatalf("overflow mileage=%d want=2", got)
}
owned := collection.Costumes()
if len(owned) != 2 || owned[1].ID != pickupID || owned[1].Level != 5 {
t.Fatalf("owned costumes=%+v", owned)
}
// A byte-identical transport retry must recover the persisted grant and
// wallet credit without another debit, level change, or exchange count.
code, replay, handled, err := service.Handle("/GachaPointExchange", lastRequest)
if err != nil || !handled || code != 147 || string(replay) != string(lastResponse) {
t.Fatalf("retry code=%d handled=%v err=%v response=%x want=%x", code, handled, err, replay, lastResponse)
}
if user := collection.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 7 {
t.Fatalf("retry changed gacha user=%+v", user)
}
if got := wallet.Snapshot().Mileage; got != 2 {
t.Fatalf("retry changed mileage=%d", got)
}
restored, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
restarted, err := NewService(infinite, regular, restored, wallet)
if err != nil {
t.Fatal(err)
}
restarted.BeginSession("point-costume-login")
_, replay, _, err = restarted.Handle("/GachaPointExchange", lastRequest)
if err != nil || string(replay) != string(lastResponse) {
t.Fatalf("restart retry err=%v response=%x want=%x", err, replay, lastResponse)
}
if user := restored.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 7 {
t.Fatalf("restored gacha user=%+v", user)
}
}
func TestGachaPointExchangeUsesOnlySavedSingleSelection(t *testing.T) {
const (
groupID = uint64(1001)
gachaID = uint64(11000001)
rollID = uint64(60901)
selected = uint64(64901)
)
characters := map[uint64]gamedata.CharacterDesign{
rollID: fixtureCharacter(6090, 253),
selected: fixtureCharacter(6490, 300),
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{rollID}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
gachaID: {ID: gachaID, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}},
}, characters)
if err != nil {
t.Fatal(err)
}
group := gamedata.GachaGroupDesign{ID: groupID, GachaType: 1, PointCount: 200, PickUpExchangeCost: 200, OneTimeGachaID: gachaID, SelectCount: 1}
if err := regular.AddGroupDesign(group, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
if _, err := collection.GrantRegularPurchase("selection-point-seed", []uint64{rollID}, regular, player.GachaPurchase{Group: group, BuyType: 1}); err != nil {
t.Fatal(err)
}
if err := collection.SaveGachaSelections(groupID, []player.GachaSelection{{GroupID: groupID, Slot: 1, ItemID: selected}}, 0); err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("selection-point-login")
bad := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, groupID), 3, rollID)
if code, _, handled, err := service.Handle("/GachaPointExchange", bad); err == nil || !handled || code != 147 {
t.Fatalf("unsaved selection code=%d handled=%v err=%v", code, handled, err)
}
if user := collection.GachaUser(groupID); user.Point != 200 || user.ExchangeItemCount != 0 {
t.Fatalf("rejected selection changed user=%+v", user)
}
request := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, groupID), 3, selected)
code, response, handled, err := service.Handle("/GachaPointExchange", request)
if err != nil || !handled || code != 147 {
t.Fatalf("saved selection code=%d handled=%v err=%v", code, handled, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found || countFields(bundle, 3) != 1 {
t.Fatalf("saved selection reward found=%v costumes=%d err=%v bundle=%x", found, countFields(bundle, 3), err, bundle)
}
if user := collection.GachaUser(groupID); user.Point != 0 || user.ExchangeItemCount != 1 {
t.Fatalf("saved selection user=%+v", user)
}
}
@@ -0,0 +1,61 @@
package gacha
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
// Captures are evidence and golden-test inputs. This audit prevents a server
// source file from acquiring a runtime dependency on the fixture reader or an
// on-disk capture path as gacha evolves.
func TestServerRuntimeHasNoCaptureFixtureDependency(t *testing.T) {
root := filepath.Join("..", "..", "..")
for _, directory := range []string{filepath.Join(root, "cmd"), filepath.Join(root, "internal")} {
err := filepath.WalkDir(directory, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if filepath.Base(path) == "fixture" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
if err != nil {
return err
}
for _, imported := range file.Imports {
value, _ := strconv.Unquote(imported.Path.Value)
if strings.HasSuffix(value, "/internal/fixture") {
t.Errorf("runtime file %s imports capture fixture package", path)
}
}
ast.Inspect(file, func(node ast.Node) bool {
literal, ok := node.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
return true
}
value, _ := strconv.Unquote(literal.Value)
normalized := strings.ToLower(strings.ReplaceAll(value, "\\", "/"))
if strings.Contains(normalized, "data/capture/") {
t.Errorf("runtime file %s contains capture path literal", path)
}
return true
})
return nil
})
if err != nil {
t.Fatal(err)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package gacha
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
)
// ScheduleSeed contains server-owned, versioned dynamic gacha facts. GameData
// supplies pools and product definitions; it deliberately does not contain the
// official server's start/end windows or the account's initial reroll preview.
type ScheduleSeed struct {
ClientVersion string `json:"client_version"`
Schedules []ScheduleWindow `json:"schedules"`
StepUps []ScheduleWindow `json:"step_ups"`
}
type ScheduleWindow struct {
GroupID uint64 `json:"group_id"`
StartTime uint64 `json:"start_time"`
EndTime uint64 `json:"end_time"`
FreeCountBonus bool `json:"free_count_bonus,omitempty"`
CashCountBonus bool `json:"cash_count_bonus,omitempty"`
}
func LoadScheduleSeed(path, clientVersion string) (*ScheduleSeed, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("gacha: read schedule seed: %w", err)
}
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
var seed ScheduleSeed
if err := decoder.Decode(&seed); err != nil {
return nil, fmt.Errorf("gacha: decode schedule seed: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return nil, errors.New("gacha: schedule seed has trailing JSON")
} else if !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("gacha: decode schedule seed trailing data: %w", err)
}
if err := seed.Validate(clientVersion); err != nil {
return nil, err
}
return &seed, nil
}
func (s *ScheduleSeed) Validate(clientVersion string) error {
if s == nil || clientVersion == "" || s.ClientVersion != clientVersion {
return fmt.Errorf("gacha: schedule seed client version %q does not match %q", seedVersion(s), clientVersion)
}
if len(s.Schedules) == 0 || len(s.StepUps) == 0 {
return errors.New("gacha: schedule seed requires regular and step-up windows")
}
seen := make(map[uint64]bool, len(s.Schedules))
for _, window := range s.Schedules {
if err := validateWindow(window); err != nil {
return err
}
if seen[window.GroupID] {
return fmt.Errorf("gacha: duplicate schedule group %d", window.GroupID)
}
seen[window.GroupID] = true
}
seen = make(map[uint64]bool, len(s.StepUps))
for _, window := range s.StepUps {
if window.FreeCountBonus || window.CashCountBonus {
return fmt.Errorf("gacha: step-up group %d has unsupported bonus flags", window.GroupID)
}
if err := validateWindow(window); err != nil {
return err
}
if seen[window.GroupID] {
return fmt.Errorf("gacha: duplicate step-up group %d", window.GroupID)
}
seen[window.GroupID] = true
}
return nil
}
func validateWindow(window ScheduleWindow) error {
if window.GroupID == 0 || window.StartTime == 0 || window.EndTime <= window.StartTime {
return fmt.Errorf("gacha: invalid schedule window for group %d", window.GroupID)
}
return nil
}
func seedVersion(seed *ScheduleSeed) string {
if seed == nil {
return ""
}
return seed.ClientVersion
}
+140
View File
@@ -0,0 +1,140 @@
package gacha
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestVersionedScheduleMatchesOfficial23510GachaInfo(t *testing.T) {
seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10")
if err != nil {
t.Fatal(err)
}
// Official 2.35.10 GachaInfo, captured 2026-09-23. It is evidence used by
// this test only; production loads the semantic JSON seed above.
golden, err := base64.StdEncoding.DecodeString("ChEIpgEQgMi/3Iw0GJiIvcaRNAoSCLrqARCA8PTEiDQYmIi9xpE0ChIIu+oBEIDw9MSINBiYiL3GkTQKEQjOARCA8PTEiDQYmIi9xpE0ChEIzQEQgPD0xIg0GJiIvcaRNAoRCNABEIDIv9yMNBiYiL3GkTQKEQiZARCAyL/cjDQYmIi9xpE0ChAISBCAyL/cjDQYmIi9xpE0ChAIRxCAyL/cjDQYmIi9xpE0ChEIzwEQgMi/3Iw0GJiIvcaRNAoRCPEHEIDo2beLNBiA+L34jzQSBAgCGAo6EAgdEIDw9MSINBiYiL3GkTQ6EAgeEIDIv9yMNBiYiL3GkTRKUAiTCUJLGgMQvR4aBRCFBzABGgUQ0ggwAhoGEOn7AzADGgYQzeMDMAQaBhDF7gMwBRoGEJnsAzAGGgYQvfkDMAcaBhDu6QMwCBoGEKnvAzAJ")
if err != nil {
t.Fatal(err)
}
regular := collectScheduleWindows(t, golden, 1)
steps := collectScheduleWindows(t, golden, 7)
if len(regular) != len(seed.Schedules) || len(steps) != len(seed.StepUps) {
t.Fatalf("golden schedules=%d/%d seed=%d/%d", len(regular), len(steps), len(seed.Schedules), len(seed.StepUps))
}
for i := range seed.Schedules {
if regular[i] != seed.Schedules[i] {
t.Fatalf("schedule %d golden=%+v seed=%+v", i, regular[i], seed.Schedules[i])
}
}
for i := range seed.StepUps {
if steps[i] != seed.StepUps[i] {
t.Fatalf("step-up %d golden=%+v seed=%+v", i, steps[i], seed.StepUps[i])
}
}
if countFields(golden, 9) != 1 {
t.Fatal("official golden must retain the player preview used to verify field 9 separately")
}
}
func TestScheduleSeedStrictValidation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "gacha.json")
bad := `{"client_version":"2.35.10","schedules":[{"group_id":1,"start_time":1,"end_time":2}],"step_ups":[{"group_id":2,"start_time":1,"end_time":2}],"unknown":true}`
if err := os.WriteFile(path, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadScheduleSeed(path, "2.35.10"); err == nil {
t.Fatal("unknown schedule field was accepted")
}
if _, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "0.0.0"); err == nil {
t.Fatal("wrong client version was accepted")
}
}
func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T) {
seed, err := LoadScheduleSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "gacha_schedule.json"), "2.35.10")
if err != nil {
t.Fatal(err)
}
character := gamedata.CharacterDesign{ID: 1, HP: 1, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 1}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{11}, map[uint64]gamedata.CharacterDesign{11: character})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 11, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{11: 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)
}
if err := service.AttachSchedule(seed); err != nil {
t.Fatal(err)
}
code, response, handled, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled || code != 145 {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
regularWindows := collectScheduleWindows(t, response, 1)
stepWindows := collectScheduleWindows(t, response, 7)
if len(regularWindows) != 11 || len(stepWindows) != 2 || countFields(response, 9) != 0 {
t.Fatalf("schedule=%d step=%d preview=%d", len(regularWindows), len(stepWindows), countFields(response, 9))
}
for i := range seed.Schedules {
if regularWindows[i] != seed.Schedules[i] {
t.Fatalf("schedule %d response=%+v seed=%+v", i, regularWindows[i], seed.Schedules[i])
}
}
}
func collectScheduleWindows(t *testing.T, proto []byte, number int) []ScheduleWindow {
t.Helper()
var result []ScheduleWindow
if err := wire.Walk(proto, func(field wire.Field) error {
if field.Number != number {
return nil
}
group, _, err := wire.Varint(field.Value, 1)
if err != nil {
return err
}
start, _, err := wire.Varint(field.Value, 2)
if err != nil {
return err
}
end, _, err := wire.Varint(field.Value, 3)
if err != nil {
return err
}
free, _, err := wire.Varint(field.Value, 4)
if err != nil {
return err
}
cash, _, err := wire.Varint(field.Value, 5)
if err != nil {
return err
}
result = append(result, ScheduleWindow{GroupID: group, StartTime: start, EndTime: end, FreeCountBonus: free != 0, CashCountBonus: cash != 0})
return nil
}); err != nil {
t.Fatal(err)
}
return result
}