feat: split client tooling and add OAuth server login
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user