feat(server): add special loot and data-driven gameplay rules
Generate equipment loot atomically, grant random boxes and prevent repeat claims. Read gacha pools, pity, products, tickets, missions, presets, chapter rewards and inventory costs from GameData; extend both collaboration UR ticket pools. Sync owned recipes, configure startup state and calendars in versioned seeds, and validate unlock tickets and revival talents.
This commit is contained in:
+45
-14
@@ -96,6 +96,7 @@ func serve(args []string) (serveErr error) {
|
||||
deckSeed := fs.String("deck-seed", "", "versioned starter deck")
|
||||
worldSeed := fs.String("world-seed", "", "versioned starter world")
|
||||
gachaScheduleSeed := fs.String("gacha-schedule-seed", "", "versioned dynamic gacha schedule")
|
||||
seasonScheduleSeed := fs.String("schedule-seed", "", "versioned server content calendar")
|
||||
devToolsConfig := fs.String("dev-tools-config", "", "development-tool settings JSON (defaults to DATA_DIR/dev-tools.json)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -179,6 +180,7 @@ func serve(args []string) (serveErr error) {
|
||||
for target, name := range map[*string]string{
|
||||
accountSeed: "login_user.json", playerSeed: "starter_player.json", readonlySeed: "readonly.json",
|
||||
mailSeed: "mail.json", deckSeed: "decks.json", worldSeed: "world.json", gachaScheduleSeed: "gacha_schedule.json",
|
||||
seasonScheduleSeed: "schedule.json",
|
||||
} {
|
||||
if *target == "" {
|
||||
*target = filepath.Join(seedRoot, name)
|
||||
@@ -297,6 +299,10 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load readonly server configuration: %w", err)
|
||||
}
|
||||
seasonSchedule, err := schedule.Load(filepath.Clean(*seasonScheduleSeed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load server content calendar: %w", err)
|
||||
}
|
||||
mailbox, err := mail.Load(filepath.Clean(*mailSeed))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load starter mailbox: %w", err)
|
||||
@@ -309,7 +315,11 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load starter deck: %w", err)
|
||||
}
|
||||
deckStateStore, err := deck.OpenStore(stateRepository, deckConfig)
|
||||
presetDesign, err := gamedata.LoadPresetDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load party preset GameData: %w", err)
|
||||
}
|
||||
deckStateStore, err := deck.OpenStore(stateRepository, deckConfig, *presetDesign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load deck state: %w", err)
|
||||
}
|
||||
@@ -320,6 +330,14 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned inventory: %w", err)
|
||||
}
|
||||
recipeDesign, err := gamedata.LoadCookingRecipeDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load cooking recipes: %w", err)
|
||||
}
|
||||
recipeService, err := feature.NewRecipeService(recipeDesign, starter.CookingRecipes, ownedItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load learned recipes: %w", err)
|
||||
}
|
||||
randomBoxes, err := gamedata.LoadRandomBoxDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load deterministic random-box GameData: %w", err)
|
||||
@@ -375,6 +393,13 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load mail state: %w", err)
|
||||
}
|
||||
contentTickets, err := gamedata.LoadGachaContentTicketDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load gacha content tickets: %w", err)
|
||||
}
|
||||
if err := mailService.AttachContentTickets(contentTickets); err != nil {
|
||||
return fmt.Errorf("attach mailbox content tickets: %w", err)
|
||||
}
|
||||
if err := mailService.AttachSeedPath(filepath.Clean(*mailSeed)); err != nil {
|
||||
return fmt.Errorf("watch mail seed: %w", err)
|
||||
}
|
||||
@@ -397,12 +422,6 @@ func serve(args []string) (serveErr error) {
|
||||
if err := missionService.AttachMail(mailService); err != nil {
|
||||
return fmt.Errorf("attach mission compensation mailbox: %w", err)
|
||||
}
|
||||
if err := missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 101}); err != nil {
|
||||
return fmt.Errorf("record daily login mission: %w", err)
|
||||
}
|
||||
if err := missionService.SetProgress(gamedata.MissionKey{GroupType: 1, GroupID: 2, ID: 201}, 1); err != nil {
|
||||
return fmt.Errorf("record weekly login mission: %w", err)
|
||||
}
|
||||
ownedEquipment, err := player.OpenEquipmentInventory(stateRepository)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned equipment: %w", err)
|
||||
@@ -454,6 +473,10 @@ func serve(args []string) (serveErr error) {
|
||||
if err := worldService.ConfigureStartPack(startingPackID, initializeAccount); err != nil {
|
||||
return fmt.Errorf("configure account starting chapter: %w", err)
|
||||
}
|
||||
missionUnlocked := worldService.MissionsUnlocked
|
||||
if err := missionService.RecordLogin(missionUnlocked); err != nil {
|
||||
return fmt.Errorf("record login missions: %w", err)
|
||||
}
|
||||
if err := login.AttachLastPlayedPack(worldService); err != nil {
|
||||
return fmt.Errorf("attach persisted login destination: %w", err)
|
||||
}
|
||||
@@ -468,7 +491,7 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("load owned collection: %w", err)
|
||||
}
|
||||
infiniteGacha, err := gamedata.LoadInfiniteGacha(gameData, *gameDataVersion)
|
||||
infiniteGacha, err := gamedata.LoadInfiniteGachaForSchedules(gameData, *gameDataVersion, scheduleGroupIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load infinite gacha GameData: %w", err)
|
||||
}
|
||||
@@ -482,7 +505,7 @@ func serve(args []string) (serveErr error) {
|
||||
if err := gachaService.AttachSchedule(gachaSchedule); err != nil {
|
||||
return fmt.Errorf("attach gacha schedule: %w", err)
|
||||
}
|
||||
previewEventIndex, err := serverConfig.CashProductEventIndex(gamedata.InfiniteProductGroupID, gamedata.InfiniteProductID)
|
||||
previewEventIndex, err := serverConfig.CashProductEventIndex(infiniteGacha.ProductGroupID, infiniteGacha.ProductID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load infinite preview event: %w", err)
|
||||
}
|
||||
@@ -499,8 +522,8 @@ func serve(args []string) (serveErr error) {
|
||||
}
|
||||
gachaService.AttachInventory(ownedItems)
|
||||
gachaService.AttachEquipmentGacha(equipmentGacha, ownedEquipment)
|
||||
gachaService.AttachPreviewMission(func() error {
|
||||
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 111})
|
||||
gachaService.AttachDrawMission(func(count uint64) error {
|
||||
return missionService.RecordEvent(missions.ConditionGachaBuy, 0, count, missionUnlocked)
|
||||
})
|
||||
if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil {
|
||||
return fmt.Errorf("bind base collection characters: %w", err)
|
||||
@@ -557,6 +580,13 @@ func serve(args []string) (serveErr error) {
|
||||
if err := worldService.CharacterService().AttachTalentGrowth(talentGrowth); err != nil {
|
||||
return fmt.Errorf("attach character talent growth: %w", err)
|
||||
}
|
||||
immortalDesign, err := gamedata.LoadImmortalDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load immortal talent GameData: %w", err)
|
||||
}
|
||||
if err := worldService.CharacterService().AttachImmortalDesign(immortalDesign); err != nil {
|
||||
return fmt.Errorf("attach immortal talent GameData: %w", err)
|
||||
}
|
||||
costumePotentialDesign, err := gamedata.LoadCostumePotentialDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load costume potential GameData: %w", err)
|
||||
@@ -640,8 +670,8 @@ func serve(args []string) (serveErr error) {
|
||||
if err := foodService.AttachContext(worldService.CurrentPackID, battleService.Active); err != nil {
|
||||
return err
|
||||
}
|
||||
battleService.AttachTutorialWin(func() error {
|
||||
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
|
||||
battleService.AttachMonsterWinMission(func() error {
|
||||
return missionService.CompleteSingleTargetEvent(missions.ConditionMonsterKill, missionUnlocked)
|
||||
})
|
||||
battleService.AttachPictorialBuffs(func() ([]gamedata.PictorialBuffStat, error) {
|
||||
_, buffs, err := pictorialService.Snapshot()
|
||||
@@ -671,13 +701,14 @@ func serve(args []string) (serveErr error) {
|
||||
masterTitleService,
|
||||
recruitService,
|
||||
foodService,
|
||||
recipeService,
|
||||
starter,
|
||||
mailService,
|
||||
gachaService,
|
||||
achievementCounters,
|
||||
missionService,
|
||||
pictorialService,
|
||||
schedule.Current(),
|
||||
seasonSchedule,
|
||||
readonly.Service{Seed: serverConfig},
|
||||
feature.Service{},
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/readonly"
|
||||
"bd2server/internal/server/resourcepolicy"
|
||||
calendarschedule "bd2server/internal/server/schedule"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
)
|
||||
|
||||
@@ -107,6 +108,9 @@ func preflight(args []string) error {
|
||||
if _, err := readonly.Load(filepath.Join(seedRoot, "readonly.json")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := calendarschedule.Load(filepath.Join(seedRoot, "schedule.json")); err != nil {
|
||||
return fmt.Errorf("load server content calendar: %w", err)
|
||||
}
|
||||
schedule, err := gacha.LoadScheduleSeed(filepath.Join(seedRoot, "gacha_schedule.json"), versions.GameVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -29,6 +29,7 @@ type Service struct {
|
||||
loadPhases func(string, string, int, uint64, uint64) ([]gamedata.BattlePhase, error)
|
||||
buffs func() ([]gamedata.PictorialBuffStat, error)
|
||||
onTutorialWin func() error
|
||||
onMonsterWin func() error
|
||||
commitHealth func(map[uint64]uint64) error
|
||||
}
|
||||
|
||||
@@ -73,6 +74,8 @@ func (s *Service) BeginSession(id string) {
|
||||
s.activeSession = id
|
||||
}
|
||||
|
||||
func (s *Service) AttachMonsterWinMission(callback func() error) { s.onMonsterWin = callback }
|
||||
|
||||
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
|
||||
|
||||
// AttachCommittedHealth persists only completed battle results. Round state
|
||||
@@ -396,6 +399,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
rewardBundle = true
|
||||
}
|
||||
}
|
||||
if result == 1 && state.monster != 0 && s.onMonsterWin != nil {
|
||||
if err := s.onMonsterWin(); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("battle: monster win mission: %w", err)
|
||||
}
|
||||
}
|
||||
if result == 1 && s.onTutorialWin != nil {
|
||||
if err := s.onTutorialWin(); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("battle: update tutorial kill mission: %w", err)
|
||||
|
||||
@@ -55,6 +55,7 @@ type Store struct {
|
||||
state state
|
||||
presets map[uint64]Preset
|
||||
presetSlots uint64
|
||||
presetDesign gamedata.PresetDesign
|
||||
costumeSettings map[uint64]CostumeSetting
|
||||
wallet *player.Wallet
|
||||
characters *player.CharacterStore
|
||||
@@ -141,14 +142,24 @@ func validDeck(entries []DeckEntry) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func NewStore(seed Seed) (*Store, error) {
|
||||
func NewStore(seed Seed, designs ...gamedata.PresetDesign) (*Store, error) {
|
||||
if e := seed.validate(); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &Store{state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64][]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil
|
||||
var design gamedata.PresetDesign
|
||||
if len(designs) > 1 {
|
||||
return nil, errors.New("deck: multiple preset designs")
|
||||
}
|
||||
func OpenStore(storage stateio.Store, seed Seed) (*Store, error) {
|
||||
s, e := NewStore(seed)
|
||||
if len(designs) == 1 {
|
||||
design = designs[0]
|
||||
if err := design.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Store{presetDesign: design, state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64][]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: design.BaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil
|
||||
}
|
||||
func OpenStore(storage stateio.Store, seed Seed, designs ...gamedata.PresetDesign) (*Store, error) {
|
||||
s, e := NewStore(seed, designs...)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
@@ -498,43 +509,6 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
|
||||
n.Costumes[char] = cost
|
||||
e = s.commit(n)
|
||||
return 41, nil, true, e
|
||||
case "/PackBuy":
|
||||
pack, ok, e := wire.Varint(req, 2)
|
||||
if e != nil || !ok || pack == 0 {
|
||||
return 0, nil, true, errors.New("deck: invalid pack")
|
||||
}
|
||||
if e = checkSeq(req); e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := clone(s.state)
|
||||
// Purchasing the story pack is idempotent. Login/bootstrap may repeat
|
||||
// the request after a client restart before all local state is restored.
|
||||
n.Packs[pack] = 1
|
||||
e = s.commit(n)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
packInfo := wire.AppendVarint(nil, 1, pack)
|
||||
packInfo = wire.AppendVarint(packInfo, 8, n.Packs[pack])
|
||||
response := wire.AppendBytes(nil, 1, packInfo)
|
||||
// Starter pack purchase grants the local story-pack entitlement and
|
||||
// starter currency. These are semantic RewardDBInfoBundle fields, not
|
||||
// recorded response bytes.
|
||||
entitlement := wire.AppendVarint(nil, 2, 1)
|
||||
entitlement = wire.AppendVarint(entitlement, 3, 19)
|
||||
entitlement = wire.AppendVarint(entitlement, 4, 1)
|
||||
entitlement = wire.AppendVarint(entitlement, 8, ^uint64(0)-32400000+1)
|
||||
currency := wire.AppendVarint(nil, 3, 12)
|
||||
currency = wire.AppendVarint(currency, 4, 200)
|
||||
tracking := wire.AppendVarint(nil, 2, 1)
|
||||
tracking = wire.AppendVarint(tracking, 3, 19)
|
||||
tracking = wire.AppendVarint(tracking, 4, 1)
|
||||
bundle := wire.AppendBytes(nil, 1, entitlement)
|
||||
bundle = wire.AppendBytes(bundle, 1, currency)
|
||||
bundle = wire.AppendBytes(bundle, 6, tracking)
|
||||
return 6, wire.AppendBytes(response, 2, bundle), true, nil
|
||||
case "/SaveTotalBattlePower":
|
||||
power, ok, e := wire.Varint(req, 2)
|
||||
if e != nil || !ok || power == 0 {
|
||||
|
||||
@@ -16,7 +16,7 @@ func seeded(t *testing.T) *Store {
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := NewStore(x)
|
||||
s, e := NewStore(x, testPresetDesign)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
@@ -132,16 +132,10 @@ func TestDeckPersistenceAndCommands(t *testing.T) {
|
||||
if code, _, _, e = s.Handle("/CostumeUse", req(3, wire.AppendBytes(nil, 2, use))); e != nil || code != 41 {
|
||||
t.Fatalf("use: %d %v", code, e)
|
||||
}
|
||||
pack := wire.AppendVarint(nil, 2, 21)
|
||||
code, b, _, e := s.Handle("/PackBuy", req(4, pack))
|
||||
if e != nil || code != 6 {
|
||||
t.Fatalf("buy: %d %v", code, e)
|
||||
}
|
||||
p, _, _ := wire.Bytes(b, 1)
|
||||
id, _, _ := wire.Varint(p, 1)
|
||||
if id != 21 {
|
||||
t.Fatalf("pack response=%d", id)
|
||||
if _, _, handled, err := s.Handle("/PackBuy", req(4, wire.AppendVarint(nil, 2, 21))); handled || err != nil {
|
||||
t.Fatal("pack purchase must be handled by world")
|
||||
}
|
||||
var b []byte
|
||||
reopened, e := OpenStore(storage, seed)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
@@ -151,7 +145,7 @@ func TestDeckPersistenceAndCommands(t *testing.T) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
entry, _, _ := wire.Bytes(b, 1)
|
||||
id, _, _ = wire.Varint(entry, 1)
|
||||
id, _, _ := wire.Varint(entry, 1)
|
||||
if id != 100 {
|
||||
t.Fatalf("persist deck=%d", id)
|
||||
}
|
||||
@@ -373,3 +367,11 @@ func TestDeckCharAutoRevivePreservesExplicitZeroCatalyst(t *testing.T) {
|
||||
t.Fatalf("reloaded state=%+v err=%v", reopened.state, err)
|
||||
}
|
||||
}
|
||||
|
||||
var testPresetDesign = func() gamedata.PresetDesign {
|
||||
d := gamedata.PresetDesign{BaseCount: 5, Maximum: 12, PriceType: 4, Price: 2000, Icons: map[uint64]bool{}}
|
||||
for id := uint64(1); id <= 21; id++ {
|
||||
d.Icons[id] = true
|
||||
}
|
||||
return d
|
||||
}()
|
||||
|
||||
@@ -16,12 +16,6 @@ import (
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
const (
|
||||
presetBaseCount = 5
|
||||
presetMaxCount = 12
|
||||
presetSlotPrice = 2000
|
||||
)
|
||||
|
||||
type Preset struct {
|
||||
Name string `json:"name"`
|
||||
ResourceID uint64 `json:"resource_id"`
|
||||
@@ -92,7 +86,7 @@ func (s *Store) loadPresetEntries() error {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if err := json.Unmarshal(rawConfig, &s.presetSlots); err != nil || s.presetSlots < presetBaseCount || s.presetSlots > presetMaxCount {
|
||||
if err := json.Unmarshal(rawConfig, &s.presetSlots); err != nil || s.presetSlots < s.presetDesign.BaseCount || s.presetSlots > s.presetDesign.Maximum {
|
||||
return errors.New("deck: invalid preset slot configuration")
|
||||
}
|
||||
}
|
||||
@@ -109,7 +103,7 @@ func (s *Store) loadPresetEntries() error {
|
||||
if err := json.Unmarshal(payload, &preset); err != nil || preset.Slot != slot {
|
||||
return fmt.Errorf("deck: invalid preset %q", key)
|
||||
}
|
||||
if err := validatePresetShape(preset, s.presetSlots); err != nil {
|
||||
if err := s.validatePresetShape(preset, s.presetSlots); err != nil {
|
||||
return fmt.Errorf("deck: invalid preset %q: %w", key, err)
|
||||
}
|
||||
s.presets[slot] = preset
|
||||
@@ -156,8 +150,8 @@ func (s *Store) validatePresetOwnershipLocked() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePresetShape(p Preset, slotCount uint64) error {
|
||||
if p.Slot >= slotCount || p.ResourceID > 21 || p.ResourceColor > 5 || !validPresetName(p.Name) || len(p.Decks) > 5 {
|
||||
func (s *Store) validatePresetShape(p Preset, slotCount uint64) error {
|
||||
if p.Slot >= slotCount || (p.ResourceID != 0 && !s.presetDesign.Icons[p.ResourceID]) || p.ResourceColor > 5 || !validPresetName(p.Name) || len(p.Decks) > 5 {
|
||||
return errors.New("invalid metadata or deck count")
|
||||
}
|
||||
characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{}
|
||||
@@ -196,7 +190,7 @@ func validPresetName(value string) bool {
|
||||
}
|
||||
|
||||
func (s *Store) validatePresetOwnedLocked(p Preset) error {
|
||||
if err := validatePresetShape(p, s.presetSlots); err != nil {
|
||||
if err := s.validatePresetShape(p, s.presetSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
ownedEquipment := make(map[uint64]player.Equipment)
|
||||
@@ -531,17 +525,28 @@ func (s *Store) handlePresetAddSlot(request []byte) (int, []byte, bool, error) {
|
||||
if reply, found := s.cachedReplyLocked("add-slot", seq); found {
|
||||
return reply.code, reply.body, true, nil
|
||||
}
|
||||
if s.wallet == nil {
|
||||
return 0, nil, true, errors.New("deck: preset wallet unavailable")
|
||||
if s.wallet == nil || s.presetDesign.Validate() != nil {
|
||||
return 0, nil, true, errors.New("deck: preset wallet/design unavailable")
|
||||
}
|
||||
if s.presetSlots > presetMaxCount || count > presetMaxCount-s.presetSlots {
|
||||
if s.presetSlots > s.presetDesign.Maximum || count > s.presetDesign.Maximum-s.presetSlots {
|
||||
return 0, nil, true, errors.New("deck: preset slot limit exceeded")
|
||||
}
|
||||
if count > ^uint64(0)/presetSlotPrice {
|
||||
if count > ^uint64(0)/s.presetDesign.Price {
|
||||
return 0, nil, true, errors.New("deck: preset slot price overflow")
|
||||
}
|
||||
identity := "preset-slot:" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, count*presetSlotPrice); err != nil {
|
||||
var spendErr error
|
||||
switch s.presetDesign.PriceType {
|
||||
case 4:
|
||||
_, spendErr = s.wallet.SpendGoldOnce(identity, count*s.presetDesign.Price)
|
||||
case 3:
|
||||
_, spendErr = s.wallet.SpendFreeJewelryOnce(identity, count*s.presetDesign.Price)
|
||||
case 2:
|
||||
_, spendErr = s.wallet.SpendJewelryOnce(identity, count*s.presetDesign.Price)
|
||||
case 12:
|
||||
_, spendErr = s.wallet.SpendCatalystOnce(identity, count*s.presetDesign.Price)
|
||||
}
|
||||
if err := spendErr; err != nil {
|
||||
return 0, nil, true, fmt.Errorf("deck: buy preset slot: %w", err)
|
||||
}
|
||||
next := s.presetSlots + count
|
||||
@@ -594,7 +599,7 @@ func (s *Store) handlePresetInfoChange(request []byte) (int, []byte, bool, error
|
||||
preset = Preset{Slot: slot, Decks: []PresetDeck{}, Blesses: []PresetBless{}}
|
||||
}
|
||||
preset.Name, preset.ResourceID, preset.ResourceColor = string(nameBytes), resourceID, color
|
||||
if err := validatePresetShape(preset, s.presetSlots); err != nil {
|
||||
if err := s.validatePresetShape(preset, s.presetSlots); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("deck: invalid preset metadata: %w", err)
|
||||
}
|
||||
if err := s.persistPresetLocked(preset); err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package deck
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
@@ -10,6 +11,38 @@ import (
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestPresetChangedDesignControlsBaseLimitAndCurrency(t *testing.T) {
|
||||
f := newPresetFixture(t)
|
||||
design := gamedata.PresetDesign{BaseCount: 2, Maximum: 3, PriceType: 3, Price: 17}
|
||||
d, err := OpenStore(f.storage, f.seed, design)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := player.OpenWallet(stateio.NewMemory(), player.Currency{FreeJewelry: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d.wallet = w
|
||||
d.BeginSession("changed-design")
|
||||
if d.PresetSlotCount() != 2 {
|
||||
t.Fatal("base count ignored")
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)
|
||||
if _, _, _, err := d.Handle("/PresetAddSlot", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.PresetSlotCount() != 3 || w.Snapshot().FreeJewelry != 83 || w.Snapshot().Gold != 0 {
|
||||
t.Fatal("changed price/currency ignored")
|
||||
}
|
||||
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1)
|
||||
if _, _, _, err := d.Handle("/PresetAddSlot", request); err == nil {
|
||||
t.Fatal("changed limit ignored")
|
||||
}
|
||||
if w.Snapshot().FreeJewelry != 83 {
|
||||
t.Fatal("invalid purchase charged")
|
||||
}
|
||||
}
|
||||
|
||||
type presetFixture struct {
|
||||
storage *stateio.Memory
|
||||
seed Seed
|
||||
@@ -67,7 +100,7 @@ func newPresetFixture(t *testing.T) *presetFixture {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decks, err := OpenStore(storage, seed)
|
||||
decks, err := OpenStore(storage, seed, testPresetDesign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -135,7 +168,7 @@ func TestPresetSaveInfoMetadataDeleteAndRestart(t *testing.T) {
|
||||
t.Fatalf("metadata change lost content: %+v", got)
|
||||
}
|
||||
|
||||
restarted, err := OpenStore(f.storage, f.seed)
|
||||
restarted, err := OpenStore(f.storage, f.seed, testPresetDesign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -177,7 +210,7 @@ func TestPresetAddSlotChargesOncePersistsAndCaps(t *testing.T) {
|
||||
if f.deck.PresetSlotCount() != 6 || f.wallet.Snapshot().Gold != 18000 {
|
||||
t.Fatalf("slots=%d gold=%d", f.deck.PresetSlotCount(), f.wallet.Snapshot().Gold)
|
||||
}
|
||||
restarted, err := OpenStore(f.storage, f.seed)
|
||||
restarted, err := OpenStore(f.storage, f.seed, testPresetDesign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -283,7 +316,7 @@ func TestDeckCostumeSettingSentinelsClearAndRestart(t *testing.T) {
|
||||
if code, _, _, err := f.deck.Handle("/DeckCostumeSettingSave", req(1, wire.AppendBytes(nil, 2, setting))); err != nil || code != 398 {
|
||||
t.Fatalf("save setting code=%d err=%v", code, err)
|
||||
}
|
||||
restarted, err := OpenStore(f.storage, f.seed)
|
||||
restarted, err := OpenStore(f.storage, f.seed, testPresetDesign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,6 @@ import "bd2server/internal/server/wire"
|
||||
// entities (inventory, characters, quests, decks) are deliberately excluded.
|
||||
func initialResponse(path string) (int, []byte, bool) {
|
||||
switch path {
|
||||
case "/PackInfo":
|
||||
// No purchased packs yet. SquareSceneId is the new-account square.
|
||||
return 4, wire.AppendVarint(nil, 5, 3), true
|
||||
case "/RecipeInfo":
|
||||
// The starter crafting recipe is ID 101 (packed repeated int32).
|
||||
return 46, wire.AppendBytes(nil, 2, []byte{101}), true
|
||||
case "/CashMailInfo":
|
||||
// MaxInvenIndex is explicitly present even with zero cash mails.
|
||||
return 140, wire.AppendVarint(nil, 3, 0), true
|
||||
@@ -20,14 +14,6 @@ func initialResponse(path string) (int, []byte, bool) {
|
||||
return 467, wire.AppendBytes(nil, 1, nil), true
|
||||
case "/GuildRaidSeasonReward":
|
||||
return 310, wire.AppendBytes(nil, 1, nil), true
|
||||
case "/DeckInfo":
|
||||
// No active deck yet; talent slots are four zero-valued packed ints.
|
||||
return 8, wire.AppendBytes(nil, 2, []byte{0, 0, 0, 0}), true
|
||||
case "/RootSortIdInfo":
|
||||
entry := wire.AppendVarint(nil, 1, 2)
|
||||
entry = wire.AppendVarint(entry, 2, 140)
|
||||
entry = wire.AppendVarint(entry, 3, 1)
|
||||
return 367, wire.AppendBytes(nil, 1, entry), true
|
||||
}
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
@@ -13,13 +13,9 @@ func TestBootstrapProtocolDefaults(t *testing.T) {
|
||||
code int
|
||||
proto []byte
|
||||
}{
|
||||
{"/PackInfo", 4, []byte{0x28, 3}},
|
||||
{"/RecipeInfo", 46, []byte{0x12, 1, 101}},
|
||||
{"/CashMailInfo", 140, []byte{0x18, 0}},
|
||||
{"/AvatarInfo", 467, []byte{0x0a, 0}},
|
||||
{"/GuildRaidSeasonReward", 310, []byte{0x0a, 0}},
|
||||
{"/DeckInfo", 8, []byte{0x12, 4, 0, 0, 0, 0}},
|
||||
{"/RootSortIdInfo", 367, []byte{0x0a, 7, 0x08, 2, 0x10, 0x8c, 1, 0x18, 1}},
|
||||
} {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
code, proto, handled, err := Handle(test.path, wire.AppendVarint(nil, 1, 42))
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestHandleAuditedEmptyResponses(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) {
|
||||
for _, path := range []string{"/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate"} {
|
||||
for _, path := range []string{"/FieldObjectInfo", "/WaypointInfo", "/WaypointSave", "/WaypointUse", "/AchievementInfo", "/AchievementUpdate", "/RecipeInfo", "/PackInfo", "/DeckInfo", "/RootSortIdInfo"} {
|
||||
if _, _, handled, err := Handle(path, wire.AppendVarint(nil, 1, 1)); handled || err != nil {
|
||||
t.Fatalf("stateful route %s still has a stateless success fallback", path)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package feature
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type RecipeItems interface{ All() []player.Item }
|
||||
|
||||
type RecipeService struct {
|
||||
design *gamedata.CookingRecipeDesign
|
||||
initial []uint64
|
||||
items RecipeItems
|
||||
}
|
||||
|
||||
func NewRecipeService(design *gamedata.CookingRecipeDesign, initial []uint64, items RecipeItems) (*RecipeService, error) {
|
||||
if design == nil || len(design.IDs) == 0 || items == nil {
|
||||
return nil, fmt.Errorf("recipes: missing design or inventory")
|
||||
}
|
||||
for _, id := range initial {
|
||||
if !design.IDs[id] {
|
||||
return nil, fmt.Errorf("recipes: initial recipe %d is absent from GameData", id)
|
||||
}
|
||||
}
|
||||
return &RecipeService{design: design, initial: append([]uint64(nil), initial...), items: items}, nil
|
||||
}
|
||||
|
||||
func (s *RecipeService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/RecipeInfo" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
known := map[uint64]bool{}
|
||||
for _, id := range s.initial {
|
||||
known[id] = true
|
||||
}
|
||||
for _, item := range s.items.All() {
|
||||
// EElementType.CookingRecipe is protocol value 7. Ownership comes from
|
||||
// seed state or real reward grants, never from every row in CookingTable.
|
||||
if item.Type != 7 || item.Count == 0 {
|
||||
continue
|
||||
}
|
||||
if !s.design.IDs[item.ID] {
|
||||
return 0, nil, true, fmt.Errorf("recipes: owned recipe %d is absent from GameData", item.ID)
|
||||
}
|
||||
known[item.ID] = true
|
||||
}
|
||||
ids := make([]uint64, 0, len(known))
|
||||
for id := range known {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
var packed []byte
|
||||
for _, id := range ids {
|
||||
packed = binary.AppendUvarint(packed, id)
|
||||
}
|
||||
if len(packed) == 0 {
|
||||
return 46, nil, true, nil
|
||||
}
|
||||
return 46, wire.AppendBytes(nil, 2, packed), true, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package feature
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestRecipesFollowOwnedGrantsAndRestart(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
starter := &player.Starter{Version: versionconfig.State()}
|
||||
items, err := player.OpenInventory(store, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := &gamedata.CookingRecipeDesign{IDs: map[uint64]bool{501: true, 701: true}}
|
||||
service, err := NewRecipeService(design, []uint64{701}, items)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check := func(service *RecipeService, want []uint64) {
|
||||
t.Helper()
|
||||
code, payload, handled, err := service.Handle("/RecipeInfo", wire.AppendVarint(nil, 1, 17))
|
||||
if err != nil || !handled || code != 46 {
|
||||
t.Fatalf("recipe response: %d %v %v", code, handled, err)
|
||||
}
|
||||
packed, _, err := wire.Bytes(payload, 2)
|
||||
var ids []uint64
|
||||
for len(packed) > 0 {
|
||||
id, n := binary.Uvarint(packed)
|
||||
if n <= 0 {
|
||||
t.Fatal("malformed packed recipes")
|
||||
}
|
||||
ids = append(ids, id)
|
||||
packed = packed[n:]
|
||||
}
|
||||
if err != nil || !reflect.DeepEqual(ids, want) {
|
||||
t.Fatalf("recipes=%v want=%v err=%v", ids, want, err)
|
||||
}
|
||||
}
|
||||
check(service, []uint64{701})
|
||||
if _, err := items.GrantOnce("recipe-reward", []gamedata.BattleReward{{Type: 7, ID: 501, Count: 1}, {Type: 7, ID: 701, Count: 1}, {Type: 1, ID: 999, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(service, []uint64{501, 701})
|
||||
reopened, err := player.OpenInventory(store, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err = NewRecipeService(design, []uint64{701}, reopened)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
check(service, []uint64{501, 701})
|
||||
if _, err := reopened.GrantOnce("unknown-recipe", []gamedata.BattleReward{{Type: 7, ID: 800, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := service.Handle("/RecipeInfo", wire.AppendVarint(nil, 1, 18)); err == nil {
|
||||
t.Fatal("unknown owned recipe accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecipeDesignRejectsUnknownInitialOwnership(t *testing.T) {
|
||||
if _, err := NewRecipeService(&gamedata.CookingRecipeDesign{IDs: map[uint64]bool{501: true}}, []uint64{101}, statefulRecipeItems{}); err == nil {
|
||||
t.Fatal("unknown initial recipe accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type statefulRecipeItems struct{}
|
||||
|
||||
func (statefulRecipeItems) All() []player.Item { return nil }
|
||||
@@ -27,11 +27,11 @@ func TestFirstGachaPreviewConfirmAndReplay(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{61001}, characters)
|
||||
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{61001}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
|
||||
1: {ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}},
|
||||
}, characters)
|
||||
if err != nil {
|
||||
@@ -129,11 +129,11 @@ func newFirstGachaTestService(t *testing.T) *Service {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{61001}, characters)
|
||||
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{61001}, characters)
|
||||
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: 61001, Weight: 1}}}}, characters)
|
||||
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 61001, Weight: 1}}}}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,23 +15,9 @@ import (
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
const infiniteGrant = "cash-product:1100001:9100033"
|
||||
|
||||
const (
|
||||
moonriseProductGroupID = 1500001
|
||||
moonriseProductID = 9100037
|
||||
moonriseTicketType = 19
|
||||
moonriseTicketID = 450030
|
||||
moonriseProductGrant = "cash-product:1500001:9100037"
|
||||
moonriseTicketGrant = "cash-product-reward:1500001:9100037:0"
|
||||
moonriseDrawGrant = "special-gacha:30011:9100037"
|
||||
)
|
||||
|
||||
const (
|
||||
infiniteScheduleGroupID = 30010
|
||||
twelvePickGroupID = 10001
|
||||
paidTwelvePickGroupID = 30011
|
||||
)
|
||||
func (s *Service) infiniteGrant() string {
|
||||
return fmt.Sprintf("cash-product:%d:%d", s.design.ProductGroupID, s.design.ProductID)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
design *gamedata.InfiniteGachaDesign
|
||||
@@ -43,6 +29,7 @@ type Service struct {
|
||||
equipmentCatalog *gamedata.EquipmentGachaCatalog
|
||||
equipmentInventory *player.EquipmentInventory
|
||||
onPreview func() error
|
||||
onDraw func(uint64) error
|
||||
schedule *ScheduleSeed
|
||||
previewEventIndex uint64
|
||||
sessionMu sync.RWMutex
|
||||
@@ -52,6 +39,8 @@ type Service struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) AttachDrawMission(callback func(uint64) error) { s.onDraw = callback }
|
||||
|
||||
func (s *Service) AttachPreviewMission(callback func() error) { s.onPreview = callback }
|
||||
func (s *Service) AttachInventory(inventory *player.Inventory) { s.inventory = inventory }
|
||||
func (s *Service) AttachPreviewEventIndex(eventIndex uint64) error {
|
||||
@@ -159,16 +148,21 @@ func (s *Service) PurchaseCountDBInfos() [][]byte {
|
||||
group, id uint64
|
||||
bought bool
|
||||
}{}
|
||||
_, infiniteBought := s.collection.Grant(infiniteGrant)
|
||||
_, infiniteBought := s.collection.Grant(s.infiniteGrant())
|
||||
products = append(products, struct {
|
||||
group, id uint64
|
||||
bought bool
|
||||
}{gamedata.InfiniteProductGroupID, gamedata.InfiniteProductID, infiniteBought})
|
||||
_, moonriseBought := s.collection.Grant(moonriseProductGrant)
|
||||
}{s.design.ProductGroupID, s.design.ProductID, infiniteBought})
|
||||
for _, group := range s.regular.Groups() {
|
||||
if group.CashProductGroupID == 0 || group.CashProductID == 0 {
|
||||
continue
|
||||
}
|
||||
_, bought := s.collection.Grant(fmt.Sprintf("cash-product:%d:%d", group.CashProductGroupID, group.CashProductID))
|
||||
products = append(products, struct {
|
||||
group, id uint64
|
||||
bought bool
|
||||
}{moonriseProductGroupID, moonriseProductID, moonriseBought})
|
||||
}{group.CashProductGroupID, group.CashProductID, bought})
|
||||
}
|
||||
var result [][]byte
|
||||
for _, product := range products {
|
||||
if !product.bought {
|
||||
@@ -217,8 +211,6 @@ func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) {
|
||||
if group, ok := s.regular.Group(groupID); ok && group.SelectCount != 0 {
|
||||
expected = int(group.SelectCount)
|
||||
changeLimit = group.SelectionChangeCount
|
||||
} else if groupID == paidTwelvePickGroupID {
|
||||
expected = 12
|
||||
}
|
||||
if expected == 0 || len(selections) != expected {
|
||||
return 198, nil, true, fmt.Errorf("gacha: group %d requires %d selections, got %d", groupID, expected, len(selections))
|
||||
@@ -483,7 +475,8 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
|
||||
}
|
||||
}
|
||||
isDailyPaid := buyType == 2 && design.Count == 1 && design.DailyPayGachaCount != 0 && design.DailyPayGachaPriceCount != 0 && knownGroup
|
||||
isMoonrise := buyType == 3 && design.PriceType == moonriseTicketType && design.PriceID == moonriseTicketID && id == moonriseProductID && group.ID == paidTwelvePickGroupID
|
||||
isMoonrise := buyType == 3 && design.PriceType == 19 && design.PriceID != 0 && knownGroup && group.GachaSubType == 1 && group.SelectCount != 0 && group.CashProductID != 0
|
||||
completionGrant := fmt.Sprintf("special-gacha:%d:%d", group.ID, id)
|
||||
validOrdinary := buyType == 1 && (design.PriceType == 2 || design.PriceType == 3)
|
||||
if !isDailyFree && !isDailyPaid && !isMoonrise && !validOrdinary {
|
||||
priceType := uint64(0)
|
||||
@@ -510,7 +503,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
|
||||
}
|
||||
grant, already := s.collection.Grant(identity)
|
||||
if isMoonrise && !already {
|
||||
if _, completed := s.collection.Grant(moonriseDrawGrant); completed {
|
||||
if _, completed := s.collection.Grant(completionGrant); completed {
|
||||
return 146, nil, true, errors.New("gacha: Moonrise product already drawn")
|
||||
}
|
||||
}
|
||||
@@ -606,7 +599,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
|
||||
if s.inventory == nil {
|
||||
return 146, nil, true, errors.New("gacha: inventory not attached")
|
||||
}
|
||||
moonriseCost, err = s.inventory.SelectMutable(moonriseTicketType, moonriseTicketID, design.Price)
|
||||
moonriseCost, err = s.inventory.SelectMutable(design.PriceType, design.PriceID, design.Price)
|
||||
if err != nil {
|
||||
return 146, nil, true, err
|
||||
}
|
||||
@@ -661,7 +654,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
|
||||
}
|
||||
purchase := player.GachaPurchase{Group: group, BuyType: buyType, Fixed: fixedStates, SelectionApplySortIDs: selectionApplySortIDs}
|
||||
if isMoonrise {
|
||||
purchase.CompletionGrant = moonriseDrawGrant
|
||||
purchase.CompletionGrant = completionGrant
|
||||
}
|
||||
if dailyLimit != 0 {
|
||||
purchase.DailyKey = day
|
||||
@@ -687,9 +680,9 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
|
||||
for _, sortID := range grant.SelectionApplySortIDs {
|
||||
response = wire.AppendVarint(response, 4, sortID)
|
||||
}
|
||||
if s.onPreview != nil {
|
||||
if err := s.onPreview(); err != nil {
|
||||
return 146, nil, true, fmt.Errorf("gacha: update buy mission: %w", err)
|
||||
if !already && s.onDraw != nil {
|
||||
if err := s.onDraw(uint64(design.Count)); err != nil {
|
||||
return 146, nil, true, fmt.Errorf("gacha: draw mission: %w", err)
|
||||
}
|
||||
}
|
||||
return 146, response, true, nil
|
||||
@@ -799,9 +792,17 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player
|
||||
return 146, nil, true, err
|
||||
}
|
||||
}
|
||||
fixed := s.equipmentCatalog.Fixed()
|
||||
var fixed gamedata.EquipmentFixedDesign
|
||||
var priorSR, priorUR uint64
|
||||
if !design.TicketOnly {
|
||||
group, found := s.equipmentCatalog.GroupForGacha(design.ID)
|
||||
if !found {
|
||||
return 146, nil, true, errors.New("gacha: equipment group missing")
|
||||
}
|
||||
fixed, found = s.equipmentCatalog.FixedForGroup(group.ID)
|
||||
if !found {
|
||||
return 146, nil, true, errors.New("gacha: equipment fixed design missing")
|
||||
}
|
||||
priorSR = s.collection.GachaFixedCount(fixed.ID, 2)
|
||||
priorUR = s.collection.GachaFixedCount(fixed.ID, 3)
|
||||
}
|
||||
@@ -857,6 +858,11 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player
|
||||
for _, fixed := range grant.GachaFixed {
|
||||
response = wire.AppendBytes(response, 3, gachaFixedWire(fixed))
|
||||
}
|
||||
if !already && s.onDraw != nil {
|
||||
if err := s.onDraw(uint64(design.Count)); err != nil {
|
||||
return 146, nil, true, fmt.Errorf("gacha: equipment draw mission: %w", err)
|
||||
}
|
||||
}
|
||||
return 146, response, true, nil
|
||||
}
|
||||
|
||||
@@ -885,7 +891,17 @@ func costumeFixedStates(fixed gamedata.GachaFixedDesign, result gamedata.GachaFi
|
||||
}
|
||||
|
||||
func (s *Service) completeSharedFixedStates(fixedID uint64, updates []player.GachaFixedState) []player.GachaFixedState {
|
||||
if fixedID == 0 || s.equipmentCatalog == nil || s.equipmentCatalog.Fixed().ID != fixedID {
|
||||
if fixedID == 0 || s.equipmentCatalog == nil {
|
||||
return updates
|
||||
}
|
||||
known := false
|
||||
for _, fixed := range s.equipmentCatalog.FixedDesigns() {
|
||||
if fixed.ID == fixedID {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return updates
|
||||
}
|
||||
states := make([]player.GachaFixedState, 4)
|
||||
@@ -1056,18 +1072,18 @@ func (s *Service) preview(request []byte) (int, []byte, bool, error) {
|
||||
if s.first != nil && id == s.first.GachaID {
|
||||
return s.previewFirstGacha(request)
|
||||
}
|
||||
if _, bought := s.collection.Grant(infiniteGrant); bought {
|
||||
if _, bought := s.collection.Grant(s.infiniteGrant()); bought {
|
||||
return 175, nil, true, errors.New("gacha: infinite product already purchased")
|
||||
}
|
||||
if id != gamedata.InfiniteGachaID {
|
||||
if id != s.design.GachaID {
|
||||
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
|
||||
}
|
||||
group, found, err := wire.Varint(request, 3)
|
||||
if err != nil || !found || group != gamedata.InfiniteProductGroupID {
|
||||
if err != nil || !found || group != s.design.ProductGroupID {
|
||||
return 0, nil, true, fmt.Errorf("gacha: unsupported preview product group %d", group)
|
||||
}
|
||||
product, found, err := wire.Varint(request, 4)
|
||||
if err != nil || !found || product != gamedata.InfiniteProductID {
|
||||
if err != nil || !found || product != s.design.ProductID {
|
||||
return 0, nil, true, fmt.Errorf("gacha: unsupported preview product %d", product)
|
||||
}
|
||||
if s.previewEventIndex == 0 {
|
||||
@@ -1127,22 +1143,30 @@ func (s *Service) confirm(request []byte) (int, []byte, bool, error) {
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if saleGroup != 0 || buyCount != 1 {
|
||||
if buyCount != 1 {
|
||||
return 0, nil, true, fmt.Errorf("gacha: unsupported product %d sale %d count %d", productID, saleGroup, buyCount)
|
||||
}
|
||||
if group == moonriseProductGroupID && productID == moonriseProductID {
|
||||
for _, cashGroup := range s.regular.Groups() {
|
||||
if group != cashGroup.CashProductGroupID || productID != cashGroup.CashProductID || saleGroup != cashGroup.CashSalesGroup {
|
||||
continue
|
||||
}
|
||||
if len(cashGroup.CashRewards) == 0 {
|
||||
return 61, nil, true, errors.New("gacha: cash product rewards unavailable")
|
||||
}
|
||||
productGrant := fmt.Sprintf("cash-product:%d:%d", group, productID)
|
||||
ticketGrant := fmt.Sprintf("cash-product-reward:%d:%d:0", group, productID)
|
||||
if s.inventory == nil {
|
||||
return 61, nil, true, errors.New("gacha: inventory not attached")
|
||||
}
|
||||
items, err := s.inventory.GrantOnce(moonriseTicketGrant, []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}})
|
||||
items, err := s.inventory.GrantOnce(ticketGrant, cashGroup.CashRewards)
|
||||
if err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
if err := s.collection.RecordGrantMarker(moonriseProductGrant); err != nil {
|
||||
if err := s.collection.RecordGrantMarker(productGrant); err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
items = s.inventory.GrantedItems(moonriseTicketGrant)
|
||||
items = s.inventory.GrantedItems(ticketGrant)
|
||||
}
|
||||
var bundle []byte
|
||||
for _, item := range items {
|
||||
@@ -1150,14 +1174,14 @@ func (s *Service) confirm(request []byte) (int, []byte, bool, error) {
|
||||
}
|
||||
return 61, wire.AppendBytes(nil, 1, bundle), true, nil
|
||||
}
|
||||
if group != gamedata.InfiniteProductGroupID || productID != gamedata.InfiniteProductID {
|
||||
if group != s.design.ProductGroupID || productID != s.design.ProductID || saleGroup != s.design.SaleGroup {
|
||||
return 0, nil, true, fmt.Errorf("gacha: unsupported cash product group=%d product=%d", group, productID)
|
||||
}
|
||||
grant, err := s.collection.ConfirmInfinite(infiniteGrant, s.design)
|
||||
grant, err := s.collection.ConfirmInfinite(s.infiniteGrant(), s.design)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if err := s.creditOverflow(infiniteGrant, grant); err != nil {
|
||||
if err := s.creditOverflow(s.infiniteGrant(), grant); err != nil {
|
||||
return 61, nil, true, err
|
||||
}
|
||||
return 61, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil
|
||||
@@ -1255,8 +1279,7 @@ func (s *Service) rewardBundle(grant player.CollectionGrant) []byte {
|
||||
|
||||
func (s *Service) gachaInfo() []byte {
|
||||
var out []byte
|
||||
_, infinitePurchased := s.collection.Grant(infiniteGrant)
|
||||
_, moonriseDrawn := s.collection.Grant(moonriseDrawGrant)
|
||||
_, infinitePurchased := s.collection.Grant(s.infiniteGrant())
|
||||
if s.schedule != nil {
|
||||
for _, entry := range s.schedule.Schedules {
|
||||
// The versioned seed is the public schedule, while availability of
|
||||
@@ -1264,12 +1287,14 @@ func (s *Service) gachaInfo() []byte {
|
||||
// grant exists, publishing this group makes the client reopen the
|
||||
// preview flow even though the authoritative preview endpoint must
|
||||
// reject a second purchase.
|
||||
if infinitePurchased && entry.GroupID == infiniteScheduleGroupID {
|
||||
if infinitePurchased && entry.GroupID == s.design.GroupID {
|
||||
continue
|
||||
}
|
||||
if moonriseDrawn && entry.GroupID == paidTwelvePickGroupID {
|
||||
if special, ok := s.regular.Group(entry.GroupID); ok && special.GachaSubType == 1 && special.CashProductID != 0 {
|
||||
if _, drawn := s.collection.Grant(fmt.Sprintf("special-gacha:%d:%d", special.ID, special.TenTimeGachaID)); drawn {
|
||||
continue
|
||||
}
|
||||
}
|
||||
schedule := wire.AppendVarint(nil, 1, entry.GroupID)
|
||||
schedule = wire.AppendVarint(schedule, 2, entry.StartTime)
|
||||
schedule = wire.AppendVarint(schedule, 3, entry.EndTime)
|
||||
|
||||
@@ -23,15 +23,15 @@ func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
const version = "20260923193640"
|
||||
infinite, err := gamedata.LoadInfiniteGacha(root, version)
|
||||
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.LoadRegularCostumeGacha(root, version)
|
||||
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{10001, 135, 205, 121}, []uint64{29})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equipmentCatalog, err := gamedata.LoadEquipmentGacha(root, version)
|
||||
equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{10002, 9, 133, 206})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ func fixtureCharacter(id, hp uint64) gamedata.CharacterDesign {
|
||||
}
|
||||
|
||||
func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
|
||||
60901: fixtureCharacter(6090, 253),
|
||||
})
|
||||
if err != nil {
|
||||
@@ -142,7 +142,7 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
|
||||
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: fixtureCharacter(6090, 253)})
|
||||
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)
|
||||
}
|
||||
@@ -171,9 +171,9 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
@@ -213,8 +213,8 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
|
||||
}
|
||||
|
||||
buy := wire.AppendVarint(nil, 1, 3)
|
||||
buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID)
|
||||
product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID)
|
||||
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)
|
||||
@@ -259,7 +259,7 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
|
||||
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 != gamedata.InfiniteProductGroupID || productID != gamedata.InfiniteProductID || count != 1 {
|
||||
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 {
|
||||
@@ -292,11 +292,11 @@ func scheduleGroupIDs(windows []ScheduleWindow) []uint64 {
|
||||
}
|
||||
|
||||
func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
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: fixtureCharacter(6090, 253)})
|
||||
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)
|
||||
}
|
||||
@@ -317,9 +317,9 @@ func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -328,8 +328,8 @@ func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
|
||||
t.Fatalf("first preview event=%d locked=%v", firstEvent, locked)
|
||||
}
|
||||
buy := wire.AppendVarint(nil, 1, 2)
|
||||
buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID)
|
||||
product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID)
|
||||
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 {
|
||||
@@ -349,11 +349,11 @@ func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegularGachaBuySpendsWalletAndPersistsCostume(t *testing.T) {
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -395,11 +395,11 @@ func TestRegularGachaBuySpendsWalletAndPersistsCostume(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegularGachaSequenceReuseAcrossLoginIsANewPurchase(t *testing.T) {
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -445,11 +445,11 @@ func TestRegularGachaSequenceReuseAcrossLoginIsANewPurchase(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPaidRegularGachaBuySpendsPaidJewelry(t *testing.T) {
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -488,11 +488,11 @@ func TestPaidRegularGachaBuySpendsPaidJewelry(t *testing.T) {
|
||||
|
||||
func TestPriceType19IsRejectedWithoutChargingDiamonds(t *testing.T) {
|
||||
character := fixtureCharacter(6090, 253)
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -528,11 +528,11 @@ func TestPriceType19IsRejectedWithoutChargingDiamonds(t *testing.T) {
|
||||
|
||||
func TestUnverifiedGachaRPCsRemainUnhandled(t *testing.T) {
|
||||
character := fixtureCharacter(6090, 253)
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -563,11 +563,11 @@ func newMultiBuyTestService(t *testing.T, storage stateio.Store) *Service {
|
||||
t.Helper()
|
||||
character := fixtureCharacter(6090, 253)
|
||||
characters := map[uint64]gamedata.CharacterDesign{60901: character}
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, characters)
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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}}},
|
||||
@@ -788,7 +788,7 @@ func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) {
|
||||
if err := service.collection.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.AttachPreviewMission(func() error { return errors.New("injected mission failure after reward") })
|
||||
service.AttachDrawMission(func(uint64) error { return errors.New("injected mission failure after reward") })
|
||||
operation, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -901,7 +901,7 @@ func TestEquipmentPointExchangeUsesGameDataAndIsIdempotent(t *testing.T) {
|
||||
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.LoadInfiniteGacha(root, version)
|
||||
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -974,7 +974,7 @@ func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
|
||||
const stepUpGroupID = 29
|
||||
stepUpGachaIDs := []uint64{8100118, 8100119, 8100120, 8100121}
|
||||
character := fixtureCharacter(6090, 253)
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -982,7 +982,7 @@ func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
|
||||
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 := gamedata.NewRegularGachaCatalog(gachas, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
regular, err := fixtureRegularCatalog(gachas, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1064,7 +1064,7 @@ func TestInstalledStepUpGroups29And30UseGenericIndependentProgress(t *testing.T)
|
||||
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
|
||||
}
|
||||
const version = "20260923193640"
|
||||
design, err := gamedata.LoadInfiniteGacha(root, version)
|
||||
design, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1135,11 +1135,11 @@ func TestInstalledStepUpGroups29And30UseGenericIndependentProgress(t *testing.T)
|
||||
|
||||
func TestRegularTenPullContainsTenClientDisplayEntriesAtMaxLevel(t *testing.T) {
|
||||
character := fixtureCharacter(6090, 253)
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -1201,7 +1201,7 @@ func TestRegularTenPullContainsTenClientDisplayEntriesAtMaxLevel(t *testing.T) {
|
||||
|
||||
func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing.T) {
|
||||
character := fixtureCharacter(6090, 253)
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1211,8 +1211,8 @@ func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing
|
||||
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
|
||||
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
11000145: {ID: 11000145, Count: 10, PriceType: 3, Price: 2000, Pool: pool},
|
||||
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)
|
||||
@@ -1294,11 +1294,11 @@ func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing
|
||||
}
|
||||
|
||||
func TestCashShopPurchaseCountStartsEmpty(t *testing.T) {
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
|
||||
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: fixtureCharacter(6090, 253)})
|
||||
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)
|
||||
}
|
||||
@@ -1331,14 +1331,17 @@ func TestTwelvePickSelectionSavePersistsAndReturnsInGachaInfo(t *testing.T) {
|
||||
}
|
||||
characters[4001] = fixtureCharacter(400, 100)
|
||||
characters[3001] = fixtureCharacter(300, 100)
|
||||
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
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: 5001, Weight: 1}}}}, characters)
|
||||
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 {
|
||||
@@ -1380,11 +1383,11 @@ func TestTwelvePickSelectionSavePersistsAndReturnsInGachaInfo(t *testing.T) {
|
||||
|
||||
func TestDailyFreeAndPaidSingleDrawsResetByUTCDate(t *testing.T) {
|
||||
characters := map[uint64]gamedata.CharacterDesign{5001: fixtureCharacter(500, 100)}
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{5001}, characters)
|
||||
design, err := fixtureInfiniteGachaDesign(10, []uint64{5001}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -1495,7 +1498,7 @@ func TestMoonriseSelectionCashProductAndOneTimeTicketDraw(t *testing.T) {
|
||||
}
|
||||
characters[4001] = fixtureCharacter(400, 100)
|
||||
characters[3001] = fixtureCharacter(300, 100)
|
||||
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1508,11 +1511,134 @@ func TestMoonriseSelectionCashProductAndOneTimeTicketDraw(t *testing.T) {
|
||||
}}},
|
||||
},
|
||||
}}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters)
|
||||
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, TenTimeGachaID: moonriseProductID, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); err != nil {
|
||||
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()
|
||||
@@ -1605,11 +1731,11 @@ func TestSelectionChangeCountPersistsEnforcesLimitAndReturnsInGachaInfo(t *testi
|
||||
4001: fixtureCharacter(400, 100),
|
||||
3001: fixtureCharacter(300, 100),
|
||||
}
|
||||
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -1689,7 +1815,7 @@ func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing
|
||||
}
|
||||
characters[4001] = fixtureCharacter(400, 100)
|
||||
characters[3001] = fixtureCharacter(300, 100)
|
||||
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1698,7 +1824,7 @@ func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing
|
||||
{Weight: 1400, Children: []gamedata.WeightedCostume{{ID: 4001, Weight: 1}}},
|
||||
{Weight: 8300, Children: []gamedata.WeightedCostume{{ID: 3001, Weight: 1}}},
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
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 {
|
||||
@@ -1761,7 +1887,7 @@ func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testi
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
infinite, err := gamedata.LoadInfiniteGacha(root, "20260923193640")
|
||||
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{infiniteScheduleGroupID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1813,24 +1939,30 @@ func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testi
|
||||
if err != nil || point != 0 {
|
||||
t.Fatalf("draw %d point=%d err=%v", draw+1, point, err)
|
||||
}
|
||||
applySort, found, err := wire.Varint(response, 4)
|
||||
if err != nil || !found || applySort != 9 {
|
||||
t.Fatalf("draw %d selected guarantee sort=%d found=%v err=%v response=%x", draw+1, applySort, found, err, response)
|
||||
}
|
||||
grant, ok := collection.Grant(service.requestIdentity(31, draw+2))
|
||||
if !ok || len(grant.ViewCostumeIDs) != 10 || !selected[grant.ViewCostumeIDs[9]] {
|
||||
t.Fatalf("draw %d grant=%+v", draw+1, grant)
|
||||
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())
|
||||
}
|
||||
for _, fixed := range collection.GachaFixedStates() {
|
||||
if fixed.FixedID == 3 && fixed.Type == 1 && fixed.Count != 0 {
|
||||
t.Fatalf("newbie five-star fixed count accumulated: %+v", fixed)
|
||||
}
|
||||
}
|
||||
before := wallet.Snapshot()
|
||||
fourth := wire.AppendVarint(nil, 1, 5)
|
||||
fourth = wire.AppendVarint(fourth, 2, 31)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package gacha
|
||||
|
||||
import "bd2server/internal/server/gamedata"
|
||||
|
||||
const (
|
||||
moonriseProductGroupID = 1500001
|
||||
moonriseProductID = 9100037
|
||||
moonriseTicketType = 19
|
||||
moonriseTicketID = 450030
|
||||
moonriseProductGrant = "cash-product:1500001:9100037"
|
||||
moonriseTicketGrant = "cash-product-reward:1500001:9100037:0"
|
||||
moonriseDrawGrant = "special-gacha:30011:9100037"
|
||||
)
|
||||
|
||||
const (
|
||||
infiniteScheduleGroupID = 30010
|
||||
twelvePickGroupID = 10001
|
||||
paidTwelvePickGroupID = 30011
|
||||
)
|
||||
|
||||
const infiniteGrant = "cash-product:1100001:9100033"
|
||||
|
||||
const (
|
||||
fixtureInfiniteGachaID = 9100033
|
||||
fixtureInfiniteProductGroupID = 1100001
|
||||
fixtureInfiniteProductID = 9100033
|
||||
)
|
||||
|
||||
func fixtureInfiniteGachaDesign(count int, ids []uint64, characters map[uint64]gamedata.CharacterDesign) (*gamedata.InfiniteGachaDesign, error) {
|
||||
return fixtureInfiniteGachaDesignWithRates(count, ids, ids, ids, characters)
|
||||
}
|
||||
func fixtureInfiniteGachaDesignWithRates(count int, five, four, three []uint64, characters map[uint64]gamedata.CharacterDesign) (*gamedata.InfiniteGachaDesign, error) {
|
||||
d, e := gamedata.NewInfiniteGachaDesignWithRates(count, five, four, three, characters)
|
||||
if e == nil {
|
||||
d.GroupID = infiniteScheduleGroupID
|
||||
d.GachaID = fixtureInfiniteGachaID
|
||||
d.ProductGroupID = fixtureInfiniteProductGroupID
|
||||
d.ProductID = fixtureInfiniteProductID
|
||||
}
|
||||
return d, e
|
||||
}
|
||||
|
||||
func fixtureRegularCatalog(gachas map[uint64]gamedata.RegularGacha, characters map[uint64]gamedata.CharacterDesign) (*gamedata.RegularGachaCatalog, error) {
|
||||
for id, g := range gachas {
|
||||
if len(g.Grades) == 0 {
|
||||
g.Grades = map[uint64]uint64{}
|
||||
var collect func([]gamedata.WeightedCostume, uint64)
|
||||
collect = func(p []gamedata.WeightedCostume, grade uint64) {
|
||||
for _, w := range p {
|
||||
if w.ID != 0 {
|
||||
g.Grades[w.ID] = grade
|
||||
} else {
|
||||
collect(w.Children, grade)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, w := range g.Pool {
|
||||
grade := uint64(5 - i)
|
||||
if len(g.Pool) == 4 {
|
||||
grade = uint64(6 - i)
|
||||
if i == 0 {
|
||||
grade = 5
|
||||
}
|
||||
}
|
||||
collect([]gamedata.WeightedCostume{w}, grade)
|
||||
}
|
||||
if g.FixedCostumeID != 0 {
|
||||
g.Grades[g.FixedCostumeID] = 5
|
||||
}
|
||||
gachas[id] = g
|
||||
}
|
||||
}
|
||||
return gamedata.NewRegularGachaCatalog(gachas, characters)
|
||||
}
|
||||
@@ -18,13 +18,13 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
|
||||
design, err := fixtureInfiniteGachaDesign(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(
|
||||
regular, err := fixtureRegularCatalog(
|
||||
map[uint64]gamedata.RegularGacha{
|
||||
1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
|
||||
},
|
||||
@@ -52,9 +52,9 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -64,8 +64,8 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buy := wire.AppendVarint(nil, 1, 3)
|
||||
buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID)
|
||||
product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID)
|
||||
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 {
|
||||
@@ -137,7 +137,7 @@ func assertInfinitePurchaseCount(t *testing.T, data []byte) {
|
||||
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 {
|
||||
group != fixtureInfiniteProductGroupID || id != fixtureInfiniteProductID || 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)
|
||||
|
||||
@@ -21,11 +21,11 @@ func TestGachaPointExchangeGrantsUpgradesOverflowsAndRetries(t *testing.T) {
|
||||
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)
|
||||
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{rollID}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
|
||||
gachaID: {ID: gachaID, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}},
|
||||
}, characters)
|
||||
if err != nil {
|
||||
@@ -141,11 +141,11 @@ func TestGachaPointExchangeUsesOnlySavedSingleSelection(t *testing.T) {
|
||||
rollID: fixtureCharacter(6090, 253),
|
||||
selected: fixtureCharacter(6490, 300),
|
||||
}
|
||||
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{rollID}, characters)
|
||||
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{rollID}, characters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
|
||||
gachaID: {ID: gachaID, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}},
|
||||
}, characters)
|
||||
if err != nil {
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestScheduleSeedStrictValidation(t *testing.T) {
|
||||
|
||||
func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) {
|
||||
character := gamedata.CharacterDesign{ID: 1, HP: 1, CostumeMaxLevel: 5}
|
||||
catalog, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
|
||||
catalog, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
|
||||
11: {ID: 11, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 101, Weight: 1}}},
|
||||
12: {ID: 12, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 102, Weight: 1}}},
|
||||
}, map[uint64]gamedata.CharacterDesign{101: character, 102: character})
|
||||
@@ -93,11 +93,11 @@ func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T)
|
||||
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})
|
||||
design, err := fixtureInfiniteGachaDesign(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})
|
||||
regular, err := fixtureRegularCatalog(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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type GachaContentTicketDesign struct{ IDs map[uint64]bool }
|
||||
|
||||
func LoadGachaContentTicketDesign(root, version string) (*GachaContentTicketDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM ContentTicketTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
d := &GachaContentTicketDesign{IDs: map[uint64]bool{}}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typ, err := optionalScalar(raw, 8)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if typ == 4 {
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid gacha content ticket")
|
||||
}
|
||||
d.IDs[id] = true
|
||||
}
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// CookingRecipeDesign describes valid recipe identities. Learning a recipe is
|
||||
// player state, so listing this table never grants all of its recipes.
|
||||
type CookingRecipeDesign struct{ IDs map[uint64]bool }
|
||||
|
||||
func LoadCookingRecipeDesign(root, version string) (*CookingRecipeDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadCookingRecipeDesign(db)
|
||||
}
|
||||
|
||||
func loadCookingRecipeDesign(db *sql.DB) (*CookingRecipeDesign, error) {
|
||||
rows, err := db.Query("SELECT id FROM CookingTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
design := &CookingRecipeDesign{IDs: map[uint64]bool{}}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id <= 0 || id > math.MaxInt32 || design.IDs[uint64(id)] {
|
||||
return nil, fmt.Errorf("gamedata: invalid cooking recipe identity")
|
||||
}
|
||||
design.IDs[uint64(id)] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(design.IDs) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty cooking recipe catalog")
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCookingRecipesReadCatalogIdentities(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE CookingTable(id INTEGER); INSERT INTO CookingTable VALUES(503),(707)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design, err := loadCookingRecipeDesign(db)
|
||||
if err != nil || len(design.IDs) != 2 || !design.IDs[503] || !design.IDs[707] || design.IDs[101] {
|
||||
t.Fatalf("design=%+v err=%v", design, err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO CookingTable VALUES(0)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadCookingRecipeDesign(db); err == nil {
|
||||
t.Fatal("invalid recipe identity accepted")
|
||||
}
|
||||
}
|
||||
@@ -32,15 +32,10 @@ func ReadDatabase(root, version, logical string) ([]byte, error) {
|
||||
return readEntry(root, version, name, logical)
|
||||
}
|
||||
|
||||
// questDatabaseEntry is the member in the 2.35.10 GameData archive that
|
||||
// contains the shared QuestTable* SQLite database, including QuestTable21.
|
||||
// Quest data is not stored in an individual per-pack database.
|
||||
const questDatabaseEntry = "9F251C63BC72551C681EE75D328FA090D56E444B"
|
||||
|
||||
// ReadQuestDatabase extracts the shared quest database. The member name is
|
||||
// opaque in the client archive, so callers must not derive it from a map ID.
|
||||
// ReadQuestDatabase extracts the shared common database using the client's
|
||||
// logical database naming rule. Quest tables are not per-pack databases.
|
||||
func ReadQuestDatabase(root, version string) ([]byte, error) {
|
||||
return readEntry(root, version, questDatabaseEntry, "quest tables")
|
||||
return ReadDatabase(root, version, "common")
|
||||
}
|
||||
|
||||
func readEntry(root, version, name, label string) ([]byte, error) {
|
||||
|
||||
@@ -53,7 +53,11 @@ func TestReadQuestDatabase(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), questDatabaseEntry, encrypted)
|
||||
member, err := DatabaseName("common")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), member, encrypted)
|
||||
got, err := ReadQuestDatabase(root, "123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -36,10 +36,86 @@ func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error
|
||||
if c == nil || c.equipment == nil {
|
||||
return fmt.Errorf("gamedata: missing equipment catalog")
|
||||
}
|
||||
g, ok := c.Gachas[71200001]
|
||||
if !ok || !g.TicketOnly || len(g.TicketIDs) != 1 || g.TicketIDs[0] != 1104 || len(g.Pool) != 3 {
|
||||
return fmt.Errorf("gamedata: missing or malformed UR equipment ticket pool")
|
||||
// Select guaranteed weapon products from their existing equipment definitions,
|
||||
// rather than tying eligibility to a product or resource-ticket number.
|
||||
var targets []uint64
|
||||
for id, g := range c.Gachas {
|
||||
if !g.TicketOnly || len(g.TicketIDs) == 0 || len(g.Pool) == 0 {
|
||||
continue
|
||||
}
|
||||
eligible := true
|
||||
for _, branch := range g.Pool {
|
||||
if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
|
||||
eligible = false
|
||||
break
|
||||
}
|
||||
for _, item := range branch.Children {
|
||||
if item.ID == 0 || len(item.Children) != 0 {
|
||||
eligible = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !eligible {
|
||||
continue
|
||||
}
|
||||
var visit func([]WeightedEquipment) error
|
||||
visit = func(pool []WeightedEquipment) error {
|
||||
for _, entry := range pool {
|
||||
if entry.ID == 0 {
|
||||
if len(entry.Children) == 0 {
|
||||
eligible = false
|
||||
}
|
||||
if err := visit(entry.Children); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentTable WHERE id=?", entry.ID).Scan(&raw); err != nil {
|
||||
return err
|
||||
}
|
||||
grade, e1 := packedInts(raw, 3)
|
||||
quality, e2 := packedInts(raw, 18)
|
||||
owner, e3 := packedInts(raw, 16)
|
||||
if e1 != nil || e2 != nil || e3 != nil {
|
||||
return fmt.Errorf("gamedata: malformed ticket equipment %d", entry.ID)
|
||||
}
|
||||
if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 || owner[0] == 0 {
|
||||
eligible = false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := visit(g.Pool); err != nil {
|
||||
return err
|
||||
}
|
||||
if eligible {
|
||||
targets = append(targets, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(targets, func(i, j int) bool { return targets[i] < targets[j] })
|
||||
// Publish only after all selected products validate successfully.
|
||||
clone := *c
|
||||
clone.Gachas = make(map[uint64]EquipmentGacha, len(c.Gachas))
|
||||
for id, g := range c.Gachas {
|
||||
clone.Gachas[id] = g
|
||||
}
|
||||
clone.equipment = make(map[uint64]EquipmentDesign, len(c.equipment))
|
||||
for id, d := range c.equipment {
|
||||
clone.equipment[id] = d
|
||||
}
|
||||
for _, id := range targets {
|
||||
if err := clone.includeCollaborationWeaponPool(db, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.Gachas, c.equipment = clone.Gachas, clone.equipment
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *EquipmentGachaCatalog) includeCollaborationWeaponPool(db *sql.DB, id uint64) error {
|
||||
g := c.Gachas[id]
|
||||
readRows := func(query string) (map[uint64][]byte, error) {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
@@ -110,7 +186,7 @@ func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error
|
||||
return err
|
||||
}
|
||||
grade := grades[owner]
|
||||
if grade < 3 || grade > 5 || (branchGrade != 0 && branchGrade != grade) {
|
||||
if grade == 0 || (branchGrade != 0 && branchGrade != grade) {
|
||||
return fmt.Errorf("gamedata: unknown or mixed UR ticket character tier")
|
||||
}
|
||||
branchGrade = grade
|
||||
@@ -155,6 +231,10 @@ func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error
|
||||
c.equipment[id] = design
|
||||
}
|
||||
g.Pool = pool
|
||||
g.Grades = make(map[uint64]uint64, len(seen))
|
||||
for id := range seen {
|
||||
g.Grades[id] = c.equipment[id].Grade
|
||||
}
|
||||
c.Gachas[g.ID] = g
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) {
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
c, err := LoadEquipmentGacha(root, "20260923193640")
|
||||
c, err := LoadEquipmentGachaGroups(root, "20260923193640", []uint64{10002, 9, 133, 206})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,10 +54,16 @@ func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) {
|
||||
t.Fatal("original pool mutated")
|
||||
}
|
||||
for id, old := range before {
|
||||
if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) {
|
||||
if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) && !old.TicketOnly {
|
||||
t.Fatalf("other product %d changed", id)
|
||||
}
|
||||
}
|
||||
// The same UR weapon ticket design is available in a one-item product.
|
||||
// Its semantic eligibility cannot depend on one versus ten draw IDs.
|
||||
one := c.Gachas[70200001]
|
||||
if len(one.Pool) == 0 || len(one.Pool[0].Children) != len(g.Pool[0].Children) {
|
||||
t.Fatal("equivalent one-ticket UR pool not extended")
|
||||
}
|
||||
for _, branch := range g.Pool {
|
||||
for _, item := range branch.Children {
|
||||
if _, _, _, err := c.RollOptions(item.ID); err != nil {
|
||||
@@ -112,11 +118,11 @@ func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := []WeightedEquipment{{Weight: 150, Children: []WeightedEquipment{{ID: 101, Weight: 1}}}, {Weight: 350, Children: []WeightedEquipment{{ID: 102, Weight: 1}}}, {Weight: 500, Children: []WeightedEquipment{{ID: 103, Weight: 1}}}}
|
||||
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{71200001: {ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}}
|
||||
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{880077: {ID: 880077, TicketOnly: true, TicketIDs: []uint64{990055}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}}
|
||||
if err := c.includeCollaborationURWeapons(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Gachas[71200001].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 {
|
||||
if len(c.Gachas[880077].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 {
|
||||
t.Fatal("extension or isolation failed")
|
||||
}
|
||||
if _, ok := c.equipment[104]; !ok {
|
||||
@@ -125,25 +131,145 @@ func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) {
|
||||
if err := c.includeCollaborationURWeapons(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Gachas[71200001].Pool[0].Children) != 2 {
|
||||
if len(c.Gachas[880077].Pool[0].Children) != 2 {
|
||||
t.Fatal("duplicate owner or repeated inclusion duplicated equipment")
|
||||
}
|
||||
// Eligibility follows the product definitions even when there are fewer
|
||||
// character rarity branches than the current installed three-tier pool.
|
||||
c.Gachas[990088] = EquipmentGacha{ID: 990088, TicketOnly: true, TicketIDs: []uint64{554433}, Pool: pool[:1]}
|
||||
if err := c.includeCollaborationURWeapons(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Gachas[990088].Pool[0].Children) != 2 {
|
||||
t.Fatal("changed product/ticket/branch count ignored")
|
||||
}
|
||||
delete(c.Gachas, 990088)
|
||||
if _, err := db.Exec("UPDATE CharTable SET ProtoBuf=? WHERE id=4", proto(20, 4, 9, 2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(c.Gachas, 71200001)
|
||||
c.Gachas[71200001] = EquipmentGacha{ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}
|
||||
delete(c.Gachas, 880077)
|
||||
c.Gachas[880077] = EquipmentGacha{ID: 880077, TicketOnly: true, TicketIDs: []uint64{990055}, Pool: pool}
|
||||
if err := c.includeCollaborationURWeapons(db); err == nil {
|
||||
t.Fatal("unknown tier accepted")
|
||||
}
|
||||
if len(c.Gachas[71200001].Pool[0].Children) != 1 {
|
||||
if len(c.Gachas[880077].Pool[0].Children) != 1 {
|
||||
t.Fatal("failed inclusion published partial pool")
|
||||
}
|
||||
invalid := c.Gachas[71200001]
|
||||
invalid := c.Gachas[880077]
|
||||
invalid.Pool = append([]WeightedEquipment(nil), pool...)
|
||||
invalid.Pool[0].Children = []WeightedEquipment{{ID: 101, Weight: 1}, {ID: 101, Weight: 1}}
|
||||
c.Gachas[71200001] = invalid
|
||||
c.Gachas[880077] = invalid
|
||||
if err := c.includeCollaborationURWeapons(db); err == nil {
|
||||
t.Fatal("duplicate existing candidate accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentFixedUsesChangedIDThresholdsAndResetDefault(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE GachaFixedTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw []byte
|
||||
for _, v := range []uint64{8, 7, 24, 43} {
|
||||
raw = binary.AppendUvarint(raw, v)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO GachaFixedTable VALUES(?,?)", 987, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixed, err := loadEquipmentFixed(db, 987)
|
||||
if err != nil || fixed.ID != 987 || fixed.SRCount != 7 || fixed.URCount != 43 || fixed.Reset {
|
||||
t.Fatalf("dynamic fixed=%+v err=%v", fixed, err)
|
||||
}
|
||||
raw = append(raw, 48, 1)
|
||||
if _, err := db.Exec("UPDATE GachaFixedTable SET ProtoBuf=?", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixed, err = loadEquipmentFixed(db, 987)
|
||||
if err != nil || !fixed.Reset {
|
||||
t.Fatalf("reset=%+v err=%v", fixed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentFixedProgramsFollowEachGroup(t *testing.T) {
|
||||
c := &EquipmentGachaCatalog{groups: map[uint64]EquipmentGachaGroup{71: {ID: 71, FixedID: 901}, 72: {ID: 72, FixedID: 902}}, fixedDesigns: map[uint64]EquipmentFixedDesign{901: {ID: 901, SRCount: 7, URCount: 43}, 902: {ID: 902, SRCount: 2, URCount: 5, Reset: true}}}
|
||||
for _, pair := range []struct{ group, fixed, threshold uint64 }{{71, 901, 43}, {72, 902, 5}} {
|
||||
fixed, ok := c.FixedForGroup(pair.group)
|
||||
if !ok || fixed.ID != pair.fixed || fixed.URCount != pair.threshold {
|
||||
t.Fatalf("group%d fixed=%+v found=%v", pair.group, fixed, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := c.FixedForGroup(73); ok {
|
||||
t.Fatal("unknown group inherited another fixed program")
|
||||
}
|
||||
programs := c.FixedDesigns()
|
||||
if len(programs) != 2 || programs[0].ID != 901 || programs[1].ID != 902 {
|
||||
t.Fatalf("fixed designs=%+v", programs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentPityUsesActualGradesAndReorderedNestedPool(t *testing.T) {
|
||||
g := EquipmentGacha{Count: 1, Grades: map[uint64]uint64{90: 2, 80: 4, 70: 3}, Pool: []WeightedEquipment{{ID: 90, Weight: 60}, {Weight: 30, Children: []WeightedEquipment{{ID: 80, Weight: 10}, {ID: 70, Weight: 20}}}}}
|
||||
fixed := EquipmentFixedDesign{ID: 99, SRCount: 7, URCount: 43, Reset: true}
|
||||
draw := func(limit uint64) (uint64, error) { return 0, nil }
|
||||
got, state, err := g.rollWith(6, 42, fixed, draw)
|
||||
if err != nil || len(got) != 1 || got[0] != 80 || state.URSort != 0 || state.SRCount != 0 || state.URCount != 0 {
|
||||
t.Fatalf("UR actualgrade got=%v state=%+v err=%v", got, state, err)
|
||||
}
|
||||
got, state, err = g.rollWith(6, 0, fixed, draw)
|
||||
if err != nil || got[0] != 70 || state.SRSort != 0 || state.SRCount != 0 || state.URCount != 1 {
|
||||
t.Fatalf("SR actualgrade got=%v state=%+v err=%v", got, state, err)
|
||||
}
|
||||
fixed.Reset = false
|
||||
got, state, err = g.rollWith(6, 42, fixed, draw)
|
||||
if err != nil || got[0] != 80 || state.SRCount != 0 || state.URCount != 0 {
|
||||
t.Fatalf("forced nonreset pity=%v state=%+v err=%v", got, state, err)
|
||||
}
|
||||
g.Pool = []WeightedEquipment{{ID: 80, Weight: 1}}
|
||||
got, state, err = g.rollWith(2, 3, fixed, draw)
|
||||
if err != nil || got[0] != 80 || state.SRCount != 3 || state.URCount != 4 {
|
||||
t.Fatalf("natural UR nonreset=%v state=%+v err=%v", got, state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentConditionalGradePreservesMixedBranchProbability(t *testing.T) {
|
||||
g := EquipmentGacha{Grades: map[uint64]uint64{1: 4, 2: 2, 3: 4}, Pool: []WeightedEquipment{{Weight: 1, Children: []WeightedEquipment{{ID: 1, Weight: 1}, {ID: 2, Weight: 3}}}, {Weight: 1, Children: []WeightedEquipment{{ID: 3, Weight: 1}}}}}
|
||||
pool, err := g.gradePool(4)
|
||||
if err != nil || len(pool) != 2 || pool[0].ID != 1 || pool[1].ID != 3 || pool[1].Weight != 4*pool[0].Weight {
|
||||
t.Fatalf("mixed conditional distribution=%+v err=%v", pool, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentGachaLoaderAcceptsChangedPriceAndPoolLength(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, table := range []string{"GachaTable", "RewardGroupTable"} {
|
||||
if _, err := db.Exec("CREATE TABLE " + table + "(id INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
proto := func(values ...uint64) []byte {
|
||||
var raw []byte
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
raw = binary.AppendUvarint(raw, values[i]<<3)
|
||||
raw = binary.AppendUvarint(raw, values[i+1])
|
||||
}
|
||||
return raw
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO GachaTable VALUES(?,?)", 808, proto(5, 2, 7, 909, 10, 666, 12, 3)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 909, proto(5, 707, 6, 10, 8, 123)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := loadEquipmentGacha(db, 808)
|
||||
if err != nil || g.Count != 2 || g.Price != 666 || len(g.Pool) != 1 || g.Pool[0].Weight != 123 {
|
||||
t.Fatalf("changed price/pool=%+v err=%v", g, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type EquipmentGachaCatalog struct {
|
||||
groups map[uint64]EquipmentGachaGroup
|
||||
byGacha map[uint64]uint64
|
||||
fixed EquipmentFixedDesign
|
||||
fixedDesigns map[uint64]EquipmentFixedDesign
|
||||
equipment map[uint64]EquipmentDesign
|
||||
}
|
||||
type EquipmentGacha struct {
|
||||
@@ -30,6 +31,7 @@ type EquipmentGacha struct {
|
||||
TicketIDs []uint64
|
||||
Pool []WeightedEquipment
|
||||
TicketOnly bool
|
||||
Grades map[uint64]uint64
|
||||
}
|
||||
type EquipmentGachaGroup struct {
|
||||
ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64
|
||||
@@ -57,10 +59,6 @@ type OptionGroup struct {
|
||||
type WeightedOption struct{ ID, Weight uint64 }
|
||||
type EquipmentOptionChoice struct{ GroupID, ID uint64 }
|
||||
|
||||
func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) {
|
||||
return LoadEquipmentGachaGroups(root, version, []uint64{10002, 9, 133, 206})
|
||||
}
|
||||
|
||||
// LoadEquipmentGachaGroups loads only groups selected by a captured dynamic
|
||||
// schedule. Presence in GameData alone does not mean a banner is open.
|
||||
func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*EquipmentGachaCatalog, error) {
|
||||
@@ -96,7 +94,7 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
|
||||
pickupEnabled, _ := packedInts(raw, 20)
|
||||
pickupCosts, _ := packedInts(raw, 25)
|
||||
pickupItems, _ := packedInts(raw, 26)
|
||||
if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] != 1 || len(points) != 1 || points[0] != 1 ||
|
||||
if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] == 0 || len(points) != 1 || points[0] == 0 ||
|
||||
len(gachaTypes) != 1 || gachaTypes[0] != 2 || len(pickupEnabled) > 1 || len(pickupCosts) > 1 || len(pickupItems) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: equipment group %d malformed", groupID)
|
||||
}
|
||||
@@ -190,20 +188,52 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var fixed []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=1").Scan(&fixed); err != nil {
|
||||
for id, g := range c.Gachas {
|
||||
g.Grades = map[uint64]uint64{}
|
||||
var visit func([]WeightedEquipment)
|
||||
visit = func(pool []WeightedEquipment) {
|
||||
for _, entry := range pool {
|
||||
if entry.ID != 0 {
|
||||
g.Grades[entry.ID] = c.equipment[entry.ID].Grade
|
||||
} else {
|
||||
visit(entry.Children)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(g.Pool)
|
||||
c.Gachas[id] = g
|
||||
}
|
||||
c.fixedDesigns = map[uint64]EquipmentFixedDesign{}
|
||||
for _, group := range c.groups {
|
||||
if _, ok := c.fixedDesigns[group.FixedID]; ok {
|
||||
continue
|
||||
}
|
||||
fixed, err := loadEquipmentFixed(db, group.FixedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sr, _ := packedInts(fixed, 1)
|
||||
ur, _ := packedInts(fixed, 3)
|
||||
reset, _ := packedInts(fixed, 6)
|
||||
if len(sr) != 1 || sr[0] != 10 || len(ur) != 1 || ur[0] != 100 || len(reset) != 1 || reset[0] != 1 {
|
||||
return nil, fmt.Errorf("gamedata: malformed equipment fixed table")
|
||||
c.fixedDesigns[fixed.ID] = fixed
|
||||
if c.fixed.ID == 0 || fixed.ID < c.fixed.ID {
|
||||
c.fixed = fixed
|
||||
}
|
||||
}
|
||||
c.fixed = EquipmentFixedDesign{ID: 1, SRCount: sr[0], URCount: ur[0], Reset: true}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func loadEquipmentFixed(db *sql.DB, id uint64) (EquipmentFixedDesign, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return EquipmentFixedDesign{}, err
|
||||
}
|
||||
sr, e1 := packedInts(raw, 1)
|
||||
ur, e2 := packedInts(raw, 3)
|
||||
reset, e3 := packedInts(raw, 6)
|
||||
if e1 != nil || e2 != nil || e3 != nil || len(sr) != 1 || sr[0] == 0 || len(ur) != 1 || ur[0] == 0 || len(reset) > 1 || (len(reset) == 1 && reset[0] > 1) {
|
||||
return EquipmentFixedDesign{}, fmt.Errorf("gamedata: malformed equipment fixed table %d", id)
|
||||
}
|
||||
return EquipmentFixedDesign{ID: id, SRCount: sr[0], URCount: ur[0], Reset: len(reset) == 1 && reset[0] == 1}, nil
|
||||
}
|
||||
|
||||
func classifyEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, bool, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&raw); err != nil {
|
||||
@@ -259,27 +289,15 @@ func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) {
|
||||
if len(freeCounts) == 1 {
|
||||
freeCount = freeCounts[0]
|
||||
}
|
||||
if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || price[0] != uint64(count[0])*200 {
|
||||
// Partial ticket consumption bills remaining draws individually; the local
|
||||
// billing path currently requires an integral price per draw.
|
||||
if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || count[0] == 0 || price[0] == 0 || price[0]%count[0] != 0 {
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: malformed equipment gacha %d", id)
|
||||
}
|
||||
pool, err := loadEquipmentRewardPool(db, reward[0])
|
||||
if err != nil {
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d: %w", id, err)
|
||||
}
|
||||
var want []uint64
|
||||
switch len(pool) {
|
||||
case 8: // permanent draw 200/201: one 3% five-star UR branch.
|
||||
want = []uint64{300, 200, 250, 850, 1700, 400, 1600, 4700}
|
||||
case 9: // pickup groups: UP and non-UP five-star UR branches.
|
||||
want = []uint64{150, 150, 200, 250, 850, 1700, 400, 1600, 4700}
|
||||
default:
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d has %d branches", id, len(pool))
|
||||
}
|
||||
for i := range pool {
|
||||
if pool[i].Weight != want[i] {
|
||||
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d branch %d rate=%d", id, i, pool[i].Weight)
|
||||
}
|
||||
}
|
||||
return EquipmentGacha{ID: id, Count: int(count[0]), FreeCountDay: freeCount, PriceType: kind[0], Price: price[0], TicketIDs: append([]uint64(nil), tickets...), Pool: pool}, nil
|
||||
}
|
||||
func loadEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, error) {
|
||||
@@ -421,6 +439,28 @@ func (c *EquipmentGachaCatalog) Groups() []EquipmentGachaGroup {
|
||||
return out
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) Fixed() EquipmentFixedDesign { return c.fixed }
|
||||
func (c *EquipmentGachaCatalog) FixedForGroup(groupID uint64) (EquipmentFixedDesign, bool) {
|
||||
if c == nil {
|
||||
return EquipmentFixedDesign{}, false
|
||||
}
|
||||
group, ok := c.groups[groupID]
|
||||
if !ok {
|
||||
return EquipmentFixedDesign{}, false
|
||||
}
|
||||
fixed, ok := c.fixedDesigns[group.FixedID]
|
||||
return fixed, ok
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) FixedDesigns() []EquipmentFixedDesign {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]EquipmentFixedDesign, 0, len(c.fixedDesigns))
|
||||
for _, fixed := range c.fixedDesigns {
|
||||
out = append(out, fixed)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out
|
||||
}
|
||||
func (c *EquipmentGachaCatalog) RollOptions(id uint64) (main, sub []EquipmentOptionChoice, private *EquipmentOptionChoice, err error) {
|
||||
d, ok := c.equipment[id]
|
||||
if !ok {
|
||||
@@ -485,21 +525,29 @@ type EquipmentRoll struct {
|
||||
|
||||
func (g EquipmentGacha) rollWith(sr, ur uint64, fixed EquipmentFixedDesign, draw func(uint64) (uint64, error)) ([]uint64, EquipmentRoll, error) {
|
||||
state := EquipmentRoll{SRCount: sr, URCount: ur, SRSort: -1, URSort: -1}
|
||||
if g.Count <= 0 || (len(g.Pool) != 8 && len(g.Pool) != 9) {
|
||||
if g.Count <= 0 || len(g.Pool) == 0 || len(g.Grades) == 0 {
|
||||
return nil, state, errors.New("gamedata: invalid equipment gacha")
|
||||
}
|
||||
out := make([]uint64, g.Count)
|
||||
for i := range out {
|
||||
forceUR := state.URCount+1 >= fixed.URCount
|
||||
forceSR := !forceUR && state.SRCount+1 >= fixed.SRCount
|
||||
forceUR := fixed.URCount != 0 && state.URCount+1 >= fixed.URCount
|
||||
forceSR := !forceUR && fixed.SRCount != 0 && state.SRCount+1 >= fixed.SRCount
|
||||
var id uint64
|
||||
var err error
|
||||
switch {
|
||||
case forceUR:
|
||||
id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, true), draw)
|
||||
var pool []WeightedEquipment
|
||||
pool, err = g.gradePool(4)
|
||||
if err == nil {
|
||||
id, err = rollEquipmentChoiceWith(pool, draw)
|
||||
}
|
||||
state.URSort = i
|
||||
case forceSR:
|
||||
id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, false), draw)
|
||||
var pool []WeightedEquipment
|
||||
pool, err = g.gradePool(3)
|
||||
if err == nil {
|
||||
id, err = rollEquipmentChoiceWith(pool, draw)
|
||||
}
|
||||
state.SRSort = i
|
||||
default:
|
||||
id, err = rollEquipmentChoiceWith(g.Pool, draw)
|
||||
@@ -508,59 +556,23 @@ func (g EquipmentGacha) rollWith(sr, ur uint64, fixed EquipmentFixedDesign, draw
|
||||
return nil, state, err
|
||||
}
|
||||
out[i] = id
|
||||
tier := equipmentTier(g.Pool, id)
|
||||
switch tier {
|
||||
case 2:
|
||||
state.SRCount = 0
|
||||
state.URCount = 0
|
||||
case 1:
|
||||
state.SRCount = 0
|
||||
state.URCount++
|
||||
default:
|
||||
grade, known := g.Grades[id]
|
||||
if !known || grade == 0 || grade > 4 {
|
||||
return nil, state, errors.New("gamedata: equipment grade unavailable")
|
||||
}
|
||||
state.SRCount++
|
||||
state.URCount++
|
||||
if forceSR || forceUR || fixed.Reset && grade >= 3 {
|
||||
state.SRCount = 0
|
||||
}
|
||||
if forceUR || fixed.Reset && grade == 4 {
|
||||
state.URCount = 0
|
||||
}
|
||||
|
||||
}
|
||||
return out, state, nil
|
||||
}
|
||||
|
||||
func equipmentGuaranteedPool(pool []WeightedEquipment, ur bool) []WeightedEquipment {
|
||||
if len(pool) == 8 {
|
||||
if ur {
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[0:1]}, {Weight: 35, Children: pool[2:3]}, {Weight: 50, Children: pool[5:6]}}
|
||||
}
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[1:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}}
|
||||
}
|
||||
if ur {
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[0:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}}
|
||||
}
|
||||
return []WeightedEquipment{{Weight: 15, Children: pool[2:3]}, {Weight: 35, Children: pool[4:5]}, {Weight: 50, Children: pool[7:8]}}
|
||||
}
|
||||
func equipmentTier(pool []WeightedEquipment, id uint64) int {
|
||||
for i, item := range pool {
|
||||
if containsEquipment(item, id) {
|
||||
if len(pool) == 8 {
|
||||
switch i {
|
||||
case 0, 2, 5:
|
||||
return 2
|
||||
case 1, 3, 6:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
switch i {
|
||||
case 0, 1, 3, 6:
|
||||
return 2
|
||||
case 2, 4, 7:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
func containsEquipment(item WeightedEquipment, id uint64) bool {
|
||||
if item.ID != 0 {
|
||||
return item.ID == id
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Local pity policy: no separate official equipment pity pool is available.
|
||||
// gradePool conditions the actual nested weighted distribution on one grade.
|
||||
// Exact rational path weights preserve branches with different child totals.
|
||||
func (g EquipmentGacha) gradePool(grade uint64) ([]WeightedEquipment, error) {
|
||||
weights := map[uint64]*big.Rat{}
|
||||
var walk func([]WeightedEquipment, *big.Rat) error
|
||||
walk = func(pool []WeightedEquipment, path *big.Rat) error {
|
||||
total := new(big.Int)
|
||||
for _, item := range pool {
|
||||
if item.Weight == 0 {
|
||||
return errors.New("gamedata: zero equipment weight")
|
||||
}
|
||||
total.Add(total, new(big.Int).SetUint64(item.Weight))
|
||||
}
|
||||
if total.Sign() == 0 {
|
||||
return errors.New("gamedata: empty equipment pool")
|
||||
}
|
||||
for _, item := range pool {
|
||||
prob := new(big.Rat).Mul(path, new(big.Rat).SetFrac(new(big.Int).SetUint64(item.Weight), total))
|
||||
if item.ID == 0 {
|
||||
if err := walk(item.Children, prob); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
actual, ok := g.Grades[item.ID]
|
||||
if !ok || actual == 0 || actual > 4 {
|
||||
return errors.New("gamedata: equipment grade unavailable")
|
||||
}
|
||||
if actual == grade {
|
||||
if weights[item.ID] == nil {
|
||||
weights[item.ID] = new(big.Rat)
|
||||
}
|
||||
weights[item.ID].Add(weights[item.ID], prob)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(g.Pool, new(big.Rat).SetInt64(1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
denom := big.NewInt(1)
|
||||
for _, weight := range weights {
|
||||
gcd := new(big.Int).GCD(nil, nil, denom, weight.Denom())
|
||||
denom.Mul(new(big.Int).Quo(denom, gcd), weight.Denom())
|
||||
}
|
||||
var ids []uint64
|
||||
for id := range weights {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// Stable table-independent ordering makes deterministic draw sources reproducible.
|
||||
for i := 1; i < len(ids); i++ {
|
||||
for j := i; j > 0 && ids[j] < ids[j-1]; j-- {
|
||||
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||
}
|
||||
}
|
||||
var out []WeightedEquipment
|
||||
for _, id := range ids {
|
||||
w := weights[id]
|
||||
count := new(big.Int).Mul(w.Num(), new(big.Int).Quo(new(big.Int).Set(denom), w.Denom()))
|
||||
if !count.IsUint64() {
|
||||
return nil, errors.New("gamedata: conditioned equipment weight overflow")
|
||||
}
|
||||
out = append(out, WeightedEquipment{ID: id, Weight: count.Uint64()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -10,7 +10,7 @@ func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23510(t *testing.T) {
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
catalog, err := LoadEquipmentGacha(root, "20260923193640")
|
||||
catalog, err := LoadEquipmentGachaGroups(root, "20260923193640", []uint64{10002, 9, 133, 206})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// FieldObjectDesign retains the client table identities, including unsupported
|
||||
// reward graphs, so callers can reject them without inventing a reward.
|
||||
type FieldObjectDesign struct{ Objects map[int]FieldRewardObject }
|
||||
type FieldObjectDesign struct {
|
||||
Objects map[int]FieldRewardObject
|
||||
Equipment *EquipmentGachaCatalog
|
||||
}
|
||||
type FieldRewardObject struct {
|
||||
ID, MapID, GroupID, Type, ResetType, QuestID, BuffID, MonsterID int
|
||||
Rewards []BattleReward
|
||||
@@ -46,7 +47,7 @@ func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error)
|
||||
return loadFieldObjects(db, common)
|
||||
}
|
||||
func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
|
||||
design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}}
|
||||
design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}, Equipment: &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}}
|
||||
groups := map[int]FieldRewardObject{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectGroupTable")
|
||||
if err != nil {
|
||||
@@ -91,17 +92,28 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
|
||||
rows.Close()
|
||||
return design, fmt.Errorf("gamedata: malformed field loot %d", group[0])
|
||||
}
|
||||
if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) == 1 && drop[0] > 0 && len(dropType) <= 1 {
|
||||
if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) <= 1 && len(dropType) <= 1 {
|
||||
if len(drop) == 1 {
|
||||
obj.DropCount = drop[0]
|
||||
}
|
||||
obj.Ratios = ratios
|
||||
if len(dropType) > 0 {
|
||||
obj.DropType = dropType[0]
|
||||
}
|
||||
for i := range ids {
|
||||
obj.Rewards = append(obj.Rewards, BattleReward{ID: ids[i], Type: types[i], Count: counts[i]})
|
||||
if types[i] == 10 {
|
||||
if e := design.Equipment.loadEquipmentTree(common, WeightedEquipment{ID: ids[i]}); e != nil {
|
||||
rows.Close()
|
||||
return design, e
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rows.Close()
|
||||
return design, fmt.Errorf("gamedata: malformed field loot entries %d", group[0])
|
||||
}
|
||||
}
|
||||
groups[id] = obj
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
@@ -138,29 +150,60 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
|
||||
return design, rows.Err()
|
||||
}
|
||||
|
||||
// Draw uses the table's normalized ratio distribution for DropType 0.
|
||||
// Independent DropType 1 uses a different ratio unit and needs separate evidence.
|
||||
// DropType 0 makes DropCount weighted selections. DropType 1 checks each
|
||||
// component's percentage independently; its DropCount may be proto default zero.
|
||||
func (o FieldRewardObject) Draw() ([]BattleReward, error) {
|
||||
if o.DropType != 0 || o.DropCount == 0 || o.DropCount > 100 || len(o.Rewards) != len(o.Ratios) {
|
||||
return o.draw(cryptoDraw)
|
||||
}
|
||||
|
||||
func (o FieldRewardObject) draw(draw func(uint64) (uint64, error)) ([]BattleReward, error) {
|
||||
if o.DropType > 1 || (o.DropType == 0 && o.DropCount == 0) || o.DropCount > 100 || len(o.Rewards) == 0 || len(o.Rewards) != len(o.Ratios) {
|
||||
return nil, fmt.Errorf("gamedata: unsupported field reward draw")
|
||||
}
|
||||
var total uint64
|
||||
for _, r := range o.Ratios {
|
||||
for i, r := range o.Ratios {
|
||||
if o.Rewards[i].Count == 0 || o.Rewards[i].Count > uint64(^uint32(0)>>1) || (o.DropType == 1 && r > 100) {
|
||||
return nil, fmt.Errorf("gamedata: malformed field reward component")
|
||||
}
|
||||
if ^uint64(0)-total < r {
|
||||
return nil, fmt.Errorf("gamedata: reward ratio overflow")
|
||||
}
|
||||
total += r
|
||||
}
|
||||
if total == 0 {
|
||||
if total == 0 && o.DropType == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty field reward distribution")
|
||||
}
|
||||
var result []BattleReward
|
||||
for n := uint64(0); n < o.DropCount; n++ {
|
||||
value, err := rand.Int(rand.Reader, new(big.Int).SetUint64(total))
|
||||
if o.DropType == 1 {
|
||||
for i, ratio := range o.Ratios {
|
||||
if ratio == 0 {
|
||||
continue
|
||||
}
|
||||
if ratio < 100 {
|
||||
value, err := draw(100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pick := value.Uint64()
|
||||
if value >= 100 {
|
||||
return nil, fmt.Errorf("gamedata: field reward random out of range")
|
||||
}
|
||||
if value >= ratio {
|
||||
continue
|
||||
}
|
||||
}
|
||||
result = append(result, o.Rewards[i])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
for n := uint64(0); n < o.DropCount; n++ {
|
||||
value, err := draw(total)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value >= total {
|
||||
return nil, fmt.Errorf("gamedata: field reward random out of range")
|
||||
}
|
||||
pick := value
|
||||
for i, ratio := range o.Ratios {
|
||||
if pick < ratio {
|
||||
result = append(result, o.Rewards[i])
|
||||
|
||||
@@ -1,10 +1,80 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFieldObjectLoadIndependentEquipmentAndRandomBox(t *testing.T) {
|
||||
pack, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pack.Close()
|
||||
common, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer common.Close()
|
||||
for _, table := range []string{"FieldRewardObjectGroupTable", "FieldRewardObjectTable"} {
|
||||
if _, err := pack.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"RewardGroupTable", "EquipmentTable"} {
|
||||
if _, err := common.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := common.Exec("CREATE TABLE EquipmentOptionTable(GroupId INTEGER, id INTEGER, ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := wire.AppendVarint(nil, 10, 66)
|
||||
group = wire.AppendVarint(group, 12, 8) // A newly introduced object kind must not need an allowlist.
|
||||
if _, err := pack.Exec("INSERT INTO FieldRewardObjectGroupTable VALUES(?,?)", 55, group); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
object := wire.AppendVarint(nil, 3, 55)
|
||||
object = wire.AppendVarint(object, 6, 44)
|
||||
if _, err := pack.Exec("INSERT INTO FieldRewardObjectTable VALUES(?,?)", 33, object); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loot := wire.AppendVarint(nil, 2, 1)
|
||||
for _, r := range []BattleReward{{Type: 10, ID: 77, Count: 2}, {Type: 9, ID: 88, Count: 1}} {
|
||||
loot = wire.AppendVarint(loot, 4, r.Count)
|
||||
loot = wire.AppendVarint(loot, 5, r.ID)
|
||||
loot = wire.AppendVarint(loot, 6, r.Type)
|
||||
loot = wire.AppendVarint(loot, 8, 100)
|
||||
}
|
||||
if _, err := common.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 66, loot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
equip := wire.AppendVarint(nil, 3, 3)
|
||||
equip = wire.AppendVarint(equip, 12, 99)
|
||||
if _, err := common.Exec("INSERT INTO EquipmentTable VALUES(?,?)", 77, equip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
option := wire.AppendVarint(nil, 5, 7)
|
||||
option = wire.AppendVarint(option, 2, 100)
|
||||
if _, err := common.Exec("INSERT INTO EquipmentOptionTable VALUES(?,?,?)", 99, 7, option); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := loadFieldObjects(pack, common)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.Objects[33].Draw()
|
||||
if err != nil || len(got) != 2 || got[0].ID != 77 || got[0].Count != 2 || got[1].Type != 9 || got[1].ID != 88 {
|
||||
t.Fatalf("rewards=%+v err=%v", got, err)
|
||||
}
|
||||
main, _, _, err := d.Equipment.RollOptions(77)
|
||||
if err != nil || len(main) != 1 || main[0].GroupID != 99 || main[0].ID != 7 {
|
||||
t.Fatalf("options=%+v err=%v", main, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldResetUsesConfiguredBoundary(t *testing.T) {
|
||||
s := FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday}
|
||||
before := time.Date(2026, 10, 4, 23, 59, 59, 0, time.UTC)
|
||||
@@ -39,7 +109,39 @@ func TestFieldWeightedDrawCountAndZeroWeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
o.DropType = 1
|
||||
o.DropCount = 0
|
||||
got, err := o.Draw()
|
||||
if err != nil || len(got) != 1 || got[0].ID != 2 {
|
||||
t.Fatalf("independent draw=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldDrawRejectsMalformedUnselectedBranch(t *testing.T) {
|
||||
o := FieldRewardObject{DropCount: 1, Rewards: []BattleReward{{Type: 5, ID: 1, Count: 1}, {Type: 5, ID: 2}}, Ratios: []uint64{100, 0}}
|
||||
if _, err := o.Draw(); err == nil {
|
||||
t.Fatal("unsupported independent distribution accepted")
|
||||
t.Fatal("zero-weight malformed reward accepted")
|
||||
}
|
||||
o.Rewards[1].Count = 1
|
||||
o.DropType = 1
|
||||
o.Ratios[1] = 101
|
||||
if _, err := o.Draw(); err == nil {
|
||||
t.Fatal("independent percentage above 100 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldIndependentDrawUsesEveryPercentageOnce(t *testing.T) {
|
||||
o := FieldRewardObject{DropType: 1, DropCount: 99, Ratios: []uint64{0, 25, 25, 100}, Rewards: []BattleReward{{ID: 1, Count: 1}, {ID: 2, Count: 1}, {ID: 3, Count: 1}, {ID: 4, Count: 1}}}
|
||||
values := []uint64{24, 25}
|
||||
calls := 0
|
||||
got, err := o.draw(func(limit uint64) (uint64, error) {
|
||||
if limit != 100 || calls >= len(values) {
|
||||
t.Fatalf("unexpected draw limit=%d calls=%d", limit, calls)
|
||||
}
|
||||
v := values[calls]
|
||||
calls++
|
||||
return v, nil
|
||||
})
|
||||
if err != nil || calls != 2 || len(got) != 2 || got[0].ID != 2 || got[1].ID != 4 {
|
||||
t.Fatalf("independent percentage boundary: got=%+v calls=%d err=%v", got, calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
InfiniteGachaID = 9100033
|
||||
InfiniteProductGroupID = 1100001
|
||||
InfiniteProductID = 9100033
|
||||
officialRateScale = 10000
|
||||
officialFiveStarRate = 300
|
||||
officialFourStarRate = 1400
|
||||
@@ -30,6 +27,9 @@ const (
|
||||
// "infinite reroll" product. The local server deliberately makes final
|
||||
// confirmation free, but still reads the official result pool from GameData.
|
||||
type InfiniteGachaDesign struct {
|
||||
GachaID, ProductGroupID, ProductID, SaleGroup uint64
|
||||
rewardProgram *CostumeRewardGroup
|
||||
GroupID uint64
|
||||
Count int
|
||||
CostumeIDs []uint64 // five-star compatibility view
|
||||
FiveStarIDs []uint64
|
||||
@@ -54,6 +54,7 @@ type WeightedCostume struct {
|
||||
}
|
||||
|
||||
type RegularGacha struct {
|
||||
Grades map[uint64]uint64
|
||||
ID uint64
|
||||
Count int
|
||||
DailyPayGachaCount uint64
|
||||
@@ -75,6 +76,7 @@ type GachaGroupDesign struct {
|
||||
CashProductGroupID uint64
|
||||
CashProductID uint64
|
||||
CashSalesGroup uint64
|
||||
CashRewards []BattleReward
|
||||
FixedID uint64
|
||||
PointCount uint64
|
||||
PickUpExchangeCost uint64
|
||||
@@ -89,6 +91,10 @@ type GachaGroupDesign struct {
|
||||
IsSelectedFromPity bool
|
||||
}
|
||||
|
||||
// CostumeRewardGroup preserves the audited costume reward program.
|
||||
// DropType 1 is the current fixed combination mode with ratio=1 per child;
|
||||
// this is distinct from field-object independent percentage rolls. Unsupported
|
||||
// combination ratios fail loading rather than silently discarding probability.
|
||||
// CostumeRewardGroup preserves RewardGroupTable's execution structure. A
|
||||
// zero DropType repeats one weighted choice DropCount times; DropType 1 emits
|
||||
// every entry once. Keeping that distinction is required for selection and
|
||||
@@ -366,24 +372,8 @@ func (c *RegularGachaCatalog) CostumeGrade(costumeID uint64) (uint64, bool) {
|
||||
}
|
||||
|
||||
func (c *RegularGachaCatalog) recordPoolGrades(gacha RegularGacha) {
|
||||
if gacha.FixedCostumeID != 0 {
|
||||
c.grades[gacha.FixedCostumeID] = 5
|
||||
}
|
||||
for branch, item := range gacha.Pool {
|
||||
grade := uint64(0)
|
||||
switch len(gacha.Pool) {
|
||||
case 4:
|
||||
if branch < 2 {
|
||||
grade = 5
|
||||
} else {
|
||||
grade = uint64(6 - branch) // branch 2/3 => grade 4/3
|
||||
}
|
||||
case 3:
|
||||
grade = uint64(5 - branch)
|
||||
}
|
||||
if grade != 0 {
|
||||
recordCostumeGrade(c.grades, item, grade)
|
||||
}
|
||||
for id, grade := range gacha.Grades {
|
||||
c.grades[id] = grade
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,12 +570,16 @@ func (g RegularGacha) RollWithCostumeFixedSelection(previous4, previous5 uint64,
|
||||
|
||||
func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed GachaFixedDesign, normalSelectedFive, pitySelectedFive []uint64, draw func(uint64) (uint64, error)) ([]uint64, GachaFixedRoll, error) {
|
||||
state := GachaFixedRoll{CostumeGrade4Count: previous4, CostumeGrade5Count: previous5, CostumeGrade4Sort: -1, CostumeGrade5Sort: -1}
|
||||
if g.Count <= 0 || (len(g.Pool) != 3 && len(g.Pool) != 4) || fixed.ID == 0 || (fixed.CostumeGrade4Count == 0 && fixed.CostumeGrade5Count == 0) || draw == nil {
|
||||
if g.Count <= 0 || len(g.Pool) == 0 || fixed.ID == 0 || (fixed.CostumeGrade4Count == 0 && fixed.CostumeGrade5Count == 0) || draw == nil {
|
||||
return nil, state, errors.New("gamedata: invalid costume fixed gacha")
|
||||
}
|
||||
fiveBranches := 1
|
||||
if len(g.Pool) == 4 {
|
||||
fiveBranches = 2
|
||||
fivePool, err := g.gradePool(5)
|
||||
if err != nil {
|
||||
return nil, state, err
|
||||
}
|
||||
fourPool, err := g.gradePool(4)
|
||||
if err != nil {
|
||||
return nil, state, err
|
||||
}
|
||||
result := make([]uint64, g.Count)
|
||||
for i := range result {
|
||||
@@ -607,18 +601,21 @@ func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed Ga
|
||||
state.SelectionSorts = append(state.SelectionSorts, i)
|
||||
}
|
||||
} else {
|
||||
id, err = rollCostumeChoiceWith(g.Pool[:fiveBranches], draw)
|
||||
id, err = rollCostumeChoiceWith(fivePool, draw)
|
||||
}
|
||||
grade = 5
|
||||
state.CostumeGrade5Sort = i
|
||||
case force4:
|
||||
id, err = rollCostumeChoiceWith([]WeightedCostume{g.Pool[fiveBranches]}, draw)
|
||||
id, err = rollCostumeChoiceWith(fourPool, draw)
|
||||
grade = 4
|
||||
state.CostumeGrade4Sort = i
|
||||
default:
|
||||
var branch int
|
||||
branch, id, err = rollCostumeBranchWith(g.Pool, draw)
|
||||
if branch < fiveBranches {
|
||||
_, id, err = rollCostumeBranchWith(g.Pool, draw)
|
||||
grade = g.Grades[id]
|
||||
if grade < 3 || grade > 5 {
|
||||
return nil, state, errors.New("gamedata: missing costume grade")
|
||||
}
|
||||
if grade == 5 {
|
||||
grade = 5
|
||||
if err == nil && len(normalSelectedFive) != 0 {
|
||||
var selected uint64
|
||||
@@ -631,25 +628,24 @@ func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed Ga
|
||||
state.SelectionSorts = append(state.SelectionSorts, i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
grade = uint64(4 + fiveBranches - branch)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, state, err
|
||||
}
|
||||
result[i] = id
|
||||
switch grade {
|
||||
case 5:
|
||||
state.CostumeGrade4Count = 0
|
||||
state.CostumeGrade5Count = 0
|
||||
case 4:
|
||||
state.CostumeGrade4Count = 0
|
||||
state.CostumeGrade5Count++
|
||||
default:
|
||||
if state.CostumeGrade4Count == math.MaxUint64 || state.CostumeGrade5Count == math.MaxUint64 {
|
||||
return nil, state, errors.New("gamedata: costume fixed counter overflow")
|
||||
}
|
||||
state.CostumeGrade4Count++
|
||||
state.CostumeGrade5Count++
|
||||
if force4 || force5 || fixed.ResetOnMatchingGrade && grade >= 4 {
|
||||
state.CostumeGrade4Count = 0
|
||||
}
|
||||
if force5 || fixed.ResetOnMatchingGrade && grade == 5 {
|
||||
state.CostumeGrade5Count = 0
|
||||
}
|
||||
|
||||
}
|
||||
return result, state, nil
|
||||
}
|
||||
@@ -666,21 +662,24 @@ func (g RegularGacha) rollWith(draw func(uint64) (uint64, error)) ([]uint64, err
|
||||
}
|
||||
hasFourOrFive := g.FixedCostumeID != 0
|
||||
for i := start; i < len(result); i++ {
|
||||
branch, id, err := rollCostumeBranchWith(g.Pool, draw)
|
||||
_, id, err := rollCostumeBranchWith(g.Pool, draw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if branch < len(g.Pool)-1 {
|
||||
if g.Grades[id] >= 4 {
|
||||
hasFourOrFive = true
|
||||
}
|
||||
result[i] = id
|
||||
}
|
||||
// Official costume ten-pulls guarantee grade four or above. The ordinary
|
||||
// four-branch pools are ordered as pickup-5, other-5, grade-4, grade-3.
|
||||
// If all ten normal rolls landed in grade 3, replace the final slot with
|
||||
// an equal-weight draw from the real grade-4 branch.
|
||||
if g.FixedCostumeID == 0 && g.Count == 10 && len(g.Pool) == 4 && !hasFourOrFive {
|
||||
id, err := rollCostumeChoiceWith([]WeightedCostume{g.Pool[2]}, draw)
|
||||
// Ordinary costume ten-pulls guarantee grade four or above. If all
|
||||
// natural results are grade three, condition the actual weighted pool on
|
||||
// grade four for the last slot, irrespective of branch order or count.
|
||||
if g.FixedCostumeID == 0 && g.Count == 10 && len(g.Grades) != 0 && !hasFourOrFive {
|
||||
fourPool, err := g.gradePool(4)
|
||||
if err != nil || len(fourPool) == 0 {
|
||||
return nil, errors.New("gamedata: grade four guarantee pool unavailable")
|
||||
}
|
||||
id, err := rollCostumeChoiceWith(fourPool, draw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -820,6 +819,9 @@ func rollCostumeBranch(pool []WeightedCostume) (int, uint64, error) {
|
||||
}
|
||||
|
||||
func rollCostumeBranchWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (int, uint64, error) {
|
||||
if len(pool) == 0 || draw == nil {
|
||||
return 0, 0, errors.New("gamedata: empty costume choice")
|
||||
}
|
||||
var total uint64
|
||||
for _, item := range pool {
|
||||
if item.Weight == 0 || math.MaxUint64-total < item.Weight {
|
||||
@@ -855,6 +857,9 @@ func rollCostumeChoice(pool []WeightedCostume) (uint64, error) {
|
||||
}
|
||||
|
||||
func rollCostumeChoiceWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (uint64, error) {
|
||||
if len(pool) == 0 || draw == nil {
|
||||
return 0, errors.New("gamedata: empty costume choice")
|
||||
}
|
||||
var total uint64
|
||||
for _, item := range pool {
|
||||
if item.Weight == 0 || math.MaxUint64-total < item.Weight {
|
||||
@@ -884,14 +889,6 @@ func rollCostumeChoiceWith(pool []WeightedCostume, draw func(uint64) (uint64, er
|
||||
return 0, errors.New("gamedata: regular gacha selection failed")
|
||||
}
|
||||
|
||||
// LoadRegularCostumeGacha retains the historical catalog used by existing
|
||||
// callers. New schedule code should pass the captured active group IDs to
|
||||
// LoadRegularCostumeGachaGroups; GameData defines a group but does not say that
|
||||
// it is currently open.
|
||||
func LoadRegularCostumeGacha(root, version string) (*RegularGachaCatalog, error) {
|
||||
return LoadRegularCostumeGachaGroups(root, version, []uint64{10001, 135, 205, 121}, []uint64{29})
|
||||
}
|
||||
|
||||
func LoadActiveGacha(root, version string, costumeGroupIDs, equipmentGroupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, *EquipmentGachaCatalog, error) {
|
||||
costumes, err := LoadRegularCostumeGachaGroups(root, version, costumeGroupIDs, stepUpGroupIDs)
|
||||
if err != nil {
|
||||
@@ -1029,10 +1026,6 @@ func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume
|
||||
}
|
||||
|
||||
func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, error) {
|
||||
infinite, err := LoadInfiniteGacha(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1069,9 +1062,6 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for id, character := range infinite.characters {
|
||||
catalog.characters[id] = character
|
||||
}
|
||||
gachaIDs := make(map[uint64]struct{})
|
||||
for _, groupID := range groupIDs {
|
||||
var raw []byte
|
||||
@@ -1169,16 +1159,6 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(pool) == 4 {
|
||||
err = validateOfficialPickupRates(pool)
|
||||
} else if len(pool) == 3 {
|
||||
err = validateRegularCostumeRates(pool)
|
||||
} else if reward.DropType == 0 {
|
||||
err = fmt.Errorf("expected three or four rarity branches, got %d", len(pool))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: regular gacha %d probability: %w", id, err)
|
||||
}
|
||||
var program *CostumeRewardGroup
|
||||
if reward.DropType != 0 {
|
||||
program = reward
|
||||
@@ -1202,7 +1182,7 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID
|
||||
if (dailyPayCount == 0) != (dailyPayPrice == 0) {
|
||||
return nil, fmt.Errorf("gamedata: regular gacha %d has incomplete daily paid design", id)
|
||||
}
|
||||
catalog.Gachas[id] = RegularGacha{ID: id, Count: int(counts[0]), DailyPayGachaCount: dailyPayCount, DailyPayGachaPriceCount: dailyPayPrice, FreeCountDay: freeCount, PriceType: priceTypes[0], PriceID: priceID, Price: prices[0], Pool: pool, RewardGroup: program, FixedCostumeID: fixedCostumeID, TicketIDs: ticketIDs}
|
||||
catalog.Gachas[id] = RegularGacha{Grades: catalog.grades, ID: id, Count: int(counts[0]), DailyPayGachaCount: dailyPayCount, DailyPayGachaPriceCount: dailyPayPrice, FreeCountDay: freeCount, PriceType: priceTypes[0], PriceID: priceID, Price: prices[0], Pool: pool, RewardGroup: program, FixedCostumeID: fixedCostumeID, TicketIDs: ticketIDs}
|
||||
catalog.recordPoolGrades(catalog.Gachas[id])
|
||||
}
|
||||
if err := catalog.loadGroupsAndFixed(db); err != nil {
|
||||
@@ -1276,11 +1256,11 @@ func loadCostumeRewardGroup(db *sql.DB, id uint64, visiting map[uint64]bool) (*C
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
dropType, _ := one(2)
|
||||
dropCount, ok := one(1)
|
||||
if !ok || dropCount == 0 {
|
||||
if dropType > 1 || dropType == 0 && (!ok || dropCount == 0) {
|
||||
return nil, errors.New("reward group has invalid drop count")
|
||||
}
|
||||
dropType, _ := one(2)
|
||||
ids, _ := packedInts(raw, 5)
|
||||
types, _ := packedInts(raw, 6)
|
||||
counts, _ := packedInts(raw, 4)
|
||||
@@ -1291,7 +1271,7 @@ func loadCostumeRewardGroup(db *sql.DB, id uint64, visiting map[uint64]bool) (*C
|
||||
group := &CostumeRewardGroup{ID: id, DropCount: dropCount, DropType: dropType, Entries: make([]CostumeRewardEntry, len(ids))}
|
||||
for i := range ids {
|
||||
entry := CostumeRewardEntry{ItemType: types[i], ItemID: ids[i], Count: counts[i], Weight: weights[i]}
|
||||
if entry.Count == 0 || entry.Weight == 0 {
|
||||
if entry.Count == 0 || entry.Weight == 0 || dropType == 1 && entry.Weight != 1 {
|
||||
return nil, errors.New("reward group has zero count or weight")
|
||||
}
|
||||
switch entry.ItemType {
|
||||
@@ -1373,8 +1353,12 @@ func collectCostumeRewardIDs(group *CostumeRewardGroup, out *[]uint64) {
|
||||
}
|
||||
|
||||
func loadCostumeGrade(db *sql.DB, costumeID uint64) (uint64, error) {
|
||||
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", costumeID/10).Scan(&raw); err != nil {
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&raw); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
grades, _ := packedInts(raw, 9)
|
||||
@@ -1384,19 +1368,6 @@ func loadCostumeGrade(db *sql.DB, costumeID uint64) (uint64, error) {
|
||||
return grades[0], nil
|
||||
}
|
||||
|
||||
func validateRegularCostumeRates(pool []WeightedCostume) error {
|
||||
if len(pool) != 3 {
|
||||
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
|
||||
}
|
||||
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
||||
for i, item := range pool {
|
||||
if item.Weight != want[i] {
|
||||
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable")
|
||||
if err != nil {
|
||||
@@ -1427,6 +1398,13 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
|
||||
if !oneLoaded && !tenLoaded {
|
||||
continue
|
||||
}
|
||||
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
|
||||
var err error
|
||||
group.CashRewards, err = loadGachaCashRewards(db, group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
c.groups[id] = group
|
||||
if oneLoaded {
|
||||
c.byGacha[group.OneTimeGachaID] = id
|
||||
@@ -1514,35 +1492,6 @@ func (c *RegularGachaCatalog) loadStepFixed(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFixedPickupRemainderRates(pool []WeightedCostume) error {
|
||||
if len(pool) != 3 {
|
||||
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
|
||||
}
|
||||
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
||||
for i, item := range pool {
|
||||
if item.Weight != want[i] {
|
||||
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOfficialPickupRates prevents a GameData/schema regression from
|
||||
// silently changing the published costume pickup rates. The two five-star
|
||||
// branches are pickup 1.5% plus the ordinary five-star pool 1.5%.
|
||||
func validateOfficialPickupRates(pool []WeightedCostume) error {
|
||||
if len(pool) != 4 {
|
||||
return fmt.Errorf("expected four rarity branches, got %d", len(pool))
|
||||
}
|
||||
want := [...]uint64{150, 150, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
||||
for i, item := range pool {
|
||||
if item.Weight != want[i] {
|
||||
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) {
|
||||
for _, item := range pool {
|
||||
if item.ID != 0 {
|
||||
@@ -1554,7 +1503,10 @@ func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) {
|
||||
}
|
||||
|
||||
func loadGachaCharacterDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) {
|
||||
characterID := costumeID / 10
|
||||
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
|
||||
if err != nil {
|
||||
return CharacterDesign{}, err
|
||||
}
|
||||
var character []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil {
|
||||
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err)
|
||||
@@ -1680,8 +1632,8 @@ func NewInfiniteGachaDesign(count int, costumeIDs []uint64, characters map[uint6
|
||||
return NewInfiniteGachaDesignWithRates(count, costumeIDs, costumeIDs, costumeIDs, characters)
|
||||
}
|
||||
|
||||
// NewInfiniteGachaDesignWithRates builds the local reroll design. Its first
|
||||
// Count-1 slots use the official costume base rates (3%/14%/83%), while the
|
||||
// NewInfiniteGachaDesignWithRates builds a synthetic reroll policy for tests.
|
||||
// Its first Count-1 slots use an explicit 3%/14%/83% synthetic policy, while the
|
||||
// last slot is a five-star guarantee chosen uniformly from the real eligible
|
||||
// five-star pool.
|
||||
func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeStarIDs []uint64, characters map[uint64]CharacterDesign) (*InfiniteGachaDesign, error) {
|
||||
@@ -1705,7 +1657,7 @@ func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeS
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
|
||||
func LoadInfiniteGachaForSchedules(root, version string, groups []uint64) (*InfiniteGachaDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1725,30 +1677,41 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
infiniteGroupID, infiniteGachaID, err := loadInfiniteGachaIdentity(db, groups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var gacha []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", InfiniteGachaID).Scan(&gacha); err != nil {
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", infiniteGachaID).Scan(&gacha); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: infinite gacha: %w", err)
|
||||
}
|
||||
count, _ := packedInts(gacha, 5)
|
||||
reward, _ := packedInts(gacha, 7)
|
||||
priceCount, _ := packedInts(gacha, 10)
|
||||
if len(count) != 1 || count[0] != 10 || len(reward) != 1 || reward[0] != InfiniteGachaID || len(priceCount) != 0 {
|
||||
if len(count) != 1 || count[0] == 0 || len(reward) != 1 || reward[0] == 0 || len(priceCount) != 0 {
|
||||
return nil, fmt.Errorf("gamedata: unexpected infinite gacha definition count=%v reward=%v price=%v", count, reward, priceCount)
|
||||
}
|
||||
var cash []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CashProductTable WHERE id=? AND GroupId=?", InfiniteProductID, InfiniteProductGroupID).Scan(&cash); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: infinite product: %w", err)
|
||||
productGroupID, productID, saleGroup, err := loadInfiniteCashIdentity(db, reward[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
productID, _ := packedInts(cash, 6)
|
||||
groupID, _ := packedInts(cash, 5)
|
||||
if len(productID) != 1 || productID[0] != InfiniteProductID || len(groupID) != 1 || groupID[0] != InfiniteProductGroupID {
|
||||
return nil, fmt.Errorf("gamedata: unexpected infinite product identity")
|
||||
program, err := loadCostumeRewardGroup(db, reward[0], map[uint64]bool{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
programCount, err := costumeRewardCount(program)
|
||||
if err != nil || programCount != count[0] {
|
||||
return nil, fmt.Errorf("gamedata: infinite reward count does not match gacha")
|
||||
}
|
||||
|
||||
// GachaTable.FixedGachaRewardId=9100035 is the official costume pool used
|
||||
// by the observed infinite previews. RewardGroup item type 11 is Costume.
|
||||
fixedPool, err := optionalScalar(gacha, 3)
|
||||
if err != nil || fixedPool == 0 {
|
||||
return nil, errors.New("gamedata: infinite fixed reward missing")
|
||||
}
|
||||
var pool []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=9100035").Scan(&pool); err != nil {
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", fixedPool).Scan(&pool); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: infinite costume pool: %w", err)
|
||||
}
|
||||
ids, err := packedInts(pool, 5)
|
||||
@@ -1793,7 +1756,10 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
|
||||
if types[i] != 11 || costumeID == 0 {
|
||||
return nil, fmt.Errorf("gamedata: infinite pool entry %d has type %d", costumeID, types[i])
|
||||
}
|
||||
characterID := costumeID / 10
|
||||
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var character []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err)
|
||||
@@ -1833,20 +1799,30 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
|
||||
costume.HP = uint64(hp)
|
||||
characters[costumeID] = costume
|
||||
}
|
||||
fourStarPool, err := loadCostumeRewardPool(db, 70008)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: infinite four-star pool: %w", err)
|
||||
}
|
||||
threeStarPool, err := loadCostumeRewardPool(db, 70004)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: infinite three-star pool: %w", err)
|
||||
}
|
||||
var allIDs []uint64
|
||||
collectCostumeRewardIDs(program, &allIDs)
|
||||
var fourStarIDs, threeStarIDs []uint64
|
||||
collectCostumeIDs(fourStarPool, &fourStarIDs)
|
||||
collectCostumeIDs(threeStarPool, &threeStarIDs)
|
||||
if len(ids) != 129 || len(fourStarIDs) != 13 || len(threeStarIDs) != 16 {
|
||||
return nil, fmt.Errorf("gamedata: unexpected infinite rarity pools five=%d four=%d three=%d", len(ids), len(fourStarIDs), len(threeStarIDs))
|
||||
seen := map[uint64]bool{}
|
||||
for _, costumeID := range allIDs {
|
||||
if seen[costumeID] {
|
||||
continue
|
||||
}
|
||||
seen[costumeID] = true
|
||||
grade, err := loadCostumeGrade(db, costumeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch grade {
|
||||
case 4:
|
||||
fourStarIDs = append(fourStarIDs, costumeID)
|
||||
case 3:
|
||||
threeStarIDs = append(threeStarIDs, costumeID)
|
||||
case 5:
|
||||
default:
|
||||
return nil, fmt.Errorf("gamedata: unsupported infinite costume grade %d", grade)
|
||||
}
|
||||
}
|
||||
|
||||
for _, costumeID := range append(append([]uint64(nil), fourStarIDs...), threeStarIDs...) {
|
||||
if _, exists := characters[costumeID]; exists {
|
||||
continue
|
||||
@@ -1857,7 +1833,10 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
|
||||
}
|
||||
characters[costumeID] = character
|
||||
}
|
||||
return NewInfiniteGachaDesignWithRates(int(count[0]), ids, fourStarIDs, threeStarIDs, characters)
|
||||
design := &InfiniteGachaDesign{Count: int(count[0]), CostumeIDs: append([]uint64(nil), ids...), FiveStarIDs: ids, FourStarIDs: fourStarIDs, ThreeStarIDs: threeStarIDs, characters: characters}
|
||||
design.GroupID, design.GachaID, design.ProductGroupID, design.ProductID, design.SaleGroup = infiniteGroupID, infiniteGachaID, productGroupID, productID, saleGroup
|
||||
design.rewardProgram = program
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func fixed64Double(proto []byte, number int) (float64, bool, error) {
|
||||
@@ -1888,9 +1867,15 @@ func (d *InfiniteGachaDesign) Roll() ([]uint64, error) {
|
||||
}
|
||||
|
||||
func (d *InfiniteGachaDesign) rollWith(draw func(uint64) (uint64, error)) ([]uint64, error) {
|
||||
if d == nil || d.Count <= 0 || len(d.FiveStarIDs) == 0 || len(d.FourStarIDs) == 0 || len(d.ThreeStarIDs) == 0 || draw == nil {
|
||||
if d == nil || d.Count <= 0 || draw == nil {
|
||||
return nil, fmt.Errorf("gamedata: invalid infinite gacha design")
|
||||
}
|
||||
if d.rewardProgram != nil {
|
||||
return rollCostumeRewardGroup(d.rewardProgram, draw)
|
||||
}
|
||||
if len(d.FiveStarIDs) == 0 || len(d.FourStarIDs) == 0 || len(d.ThreeStarIDs) == 0 {
|
||||
return nil, errors.New("gamedata: incomplete synthetic infinite pools")
|
||||
}
|
||||
result := make([]uint64, d.Count)
|
||||
for i := 0; i < d.Count-1; i++ {
|
||||
rate, err := draw(officialRateScale)
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestSingleSidedCostumeFixedThresholdsDoNotTriggerZeroSide(t *testing.T) {
|
||||
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002},
|
||||
{Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
|
||||
}
|
||||
gacha := RegularGacha{ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: pool}
|
||||
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: pool}
|
||||
chooseThree := func(limit uint64) (uint64, error) {
|
||||
if limit == officialRateScale {
|
||||
return limit - 1, nil
|
||||
@@ -59,7 +59,7 @@ func TestCompositeCostumeRewardGroupExecutesAllChildren(t *testing.T) {
|
||||
if count, err := costumeRewardCount(program); err != nil || count != 10 {
|
||||
t.Fatalf("count=%d err=%v", count, err)
|
||||
}
|
||||
gacha := RegularGacha{ID: 1, Count: 10, PriceType: 2, Price: 500, RewardGroup: program}
|
||||
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 10, PriceType: 2, Price: 500, RewardGroup: program}
|
||||
roll, err := gacha.rollRewardGroup(func(limit uint64) (uint64, error) { return 0, nil })
|
||||
if err != nil || len(roll) != 10 || roll[0] != 5001 {
|
||||
t.Fatalf("roll=%v err=%v", roll, err)
|
||||
@@ -78,7 +78,7 @@ func TestMoonriseSpecialSelectionUsesThreeChoicesAndGameDataRemainder(t *testing
|
||||
selected[i] = uint64(5001 + i)
|
||||
choices[i] = CostumeRewardEntry{ItemType: 11, ItemID: selected[i], Count: 1, Weight: 1}
|
||||
}
|
||||
gacha := RegularGacha{ID: 9100037, Count: 10, PriceType: 19, PriceID: 450030, Price: 1, RewardGroup: &CostumeRewardGroup{
|
||||
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 9100037, Count: 10, PriceType: 19, PriceID: 450030, Price: 1, RewardGroup: &CostumeRewardGroup{
|
||||
ID: 9100037, DropCount: 1, DropType: 1, Entries: []CostumeRewardEntry{
|
||||
{ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}},
|
||||
{ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []CostumeRewardEntry{
|
||||
@@ -108,7 +108,7 @@ func TestMoonriseSpecialSelectionUsesThreeChoicesAndGameDataRemainder(t *testing
|
||||
|
||||
func TestAddAndQueryStepUpDesign(t *testing.T) {
|
||||
characters := map[uint64]CharacterDesign{5001: {ID: 500, HP: 100}}
|
||||
catalog, err := NewRegularGachaCatalog(map[uint64]RegularGacha{
|
||||
catalog, err := fixtureRegularCatalog(map[uint64]RegularGacha{
|
||||
10: {ID: 10, Count: 1, PriceType: 2, Price: 1, Pool: []WeightedCostume{{ID: 5001, Weight: 1}}},
|
||||
}, characters)
|
||||
if err != nil {
|
||||
@@ -210,7 +210,7 @@ func TestLegacyRegularGachaLoaderAgainstInstalledVersion23510(t *testing.T) {
|
||||
if root == "" {
|
||||
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
|
||||
}
|
||||
catalog, err := LoadRegularCostumeGacha(root, "20260923193640")
|
||||
catalog, err := LoadRegularCostumeGachaGroups(root, "20260923193640", []uint64{10001, 135, 205, 121}, []uint64{29})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Cash product tickets are the deterministic CashProductTable -> RandomBoxTable
|
||||
// -> RewardGroupTable grant. Their IDs and quantities are version facts.
|
||||
func loadGachaCashRewards(db *sql.DB, group GachaGroupDesign) ([]BattleReward, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CashProductTable WHERE GroupId=? AND id=? AND saleGroup=?", group.CashProductGroupID, group.CashProductID, group.CashSalesGroup).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
box, err := optionalScalar(raw, 14)
|
||||
if err != nil || box == 0 {
|
||||
return nil, fmt.Errorf("gamedata: gacha cash product has no random box")
|
||||
}
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewardID, err := optionalScalar(raw, 9)
|
||||
if err != nil || rewardID == 0 {
|
||||
return nil, fmt.Errorf("gamedata: gacha cash box has no reward group")
|
||||
}
|
||||
return deterministicGachaCashRewards(db, rewardID, map[uint64]bool{})
|
||||
}
|
||||
|
||||
func deterministicGachaCashRewards(db *sql.DB, id uint64, visiting map[uint64]bool) ([]BattleReward, error) {
|
||||
if visiting[id] {
|
||||
return nil, fmt.Errorf("gamedata: cyclic cash reward group %d", id)
|
||||
}
|
||||
visiting[id] = true
|
||||
defer delete(visiting, id)
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", id).Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dropType, err := optionalScalar(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(raw, 5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
types, err := packedInts(raw, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := packedInts(raw, 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ratios, err := packedInts(raw, 8)
|
||||
if err != nil || len(ratios) != len(ids) {
|
||||
return nil, fmt.Errorf("gamedata: malformed cash reward ratio")
|
||||
}
|
||||
for _, ratio := range ratios {
|
||||
if dropType == 0 && ratio == 0 || dropType == 1 && ratio != 100 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward is not deterministic")
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(counts) || dropType > 1 || dropType == 0 && len(ids) != 1 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward %d is not deterministic", id)
|
||||
}
|
||||
dropCount, err := optionalScalar(raw, 1)
|
||||
if err != nil || dropType == 0 && dropCount == 0 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward has invalid drop count")
|
||||
}
|
||||
if dropType == 0 {
|
||||
for i := range counts {
|
||||
if counts[i] > ^uint64(0)/dropCount {
|
||||
return nil, fmt.Errorf("gamedata: cash reward count overflow")
|
||||
}
|
||||
counts[i] *= dropCount
|
||||
}
|
||||
}
|
||||
var out []BattleReward
|
||||
for i, itemID := range ids {
|
||||
if counts[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: cash reward %d has zero count", id)
|
||||
}
|
||||
if types[i] == 9 {
|
||||
children, err := deterministicGachaCashRewards(db, itemID, visiting)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.Count > ^uint64(0)/counts[i] {
|
||||
return nil, fmt.Errorf("gamedata: cash reward overflow")
|
||||
}
|
||||
child.Count *= counts[i]
|
||||
out = append(out, child)
|
||||
}
|
||||
} else {
|
||||
if (!itemDBInfoRewardType(types[i]) && types[i] != 19) || itemID == 0 {
|
||||
return nil, fmt.Errorf("gamedata: unsupported cash reward type %d", types[i])
|
||||
}
|
||||
out = append(out, BattleReward{Type: types[i], ID: itemID, Count: counts[i]})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCashRewardsUseItemCountAndRejectRandomIndependentRolls(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
makeRaw := func(dropType, dropCount, count, ratio, typ uint64) []byte {
|
||||
raw := wire.AppendVarint(nil, 1, dropCount)
|
||||
raw = wire.AppendVarint(raw, 2, dropType)
|
||||
for _, field := range []struct {
|
||||
n int
|
||||
v uint64
|
||||
}{{4, count}, {5, 9876}, {6, typ}, {8, ratio}} {
|
||||
raw = wire.AppendVarint(raw, field.n, field.v)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
id, typ, dropType, dropCount, count, ratio, want uint64
|
||||
valid bool
|
||||
}{
|
||||
{1, 19, 0, 2, 7, 1, 14, true}, {2, 8, 1, 0, 7, 100, 7, true}, {3, 8, 1, 0, 7, 35, 0, false}, {4, 10, 0, 1, 1, 1, 0, false}, {5, 4, 0, 1, 1, 1, 0, false},
|
||||
} {
|
||||
if _, err := db.Exec("INSERT INTO RewardGroupTable VALUES (?,?)", tc.id, makeRaw(tc.dropType, tc.dropCount, tc.count, tc.ratio, tc.typ)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := deterministicGachaCashRewards(db, tc.id, map[uint64]bool{})
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatalf("id=%d valid=%v err=%v", tc.id, tc.valid, err)
|
||||
}
|
||||
if tc.valid && (len(got) != 1 || got[0].Count != tc.want) {
|
||||
t.Fatalf("id=%d count=%+v want=%d", tc.id, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfiniteRewardProgramDoesNotRequireEveryRarity(t *testing.T) {
|
||||
d := &InfiniteGachaDesign{Count: 2, rewardProgram: &CostumeRewardGroup{ID: 1, DropCount: 2, Entries: []CostumeRewardEntry{{ItemType: 11, ItemID: 5001, Count: 1, Weight: 1}}}}
|
||||
got, err := d.rollWith(func(uint64) (uint64, error) { return 0, nil })
|
||||
if err != nil || len(got) != 2 || got[0] != 5001 {
|
||||
t.Fatalf("roll=%v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Costume ownership refers to a unique character, not a numeric costume ID
|
||||
// prefix. The initial growth grade is the base character, excluding pack-only
|
||||
// temporary copies; ambiguous rows are rejected instead of selecting an ID.
|
||||
func loadCostumeBaseCharacterID(db *sql.DB, costumeID uint64) (uint64, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&raw); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
unique, err := optionalScalar(raw, 27)
|
||||
if err != nil || unique == 0 {
|
||||
return 0, fmt.Errorf("gamedata: costume %d unique character unavailable", costumeID)
|
||||
}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId=?", unique)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result uint64
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var row []byte
|
||||
if err := rows.Scan(&id, &row); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
growth, err := optionalScalar(row, 10)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
temporary, err := optionalScalar(row, 21)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if growth != 1 || temporary != 0 {
|
||||
continue
|
||||
}
|
||||
if result != 0 {
|
||||
return 0, fmt.Errorf("gamedata: costume %d has ambiguous base character", costumeID)
|
||||
}
|
||||
result = id
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if result == 0 {
|
||||
return 0, fmt.Errorf("gamedata: costume %d has no base character", costumeID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCostumeCharacterRelationshipDoesNotUseNumericPrefix(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec("CREATE TABLE CostumeTable(id INTEGER,ProtoBuf BLOB);CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.Exec("INSERT INTO CostumeTable VALUES (?,?)", 76543, wire.AppendVarint(nil, 27, 88))
|
||||
for _, c := range []struct{ id, growth, temp uint64 }{{999, 1, 0}, {1000, 2, 0}, {222, 1, 4}} {
|
||||
raw := wire.AppendVarint(wire.AppendVarint(nil, 10, c.growth), 21, c.temp)
|
||||
db.Exec("INSERT INTO CharTable VALUES (?,?,?)", c.id, 88, raw)
|
||||
}
|
||||
got, err := loadCostumeBaseCharacterID(db, 76543)
|
||||
if err != nil || got != 999 {
|
||||
t.Fatalf("character=%d err=%v", got, err)
|
||||
}
|
||||
db.Exec("INSERT INTO CharTable VALUES (?,?,?)", 777, 88, wire.AppendVarint(nil, 10, 1))
|
||||
if _, err := loadCostumeBaseCharacterID(db, 76543); err == nil {
|
||||
t.Fatal("ambiguous base guessed")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func TestCostumeGuaranteedFourAndFiveCountsAcrossOneAndTen(t *testing.T) {
|
||||
}
|
||||
// The first of a ten-pull hits the shared four-star boundary after nine
|
||||
// unsuccessful singles, and the next nine slots continue the new count.
|
||||
gacha := RegularGacha{ID: 1, Count: 10, PriceType: 3, Price: 2000, Pool: pool}
|
||||
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 10, PriceType: 3, Price: 2000, Pool: pool}
|
||||
roll, state, err := gacha.rollWithCostumeFixed(9, 25, fixed, nil, nil, chooseThree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -52,7 +52,7 @@ func TestTwelvePickSelectionAppliesToNaturalAndPityFiveStar(t *testing.T) {
|
||||
{Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}},
|
||||
{Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}},
|
||||
}
|
||||
gacha := RegularGacha{ID: 101, Count: 1, PriceType: 3, Price: 200, Pool: pool}
|
||||
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 101, Count: 1, PriceType: 3, Price: 200, Pool: pool}
|
||||
fixed := GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}
|
||||
selected := []uint64{5012, 5013}
|
||||
chooseFirst := func(limit uint64) (uint64, error) { return 0, nil }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// gradePool conditions the actual nested weighted distribution on one grade.
|
||||
// Exact rational path weights preserve branches with different child totals.
|
||||
func (g RegularGacha) gradePool(grade uint64) ([]WeightedCostume, error) {
|
||||
weights := map[uint64]*big.Rat{}
|
||||
var walk func([]WeightedCostume, *big.Rat) error
|
||||
walk = func(pool []WeightedCostume, path *big.Rat) error {
|
||||
total := new(big.Int)
|
||||
for _, item := range pool {
|
||||
if item.Weight == 0 {
|
||||
return errors.New("gamedata: zero costume weight")
|
||||
}
|
||||
total.Add(total, new(big.Int).SetUint64(item.Weight))
|
||||
}
|
||||
if total.Sign() == 0 {
|
||||
return errors.New("gamedata: empty costume pool")
|
||||
}
|
||||
for _, item := range pool {
|
||||
prob := new(big.Rat).Mul(path, new(big.Rat).SetFrac(new(big.Int).SetUint64(item.Weight), total))
|
||||
if item.ID == 0 {
|
||||
if err := walk(item.Children, prob); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
actual, ok := g.Grades[item.ID]
|
||||
if !ok || actual < 3 || actual > 5 {
|
||||
return errors.New("gamedata: costume grade unavailable")
|
||||
}
|
||||
if actual == grade {
|
||||
if weights[item.ID] == nil {
|
||||
weights[item.ID] = new(big.Rat)
|
||||
}
|
||||
weights[item.ID].Add(weights[item.ID], prob)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(g.Pool, new(big.Rat).SetInt64(1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
denom := big.NewInt(1)
|
||||
for _, weight := range weights {
|
||||
gcd := new(big.Int).GCD(nil, nil, denom, weight.Denom())
|
||||
denom.Mul(new(big.Int).Quo(denom, gcd), weight.Denom())
|
||||
}
|
||||
var ids []uint64
|
||||
for id := range weights {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// Stable table-independent ordering makes deterministic draw sources reproducible.
|
||||
for i := 1; i < len(ids); i++ {
|
||||
for j := i; j > 0 && ids[j] < ids[j-1]; j-- {
|
||||
ids[j], ids[j-1] = ids[j-1], ids[j]
|
||||
}
|
||||
}
|
||||
var out []WeightedCostume
|
||||
for _, id := range ids {
|
||||
w := weights[id]
|
||||
count := new(big.Int).Mul(w.Num(), new(big.Int).Quo(new(big.Int).Set(denom), w.Denom()))
|
||||
if !count.IsUint64() {
|
||||
return nil, errors.New("gamedata: conditioned costume weight overflow")
|
||||
}
|
||||
out = append(out, WeightedCostume{ID: id, Weight: count.Uint64()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gamedata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNonResettingFixedGuaranteeKeepsNaturalFiveStarProgress(t *testing.T) {
|
||||
g := RegularGacha{Count: 10, Grades: map[uint64]uint64{55: 5}, Pool: []WeightedCostume{{ID: 55, Weight: 1}}}
|
||||
_, state, err := g.rollWithCostumeFixed(0, 0, GachaFixedDesign{ID: 3, CostumeGrade5Count: 10}, nil, []uint64{55}, func(uint64) (uint64, error) { return 0, nil })
|
||||
if err != nil || state.CostumeGrade5Sort != 9 || state.CostumeGrade5Count != 0 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 9 {
|
||||
t.Fatalf("state=%+v err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeFixedUsesActualGradesAcrossMixedAndReorderedBranches(t *testing.T) {
|
||||
g := RegularGacha{Count: 1, Grades: map[uint64]uint64{99: 3, 77: 5, 66: 4, 55: 5}, Pool: []WeightedCostume{
|
||||
{Weight: 10, Children: []WeightedCostume{{ID: 99, Weight: 3}, {ID: 77, Weight: 1}}},
|
||||
{ID: 66, Weight: 7}, {ID: 55, Weight: 5},
|
||||
}}
|
||||
fixed := GachaFixedDesign{ResetOnMatchingGrade: true, ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100}
|
||||
first := func(uint64) (uint64, error) { return 0, nil }
|
||||
roll, state, err := g.rollWithCostumeFixed(9, 99, fixed, nil, nil, first)
|
||||
if err != nil || roll[0] != 55 || state.CostumeGrade5Count != 0 {
|
||||
t.Fatalf("five=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
roll, state, err = g.rollWithCostumeFixed(9, 0, fixed, nil, nil, first)
|
||||
if err != nil || roll[0] != 66 || state.CostumeGrade5Count != 1 {
|
||||
t.Fatalf("four=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
// Last branch is five-star despite the historical assumption of grade three.
|
||||
last := func(limit uint64) (uint64, error) { return limit - 1, nil }
|
||||
roll, state, err = g.rollWithCostumeFixed(2, 3, fixed, nil, nil, last)
|
||||
if err != nil || roll[0] != 55 || state.CostumeGrade4Count != 0 || state.CostumeGrade5Count != 0 {
|
||||
t.Fatalf("natural=%v state=%+v err=%v", roll, state, err)
|
||||
}
|
||||
pool, err := g.gradePool(5)
|
||||
if err != nil || len(pool) != 2 || pool[0].ID != 55 || pool[0].Weight != 10 || pool[1].ID != 77 || pool[1].Weight != 5 {
|
||||
t.Fatalf("conditioned=%+v err=%v", pool, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenDrawGradeFourGuaranteeWorksWithTwoReorderedBranches(t *testing.T) {
|
||||
g := RegularGacha{Count: 10, Grades: map[uint64]uint64{99: 3, 66: 4}, Pool: []WeightedCostume{{ID: 99, Weight: 9}, {ID: 66, Weight: 1}}}
|
||||
roll, err := g.rollWith(func(uint64) (uint64, error) { return 0, nil })
|
||||
if err != nil || roll[9] != 66 {
|
||||
t.Fatalf("roll=%v err=%v", roll, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func loadInfiniteGachaIdentity(db *sql.DB, selectedGroups []uint64) (groupID, gachaID uint64, err error) {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable ORDER BY id")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return
|
||||
}
|
||||
typ, e := optionalScalar(raw, 17)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
sub, e := optionalScalar(raw, 16)
|
||||
if e != nil {
|
||||
return 0, 0, e
|
||||
}
|
||||
selected := false
|
||||
for _, wanted := range selectedGroups {
|
||||
if wanted == id {
|
||||
selected = true
|
||||
}
|
||||
}
|
||||
if !selected || typ != 1 || sub != 5 {
|
||||
continue
|
||||
}
|
||||
if groupID != 0 {
|
||||
return 0, 0, fmt.Errorf("gamedata: multiple infinite gacha groups require schedule selection")
|
||||
}
|
||||
groupID = id
|
||||
gachaID, err = optionalScalar(raw, 33)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
if groupID == 0 || gachaID == 0 {
|
||||
err = fmt.Errorf("gamedata: infinite gacha group missing")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func loadInfiniteCashIdentity(db *sql.DB, rewardID uint64) (groupID, productID, saleGroup uint64, err error) {
|
||||
rows, err := db.Query("SELECT ProtoBuf FROM CashProductTable ORDER BY GroupId,id,saleGroup")
|
||||
if err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var raw []byte
|
||||
if err = rows.Scan(&raw); err != nil {
|
||||
return
|
||||
}
|
||||
box, e := optionalScalar(raw, 14)
|
||||
if e != nil {
|
||||
return 0, 0, 0, e
|
||||
}
|
||||
if box == 0 {
|
||||
continue
|
||||
}
|
||||
var boxRaw []byte
|
||||
if e = db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&boxRaw); e != nil {
|
||||
return 0, 0, 0, e
|
||||
}
|
||||
reward, e := optionalScalar(boxRaw, 9)
|
||||
if e != nil {
|
||||
return 0, 0, 0, e
|
||||
}
|
||||
if reward != rewardID {
|
||||
continue
|
||||
}
|
||||
if productID != 0 {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: ambiguous infinite cash product")
|
||||
}
|
||||
groupID, err = optionalScalar(raw, 5)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
productID, err = optionalScalar(raw, 6)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
saleGroup, err = optionalScalar(raw, 15)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return
|
||||
}
|
||||
if groupID == 0 || productID == 0 {
|
||||
err = fmt.Errorf("gamedata: infinite cash product missing")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func TestOfficialPickupRateDefinition(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T) {
|
||||
gacha := RegularGacha{Count: 10, Pool: []WeightedCostume{
|
||||
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, Count: 10, Pool: []WeightedCostume{
|
||||
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
|
||||
}}
|
||||
roll, err := gacha.rollWith(func(limit uint64) (uint64, error) {
|
||||
@@ -82,7 +82,7 @@ func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T)
|
||||
}
|
||||
|
||||
func TestFixedPickupTenPullAlwaysStartsWithPickupCostume(t *testing.T) {
|
||||
gacha := RegularGacha{Count: 10, FixedCostumeID: 21201, Pool: []WeightedCostume{
|
||||
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, Count: 10, FixedCostumeID: 21201, Pool: []WeightedCostume{
|
||||
{Weight: 300, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
|
||||
}}
|
||||
roll, err := gacha.rollWith(func(limit uint64) (uint64, error) { return limit - 1, nil })
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ImmortalDesign resolves exactly the current character talent and skill level.
|
||||
// Full restoration is evidenced for value 10000; other values require official
|
||||
// server evidence of their rounding/scaling semantics before being enabled.
|
||||
type ImmortalDesign struct {
|
||||
Characters map[uint64]uint64
|
||||
FullRestore map[[2]uint64]bool
|
||||
}
|
||||
|
||||
func LoadImmortalDesign(root, version string) (*ImmortalDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
d := &ImmortalDesign{Characters: map[uint64]uint64{}, FullRestore: map[[2]uint64]bool{}}
|
||||
talents := map[uint64]uint64{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM TalentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
group, err := optionalScalar(raw, 18)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
talents[id] = group
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
talent, err := optionalScalar(raw, 18)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if group := talents[talent]; group != 0 {
|
||||
d.Characters[id] = group
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var group, level uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&group, &level, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
class, err := optionalScalar(raw, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if class != 14 {
|
||||
continue
|
||||
}
|
||||
values, err := fixed32Floats(raw, 14)
|
||||
if err != nil || len(values) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: immortal skill missing value")
|
||||
}
|
||||
d.FullRestore[[2]uint64{group, level}] = values[0] == 10000
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
|
||||
func (d *ImmortalDesign) CanRestore(characterID, level uint64) bool {
|
||||
return d != nil && d.FullRestore[[2]uint64{d.Characters[characterID], level}]
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func loadInventorySlotDesign(db *sql.DB) (*InventorySlotDesign, error) {
|
||||
return InventorySlotRule{}, err
|
||||
}
|
||||
rule := InventorySlotRule{Default: initial, Maximum: maximum, PriceType: priceType, BasePrice: basePrice, MaxPrice: maxPrice}
|
||||
if rule.Default > rule.Maximum || rule.PriceType != 4 {
|
||||
if rule.Default > rule.Maximum || (rule.PriceType != 2 && rule.PriceType != 3 && rule.PriceType != 4) {
|
||||
return InventorySlotRule{}, errors.New("gamedata: unsupported inventory slot rule")
|
||||
}
|
||||
return rule, nil
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
func validateRegularCostumeRates(pool []WeightedCostume) error {
|
||||
if len(pool) != 3 {
|
||||
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
|
||||
}
|
||||
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
||||
for i, item := range pool {
|
||||
if item.Weight != want[i] {
|
||||
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFixedPickupRemainderRates(pool []WeightedCostume) error {
|
||||
if len(pool) != 3 {
|
||||
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
|
||||
}
|
||||
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
||||
for i, item := range pool {
|
||||
if item.Weight != want[i] {
|
||||
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateOfficialPickupRates prevents a GameData/schema regression from
|
||||
// silently changing the published costume pickup rates. The two five-star
|
||||
// branches are pickup 1.5% plus the ordinary five-star pool 1.5%.
|
||||
func validateOfficialPickupRates(pool []WeightedCostume) error {
|
||||
if len(pool) != 4 {
|
||||
return fmt.Errorf("expected four rarity branches, got %d", len(pool))
|
||||
}
|
||||
want := [...]uint64{150, 150, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
|
||||
for i, item := range pool {
|
||||
if item.Weight != want[i] {
|
||||
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func fixtureRegularCatalog(gachas map[uint64]RegularGacha, characters map[uint64]CharacterDesign) (*RegularGachaCatalog, error) {
|
||||
for id, g := range gachas {
|
||||
if len(g.Grades) == 0 {
|
||||
g.Grades = map[uint64]uint64{}
|
||||
var collect func([]WeightedCostume, uint64)
|
||||
collect = func(p []WeightedCostume, grade uint64) {
|
||||
for _, w := range p {
|
||||
if w.ID != 0 {
|
||||
g.Grades[w.ID] = grade
|
||||
} else {
|
||||
collect(w.Children, grade)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, w := range g.Pool {
|
||||
grade := uint64(5 - i)
|
||||
if len(g.Pool) == 4 {
|
||||
grade = uint64(6 - i)
|
||||
if i == 0 {
|
||||
grade = 5
|
||||
}
|
||||
}
|
||||
collect([]WeightedCostume{w}, grade)
|
||||
}
|
||||
if g.FixedCostumeID != 0 {
|
||||
g.Grades[g.FixedCostumeID] = 5
|
||||
}
|
||||
gachas[id] = g
|
||||
}
|
||||
}
|
||||
return NewRegularGachaCatalog(gachas, characters)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ type MissionDesign struct {
|
||||
type MissionCondition struct {
|
||||
Type uint64
|
||||
SubType uint64
|
||||
SubTypeComparison uint64
|
||||
Params []uint64
|
||||
TargetValue uint64
|
||||
UnlockPack uint64
|
||||
@@ -154,7 +155,11 @@ func loadMissionRows(db *sql.DB, design *MissionDesign) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable unlock quest: %w", err)
|
||||
}
|
||||
design.Conditions[key] = MissionCondition{Type: conditionType, SubType: conditionSubType, Params: params, TargetValue: target, UnlockPack: unlockPack, UnlockQuest: unlockQuest}
|
||||
subTypeMore, err := optionalScalar(proto, 9)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gamedata: MissionTable subtype comparison invalid")
|
||||
}
|
||||
design.Conditions[key] = MissionCondition{SubTypeComparison: subTypeMore, Type: conditionType, SubType: conditionSubType, Params: params, TargetValue: target, UnlockPack: unlockPack, UnlockQuest: unlockQuest}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func loadPackJamDesign(db *sql.DB) (*PackJamDesign, error) {
|
||||
values[field] = v[0]
|
||||
}
|
||||
}
|
||||
if values[1] != 0 || values[3] == 0 || values[2] < values[3] || values[2] > uint64(^uint32(0)>>1) || values[4] == 0 || values[4] > uint64(^uint32(0)>>1) || values[6] != 3 || values[5] != 0 {
|
||||
if values[1] != 0 || values[3] == 0 || values[2] < values[3] || values[2] > uint64(^uint32(0)>>1) || values[4] == 0 || values[4] > uint64(^uint32(0)>>1) || !packJamCurrency(values[6]) || values[5] != 0 {
|
||||
return nil, fmt.Errorf("gamedata: unsupported pack jam reward or insertion rule")
|
||||
}
|
||||
design = &PackJamDesign{InsertMin: values[3], InsertMax: values[2], Reward: Reward{Count: values[4], ID: values[5], Type: values[6]}}
|
||||
@@ -59,3 +59,18 @@ func loadPackJamDesign(db *sql.DB) (*PackJamDesign, error) {
|
||||
}
|
||||
return design, nil
|
||||
}
|
||||
|
||||
func packJamCurrency(typ uint64) bool {
|
||||
switch typ {
|
||||
case 2, 3, 4, 12, 20:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d PackJamDesign) ValidateReward() error {
|
||||
if !packJamCurrency(d.Reward.Type) || d.Reward.ID != 0 || d.Reward.Count == 0 || d.Reward.Count > uint64(^uint32(0)>>1) {
|
||||
return fmt.Errorf("gamedata: unsupported pack jam reward")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestPackJamDesignLoadsDefaultsAndRejectsUnsupportedRewards(t *testing.T) {
|
||||
if err != nil || design.InsertMin != 5 || design.InsertMax != 5 || design.Reward.ID != 0 || design.Reward.Type != 3 || design.Reward.Count != 150 {
|
||||
t.Fatalf("design=%+v err=%v", design, err)
|
||||
}
|
||||
raw = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 5), 3, 5), 4, 150), 6, 4)
|
||||
raw = wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 5), 3, 5), 4, 150), 6, 8)
|
||||
if _, err := db.Exec("UPDATE PackJamEventTable SET ProtoBuf=?", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// PresetDesign is the normal party preset configuration in GameDefaultTable.
|
||||
type PresetDesign struct {
|
||||
BaseCount, Maximum, PriceType, PriceID, Price uint64
|
||||
Icons map[uint64]bool
|
||||
}
|
||||
|
||||
func (d PresetDesign) Validate() error {
|
||||
if d.BaseCount == 0 || d.Maximum < d.BaseCount || d.Price == 0 || d.PriceID != 0 {
|
||||
return fmt.Errorf("gamedata: invalid preset configuration")
|
||||
}
|
||||
switch d.PriceType {
|
||||
case 2, 3, 4, 12:
|
||||
default:
|
||||
return fmt.Errorf("gamedata: unsupported preset currency %d", d.PriceType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadPresetDesign(root, version string) (*PresetDesign, error) {
|
||||
plain, err := ReadQuestDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "bd2-presets-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
path := filepath.Join(dir, "common.db")
|
||||
if err := os.WriteFile(path, plain, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
return loadPresetDesign(db)
|
||||
}
|
||||
|
||||
func loadPresetDesign(db *sql.DB) (*PresetDesign, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &PresetDesign{}
|
||||
for field, target := range map[int]*uint64{91: &d.BaseCount, 92: &d.Price, 93: &d.PriceID, 94: &d.PriceType, 95: &d.Maximum} {
|
||||
v, err := optionalScalar(raw, field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*target = v
|
||||
}
|
||||
if err := d.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := db.Query("SELECT id FROM PresetTable ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
d.Icons = map[uint64]bool{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Icons[id] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPresetDesignReadsChangedLimitsAndPrice(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err = db.Exec("CREATE TABLE GameDefaultTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec("CREATE TABLE PresetTable(id INTEGER)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.Exec("INSERT INTO PresetTable VALUES(79)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw []byte
|
||||
for field, value := range map[int]uint64{91: 3, 92: 73, 94: 3, 95: 9} {
|
||||
raw = wire.AppendVarint(raw, field, value)
|
||||
}
|
||||
if _, err = db.Exec("INSERT INTO GameDefaultTable VALUES(0,?)", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := loadPresetDesign(db)
|
||||
if err != nil || got.BaseCount != 3 || got.Maximum != 9 || got.Price != 73 || got.PriceType != 3 || !got.Icons[79] {
|
||||
t.Fatalf("design=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuestCollectionsFollowChangedPackQuestAndItemIDs(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "quest.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, sqlText := range []string{"CREATE TABLE QuestTable77(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CollectionTable(id INTEGER,ProtoBuf BLOB)"} {
|
||||
if _, err := db.Exec(sqlText); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
quest := wire.AppendVarint(wire.AppendVarint(nil, 6, 801), 6, 802)
|
||||
if _, err := db.Exec("INSERT INTO QuestTable77 VALUES(49,?)", quest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, collectionID := range []uint64{801, 802} {
|
||||
if _, err := db.Exec("INSERT INTO CollectionTable VALUES(?,?)", collectionID, wire.AppendVarint(nil, 3, collectionID)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got, err := loadQuestDesignDB(db, 77)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rewards := got[49].CollectionRewards
|
||||
if len(rewards) != 2 || rewards[0] != (Reward{Type: 17, ID: 801, Count: 1}) || rewards[1] != (Reward{Type: 17, ID: 802, Count: 1}) {
|
||||
t.Fatalf("rewards=%+v", rewards)
|
||||
}
|
||||
if _, err := db.Exec("DELETE FROM CollectionTable WHERE id=802"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadQuestDesignDB(db, 77); err == nil {
|
||||
t.Fatal("missing collection design accepted")
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,8 @@ type QuestCharacterDesign struct {
|
||||
|
||||
// QuestCostumeDesign is one row from StoryCharGroupTable. UniqueCharacterID
|
||||
// comes from CostumeTable.UseUniqueCharId and is useful for matching a story
|
||||
// costume to its character design. Costume 996000 is the official placeholder
|
||||
// used for an account-owned party slot; callers must not treat it as owned.
|
||||
// costume to its character design. Versioned world configuration identifies
|
||||
// player-controlled placeholders; this loader preserves their authored IDs.
|
||||
type QuestCostumeDesign struct {
|
||||
Order uint64
|
||||
CostumeID uint64
|
||||
|
||||
@@ -18,6 +18,7 @@ type QuestDesign struct {
|
||||
PriorQuestID int
|
||||
NextQuestID int
|
||||
GiveQuestItemIDs []uint64
|
||||
CollectionRewards []Reward
|
||||
Rewards [5][]Reward
|
||||
}
|
||||
|
||||
@@ -66,6 +67,24 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) {
|
||||
continue
|
||||
}
|
||||
entry := QuestDesign{ID: id}
|
||||
collectionIDs, err := packedInts(proto, 6)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest%d collections: %w", id, err)
|
||||
}
|
||||
for _, collectionID := range collectionIDs {
|
||||
var collection []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CollectionTable WHERE id=?", collectionID).Scan(&collection); err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest%d collection%d: %w", id, collectionID, err)
|
||||
}
|
||||
itemID, err := requiredScalar(collection, 3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if itemID != collectionID {
|
||||
return nil, fmt.Errorf("gamedata: quest%d collection%d identity mismatch%d", id, collectionID, itemID)
|
||||
}
|
||||
entry.CollectionRewards = append(entry.CollectionRewards, Reward{Type: 17, ID: collectionID, Count: 1})
|
||||
}
|
||||
entry.GiveQuestItemIDs, err = packedInts(proto, 29)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gamedata: quest%d give items: %w", id, err)
|
||||
|
||||
@@ -13,7 +13,14 @@ type StoryCharacterCatalog struct {
|
||||
characters map[[2]int][]StoryCharacterDesign
|
||||
}
|
||||
|
||||
func LoadStoryCharacterCatalog(root, version string, packs []int) (*StoryCharacterCatalog, error) {
|
||||
func LoadStoryCharacterCatalog(root, version string, packs []int, placeholderCostumes ...uint64) (*StoryCharacterCatalog, error) {
|
||||
var placeholder uint64
|
||||
if len(placeholderCostumes) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: multiple story placeholders")
|
||||
}
|
||||
if len(placeholderCostumes) == 1 {
|
||||
placeholder = placeholderCostumes[0]
|
||||
}
|
||||
db, cleanup, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -60,7 +67,7 @@ func LoadStoryCharacterCatalog(root, version string, packs []int) (*StoryCharact
|
||||
return nil, err
|
||||
}
|
||||
for _, story := range formation.StoryCostumes {
|
||||
if story.UniqueCharacterID == unique && story.CostumeID != 996000 {
|
||||
if story.UniqueCharacterID == unique && (placeholder == 0 || story.CostumeID != placeholder) {
|
||||
costume = story.CostumeID
|
||||
break
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContentTicketsUseCatalogAndRequireSingleUse(t *testing.T) {
|
||||
s := &Service{}
|
||||
if err := s.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{660003: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
id, count uint64
|
||||
want bool
|
||||
}{{660003, 1, true}, {450030, 1, false}, {660003, 2, false}, {660003, 0, false}} {
|
||||
if got := s.supportedItemDBInfoReward(gamedata.Reward{Type: 19, ID: tc.id, Count: tc.count}); got != tc.want {
|
||||
t.Fatalf("ticket=%+v supported=%v", tc, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
@@ -263,7 +264,6 @@ func TestGrantSpoolEnforcesMailInt32IDAndCountBoundaries(t *testing.T) {
|
||||
{Type: 8, ID: 1000, Count: math.MaxInt32 + 1},
|
||||
{Type: 2, ID: 1, Count: 1},
|
||||
{Type: 2, Count: math.MaxInt32 + 1},
|
||||
{Type: 19, ID: 450031, Count: 1},
|
||||
{Type: 19, ID: 450030, Count: 0},
|
||||
{Type: 19, ID: 450030, Count: 2},
|
||||
} {
|
||||
@@ -282,6 +282,9 @@ func TestGrantSpoolOneUseContentTicketItemDBInfoPersistsAndIsIdempotent(t *testi
|
||||
// AddContentTicketItem; it consumes a normal ItemDBInfo with an instance ID.
|
||||
store := stateio.NewMemory()
|
||||
service, inventory, _ := spoolTestService(t, store)
|
||||
if err := service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "grants.json")
|
||||
writeSpool(t, path, spoolGrant("full-moon-one-use", GrantReward{Type: 19, ID: 450030, Count: 1}))
|
||||
if err := service.AttachGrantSpoolPath(path); err != nil {
|
||||
@@ -316,6 +319,7 @@ func TestGrantSpoolOneUseContentTicketItemDBInfoPersistsAndIsIdempotent(t *testi
|
||||
t.Fatalf("content ticket not stored: %+v", got)
|
||||
}
|
||||
service, inventory, _ = spoolTestService(t, store)
|
||||
service.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{450030: true, 660003: true}})
|
||||
if err := service.AttachGrantSpoolPath(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -50,12 +50,14 @@ var itemDBInfoTypes = map[uint64]bool{
|
||||
29: true, // instant-use item
|
||||
}
|
||||
|
||||
// The currently audited ContentTicket claim uses CommonPacket.AddContentTicketItem
|
||||
// through ItemDBInfo. Other content tickets have independent state and are not
|
||||
// enabled by this local mail implementation.
|
||||
// Content tickets are one-use dictionary entries; only Gacha semantics are
|
||||
// supported by this mailbox, with IDs read from the current table.
|
||||
func supportedItemDBInfoReward(reward gamedata.Reward) bool {
|
||||
return itemDBInfoTypes[reward.Type] && reward.ID != 0 &&
|
||||
(reward.Type != 19 || reward.ID == 450030 && reward.Count == 1)
|
||||
return itemDBInfoTypes[reward.Type] && reward.ID != 0 && (reward.Type != 19 || reward.Count == 1)
|
||||
}
|
||||
|
||||
func (s *Service) supportedItemDBInfoReward(reward gamedata.Reward) bool {
|
||||
return itemDBInfoTypes[reward.Type] && reward.ID != 0 && (reward.Type != 19 || reward.Count == 1 && s.contentTickets != nil && s.contentTickets.IDs[reward.ID])
|
||||
}
|
||||
|
||||
var currencyRewardTypes = map[uint64]bool{
|
||||
@@ -128,8 +130,8 @@ func (s *Starter) Validate() error {
|
||||
return errors.New("mail: reward arrays differ in length")
|
||||
}
|
||||
for i, typ := range m.RewardTypes {
|
||||
if typ == 19 && !supportedItemDBInfoReward(gamedata.Reward{Type: typ, ID: m.RewardIDs[i], Count: m.RewardCounts[i]}) {
|
||||
return errors.New("mail: content ticket reward requires id 450030 and count 1")
|
||||
if typ == 19 && (m.RewardIDs[i] == 0 || m.RewardCounts[i] != 1) {
|
||||
return errors.New("mail: content ticket reward requires an ID and count 1")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +239,7 @@ type stateSnapshot struct {
|
||||
// delivery. Starter is immutable source data; mutable rows live in the player
|
||||
// state alongside the bounded core snapshot.
|
||||
type Service struct {
|
||||
contentTickets *gamedata.GachaContentTicketDesign
|
||||
mu sync.Mutex
|
||||
Starter *Starter
|
||||
seedPath string
|
||||
@@ -254,6 +257,14 @@ type Service struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) AttachContentTickets(design *gamedata.GachaContentTicketDesign) error {
|
||||
if design == nil {
|
||||
return errors.New("mail: nil content tickets")
|
||||
}
|
||||
s.contentTickets = design
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachCostumeRewards enables ElementType 11 mail attachments. The catalog is
|
||||
// GameData-backed and the collection ledger makes repeated MailOpen calls safe.
|
||||
func (s *Service) AttachCostumeRewards(collection *player.CollectionStore, design player.CostumeDesignSource) error {
|
||||
@@ -576,7 +587,7 @@ func (s *Service) open(request []byte) ([]byte, error) {
|
||||
// ItemDBInfo would make the local state and client model disagree.
|
||||
return nil, errors.New("mail: my-room trophy rewards require MyRoomTrophyDBInfo")
|
||||
default:
|
||||
if !supportedItemDBInfoReward(reward) {
|
||||
if !s.supportedItemDBInfoReward(reward) {
|
||||
return nil, fmt.Errorf("mail: unsupported reward type %d", reward.Type)
|
||||
}
|
||||
if reward.ID == 0 || reward.Count == 0 {
|
||||
|
||||
@@ -398,7 +398,7 @@ func TestStarterContentTicketOnlyAllowsAuditedSingleUseReward(t *testing.T) {
|
||||
for _, reward := range []struct {
|
||||
id, count uint64
|
||||
valid bool
|
||||
}{{450030, 1, true}, {450029, 1, false}, {450030, 0, false}, {450030, 2, false}} {
|
||||
}{{450030, 1, true}, {450029, 1, true}, {450030, 0, false}, {450030, 2, false}} {
|
||||
seed := &Starter{Version: "2.35.10", MailCount: 2, MaxMailID: 13, Mails: []MailDBInfo{{
|
||||
MailID: 13, MailType: 2, ExpiresAt: 100, SentAt: 10,
|
||||
RewardTypes: []uint64{19}, RewardIDs: []uint64{reward.id}, RewardCounts: []uint64{reward.count},
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package missions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
)
|
||||
|
||||
// These values are Define_MissionConditionType protocol enums, not mission IDs.
|
||||
const (
|
||||
ConditionConnect uint64 = 0
|
||||
ConditionMonsterKill uint64 = 2
|
||||
ConditionGachaBuy uint64 = 9
|
||||
)
|
||||
|
||||
// RecordEvent applies an authoritative gameplay count to every matching regular
|
||||
// mission. The caller supplies quest unlock state; locked and scheduled event
|
||||
// missions cannot become eligible merely because they share the condition.
|
||||
func (s *Service) RecordEvent(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.rolloverLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.recordEventLocked(conditionType, subType, count, unlocked)
|
||||
}
|
||||
|
||||
// RecordLogin records at most one connection per daily period. The existing
|
||||
// progress map stores the period marker, so reconnects and restarts cannot
|
||||
// inflate weekly connection missions and no additional save format is needed.
|
||||
func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.rolloverLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.state.Progress["0/event/connect"] != 0 {
|
||||
return nil
|
||||
}
|
||||
return s.recordEventLocked(ConditionConnect, 0, 1, unlocked)
|
||||
}
|
||||
|
||||
// CompleteSingleTargetEvent covers evidence of at least one occurrence, without
|
||||
// inventing a count or subtype for richer missions (e.g. a monster encounter win).
|
||||
func (s *Service) CompleteSingleTargetEvent(conditionType uint64, unlocked func(uint64, uint64) bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.rolloverLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
next := cloneSnapshot(s.state)
|
||||
for key, c := range s.design.Conditions {
|
||||
if key.GroupType > 1 || c.Type != conditionType || c.SubType != 0 || c.TargetValue != 1 || len(c.Params) != 0 {
|
||||
continue
|
||||
}
|
||||
if (c.UnlockPack != 0 || c.UnlockQuest != 0) && (unlocked == nil || !unlocked(c.UnlockPack, c.UnlockQuest)) {
|
||||
continue
|
||||
}
|
||||
name := missionName(key)
|
||||
if next.Progress[name] >= 1 {
|
||||
continue
|
||||
}
|
||||
next.Progress[name] = 1
|
||||
s.applyCompletionDependencies(&next, key)
|
||||
}
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func (s *Service) recordEventLocked(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error {
|
||||
if count == 0 {
|
||||
return errors.New("missions: event count is zero")
|
||||
}
|
||||
next := cloneSnapshot(s.state)
|
||||
var keys []gamedata.MissionKey
|
||||
for key, condition := range s.design.Conditions {
|
||||
if key.GroupType > 1 || condition.Type != conditionType || len(condition.Params) != 0 {
|
||||
continue
|
||||
}
|
||||
if condition.SubType != 0 && ((condition.SubTypeComparison == 0 && condition.SubType != subType) || (condition.SubTypeComparison == 1 && subType < condition.SubType)) {
|
||||
continue
|
||||
}
|
||||
if (condition.UnlockPack != 0 || condition.UnlockQuest != 0) && (unlocked == nil || !unlocked(condition.UnlockPack, condition.UnlockQuest)) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
a, b := keys[i], keys[j]
|
||||
if a.GroupType != b.GroupType {
|
||||
return a.GroupType < b.GroupType
|
||||
}
|
||||
if a.GroupID != b.GroupID {
|
||||
return a.GroupID < b.GroupID
|
||||
}
|
||||
return a.ID < b.ID
|
||||
})
|
||||
for _, key := range keys {
|
||||
name := missionName(key)
|
||||
target := s.design.Conditions[key].TargetValue
|
||||
if target == 0 {
|
||||
target = 1
|
||||
}
|
||||
before := next.Progress[name]
|
||||
if before >= target {
|
||||
continue
|
||||
}
|
||||
increment := count
|
||||
if increment > target-before {
|
||||
increment = target - before
|
||||
}
|
||||
next.Progress[name] = before + increment
|
||||
if next.Progress[name] == target {
|
||||
s.applyCompletionDependencies(&next, key)
|
||||
}
|
||||
}
|
||||
if conditionType == ConditionConnect {
|
||||
next.Progress["0/event/connect"] = 1
|
||||
}
|
||||
return s.commit(next)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package missions
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/mail"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEventsFollowConditionsAndLoginPersistsAcrossRestart(t *testing.T) {
|
||||
daily := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 9876}
|
||||
weekly := gamedata.MissionKey{GroupType: 1, GroupID: 66, ID: 7654}
|
||||
gacha := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 8765}
|
||||
locked := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 8654}
|
||||
design := &gamedata.MissionDesign{Missions: map[gamedata.MissionKey][]gamedata.Reward{daily: nil, weekly: nil, gacha: nil, locked: nil}, Conditions: map[gamedata.MissionKey]gamedata.MissionCondition{
|
||||
daily: {Type: ConditionConnect, TargetValue: 1}, weekly: {Type: ConditionConnect, TargetValue: 3}, gacha: {Type: ConditionGachaBuy, SubType: 5, SubTypeComparison: 1, TargetValue: 7}, locked: {Type: ConditionGachaBuy, UnlockPack: 21, UnlockQuest: 4, TargetValue: 1},
|
||||
}}
|
||||
store := stateio.NewMemory()
|
||||
inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(store, design, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
s.now = func() time.Time { return now }
|
||||
if err := s.RecordLogin(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err = Open(store, design, inv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.now = func() time.Time { return now }
|
||||
if err := s.RecordLogin(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.state.Progress[missionName(weekly)] != 1 {
|
||||
t.Fatal("reconnect incremented weekly login")
|
||||
}
|
||||
if err := s.RecordEvent(ConditionGachaBuy, 4, 20, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.state.Progress[missionName(gacha)] != 0 || s.state.Progress[missionName(locked)] != 0 {
|
||||
t.Fatal("subtype/locked mission matched")
|
||||
}
|
||||
if err := s.RecordEvent(ConditionGachaBuy, 6, 3, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.state.Progress[missionName(gacha)] != 3 {
|
||||
t.Fatal("event did not use count")
|
||||
}
|
||||
if err := s.RecordEvent(ConditionGachaBuy, 6, 100, func(pack, quest uint64) bool { return pack == 21 && quest == 4 }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.state.Progress[missionName(gacha)] != 7 || s.state.Progress[missionName(locked)] != 1 {
|
||||
t.Fatal("target cap/unlock did not apply")
|
||||
}
|
||||
wallet, err := player.OpenWallet(store, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MaxMailID: 100, MailCount: 1}, inv, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.AttachMail(mailbox); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.AddDate(0, 0, 1)
|
||||
if err := s.RecordLogin(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Monday resets weekly; other days increment its connection count.
|
||||
want := uint64(2)
|
||||
if now.Weekday() == time.Monday {
|
||||
want = 1
|
||||
}
|
||||
if s.state.Progress[missionName(weekly)] != want {
|
||||
t.Fatalf("weekly progress=%d want=%d", s.state.Progress[missionName(weekly)], want)
|
||||
}
|
||||
}
|
||||
@@ -431,8 +431,8 @@ func (s *CollectionStore) GachaSelectionChangeCounts() []GachaSelectionChangeCou
|
||||
}
|
||||
|
||||
func (s *CollectionStore) SetPreview(eventIndex uint64, costumeIDs []uint64) error {
|
||||
if eventIndex == 0 || len(costumeIDs) != 10 {
|
||||
return errors.New("player: infinite preview requires an event index and ten costumes")
|
||||
if eventIndex == 0 || len(costumeIDs) == 0 {
|
||||
return errors.New("player: infinite preview requires an event index and costumes")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -452,7 +452,7 @@ func (s *CollectionStore) LockPreview(eventIndex uint64) error {
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.data.LatestPreview) != 10 || s.data.PreviewEventIndex == 0 {
|
||||
if len(s.data.LatestPreview) == 0 || s.data.PreviewEventIndex == 0 {
|
||||
return errors.New("player: no complete infinite gacha preview to lock")
|
||||
}
|
||||
if eventIndex != s.data.PreviewEventIndex {
|
||||
|
||||
@@ -307,6 +307,9 @@ func TestCurrentHealthGrowthAndImmortalClearPersistedInjury(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77)
|
||||
target, _, _ := wire.Varint(request, 2)
|
||||
candidate, _ := characters.Find(target)
|
||||
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}})
|
||||
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -387,6 +390,9 @@ func TestCurrentHealthRetainsSavedCharacterHPWithoutSeparateEntryAcrossReopen(t
|
||||
// Revival must write the restored value, since deleting the entry alone
|
||||
// would expose the zero HP in the owned character record again.
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 78)
|
||||
target, _, _ := wire.Varint(request, 2)
|
||||
candidate, _ := characters.Find(target)
|
||||
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}})
|
||||
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -38,6 +39,8 @@ type CharacterStore struct {
|
||||
maxHealth func(Character) (uint64, error)
|
||||
promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error)
|
||||
talentGrowth *gamedata.TalentGrowthDesign
|
||||
immortal *gamedata.ImmortalDesign
|
||||
immortalReplies map[string]talentUpgradeReply
|
||||
sessionID string
|
||||
talentReplies map[string]map[string]talentUpgradeReply
|
||||
talentApplied map[string]talentUpgradeReply
|
||||
@@ -59,6 +62,14 @@ func (s *CharacterStore) AttachWallet(wallet *Wallet) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) AttachImmortalDesign(design *gamedata.ImmortalDesign) error {
|
||||
if design == nil {
|
||||
return errors.New("player: nil immortal design")
|
||||
}
|
||||
s.immortal = design
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) AttachTalentGrowth(design *gamedata.TalentGrowthDesign) error {
|
||||
if design == nil {
|
||||
return errors.New("player: nil talent growth design")
|
||||
@@ -481,6 +492,17 @@ func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error)
|
||||
if err != nil || !present || seq == 0 {
|
||||
return 0, nil, true, errors.New("player: CharImmortal missing sequence")
|
||||
}
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||
s.mu.Lock()
|
||||
key := fmt.Sprintf("%s:%d", s.sessionID, seq)
|
||||
prior, already := s.immortalReplies[key]
|
||||
s.mu.Unlock()
|
||||
if already {
|
||||
if prior.Digest != digest {
|
||||
return 0, nil, true, errors.New("player: immortal replay payload changed")
|
||||
}
|
||||
return prior.Code, append([]byte(nil), prior.Body...), true, nil
|
||||
}
|
||||
var indices []uint64
|
||||
err = wire.Walk(request, func(field wire.Field) error {
|
||||
if field.Number != 2 {
|
||||
@@ -516,6 +538,12 @@ func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: CharImmortal unknown character %d", index)
|
||||
}
|
||||
if character.HP != 0 {
|
||||
return 0, nil, true, errors.New("player: immortal requires a defeated character")
|
||||
}
|
||||
if !s.immortal.CanRestore(character.ID, character.TalentLevel) {
|
||||
return 0, nil, true, errors.New("player: character has no supported immortal talent at current level")
|
||||
}
|
||||
maximum, err := s.MaxHealth(index)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
@@ -532,6 +560,15 @@ func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error)
|
||||
return 0, nil, true, err
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.immortalReplies == nil {
|
||||
s.immortalReplies = map[string]talentUpgradeReply{}
|
||||
}
|
||||
if len(s.immortalReplies) >= 1024 {
|
||||
s.immortalReplies = map[string]talentUpgradeReply{}
|
||||
}
|
||||
s.immortalReplies[key] = talentUpgradeReply{Digest: digest, Code: 96, Body: append([]byte(nil), response...)}
|
||||
s.mu.Unlock()
|
||||
return 96, response, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.SetCurrentHealth(owned[0].InvenIndex, 0)
|
||||
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{6010: 42}, FullRestore: map[[2]uint64]bool{{42, 1}: true}})
|
||||
packed := binary.AppendUvarint(nil, owned[0].InvenIndex)
|
||||
request := wire.AppendBytes(wire.AppendVarint(nil, 1, 23), 2, packed)
|
||||
code, response, ok, err := characters.Handle("/CharImmortal", request)
|
||||
@@ -167,6 +169,8 @@ func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) {
|
||||
if hp, _, _ := wire.Varint(grown, 3); hp != 513 {
|
||||
t.Fatalf("grown HP=%d want 513", hp)
|
||||
}
|
||||
characters.SetCurrentHealth(77, 0)
|
||||
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{350: 42}, FullRestore: map[[2]uint64]bool{{42, 0}: true}})
|
||||
immortal := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77)
|
||||
_, response, _, err = characters.Handle("/CharImmortal", immortal)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImmortalRequiresDesignedDeadTalentAndReplaysSameSequence(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
inv, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 999, HP: 0, Level: 1, TalentLevel: 3}}, inv, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.maxHealth = func(Character) (uint64, error) { return 700, nil }
|
||||
s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 3}: true}})
|
||||
s.BeginSession("a")
|
||||
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77)
|
||||
_, body, _, err := s.Handle("/CharImmortal", req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, again, _, err := s.Handle("/CharImmortal", req)
|
||||
if err != nil || !bytes.Equal(body, again) {
|
||||
t.Fatalf("replay err=%v", err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 13), 2, 77)); err == nil {
|
||||
t.Fatal("alive new request restored")
|
||||
}
|
||||
s.SetCurrentHealth(77, 0)
|
||||
s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 2}: true}})
|
||||
if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 14), 2, 77)); err == nil {
|
||||
t.Fatal("wrong talent level revived")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInventorySlotChargesDesignedCurrencyAndReplays(t *testing.T) {
|
||||
for _, typ := range []uint64{2, 3, 4} {
|
||||
t.Run(string(rune('0'+typ)), func(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
wallet, err := OpenWallet(store, Currency{Jewelry: 1000, FreeJewelry: 1000, Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := gamedata.InventorySlotRule{Default: 10, Maximum: 20, PriceType: typ, BasePrice: 30, MaxPrice: 100}
|
||||
s, err := OpenInventorySlots(store, &gamedata.InventorySlotDesign{Items: rule, Storage: rule, Equipment: rule, EquipmentStorage: rule}, InventorySlotCounts{10, 10, 10, 10}, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.BeginSession("test")
|
||||
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 2)
|
||||
if _, _, _, err = s.Handle("/InvenAddSlot", req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = s.Handle("/InvenAddSlot", req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := wallet.Snapshot()
|
||||
want := Currency{Jewelry: 1000, FreeJewelry: 1000, Gold: 1000}
|
||||
switch typ {
|
||||
case 2:
|
||||
want.Jewelry -= 90
|
||||
case 3:
|
||||
want.FreeJewelry -= 90
|
||||
case 4:
|
||||
want.Gold -= 90
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("currency=%+v want=%+v", got, want)
|
||||
}
|
||||
if s.state.Items != 12 {
|
||||
t.Fatal("replay expanded slots twice")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -207,7 +207,18 @@ func (s *InventorySlots) Handle(path string, request []byte) (int, []byte, bool,
|
||||
return 0, nil, true, err
|
||||
}
|
||||
identity := fmt.Sprintf("inventory-slot:%s:%s:%d", path, s.sessionID, seq)
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, cost); err != nil {
|
||||
var chargeErr error
|
||||
switch rule.PriceType {
|
||||
case 2:
|
||||
_, chargeErr = s.wallet.SpendJewelryOnce(identity, cost)
|
||||
case 3:
|
||||
_, chargeErr = s.wallet.SpendFreeJewelryOnce(identity, cost)
|
||||
case 4:
|
||||
_, chargeErr = s.wallet.SpendGoldOnce(identity, cost)
|
||||
default:
|
||||
chargeErr = errors.New("unsupported slot currency")
|
||||
}
|
||||
if err := chargeErr; err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: inventory slot price: %w", err)
|
||||
}
|
||||
next := s.state
|
||||
@@ -229,7 +240,7 @@ func (s *InventorySlots) Handle(path string, request []byte) (int, []byte, bool,
|
||||
}
|
||||
|
||||
func inventorySlotPrice(rule gamedata.InventorySlotRule, current, count uint64) (uint64, error) {
|
||||
if rule.PriceType != 4 || count == 0 || current < rule.Default || current > rule.Maximum || count > rule.Maximum-current {
|
||||
if (rule.PriceType != 2 && rule.PriceType != 3 && rule.PriceType != 4) || count == 0 || current < rule.Default || current > rule.Maximum || count > rule.Maximum-current {
|
||||
return 0, errors.New("player: invalid inventory slot price request")
|
||||
}
|
||||
var total uint64
|
||||
|
||||
@@ -63,6 +63,7 @@ type Character struct {
|
||||
|
||||
type Starter struct {
|
||||
Version string `json:"version"`
|
||||
CookingRecipes []uint64 `json:"cooking_recipes,omitempty"`
|
||||
Items []Item `json:"items"`
|
||||
Costumes []Costume `json:"costumes"`
|
||||
Characters []Character `json:"characters"`
|
||||
@@ -89,6 +90,13 @@ func (s *Starter) Validate() error {
|
||||
if s == nil || s.Version != versionconfig.State() {
|
||||
return errors.New("player: wrong starter version")
|
||||
}
|
||||
seenRecipes := map[uint64]bool{}
|
||||
for _, id := range s.CookingRecipes {
|
||||
if id == 0 || id > 0x7fffffff || seenRecipes[id] {
|
||||
return errors.New("player: invalid initial cooking recipe")
|
||||
}
|
||||
seenRecipes[id] = true
|
||||
}
|
||||
for _, item := range s.Items {
|
||||
if item.ID == 0 || item.Count == 0 {
|
||||
return errors.New("player: invalid item")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package readonly
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestRootSortUsesVersionedServerOrdering(t *testing.T) {
|
||||
seed := &Seed{Version: StateVersion(), Responses: map[string]Response{"/RootSortIdInfo": {PacketCode: 367, Fields: []Field{
|
||||
{Number: 1, Type: 2, Fields: []Field{{Number: 1, Type: 0, Varint: 4}, {Number: 2, Type: 0, Varint: 907}, {Number: 3, Type: 0, Varint: 8}}},
|
||||
}}}}
|
||||
path := filepath.Join(t.TempDir(), "readonly.json")
|
||||
if err := seed.Write(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, payload, handled, err := loaded.Handle("/RootSortIdInfo", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil || !handled || code != 367 {
|
||||
t.Fatalf("response: %d %v %v", code, handled, err)
|
||||
}
|
||||
entry, _, err := wire.Bytes(payload, 1)
|
||||
id, _, _ := wire.Varint(entry, 2)
|
||||
order, _, _ := wire.Varint(entry, 3)
|
||||
if err != nil || id != 907 || order != 8 {
|
||||
t.Fatalf("ordering id=%d sort=%d err=%v", id, order, err)
|
||||
}
|
||||
}
|
||||
@@ -3,55 +3,88 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// Season is the named semantic form of Proto.Net.SeasonInfo.
|
||||
type Season struct {
|
||||
ID uint64
|
||||
StartMilliseconds uint64
|
||||
EndMilliseconds uint64
|
||||
RankRewardGroupID uint64
|
||||
ID uint64 `json:"id"`
|
||||
StartMilliseconds uint64 `json:"start_milliseconds"`
|
||||
EndMilliseconds uint64 `json:"end_milliseconds"`
|
||||
RankRewardGroupID uint64 `json:"rank_reward_group_id"`
|
||||
Error bool `json:"error,omitempty"`
|
||||
Return bool `json:"return,omitempty"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
ID uint64
|
||||
Current Season
|
||||
Next Season
|
||||
ID uint64 `json:"id"`
|
||||
Current Season `json:"current"`
|
||||
Next Season `json:"next"`
|
||||
}
|
||||
|
||||
type RegularSeason struct {
|
||||
ContentID uint64
|
||||
Season uint64
|
||||
ContentID uint64 `json:"content_id"`
|
||||
Season uint64 `json:"season"`
|
||||
}
|
||||
|
||||
// Service contains the configured regular-content calendar for this fixed
|
||||
// client/GameData version. It is server configuration, not a captured packet:
|
||||
// Handle encodes every protobuf field from these named values.
|
||||
type Service struct {
|
||||
CalculateMilliseconds uint64
|
||||
Contents []Content
|
||||
Regular []RegularSeason
|
||||
Version string `json:"version"`
|
||||
CalculateMilliseconds uint64 `json:"calculate_milliseconds"`
|
||||
Contents []Content `json:"contents"`
|
||||
Regular []RegularSeason `json:"regular"`
|
||||
}
|
||||
|
||||
func Current() *Service {
|
||||
return &Service{
|
||||
CalculateMilliseconds: 32400000,
|
||||
Contents: []Content{
|
||||
{ID: 1, Current: Season{160, 1789344000000, 1789916399000, 1}, Next: Season{161, 1789948800000, 1790521199000, 1}},
|
||||
{ID: 2, Current: Season{58, 1755129600000, 253370678399000, 1}, Next: Season{58, 1755129600000, 253370678399000, 1}},
|
||||
{ID: 3, Current: Season{129, 1789657200000, 1790262000000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}},
|
||||
{ID: 4, Current: Season{862, 1789830000000, 1789916400000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}},
|
||||
{ID: 5, Current: Season{29, 1787788800000, 1790089199000, 1}, Next: Season{30, 1790121600000, 1791385199000, 1}},
|
||||
{ID: 6, Current: Season{26, 1787788800000, 1788361199000, 1}, Next: Season{27, 1790121600000, 1790693999000, 1}},
|
||||
{ID: 7, Current: Season{14, 1788220800000, 1790812800000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}},
|
||||
{ID: 8, Current: Season{42, 1789603200000, 1790089199000, 1}, Next: Season{43, 1790121600000, 1790780399000, 1}},
|
||||
{ID: 9, Current: Season{2, 1787788800000, 1792594799000, 1}, Next: Season{999999, 253370764800000, 253370764800000, 1}},
|
||||
},
|
||||
Regular: []RegularSeason{{1, 5}, {2, 6}, {5, 5}, {8, 0}, {9, 0}},
|
||||
// Load reads this server's versioned calendar policy. It does not infer live
|
||||
// official seasons from today's clock or from static reward design tables.
|
||||
func Load(path string) (*Service, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("schedule: read seed: %w", err)
|
||||
}
|
||||
var s Service
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return nil, fmt.Errorf("schedule: decode seed: %w", err)
|
||||
}
|
||||
if err := s.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (s *Service) Validate() error {
|
||||
if s == nil || s.Version != versionconfig.State() || s.CalculateMilliseconds == 0 || s.CalculateMilliseconds > math.MaxInt32 || len(s.Contents) == 0 {
|
||||
return errors.New("schedule: invalid calendar version/calculation interval")
|
||||
}
|
||||
ids := map[uint64]bool{}
|
||||
for _, content := range s.Contents {
|
||||
if content.ID == 0 || content.ID > math.MaxInt32 || ids[content.ID] {
|
||||
return errors.New("schedule: invalid/duplicate content identity")
|
||||
}
|
||||
ids[content.ID] = true
|
||||
for _, season := range []Season{content.Current, content.Next} {
|
||||
if season.ID == 0 || season.ID > math.MaxInt32 || season.StartMilliseconds == 0 || season.StartMilliseconds > season.EndMilliseconds || season.EndMilliseconds > math.MaxInt64 || season.RankRewardGroupID > math.MaxInt32 {
|
||||
return errors.New("schedule: invalid season")
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, regular := range s.Regular {
|
||||
if !ids[regular.ContentID] || seen[regular.ContentID] || regular.Season > math.MaxInt32 {
|
||||
return errors.New("schedule: invalid regular season")
|
||||
}
|
||||
seen[regular.ContentID] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
@@ -86,6 +119,12 @@ func encodeSeason(season Season) []byte {
|
||||
result := wire.AppendVarint(nil, 1, season.ID)
|
||||
result = wire.AppendVarint(result, 2, season.StartMilliseconds)
|
||||
result = wire.AppendVarint(result, 3, season.EndMilliseconds)
|
||||
if season.Error {
|
||||
result = wire.AppendVarint(result, 4, 1)
|
||||
}
|
||||
if season.Return {
|
||||
result = wire.AppendVarint(result, 5, 1)
|
||||
}
|
||||
if season.RankRewardGroupID != 0 {
|
||||
result = wire.AppendVarint(result, 6, season.RankRewardGroupID)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestCalendarReloadsChangedIDsAndTimesFromFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "schedule.json")
|
||||
for _, revision := range []struct {
|
||||
id, start, end uint64
|
||||
raw string
|
||||
}{
|
||||
{709, 101, 303, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":709,"start_milliseconds":101,"end_milliseconds":303},"next":{"id":710,"start_milliseconds":401,"end_milliseconds":501}}],"regular":[{"content_id":81,"season":9}]}`},
|
||||
{811, 701, 903, `{"version":"2.35.10","calculate_milliseconds":17,"contents":[{"id":81,"current":{"id":811,"start_milliseconds":701,"end_milliseconds":903},"next":{"id":812,"start_milliseconds":1001,"end_milliseconds":1201}}],"regular":[{"content_id":81,"season":11}]}`},
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(revision.raw), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, response, _, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, _, _ := wire.Bytes(response, 2)
|
||||
current, _, _ := wire.Bytes(content, 2)
|
||||
id, _, _ := wire.Varint(current, 1)
|
||||
start, _, _ := wire.Varint(current, 2)
|
||||
end, _, _ := wire.Varint(current, 3)
|
||||
if id != revision.id || start != revision.start || end != revision.end {
|
||||
t.Fatalf("season=%d times=%d/%d", id, start, end)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(`{"version":"2.35.10","calculate_milliseconds":1,"contents":[{"id":1,"current":{"id":2,"start_milliseconds":4,"end_milliseconds":3}}]}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatal("reversed date accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersion23510ContainsRequiredContentSix(t *testing.T) {
|
||||
service := Current()
|
||||
service, err := Load("../../../seed/v2_35_10/schedule.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, response, handled, err := service.Handle("/ScheduleInfo", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil || !handled || code != 117 {
|
||||
t.Fatalf("schedule code=%d handled=%v err=%v", code, handled, err)
|
||||
@@ -32,7 +74,7 @@ func TestVersion23510ContainsRequiredContentSix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScheduleRejectsMissingSequence(t *testing.T) {
|
||||
if _, _, handled, err := Current().Handle("/ScheduleInfo", nil); !handled || err == nil {
|
||||
if _, _, handled, err := (&Service{}).Handle("/ScheduleInfo", nil); !handled || err == nil {
|
||||
t.Fatalf("missing sequence handled=%v err=%v", handled, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
|
||||
if position, ok := s.state.Position(); ok && (position.PackID != pack || position.Position.MapID != obj.MapID) {
|
||||
return nil, fmt.Errorf("%w: field object outside current map", ErrInvalidRequest)
|
||||
}
|
||||
if obj.Type < 1 || obj.Type > 3 || obj.BuffID != 0 || obj.MonsterID != 0 || obj.QuestID != 0 || len(obj.Rewards) == 0 {
|
||||
if obj.BuffID != 0 || obj.MonsterID != 0 || obj.QuestID != 0 || len(obj.Rewards) == 0 {
|
||||
return nil, fmt.Errorf("%w: unsupported field object reward graph/reset", ErrInvalidRequest)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,28 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
var itemRewards []gamedata.BattleReward
|
||||
var equipmentRewards []player.Equipment
|
||||
// Validate every branch's type and quantity before drawing or writing a
|
||||
// receipt. LoadFieldObjects validates every equipment option tree, including
|
||||
// branches with zero weight, before installing the catalog.
|
||||
for _, r := range obj.Rewards {
|
||||
if r.Count == 0 || r.Count > uint64(^uint32(0)>>1) {
|
||||
return nil, fmt.Errorf("world: invalid field reward count")
|
||||
}
|
||||
switch r.Type {
|
||||
case 2, 3, 4, 12, 20:
|
||||
case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29:
|
||||
if r.ID == 0 {
|
||||
return nil, fmt.Errorf("world: invalid field item")
|
||||
}
|
||||
case 10:
|
||||
if r.ID == 0 || r.Count > 100 || s.equipment == nil || design.Equipment == nil {
|
||||
return nil, fmt.Errorf("world: invalid field equipment")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type)
|
||||
}
|
||||
}
|
||||
selected, err := obj.Draw()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -167,6 +189,27 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
|
||||
return nil, fmt.Errorf("world: invalid field item")
|
||||
}
|
||||
itemRewards = append(itemRewards, r)
|
||||
case 10:
|
||||
if s.equipment == nil || design.Equipment == nil || r.Count > 100 || len(equipmentRewards)+int(r.Count) > 100 {
|
||||
return nil, fmt.Errorf("world: field equipment reward unavailable")
|
||||
}
|
||||
for n := uint64(0); n < r.Count; n++ {
|
||||
main, sub, private, e := design.Equipment.RollOptions(r.ID)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
entry := player.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}}
|
||||
for _, option := range main {
|
||||
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
|
||||
}
|
||||
for _, option := range sub {
|
||||
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
|
||||
}
|
||||
if private != nil {
|
||||
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
|
||||
}
|
||||
equipmentRewards = append(equipmentRewards, entry)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type)
|
||||
}
|
||||
@@ -179,6 +222,12 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, entry := range equipmentRewards {
|
||||
equipmentRewards[i], err = s.equipment.GrantGeneratedOnce(fmt.Sprintf("%s:equipment:%d", identity, i), entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err = s.state.MarkFieldRewardOpened(pack, id, period); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -189,5 +238,8 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
|
||||
for _, item := range items {
|
||||
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
|
||||
}
|
||||
for _, entry := range equipmentRewards {
|
||||
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry))
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
@@ -9,9 +9,199 @@ import (
|
||||
"bd2server/internal/server/wire"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fieldEquipmentCatalog(t *testing.T) *gamedata.EquipmentGachaCatalog {
|
||||
t.Helper()
|
||||
options := []gamedata.OptionGroup{{ID: 31, Choices: []gamedata.WeightedOption{{ID: 4, Weight: 1}}}}
|
||||
d, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 1, GachaSubType: 3, TenTimeGachaID: 2}, 2, 1,
|
||||
&gamedata.FirstGachaRewardGroup{ID: 3, DropType: 1, DropCount: 1, Entries: []gamedata.FirstGachaRewardEntry{{ItemType: 10, ItemID: 77, Count: 1, Weight: 1}}}, nil,
|
||||
map[uint64]gamedata.EquipmentDesign{77: {ID: 77, Grade: 3, Main: options, Sub: options, Private: options}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.EquipmentCatalog()
|
||||
}
|
||||
|
||||
func TestFieldEquipmentTransactionRollbackAndRetry(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { repo.Close() }()
|
||||
store := &fieldRewardFailStore{Repository: repo}
|
||||
s := testService()
|
||||
s.state, err = progress.OpenStore(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.state.SetActivePackID(21); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.wallet, err = player.OpenWallet(store, player.Currency{FreeJewelry: 100})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.inventory, err = player.OpenInventory(store, s.starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.equipment, err = player.OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.wallet.GrantQuestOnce("baseline", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obj := gamedata.FieldRewardObject{ID: 1001, MapID: 211, GroupID: 101, ResetType: 1, DropType: 1, Ratios: []uint64{100, 100, 100}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}, {Type: 9, ID: 88, Count: 1}, {Type: 10, ID: 77, Count: 2}}}
|
||||
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: obj}, Equipment: fieldEquipmentCatalog(t)}})
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.fail = true
|
||||
if _, err := s.openFieldObject(21, 101, 1001); err == nil {
|
||||
t.Fatal("failed marker accepted")
|
||||
}
|
||||
if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.Repository = repo
|
||||
store.fail = false
|
||||
s.state, err = progress.OpenStore(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.wallet, err = player.OpenWallet(store, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.inventory, err = player.OpenInventory(store, s.starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.equipment, err = player.OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opened, err := s.state.FieldRewardOpened(21, 1001, "once")
|
||||
if err != nil || opened || len(s.equipment.All()) != 0 || len(s.inventory.All()) != 0 || s.wallet.Snapshot().FreeJewelry != 100 {
|
||||
t.Fatalf("partial transaction: opened=%v equipment=%+v items=%+v wallet=%+v err=%v", opened, s.equipment.All(), s.inventory.All(), s.wallet.Snapshot(), err)
|
||||
}
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.openFieldObject(21, 101, 1001); err != nil {
|
||||
op.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.equipment.All()) != 2 || len(s.inventory.All()) != 1 || s.wallet.Snapshot().FreeJewelry != 150 {
|
||||
t.Fatal("retry lost grants or grant receipts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldIndependentEquipmentAndBoxPersistAcrossRestart(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
s := testService()
|
||||
var err error
|
||||
s.state, err = progress.OpenStore(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.state.SetActivePackID(21); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.wallet, err = player.OpenWallet(store, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.inventory, err = player.OpenInventory(store, s.starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.equipment, err = player.OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
obj := gamedata.FieldRewardObject{ID: 1001, MapID: 211, GroupID: 101, Type: 8, ResetType: 1, DropType: 1, Ratios: []uint64{100, 100, 100}, Rewards: []gamedata.BattleReward{{Type: 10, ID: 77, Count: 2}, {Type: 9, ID: 88, Count: 1}, {Type: 3, Count: 50}}}
|
||||
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: obj}, Equipment: fieldEquipmentCatalog(t)}})
|
||||
bundle, err := s.openFieldObject(21, 101, 1001)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries := s.equipment.All()
|
||||
if len(entries) != 2 || entries[0].InvenIndex == entries[1].InvenIndex {
|
||||
t.Fatalf("equipment=%+v", entries)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if len(entry.MainOption) != 1 || len(entry.SubOption) != 1 || entry.PrivateOption == nil || !reflect.DeepEqual(entry.Rank, []uint64{0, 0, 0}) {
|
||||
t.Fatalf("missing equipment metadata=%+v", entry)
|
||||
}
|
||||
}
|
||||
var eqCount, boxCount int
|
||||
if err := wire.Walk(bundle, func(f wire.Field) error {
|
||||
if f.Number == 4 {
|
||||
eqCount++
|
||||
if !reflect.DeepEqual(f.Value, player.EquipmentWire(entries[eqCount-1])) {
|
||||
t.Error("equipment wire lost metadata/index")
|
||||
}
|
||||
}
|
||||
if f.Number == 1 {
|
||||
kind, _, _ := wire.Varint(f.Value, 3)
|
||||
if kind == 9 {
|
||||
boxCount++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if eqCount != 2 || boxCount != 1 || s.wallet.Snapshot().FreeJewelry != 50 {
|
||||
t.Fatalf("equipment=%d box=%d wallet=%+v", eqCount, boxCount, s.wallet.Snapshot())
|
||||
}
|
||||
s.state, err = progress.OpenStore(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.equipment, err = player.OpenEquipmentInventory(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.state.SelectQuest(21, progress.QuestSelection{QuestID: 1, Difficulty: 2}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bundle, err = s.openFieldObject(21, 101, 1001)
|
||||
if err != nil || len(bundle) != 0 || !reflect.DeepEqual(entries, s.equipment.All()) {
|
||||
t.Fatalf("duplicate after restart: bundle=%x err=%v", bundle, err)
|
||||
}
|
||||
obj.ID = 1002
|
||||
obj.DropType = 0
|
||||
obj.DropCount = 1
|
||||
obj.Rewards = []gamedata.BattleReward{{Type: 3, Count: 50}, {Type: 12345, ID: 1, Count: 1}}
|
||||
obj.Ratios = []uint64{100, 0}
|
||||
s.fieldObjects[21].Objects[1002] = obj
|
||||
if _, err := s.openFieldObject(21, 101, 1002); err == nil {
|
||||
t.Fatal("malformed unselected branch accepted")
|
||||
}
|
||||
if s.wallet.Snapshot().FreeJewelry != 50 || len(s.equipment.All()) != 2 {
|
||||
t.Fatal("malformed branch changed stores")
|
||||
}
|
||||
}
|
||||
|
||||
type fieldRewardFailStore struct {
|
||||
*accountstate.Repository
|
||||
fail bool
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package world
|
||||
|
||||
import "math"
|
||||
|
||||
// MissionsUnlocked uses the same pack authorization as map entry. A quest
|
||||
// condition requires a committed normal-difficulty clear in that pack.
|
||||
func (s *Service) MissionsUnlocked(pack, quest uint64) bool {
|
||||
if pack == 0 {
|
||||
return quest == 0
|
||||
}
|
||||
if pack > math.MaxInt32 || quest > math.MaxInt32 {
|
||||
return false
|
||||
}
|
||||
if quest == 0 {
|
||||
return s.packUnlocked(int(pack))
|
||||
}
|
||||
return s.state.QuestCleared(int(quest), int(pack), 0)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMissionPackOnlyUnlockUsesTicketAndLevelAuthorization(t *testing.T) {
|
||||
s := testService()
|
||||
s.squadLevel = func() (uint64, error) { return 3, nil }
|
||||
s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, Open: &gamedata.ContentOpenRule{SquadLevel: 4}}
|
||||
if s.MissionsUnlocked(709, 0) || s.MissionsUnlocked(999, 0) || s.MissionsUnlocked(0, 1) {
|
||||
t.Fatal("locked pack/invalid condition opened")
|
||||
}
|
||||
s.squadLevel = func() (uint64, error) { return 4, nil }
|
||||
if !s.MissionsUnlocked(709, 0) || !s.MissionsUnlocked(0, 0) {
|
||||
t.Fatal("authorized pack not opened")
|
||||
}
|
||||
s.storyCatalog.Packs[710] = gamedata.StoryPack{ID: 710, Open: &gamedata.ContentOpenRule{TicketID: 901}}
|
||||
var err error
|
||||
s.inventory, err = player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.MissionsUnlocked(710, 0) {
|
||||
t.Fatal("missing ticket opened")
|
||||
}
|
||||
if _, err := s.inventory.GrantOnce("ticket", []gamedata.BattleReward{{Type: 19, ID: 901, Count: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.MissionsUnlocked(710, 0) {
|
||||
t.Fatal("ticket authorization ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionRewardsGrantChangedQuestAndPackOnlyOnNormal(t *testing.T) {
|
||||
s := testService()
|
||||
s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, Quests: map[int]gamedata.QuestDesign{49: {ID: 49, CollectionRewards: []gamedata.Reward{{Type: 17, ID: 9901, Count: 1}}}}}
|
||||
storage := stateio.NewMemory()
|
||||
var err error
|
||||
s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.wallet, err = player.OpenWallet(storage, player.Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, _, err := s.grantQuestRewards(709, 49, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].ID != 9901 || items[0].Type != 17 {
|
||||
t.Fatalf("items=%+v", items)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func (s *Service) handlePackDocking(path string, request []byte) (int, []byte, b
|
||||
return 72, nil, true, nil
|
||||
}
|
||||
reward := s.packJamDesign.Reward
|
||||
if reward.Type != 3 || reward.ID != 0 || reward.Count == 0 || reward.Count > uint64(^uint32(0)>>1) {
|
||||
if s.packJamDesign.ValidateReward() != nil {
|
||||
return 0, nil, true, fmt.Errorf("world: unsupported pack jam reward")
|
||||
}
|
||||
if _, err := s.wallet.GrantQuestOnce(identity, []gamedata.Reward{reward}); err != nil {
|
||||
|
||||
@@ -95,11 +95,20 @@ func TestPackJamClaimPersistsAndRepeatedDockingSucceeds(t *testing.T) {
|
||||
if s.wallet.Snapshot().FreeJewelry != 150 || s.wallet.WasGranted(packJamIdentity(9999)) {
|
||||
t.Fatal("invalid pack changed wallet")
|
||||
}
|
||||
s.packJamDesign.Reward.Type = 4
|
||||
s.packJamDesign.Reward.Type = 8
|
||||
if _, _, _, err := s.Handle("/PackJamEvent", story); err == nil {
|
||||
t.Fatal("unsupported reward accepted")
|
||||
}
|
||||
if s.wallet.WasGranted(packJamIdentity(21)) {
|
||||
t.Fatal("unsupported reward marked claimed")
|
||||
}
|
||||
s.packJamDesign.Reward.Type = 4
|
||||
s.packJamDesign.Reward.Count = 73
|
||||
code, body, _, err = s.Handle("/PackJamEvent", story)
|
||||
item, _, _ = wire.Bytes(body, 1)
|
||||
typ, _, _ = wire.Varint(item, 3)
|
||||
count, _, _ = wire.Varint(item, 4)
|
||||
if err != nil || code != 72 || typ != 4 || count != 73 || s.wallet.Snapshot().Gold != 73 {
|
||||
t.Fatal("changed reward currency/count ignored")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ func (s *Service) grantPurchaseRewards(identity string, rewards []gamedata.Rewar
|
||||
}
|
||||
var exchanges []gamedata.Reward
|
||||
for _, x := range grant.Exchanges {
|
||||
if x.ExchangeItemType != 20 {
|
||||
if !purchaseCurrency(x.ExchangeItemType) || x.ExchangeItemID != 0 {
|
||||
return nil, fmt.Errorf("world: unsupported purchase costume exchange")
|
||||
}
|
||||
exchanges = append(exchanges, gamedata.Reward{Type: x.ExchangeItemType, ID: x.ExchangeItemID, Count: x.ExchangeCount})
|
||||
@@ -113,14 +113,29 @@ func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) {
|
||||
if _, owned := s.collection.Grant(identity); owned {
|
||||
return []byte{}, nil
|
||||
}
|
||||
if !initial && pack.BuyPrice != 0 {
|
||||
return nil, fmt.Errorf("%w: paid pack purchase type %d is unsupported", ErrInvalidRequest, pack.BuyType)
|
||||
}
|
||||
for _, reward := range buyRewards {
|
||||
if reward.Type != 3 && reward.Type != 4 && reward.Type != 12 && reward.Type != 19 && reward.Type != 11 {
|
||||
if !purchaseCurrency(reward.Type) && reward.Type != 19 && reward.Type != 11 {
|
||||
return nil, fmt.Errorf("world: unsupported pack purchase reward type %d", reward.Type)
|
||||
}
|
||||
}
|
||||
if !initial && pack.BuyPrice != 0 {
|
||||
var err error
|
||||
switch pack.BuyType {
|
||||
case 2:
|
||||
_, err = s.wallet.SpendJewelryOnce(identity+":price", pack.BuyPrice)
|
||||
case 3:
|
||||
_, err = s.wallet.SpendFreeJewelryOnce(identity+":price", pack.BuyPrice)
|
||||
case 4:
|
||||
_, err = s.wallet.SpendGoldOnce(identity+":price", pack.BuyPrice)
|
||||
case 12:
|
||||
_, err = s.wallet.SpendCatalystOnce(identity+":price", pack.BuyPrice)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unsupported pack purchase currency%d", ErrInvalidRequest, pack.BuyType)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
items, err := s.grantPurchaseRewards(identity, buyRewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -133,7 +148,7 @@ func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) {
|
||||
bundle = append(bundle, player.CollectionRewardBundle(s.collection, grant)...)
|
||||
}
|
||||
for _, reward := range buyRewards {
|
||||
if reward.Type == 3 || reward.Type == 4 || reward.Type == 12 {
|
||||
if purchaseCurrency(reward.Type) {
|
||||
currency := wire.AppendVarint(wire.AppendVarint(nil, 3, reward.Type), 4, reward.Count)
|
||||
bundle = wire.AppendBytes(bundle, 1, currency)
|
||||
}
|
||||
@@ -143,3 +158,11 @@ func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) {
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func purchaseCurrency(typ uint64) bool {
|
||||
switch typ {
|
||||
case 2, 3, 4, 12, 20:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -11,6 +11,41 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPaidPackUsesDesignCostAndCurrenciesOnce(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
s := testService()
|
||||
var err error
|
||||
s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.collection, err = player.OpenCollectionStore(storage, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.wallet, err = player.OpenWallet(storage, player.Currency{FreeJewelry: 101})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, BuyType: 3, BuyPrice: 37, BuyRewards: []gamedata.Reward{{Type: 2, Count: 8}, {Type: 20, Count: 11}}}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if _, err := s.purchaseStoryPack(709, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
got := s.wallet.Snapshot()
|
||||
if got.FreeJewelry != 64 || got.Jewelry != 8 || got.Mileage != 11 {
|
||||
t.Fatalf("currency=%+v", got)
|
||||
}
|
||||
s.storyCatalog.Packs[710] = gamedata.StoryPack{ID: 710, BuyType: 3, BuyPrice: 37, BuyRewards: []gamedata.Reward{{Type: 8, ID: 4, Count: 1}}}
|
||||
if _, err := s.purchaseStoryPack(710, false); err == nil {
|
||||
t.Fatal("invalid reward accepted")
|
||||
}
|
||||
if s.wallet.Snapshot().FreeJewelry != 64 {
|
||||
t.Fatal("invalid reward charged")
|
||||
}
|
||||
}
|
||||
|
||||
type purchaseFailStore struct {
|
||||
*accountstate.Repository
|
||||
fail bool
|
||||
|
||||
@@ -71,7 +71,7 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er
|
||||
// Placeholder entries are player-controlled party slots. Their costume
|
||||
// and character cannot be inferred from the design placeholder itself.
|
||||
for _, costume := range formation.StoryCostumes {
|
||||
if costume.CostumeID == 996000 {
|
||||
if s.seed.PlaceholderCostumeID != 0 && costume.CostumeID == s.seed.PlaceholderCostumeID {
|
||||
continue
|
||||
}
|
||||
temporaryMember := false
|
||||
@@ -112,7 +112,7 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er
|
||||
// Fill authored player-controlled slots from the persisted field party,
|
||||
// then the persisted battle party. Never synthesize the placeholder ID.
|
||||
for _, costume := range formation.StoryCostumes {
|
||||
if costume.CostumeID != 996000 || s.decks == nil {
|
||||
if (s.seed.PlaceholderCostumeID == 0 || costume.CostumeID != s.seed.PlaceholderCostumeID) || s.decks == nil {
|
||||
continue
|
||||
}
|
||||
var candidates []uint64
|
||||
|
||||
@@ -23,7 +23,23 @@ import (
|
||||
|
||||
var ErrInvalidRequest = errors.New("world: invalid request")
|
||||
|
||||
type InitialReputation struct {
|
||||
GroupID uint64 `json:"group_id"`
|
||||
State uint64 `json:"state"`
|
||||
ElapsedSeconds uint64 `json:"elapsed_seconds,omitempty"`
|
||||
}
|
||||
type InitialRankStatue struct {
|
||||
ID uint64 `json:"id"`
|
||||
Season uint64 `json:"season"`
|
||||
Error bool `json:"error"`
|
||||
}
|
||||
|
||||
type Seed struct {
|
||||
SquareSceneID uint64 `json:"square_scene_id,omitempty"`
|
||||
InitialReputations []InitialReputation `json:"initial_reputations,omitempty"`
|
||||
InitialRankStatues []InitialRankStatue `json:"initial_rank_statues,omitempty"`
|
||||
// Versioned story slot placeholder; confirmed current CostumeTable row, slot semantics await current capture.
|
||||
PlaceholderCostumeID uint64 `json:"placeholder_costume_id,omitempty"`
|
||||
Version string `json:"version"`
|
||||
PackID int `json:"pack_id"`
|
||||
StartQuestID int `json:"start_quest_id"`
|
||||
@@ -115,7 +131,7 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store,
|
||||
for id := range packs {
|
||||
storyPackIDs = append(storyPackIDs, id)
|
||||
}
|
||||
service.storyRoster, err = gamedata.LoadStoryCharacterCatalog(gameDataRoot, gameDataVersion, storyPackIDs)
|
||||
service.storyRoster, err = gamedata.LoadStoryCharacterCatalog(gameDataRoot, gameDataVersion, storyPackIDs, seed.PlaceholderCostumeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -502,8 +518,12 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.
|
||||
}
|
||||
}
|
||||
}
|
||||
if packID == s.seed.PackID && s.questDifficultyFor(packID, quest) == 0 {
|
||||
for _, reward := range questPictorialItems[quest] {
|
||||
if s.questDifficultyFor(packID, quest) == 0 {
|
||||
quests, known := s.questsFor(packID)
|
||||
if !known {
|
||||
return nil, nil, fmt.Errorf("world: unknown collection reward pack%d", packID)
|
||||
}
|
||||
for _, reward := range quests[quest].CollectionRewards {
|
||||
itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
|
||||
}
|
||||
}
|
||||
@@ -586,17 +606,32 @@ func (s *Service) packInfoFor(packID int) ([]byte, error) {
|
||||
visit := wire.AppendVarint(nil, 5, uint64(packID))
|
||||
return wire.AppendBytes(out, 12, visit), nil
|
||||
}
|
||||
open := wire.AppendVarint(nil, 1, 1)
|
||||
open = wire.AppendVarint(open, 2, 1)
|
||||
out = wire.AppendBytes(out, 9, open)
|
||||
for _, state := range s.seed.InitialReputations {
|
||||
row := wire.AppendVarint(nil, 1, state.GroupID)
|
||||
row = wire.AppendVarint(row, 2, state.State)
|
||||
if state.ElapsedSeconds != 0 {
|
||||
row = wire.AppendVarint(row, 3, state.ElapsedSeconds)
|
||||
}
|
||||
out = wire.AppendBytes(out, 9, row)
|
||||
}
|
||||
visit := wire.AppendVarint(nil, 5, uint64(packID))
|
||||
out = wire.AppendBytes(out, 12, visit)
|
||||
stat := wire.AppendVarint(nil, 1, 3)
|
||||
stat = wire.AppendVarint(stat, 2, 77)
|
||||
stat = wire.AppendVarint(stat, 3, 1)
|
||||
out = wire.AppendBytes(out, 14, stat)
|
||||
reward := wire.AppendVarint(nil, 3, 3)
|
||||
reward = wire.AppendVarint(reward, 4, 150)
|
||||
for _, state := range s.seed.InitialRankStatues {
|
||||
row := wire.AppendVarint(nil, 1, state.ID)
|
||||
row = wire.AppendVarint(row, 2, state.Season)
|
||||
if state.Error {
|
||||
row = wire.AppendVarint(row, 3, 1)
|
||||
}
|
||||
out = wire.AppendBytes(out, 14, row)
|
||||
}
|
||||
if s.packJamDesign == nil {
|
||||
return out, nil
|
||||
}
|
||||
if err := s.packJamDesign.ValidateReward(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reward := wire.AppendVarint(nil, 3, s.packJamDesign.Reward.Type)
|
||||
reward = wire.AppendVarint(reward, 4, s.packJamDesign.Reward.Count)
|
||||
group := wire.AppendBytes(nil, 1, reward)
|
||||
group = wire.AppendBytes(group, 6, reward)
|
||||
return wire.AppendBytes(out, 16, group), nil
|
||||
@@ -653,12 +688,8 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
|
||||
}
|
||||
if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 {
|
||||
rewardCharacter := encodeCharacter(s.seed.RewardCharacter)
|
||||
// Pictorial state: acquired level 1, progress 60.
|
||||
rewardCharacter = wire.AppendBytes(rewardCharacter, 12, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 60))
|
||||
rewards = wire.AppendBytes(rewards, 2, rewardCharacter)
|
||||
costume := encodeCostume(s.seed.RewardCostume)
|
||||
// Pictorial state observed for the starter costume.
|
||||
costume = wire.AppendBytes(costume, 5, wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 116))
|
||||
rewards = wire.AppendBytes(rewards, 3, costume)
|
||||
for _, character := range s.seed.StoryCharacters {
|
||||
view := wire.AppendVarint(nil, 2, character.ID)
|
||||
@@ -705,7 +736,13 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
|
||||
}
|
||||
}
|
||||
if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 {
|
||||
deckIDs := []uint64{s.seed.RewardCharacter.InvenIndex, s.seed.StoryCharacters[0].InvenIndex, s.seed.StoryCharacters[1].InvenIndex, s.seed.StoryCharacters[2].InvenIndex, s.starter.Characters[0].InvenIndex}
|
||||
deckIDs := []uint64{s.seed.RewardCharacter.InvenIndex}
|
||||
for _, character := range s.seed.StoryCharacters {
|
||||
deckIDs = append(deckIDs, character.InvenIndex)
|
||||
}
|
||||
for _, character := range s.starter.Characters {
|
||||
deckIDs = append(deckIDs, character.InvenIndex)
|
||||
}
|
||||
for index, characterID := range deckIDs {
|
||||
deck := wire.AppendVarint(nil, 1, characterID)
|
||||
deck = wire.AppendVarint(deck, 2, ^uint64(0))
|
||||
@@ -776,13 +813,10 @@ func (s *Service) accountPackInfo() []byte {
|
||||
}
|
||||
}
|
||||
}
|
||||
return wire.AppendVarint(out, 5, 3)
|
||||
if s.seed.SquareSceneID != 0 {
|
||||
out = wire.AppendVarint(out, 5, s.seed.SquareSceneID)
|
||||
}
|
||||
|
||||
var questPictorialItems = map[int][]gamedata.Reward{
|
||||
3: {{ID: 2101, Type: 17, Count: 1}},
|
||||
10: {{ID: 2102, Type: 17, Count: 1}},
|
||||
28: {{ID: 2103, Type: 17, Count: 1}},
|
||||
return out
|
||||
}
|
||||
|
||||
// PictorialCharacters hides quest-26 rewards until they are earned, even
|
||||
|
||||
@@ -49,7 +49,11 @@ func attachTestStoryCatalog(s *Service) {
|
||||
}
|
||||
|
||||
func testService() *Service {
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}}
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}}
|
||||
s.seed.SquareSceneID = 3
|
||||
s.seed.InitialReputations = []InitialReputation{{GroupID: 1, State: 1}}
|
||||
s.seed.InitialRankStatues = []InitialRankStatue{{ID: 3, Season: 77, Error: true}}
|
||||
s.packJamDesign = &gamedata.PackJamDesign{Reward: gamedata.Reward{Type: 3, Count: 150}}
|
||||
attachTestStoryCatalog(s)
|
||||
return s
|
||||
}
|
||||
@@ -82,7 +86,7 @@ func TestQuest29EchoesCurrentStoryDeck(t *testing.T) {
|
||||
if _, _, ok, err := decks.Handle("/DeckSave", save); err != nil || !ok {
|
||||
t.Fatalf("deck save ok=%v err=%v", ok, err)
|
||||
}
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state,
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state,
|
||||
starter: &player.Starter{Version: "2.35.10"}, quests: quests, decks: decks}
|
||||
attachTestStoryCatalog(s)
|
||||
request := wire.AppendVarint(nil, 1, 2)
|
||||
@@ -351,7 +355,7 @@ func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) {
|
||||
entry := quests[28]
|
||||
entry.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}, {Type: 10, ID: 10010}}
|
||||
quests[28] = entry
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state,
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state,
|
||||
starter: starter, equipment: equipment, inventory: inventory, quests: quests}
|
||||
attachTestStoryCatalog(s)
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
@@ -398,7 +402,7 @@ func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state,
|
||||
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state,
|
||||
starter: &player.Starter{Version: "2.35.10"}, wallet: wallet, quests: quests}
|
||||
attachTestStoryCatalog(s)
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
|
||||
@@ -3666,6 +3666,32 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"/RootSortIdInfo": {
|
||||
"packet_code": 367,
|
||||
"fields": [
|
||||
{
|
||||
"number": 1,
|
||||
"type": 2,
|
||||
"fields": [
|
||||
{
|
||||
"number": 1,
|
||||
"type": 0,
|
||||
"varint": 2
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"type": 0,
|
||||
"varint": 140
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"type": 0,
|
||||
"varint": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"version": "2.35.10",
|
||||
"calculate_milliseconds": 32400000,
|
||||
"contents": [
|
||||
{
|
||||
"id": 1,
|
||||
"current": {
|
||||
"id": 160,
|
||||
"start_milliseconds": 1789344000000,
|
||||
"end_milliseconds": 1789916399000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 161,
|
||||
"start_milliseconds": 1789948800000,
|
||||
"end_milliseconds": 1790521199000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"current": {
|
||||
"id": 58,
|
||||
"start_milliseconds": 1755129600000,
|
||||
"end_milliseconds": 253370678399000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 58,
|
||||
"start_milliseconds": 1755129600000,
|
||||
"end_milliseconds": 253370678399000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"current": {
|
||||
"id": 129,
|
||||
"start_milliseconds": 1789657200000,
|
||||
"end_milliseconds": 1790262000000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 999999,
|
||||
"start_milliseconds": 253370764800000,
|
||||
"end_milliseconds": 253370764800000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"current": {
|
||||
"id": 862,
|
||||
"start_milliseconds": 1789830000000,
|
||||
"end_milliseconds": 1789916400000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 999999,
|
||||
"start_milliseconds": 253370764800000,
|
||||
"end_milliseconds": 253370764800000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"current": {
|
||||
"id": 29,
|
||||
"start_milliseconds": 1787788800000,
|
||||
"end_milliseconds": 1790089199000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 30,
|
||||
"start_milliseconds": 1790121600000,
|
||||
"end_milliseconds": 1791385199000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"current": {
|
||||
"id": 26,
|
||||
"start_milliseconds": 1787788800000,
|
||||
"end_milliseconds": 1788361199000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 27,
|
||||
"start_milliseconds": 1790121600000,
|
||||
"end_milliseconds": 1790693999000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"current": {
|
||||
"id": 14,
|
||||
"start_milliseconds": 1788220800000,
|
||||
"end_milliseconds": 1790812800000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 999999,
|
||||
"start_milliseconds": 253370764800000,
|
||||
"end_milliseconds": 253370764800000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"current": {
|
||||
"id": 42,
|
||||
"start_milliseconds": 1789603200000,
|
||||
"end_milliseconds": 1790089199000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 43,
|
||||
"start_milliseconds": 1790121600000,
|
||||
"end_milliseconds": 1790780399000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"current": {
|
||||
"id": 2,
|
||||
"start_milliseconds": 1787788800000,
|
||||
"end_milliseconds": 1792594799000,
|
||||
"rank_reward_group_id": 1
|
||||
},
|
||||
"next": {
|
||||
"id": 999999,
|
||||
"start_milliseconds": 253370764800000,
|
||||
"end_milliseconds": 253370764800000,
|
||||
"rank_reward_group_id": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"regular": [
|
||||
{
|
||||
"content_id": 1,
|
||||
"season": 5
|
||||
},
|
||||
{
|
||||
"content_id": 2,
|
||||
"season": 6
|
||||
},
|
||||
{
|
||||
"content_id": 5,
|
||||
"season": 5
|
||||
},
|
||||
{
|
||||
"content_id": 8,
|
||||
"season": 0
|
||||
},
|
||||
{
|
||||
"content_id": 9,
|
||||
"season": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"version": "2.35.10",
|
||||
"cooking_recipes": [101],
|
||||
"items": [
|
||||
{
|
||||
"inven_index": 853258187,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"version": "2.35.10",
|
||||
"pack_id": 21,
|
||||
"placeholder_costume_id": 996000,
|
||||
"square_scene_id": 3,
|
||||
"initial_reputations": [{"group_id": 1, "state": 1}],
|
||||
"initial_rank_statues": [{"id": 3, "season": 77, "error": true}],
|
||||
"start_quest_id": 1,
|
||||
"battle_unlock_quest_id": 26,
|
||||
"reward_character": {
|
||||
|
||||
Reference in New Issue
Block a user