diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index c1217d8..57021ca 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -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{}, ) diff --git a/go/cmd/bd2server/preflight.go b/go/cmd/bd2server/preflight.go index 461a9b5..e2ff0ab 100644 --- a/go/cmd/bd2server/preflight.go +++ b/go/cmd/bd2server/preflight.go @@ -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 diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go index 7930a0b..602afee 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -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) diff --git a/go/internal/server/deck/deck.go b/go/internal/server/deck/deck.go index 5b588e4..7fa83a7 100644 --- a/go/internal/server/deck/deck.go +++ b/go/internal/server/deck/deck.go @@ -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") + } + 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) (*Store, error) { - s, e := NewStore(seed) +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 { diff --git a/go/internal/server/deck/deck_test.go b/go/internal/server/deck/deck_test.go index f11d09c..352d910 100644 --- a/go/internal/server/deck/deck_test.go +++ b/go/internal/server/deck/deck_test.go @@ -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 +}() diff --git a/go/internal/server/deck/preset.go b/go/internal/server/deck/preset.go index a87da8f..f8fab00 100644 --- a/go/internal/server/deck/preset.go +++ b/go/internal/server/deck/preset.go @@ -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 { diff --git a/go/internal/server/deck/preset_test.go b/go/internal/server/deck/preset_test.go index 4f17ccb..2907ff4 100644 --- a/go/internal/server/deck/preset_test.go +++ b/go/internal/server/deck/preset_test.go @@ -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) } diff --git a/go/internal/server/feature/bootstrap.go b/go/internal/server/feature/bootstrap.go index 0d9d46d..ca30538 100644 --- a/go/internal/server/feature/bootstrap.go +++ b/go/internal/server/feature/bootstrap.go @@ -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 } diff --git a/go/internal/server/feature/bootstrap_test.go b/go/internal/server/feature/bootstrap_test.go index a29ca22..8f6da52 100644 --- a/go/internal/server/feature/bootstrap_test.go +++ b/go/internal/server/feature/bootstrap_test.go @@ -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)) diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index 671ada0..22d8556 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -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) } diff --git a/go/internal/server/feature/recipes.go b/go/internal/server/feature/recipes.go new file mode 100644 index 0000000..75664e0 --- /dev/null +++ b/go/internal/server/feature/recipes.go @@ -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 +} diff --git a/go/internal/server/feature/recipes_test.go b/go/internal/server/feature/recipes_test.go new file mode 100644 index 0000000..a04b823 --- /dev/null +++ b/go/internal/server/feature/recipes_test.go @@ -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 } diff --git a/go/internal/server/gacha/first_gacha_test.go b/go/internal/server/gacha/first_gacha_test.go index 80acdec..95d3a0e 100644 --- a/go/internal/server/gacha/first_gacha_test.go +++ b/go/internal/server/gacha/first_gacha_test.go @@ -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) } diff --git a/go/internal/server/gacha/gacha.go b/go/internal/server/gacha/gacha.go index 7e5e27b..dbc9713 100644 --- a/go/internal/server/gacha/gacha.go +++ b/go/internal/server/gacha/gacha.go @@ -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) - products = append(products, struct { - group, id uint64 - bought bool - }{moonriseProductGroupID, moonriseProductID, moonriseBought}) + }{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 + }{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,11 +1287,13 @@ 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 { - continue + 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) diff --git a/go/internal/server/gacha/gacha_test.go b/go/internal/server/gacha/gacha_test.go index c7cbd00..0bdde87 100644 --- a/go/internal/server/gacha/gacha_test.go +++ b/go/internal/server/gacha/gacha_test.go @@ -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) diff --git a/go/internal/server/gacha/legacy_design_test.go b/go/internal/server/gacha/legacy_design_test.go new file mode 100644 index 0000000..7c8a91c --- /dev/null +++ b/go/internal/server/gacha/legacy_design_test.go @@ -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) +} diff --git a/go/internal/server/gacha/login_purchase_count_test.go b/go/internal/server/gacha/login_purchase_count_test.go index 9b79d15..79b0fde 100644 --- a/go/internal/server/gacha/login_purchase_count_test.go +++ b/go/internal/server/gacha/login_purchase_count_test.go @@ -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) diff --git a/go/internal/server/gacha/point_exchange_test.go b/go/internal/server/gacha/point_exchange_test.go index 1fc30d6..c3ae1e0 100644 --- a/go/internal/server/gacha/point_exchange_test.go +++ b/go/internal/server/gacha/point_exchange_test.go @@ -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 { diff --git a/go/internal/server/gacha/schedule_test.go b/go/internal/server/gacha/schedule_test.go index 5f55111..b67be34 100644 --- a/go/internal/server/gacha/schedule_test.go +++ b/go/internal/server/gacha/schedule_test.go @@ -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) } diff --git a/go/internal/server/gamedata/content_ticket.go b/go/internal/server/gamedata/content_ticket.go new file mode 100644 index 0000000..67941b3 --- /dev/null +++ b/go/internal/server/gamedata/content_ticket.go @@ -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() +} diff --git a/go/internal/server/gamedata/cooking_recipes.go b/go/internal/server/gamedata/cooking_recipes.go new file mode 100644 index 0000000..0450ea3 --- /dev/null +++ b/go/internal/server/gamedata/cooking_recipes.go @@ -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 +} diff --git a/go/internal/server/gamedata/cooking_recipes_test.go b/go/internal/server/gamedata/cooking_recipes_test.go new file mode 100644 index 0000000..7362f54 --- /dev/null +++ b/go/internal/server/gamedata/cooking_recipes_test.go @@ -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") + } +} diff --git a/go/internal/server/gamedata/database.go b/go/internal/server/gamedata/database.go index 83e599b..21bdbe9 100644 --- a/go/internal/server/gamedata/database.go +++ b/go/internal/server/gamedata/database.go @@ -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) { diff --git a/go/internal/server/gamedata/database_test.go b/go/internal/server/gamedata/database_test.go index 4dc1723..738544f 100644 --- a/go/internal/server/gamedata/database_test.go +++ b/go/internal/server/gamedata/database_test.go @@ -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) diff --git a/go/internal/server/gamedata/equipment_collaboration.go b/go/internal/server/gamedata/equipment_collaboration.go index 770f2ca..340d91d 100644 --- a/go/internal/server/gamedata/equipment_collaboration.go +++ b/go/internal/server/gamedata/equipment_collaboration.go @@ -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 } diff --git a/go/internal/server/gamedata/equipment_collaboration_test.go b/go/internal/server/gamedata/equipment_collaboration_test.go index 18415ac..ac8aaf9 100644 --- a/go/internal/server/gamedata/equipment_collaboration_test.go +++ b/go/internal/server/gamedata/equipment_collaboration_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/equipment_gacha.go b/go/internal/server/gamedata/equipment_gacha.go index 722b2d3..4cae8a2 100644 --- a/go/internal/server/gamedata/equipment_gacha.go +++ b/go/internal/server/gamedata/equipment_gacha.go @@ -16,11 +16,12 @@ import ( // draws. Scheduled groups and standalone ticket-only draws are both derived // from GameData; the server does not enumerate product IDs in request logic. type EquipmentGachaCatalog struct { - Gachas map[uint64]EquipmentGacha - groups map[uint64]EquipmentGachaGroup - byGacha map[uint64]uint64 - fixed EquipmentFixedDesign - equipment map[uint64]EquipmentDesign + Gachas map[uint64]EquipmentGacha + groups map[uint64]EquipmentGachaGroup + byGacha map[uint64]uint64 + fixed EquipmentFixedDesign + fixedDesigns map[uint64]EquipmentFixedDesign + equipment map[uint64]EquipmentDesign } type EquipmentGacha struct { ID uint64 @@ -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 { - return nil, err + 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 } - 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 = 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 + } + 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: - state.SRCount++ - state.URCount++ + 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 diff --git a/go/internal/server/gamedata/equipment_grades.go b/go/internal/server/gamedata/equipment_grades.go new file mode 100644 index 0000000..f110e86 --- /dev/null +++ b/go/internal/server/gamedata/equipment_grades.go @@ -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 +} diff --git a/go/internal/server/gamedata/equipment_ticket_gacha_test.go b/go/internal/server/gamedata/equipment_ticket_gacha_test.go index e9fc87b..9f993f8 100644 --- a/go/internal/server/gamedata/equipment_ticket_gacha_test.go +++ b/go/internal/server/gamedata/equipment_ticket_gacha_test.go @@ -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) } diff --git a/go/internal/server/gamedata/field_objects.go b/go/internal/server/gamedata/field_objects.go index d5aa736..9166bf7 100644 --- a/go/internal/server/gamedata/field_objects.go +++ b/go/internal/server/gamedata/field_objects.go @@ -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,15 +92,26 @@ 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 { - obj.DropCount = drop[0] + 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 @@ -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 + 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 + } + 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 := rand.Int(rand.Reader, new(big.Int).SetUint64(total)) + value, err := draw(total) if err != nil { return nil, err } - pick := value.Uint64() + 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]) diff --git a/go/internal/server/gamedata/field_objects_test.go b/go/internal/server/gamedata/field_objects_test.go index 55ec026..e3617e2 100644 --- a/go/internal/server/gamedata/field_objects_test.go +++ b/go/internal/server/gamedata/field_objects_test.go @@ -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) } } diff --git a/go/internal/server/gamedata/gacha.go b/go/internal/server/gamedata/gacha.go index 6249b46..70d5174 100644 --- a/go/internal/server/gamedata/gacha.go +++ b/go/internal/server/gamedata/gacha.go @@ -18,24 +18,24 @@ import ( ) const ( - InfiniteGachaID = 9100033 - InfiniteProductGroupID = 1100001 - InfiniteProductID = 9100033 - officialRateScale = 10000 - officialFiveStarRate = 300 - officialFourStarRate = 1400 + officialRateScale = 10000 + officialFiveStarRate = 300 + officialFourStarRate = 1400 ) // InfiniteGachaDesign is the non-account portion of the paid // "infinite reroll" product. The local server deliberately makes final // confirmation free, but still reads the official result pool from GameData. type InfiniteGachaDesign struct { - Count int - CostumeIDs []uint64 // five-star compatibility view - FiveStarIDs []uint64 - FourStarIDs []uint64 - ThreeStarIDs []uint64 - characters map[uint64]CharacterDesign + GachaID, ProductGroupID, ProductID, SaleGroup uint64 + rewardProgram *CostumeRewardGroup + GroupID uint64 + Count int + CostumeIDs []uint64 // five-star compatibility view + FiveStarIDs []uint64 + FourStarIDs []uint64 + ThreeStarIDs []uint64 + characters map[uint64]CharacterDesign } type CharacterDesign struct { @@ -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: - state.CostumeGrade4Count++ - state.CostumeGrade5Count++ + 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) diff --git a/go/internal/server/gamedata/gacha_active_test.go b/go/internal/server/gamedata/gacha_active_test.go index 148ef34..2a3f9cc 100644 --- a/go/internal/server/gamedata/gacha_active_test.go +++ b/go/internal/server/gamedata/gacha_active_test.go @@ -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) } diff --git a/go/internal/server/gamedata/gacha_cash.go b/go/internal/server/gamedata/gacha_cash.go new file mode 100644 index 0000000..97b8f71 --- /dev/null +++ b/go/internal/server/gamedata/gacha_cash.go @@ -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 +} diff --git a/go/internal/server/gamedata/gacha_cash_test.go b/go/internal/server/gamedata/gacha_cash_test.go new file mode 100644 index 0000000..ce182c0 --- /dev/null +++ b/go/internal/server/gamedata/gacha_cash_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/gacha_character.go b/go/internal/server/gamedata/gacha_character.go new file mode 100644 index 0000000..8681bcd --- /dev/null +++ b/go/internal/server/gamedata/gacha_character.go @@ -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 +} diff --git a/go/internal/server/gamedata/gacha_character_test.go b/go/internal/server/gamedata/gacha_character_test.go new file mode 100644 index 0000000..d80760f --- /dev/null +++ b/go/internal/server/gamedata/gacha_character_test.go @@ -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") + } +} diff --git a/go/internal/server/gamedata/gacha_fixed_test.go b/go/internal/server/gamedata/gacha_fixed_test.go index f22058e..34b4bc1 100644 --- a/go/internal/server/gamedata/gacha_fixed_test.go +++ b/go/internal/server/gamedata/gacha_fixed_test.go @@ -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 } diff --git a/go/internal/server/gamedata/gacha_grades.go b/go/internal/server/gamedata/gacha_grades.go new file mode 100644 index 0000000..94e9c3f --- /dev/null +++ b/go/internal/server/gamedata/gacha_grades.go @@ -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 +} diff --git a/go/internal/server/gamedata/gacha_grades_test.go b/go/internal/server/gamedata/gacha_grades_test.go new file mode 100644 index 0000000..6a3f979 --- /dev/null +++ b/go/internal/server/gamedata/gacha_grades_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/gacha_infinite_metadata.go b/go/internal/server/gamedata/gacha_infinite_metadata.go new file mode 100644 index 0000000..786008d --- /dev/null +++ b/go/internal/server/gamedata/gacha_infinite_metadata.go @@ -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 +} diff --git a/go/internal/server/gamedata/gacha_probability_test.go b/go/internal/server/gamedata/gacha_probability_test.go index a68a388..88297e5 100644 --- a/go/internal/server/gamedata/gacha_probability_test.go +++ b/go/internal/server/gamedata/gacha_probability_test.go @@ -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 }) diff --git a/go/internal/server/gamedata/immortal.go b/go/internal/server/gamedata/immortal.go new file mode 100644 index 0000000..02cf887 --- /dev/null +++ b/go/internal/server/gamedata/immortal.go @@ -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}] +} diff --git a/go/internal/server/gamedata/inventory_slots.go b/go/internal/server/gamedata/inventory_slots.go index e3dc2c9..953affc 100644 --- a/go/internal/server/gamedata/inventory_slots.go +++ b/go/internal/server/gamedata/inventory_slots.go @@ -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 diff --git a/go/internal/server/gamedata/legacy_probability_test.go b/go/internal/server/gamedata/legacy_probability_test.go new file mode 100644 index 0000000..81ea881 --- /dev/null +++ b/go/internal/server/gamedata/legacy_probability_test.go @@ -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) +} diff --git a/go/internal/server/gamedata/missions.go b/go/internal/server/gamedata/missions.go index 630edee..1a2f4ad 100644 --- a/go/internal/server/gamedata/missions.go +++ b/go/internal/server/gamedata/missions.go @@ -30,12 +30,13 @@ type MissionDesign struct { } type MissionCondition struct { - Type uint64 - SubType uint64 - Params []uint64 - TargetValue uint64 - UnlockPack uint64 - UnlockQuest uint64 + Type uint64 + SubType uint64 + SubTypeComparison uint64 + Params []uint64 + TargetValue uint64 + UnlockPack uint64 + UnlockQuest uint64 } type SectionRewardDesign struct { @@ -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() } diff --git a/go/internal/server/gamedata/pack_jam.go b/go/internal/server/gamedata/pack_jam.go index fb75be3..31b961e 100644 --- a/go/internal/server/gamedata/pack_jam.go +++ b/go/internal/server/gamedata/pack_jam.go @@ -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 +} diff --git a/go/internal/server/gamedata/pack_jam_test.go b/go/internal/server/gamedata/pack_jam_test.go index 68fa3ef..76e5e93 100644 --- a/go/internal/server/gamedata/pack_jam_test.go +++ b/go/internal/server/gamedata/pack_jam_test.go @@ -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) } diff --git a/go/internal/server/gamedata/preset.go b/go/internal/server/gamedata/preset.go new file mode 100644 index 0000000..8711874 --- /dev/null +++ b/go/internal/server/gamedata/preset.go @@ -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 +} diff --git a/go/internal/server/gamedata/preset_test.go b/go/internal/server/gamedata/preset_test.go new file mode 100644 index 0000000..1b58f3f --- /dev/null +++ b/go/internal/server/gamedata/preset_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/quest_collections_test.go b/go/internal/server/gamedata/quest_collections_test.go new file mode 100644 index 0000000..f69a8c0 --- /dev/null +++ b/go/internal/server/gamedata/quest_collections_test.go @@ -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") + } +} diff --git a/go/internal/server/gamedata/quest_formation.go b/go/internal/server/gamedata/quest_formation.go index e157f54..1da60c9 100644 --- a/go/internal/server/gamedata/quest_formation.go +++ b/go/internal/server/gamedata/quest_formation.go @@ -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 diff --git a/go/internal/server/gamedata/quests.go b/go/internal/server/gamedata/quests.go index d2e2ab7..d359990 100644 --- a/go/internal/server/gamedata/quests.go +++ b/go/internal/server/gamedata/quests.go @@ -13,12 +13,13 @@ import ( // Reward slot zero is normal story difficulty; later slots are separate // difficulty tiers and must never be added to the same clear. type QuestDesign struct { - ID int - Type int - PriorQuestID int - NextQuestID int - GiveQuestItemIDs []uint64 - Rewards [5][]Reward + ID int + Type int + PriorQuestID int + NextQuestID int + GiveQuestItemIDs []uint64 + CollectionRewards []Reward + Rewards [5][]Reward } func LoadQuestDesign(root, version string, packID int) (map[int]QuestDesign, error) { @@ -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) diff --git a/go/internal/server/gamedata/story_characters.go b/go/internal/server/gamedata/story_characters.go index 635f24e..d3949f8 100644 --- a/go/internal/server/gamedata/story_characters.go +++ b/go/internal/server/gamedata/story_characters.go @@ -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 } diff --git a/go/internal/server/mail/content_ticket_test.go b/go/internal/server/mail/content_ticket_test.go new file mode 100644 index 0000000..a791515 --- /dev/null +++ b/go/internal/server/mail/content_ticket_test.go @@ -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) + } + } +} diff --git a/go/internal/server/mail/grant_spool_test.go b/go/internal/server/mail/grant_spool_test.go index 5067128..37bf660 100644 --- a/go/internal/server/mail/grant_spool_test.go +++ b/go/internal/server/mail/grant_spool_test.go @@ -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) } diff --git a/go/internal/server/mail/mail.go b/go/internal/server/mail/mail.go index fcc0d82..5e08544 100644 --- a/go/internal/server/mail/mail.go +++ b/go/internal/server/mail/mail.go @@ -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 { diff --git a/go/internal/server/mail/mail_test.go b/go/internal/server/mail/mail_test.go index f030a68..86c993a 100644 --- a/go/internal/server/mail/mail_test.go +++ b/go/internal/server/mail/mail_test.go @@ -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}, diff --git a/go/internal/server/missions/events.go b/go/internal/server/missions/events.go new file mode 100644 index 0000000..2600cdd --- /dev/null +++ b/go/internal/server/missions/events.go @@ -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) +} diff --git a/go/internal/server/missions/events_test.go b/go/internal/server/missions/events_test.go new file mode 100644 index 0000000..c693afa --- /dev/null +++ b/go/internal/server/missions/events_test.go @@ -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) + } +} diff --git a/go/internal/server/player/collection.go b/go/internal/server/player/collection.go index 467ff38..ae41a8c 100644 --- a/go/internal/server/player/collection.go +++ b/go/internal/server/player/collection.go @@ -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 { diff --git a/go/internal/server/player/eat_food_test.go b/go/internal/server/player/eat_food_test.go index 7a136bb..d3cba2e 100644 --- a/go/internal/server/player/eat_food_test.go +++ b/go/internal/server/player/eat_food_test.go @@ -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) } diff --git a/go/internal/server/player/growth.go b/go/internal/server/player/growth.go index c9afe87..be968d0 100644 --- a/go/internal/server/player/growth.go +++ b/go/internal/server/player/growth.go @@ -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 } diff --git a/go/internal/server/player/growth_test.go b/go/internal/server/player/growth_test.go index 9923c94..0cffed5 100644 --- a/go/internal/server/player/growth_test.go +++ b/go/internal/server/player/growth_test.go @@ -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 { diff --git a/go/internal/server/player/immortal_design_test.go b/go/internal/server/player/immortal_design_test.go new file mode 100644 index 0000000..e286fd6 --- /dev/null +++ b/go/internal/server/player/immortal_design_test.go @@ -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") + } +} diff --git a/go/internal/server/player/inventory_slot_currency_test.go b/go/internal/server/player/inventory_slot_currency_test.go new file mode 100644 index 0000000..8e637e5 --- /dev/null +++ b/go/internal/server/player/inventory_slot_currency_test.go @@ -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") + } + }) + } +} diff --git a/go/internal/server/player/inventory_slots.go b/go/internal/server/player/inventory_slots.go index 79ed56e..df62838 100644 --- a/go/internal/server/player/inventory_slots.go +++ b/go/internal/server/player/inventory_slots.go @@ -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 diff --git a/go/internal/server/player/starter.go b/go/internal/server/player/starter.go index 25784b9..7f2d794 100644 --- a/go/internal/server/player/starter.go +++ b/go/internal/server/player/starter.go @@ -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") diff --git a/go/internal/server/readonly/root_sort_test.go b/go/internal/server/readonly/root_sort_test.go new file mode 100644 index 0000000..6ea3625 --- /dev/null +++ b/go/internal/server/readonly/root_sort_test.go @@ -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) + } +} diff --git a/go/internal/server/schedule/schedule.go b/go/internal/server/schedule/schedule.go index ff109ad..cdd07b2 100644 --- a/go/internal/server/schedule/schedule.go +++ b/go/internal/server/schedule/schedule.go @@ -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) } diff --git a/go/internal/server/schedule/schedule_test.go b/go/internal/server/schedule/schedule_test.go index 9b92393..76c91e8 100644 --- a/go/internal/server/schedule/schedule_test.go +++ b/go/internal/server/schedule/schedule_test.go @@ -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) } } diff --git a/go/internal/server/world/field_objects.go b/go/internal/server/world/field_objects.go index eb88208..788e0d6 100644 --- a/go/internal/server/world/field_objects.go +++ b/go/internal/server/world/field_objects.go @@ -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 } diff --git a/go/internal/server/world/field_objects_test.go b/go/internal/server/world/field_objects_test.go index 813807f..2fece51 100644 --- a/go/internal/server/world/field_objects_test.go +++ b/go/internal/server/world/field_objects_test.go @@ -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 diff --git a/go/internal/server/world/mission_unlock.go b/go/internal/server/world/mission_unlock.go new file mode 100644 index 0000000..fe6ec3a --- /dev/null +++ b/go/internal/server/world/mission_unlock.go @@ -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) +} diff --git a/go/internal/server/world/mission_unlock_test.go b/go/internal/server/world/mission_unlock_test.go new file mode 100644 index 0000000..14063c9 --- /dev/null +++ b/go/internal/server/world/mission_unlock_test.go @@ -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) + } +} diff --git a/go/internal/server/world/pack_jam.go b/go/internal/server/world/pack_jam.go index d6c9271..63bd070 100644 --- a/go/internal/server/world/pack_jam.go +++ b/go/internal/server/world/pack_jam.go @@ -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 { diff --git a/go/internal/server/world/pack_jam_test.go b/go/internal/server/world/pack_jam_test.go index 7bec380..05e5e9c 100644 --- a/go/internal/server/world/pack_jam_test.go +++ b/go/internal/server/world/pack_jam_test.go @@ -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") + } } diff --git a/go/internal/server/world/pack_purchase.go b/go/internal/server/world/pack_purchase.go index 0a1197f..132c7d6 100644 --- a/go/internal/server/world/pack_purchase.go +++ b/go/internal/server/world/pack_purchase.go @@ -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 +} diff --git a/go/internal/server/world/pack_purchase_test.go b/go/internal/server/world/pack_purchase_test.go index 226c607..224354c 100644 --- a/go/internal/server/world/pack_purchase_test.go +++ b/go/internal/server/world/pack_purchase_test.go @@ -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 diff --git a/go/internal/server/world/story_characters.go b/go/internal/server/world/story_characters.go index 6c0b67f..2b1c0e4 100644 --- a/go/internal/server/world/story_characters.go +++ b/go/internal/server/world/story_characters.go @@ -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 diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 39ea131..f459ea5 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -23,14 +23,30 @@ 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 { - Version string `json:"version"` - PackID int `json:"pack_id"` - StartQuestID int `json:"start_quest_id"` - BattleUnlockQuestID int `json:"battle_unlock_quest_id"` - RewardCharacter player.Character `json:"reward_character"` - RewardCostume player.Costume `json:"reward_costume"` - StoryCharacters []player.Character `json:"story_characters"` + 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"` + BattleUnlockQuestID int `json:"battle_unlock_quest_id"` + RewardCharacter player.Character `json:"reward_character"` + RewardCostume player.Costume `json:"reward_costume"` + StoryCharacters []player.Character `json:"story_characters"` } // Load reads the small, versioned world seed. Quest IDs are then verified @@ -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) -} - -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}}, + if s.seed.SquareSceneID != 0 { + out = wire.AppendVarint(out, 5, s.seed.SquareSceneID) + } + return out } // PictorialCharacters hides quest-26 rewards until they are earned, even diff --git a/go/internal/server/world/world_test.go b/go/internal/server/world/world_test.go index 6a4e0e4..6de21b1 100644 --- a/go/internal/server/world/world_test.go +++ b/go/internal/server/world/world_test.go @@ -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) diff --git a/go/seed/v2_35_10/readonly.json b/go/seed/v2_35_10/readonly.json index 2f9c200..75c10cb 100644 --- a/go/seed/v2_35_10/readonly.json +++ b/go/seed/v2_35_10/readonly.json @@ -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 + } + ] + } + ] } } } diff --git a/go/seed/v2_35_10/schedule.json b/go/seed/v2_35_10/schedule.json new file mode 100644 index 0000000..1b8381c --- /dev/null +++ b/go/seed/v2_35_10/schedule.json @@ -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 + } + ] +} diff --git a/go/seed/v2_35_10/starter_player.json b/go/seed/v2_35_10/starter_player.json index 6c47469..ae0eddc 100644 --- a/go/seed/v2_35_10/starter_player.json +++ b/go/seed/v2_35_10/starter_player.json @@ -1,5 +1,6 @@ { "version": "2.35.10", + "cooking_recipes": [101], "items": [ { "inven_index": 853258187, diff --git a/go/seed/v2_35_10/world.json b/go/seed/v2_35_10/world.json index a621c8d..1c8122b 100644 --- a/go/seed/v2_35_10/world.json +++ b/go/seed/v2_35_10/world.json @@ -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": {