feat(server): add special loot and data-driven gameplay rules

Generate equipment loot atomically, grant random boxes and prevent repeat claims.
Read gacha pools, pity, products, tickets, missions, presets, chapter rewards
and inventory costs from GameData; extend both collaboration UR ticket pools.
Sync owned recipes, configure startup state and calendars in versioned seeds,
and validate unlock tickets and revival talents.
This commit is contained in:
2026-10-04 19:15:59 +08:00
parent 64454e71a3
commit 5b5a219dc2
86 changed files with 3577 additions and 646 deletions
+45 -14
View File
@@ -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{},
)
+4
View File
@@ -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
+8
View File
@@ -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)
+15 -41
View File
@@ -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 {
+13 -11
View File
@@ -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
}()
+22 -17
View File
@@ -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 {
+37 -4
View File
@@ -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)
}
-14
View File
@@ -7,12 +7,6 @@ import "bd2server/internal/server/wire"
// entities (inventory, characters, quests, decks) are deliberately excluded.
func initialResponse(path string) (int, []byte, bool) {
switch path {
case "/PackInfo":
// No purchased packs yet. SquareSceneId is the new-account square.
return 4, wire.AppendVarint(nil, 5, 3), true
case "/RecipeInfo":
// The starter crafting recipe is ID 101 (packed repeated int32).
return 46, wire.AppendBytes(nil, 2, []byte{101}), true
case "/CashMailInfo":
// MaxInvenIndex is explicitly present even with zero cash mails.
return 140, wire.AppendVarint(nil, 3, 0), true
@@ -20,14 +14,6 @@ func initialResponse(path string) (int, []byte, bool) {
return 467, wire.AppendBytes(nil, 1, nil), true
case "/GuildRaidSeasonReward":
return 310, wire.AppendBytes(nil, 1, nil), true
case "/DeckInfo":
// No active deck yet; talent slots are four zero-valued packed ints.
return 8, wire.AppendBytes(nil, 2, []byte{0, 0, 0, 0}), true
case "/RootSortIdInfo":
entry := wire.AppendVarint(nil, 1, 2)
entry = wire.AppendVarint(entry, 2, 140)
entry = wire.AppendVarint(entry, 3, 1)
return 367, wire.AppendBytes(nil, 1, entry), true
}
return 0, nil, false
}
@@ -13,13 +13,9 @@ func TestBootstrapProtocolDefaults(t *testing.T) {
code int
proto []byte
}{
{"/PackInfo", 4, []byte{0x28, 3}},
{"/RecipeInfo", 46, []byte{0x12, 1, 101}},
{"/CashMailInfo", 140, []byte{0x18, 0}},
{"/AvatarInfo", 467, []byte{0x0a, 0}},
{"/GuildRaidSeasonReward", 310, []byte{0x0a, 0}},
{"/DeckInfo", 8, []byte{0x12, 4, 0, 0, 0, 0}},
{"/RootSortIdInfo", 367, []byte{0x0a, 7, 0x08, 2, 0x10, 0x8c, 1, 0x18, 1}},
} {
t.Run(test.path, func(t *testing.T) {
code, proto, handled, err := Handle(test.path, wire.AppendVarint(nil, 1, 42))
+1 -1
View File
@@ -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)
}
+70
View File
@@ -0,0 +1,70 @@
package feature
import (
"encoding/binary"
"fmt"
"math"
"sort"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/wire"
)
type RecipeItems interface{ All() []player.Item }
type RecipeService struct {
design *gamedata.CookingRecipeDesign
initial []uint64
items RecipeItems
}
func NewRecipeService(design *gamedata.CookingRecipeDesign, initial []uint64, items RecipeItems) (*RecipeService, error) {
if design == nil || len(design.IDs) == 0 || items == nil {
return nil, fmt.Errorf("recipes: missing design or inventory")
}
for _, id := range initial {
if !design.IDs[id] {
return nil, fmt.Errorf("recipes: initial recipe %d is absent from GameData", id)
}
}
return &RecipeService{design: design, initial: append([]uint64(nil), initial...), items: items}, nil
}
func (s *RecipeService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/RecipeInfo" {
return 0, nil, false, nil
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, true, ErrInvalidRequest
}
known := map[uint64]bool{}
for _, id := range s.initial {
known[id] = true
}
for _, item := range s.items.All() {
// EElementType.CookingRecipe is protocol value 7. Ownership comes from
// seed state or real reward grants, never from every row in CookingTable.
if item.Type != 7 || item.Count == 0 {
continue
}
if !s.design.IDs[item.ID] {
return 0, nil, true, fmt.Errorf("recipes: owned recipe %d is absent from GameData", item.ID)
}
known[item.ID] = true
}
ids := make([]uint64, 0, len(known))
for id := range known {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var packed []byte
for _, id := range ids {
packed = binary.AppendUvarint(packed, id)
}
if len(packed) == 0 {
return 46, nil, true, nil
}
return 46, wire.AppendBytes(nil, 2, packed), true, nil
}
@@ -0,0 +1,77 @@
package feature
import (
"encoding/binary"
"reflect"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
func TestRecipesFollowOwnedGrantsAndRestart(t *testing.T) {
store := stateio.NewMemory()
starter := &player.Starter{Version: versionconfig.State()}
items, err := player.OpenInventory(store, starter)
if err != nil {
t.Fatal(err)
}
design := &gamedata.CookingRecipeDesign{IDs: map[uint64]bool{501: true, 701: true}}
service, err := NewRecipeService(design, []uint64{701}, items)
if err != nil {
t.Fatal(err)
}
check := func(service *RecipeService, want []uint64) {
t.Helper()
code, payload, handled, err := service.Handle("/RecipeInfo", wire.AppendVarint(nil, 1, 17))
if err != nil || !handled || code != 46 {
t.Fatalf("recipe response: %d %v %v", code, handled, err)
}
packed, _, err := wire.Bytes(payload, 2)
var ids []uint64
for len(packed) > 0 {
id, n := binary.Uvarint(packed)
if n <= 0 {
t.Fatal("malformed packed recipes")
}
ids = append(ids, id)
packed = packed[n:]
}
if err != nil || !reflect.DeepEqual(ids, want) {
t.Fatalf("recipes=%v want=%v err=%v", ids, want, err)
}
}
check(service, []uint64{701})
if _, err := items.GrantOnce("recipe-reward", []gamedata.BattleReward{{Type: 7, ID: 501, Count: 1}, {Type: 7, ID: 701, Count: 1}, {Type: 1, ID: 999, Count: 1}}); err != nil {
t.Fatal(err)
}
check(service, []uint64{501, 701})
reopened, err := player.OpenInventory(store, starter)
if err != nil {
t.Fatal(err)
}
service, err = NewRecipeService(design, []uint64{701}, reopened)
if err != nil {
t.Fatal(err)
}
check(service, []uint64{501, 701})
if _, err := reopened.GrantOnce("unknown-recipe", []gamedata.BattleReward{{Type: 7, ID: 800, Count: 1}}); err != nil {
t.Fatal(err)
}
if _, _, _, err := service.Handle("/RecipeInfo", wire.AppendVarint(nil, 1, 18)); err == nil {
t.Fatal("unknown owned recipe accepted")
}
}
func TestRecipeDesignRejectsUnknownInitialOwnership(t *testing.T) {
if _, err := NewRecipeService(&gamedata.CookingRecipeDesign{IDs: map[uint64]bool{501: true}}, []uint64{101}, statefulRecipeItems{}); err == nil {
t.Fatal("unknown initial recipe accepted")
}
}
type statefulRecipeItems struct{}
func (statefulRecipeItems) All() []player.Item { return nil }
+4 -4
View File
@@ -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)
}
+73 -48
View File
@@ -15,23 +15,9 @@ import (
"bd2server/internal/server/wire"
)
const infiniteGrant = "cash-product:1100001:9100033"
const (
moonriseProductGroupID = 1500001
moonriseProductID = 9100037
moonriseTicketType = 19
moonriseTicketID = 450030
moonriseProductGrant = "cash-product:1500001:9100037"
moonriseTicketGrant = "cash-product-reward:1500001:9100037:0"
moonriseDrawGrant = "special-gacha:30011:9100037"
)
const (
infiniteScheduleGroupID = 30010
twelvePickGroupID = 10001
paidTwelvePickGroupID = 30011
)
func (s *Service) infiniteGrant() string {
return fmt.Sprintf("cash-product:%d:%d", s.design.ProductGroupID, s.design.ProductID)
}
type Service struct {
design *gamedata.InfiniteGachaDesign
@@ -43,6 +29,7 @@ type Service struct {
equipmentCatalog *gamedata.EquipmentGachaCatalog
equipmentInventory *player.EquipmentInventory
onPreview func() error
onDraw func(uint64) error
schedule *ScheduleSeed
previewEventIndex uint64
sessionMu sync.RWMutex
@@ -52,6 +39,8 @@ type Service struct {
now func() time.Time
}
func (s *Service) AttachDrawMission(callback func(uint64) error) { s.onDraw = callback }
func (s *Service) AttachPreviewMission(callback func() error) { s.onPreview = callback }
func (s *Service) AttachInventory(inventory *player.Inventory) { s.inventory = inventory }
func (s *Service) AttachPreviewEventIndex(eventIndex uint64) error {
@@ -159,16 +148,21 @@ func (s *Service) PurchaseCountDBInfos() [][]byte {
group, id uint64
bought bool
}{}
_, infiniteBought := s.collection.Grant(infiniteGrant)
_, infiniteBought := s.collection.Grant(s.infiniteGrant())
products = append(products, struct {
group, id uint64
bought bool
}{gamedata.InfiniteProductGroupID, gamedata.InfiniteProductID, infiniteBought})
_, moonriseBought := s.collection.Grant(moonriseProductGrant)
}{s.design.ProductGroupID, s.design.ProductID, infiniteBought})
for _, group := range s.regular.Groups() {
if group.CashProductGroupID == 0 || group.CashProductID == 0 {
continue
}
_, bought := s.collection.Grant(fmt.Sprintf("cash-product:%d:%d", group.CashProductGroupID, group.CashProductID))
products = append(products, struct {
group, id uint64
bought bool
}{moonriseProductGroupID, moonriseProductID, moonriseBought})
}{group.CashProductGroupID, group.CashProductID, bought})
}
var result [][]byte
for _, product := range products {
if !product.bought {
@@ -217,8 +211,6 @@ func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) {
if group, ok := s.regular.Group(groupID); ok && group.SelectCount != 0 {
expected = int(group.SelectCount)
changeLimit = group.SelectionChangeCount
} else if groupID == paidTwelvePickGroupID {
expected = 12
}
if expected == 0 || len(selections) != expected {
return 198, nil, true, fmt.Errorf("gacha: group %d requires %d selections, got %d", groupID, expected, len(selections))
@@ -483,7 +475,8 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
}
}
isDailyPaid := buyType == 2 && design.Count == 1 && design.DailyPayGachaCount != 0 && design.DailyPayGachaPriceCount != 0 && knownGroup
isMoonrise := buyType == 3 && design.PriceType == moonriseTicketType && design.PriceID == moonriseTicketID && id == moonriseProductID && group.ID == paidTwelvePickGroupID
isMoonrise := buyType == 3 && design.PriceType == 19 && design.PriceID != 0 && knownGroup && group.GachaSubType == 1 && group.SelectCount != 0 && group.CashProductID != 0
completionGrant := fmt.Sprintf("special-gacha:%d:%d", group.ID, id)
validOrdinary := buyType == 1 && (design.PriceType == 2 || design.PriceType == 3)
if !isDailyFree && !isDailyPaid && !isMoonrise && !validOrdinary {
priceType := uint64(0)
@@ -510,7 +503,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
}
grant, already := s.collection.Grant(identity)
if isMoonrise && !already {
if _, completed := s.collection.Grant(moonriseDrawGrant); completed {
if _, completed := s.collection.Grant(completionGrant); completed {
return 146, nil, true, errors.New("gacha: Moonrise product already drawn")
}
}
@@ -606,7 +599,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
if s.inventory == nil {
return 146, nil, true, errors.New("gacha: inventory not attached")
}
moonriseCost, err = s.inventory.SelectMutable(moonriseTicketType, moonriseTicketID, design.Price)
moonriseCost, err = s.inventory.SelectMutable(design.PriceType, design.PriceID, design.Price)
if err != nil {
return 146, nil, true, err
}
@@ -661,7 +654,7 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
}
purchase := player.GachaPurchase{Group: group, BuyType: buyType, Fixed: fixedStates, SelectionApplySortIDs: selectionApplySortIDs}
if isMoonrise {
purchase.CompletionGrant = moonriseDrawGrant
purchase.CompletionGrant = completionGrant
}
if dailyLimit != 0 {
purchase.DailyKey = day
@@ -687,9 +680,9 @@ func (s *Service) buyWithIdentity(request []byte, seq uint64, identity string) (
for _, sortID := range grant.SelectionApplySortIDs {
response = wire.AppendVarint(response, 4, sortID)
}
if s.onPreview != nil {
if err := s.onPreview(); err != nil {
return 146, nil, true, fmt.Errorf("gacha: update buy mission: %w", err)
if !already && s.onDraw != nil {
if err := s.onDraw(uint64(design.Count)); err != nil {
return 146, nil, true, fmt.Errorf("gacha: draw mission: %w", err)
}
}
return 146, response, true, nil
@@ -799,9 +792,17 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player
return 146, nil, true, err
}
}
fixed := s.equipmentCatalog.Fixed()
var fixed gamedata.EquipmentFixedDesign
var priorSR, priorUR uint64
if !design.TicketOnly {
group, found := s.equipmentCatalog.GroupForGacha(design.ID)
if !found {
return 146, nil, true, errors.New("gacha: equipment group missing")
}
fixed, found = s.equipmentCatalog.FixedForGroup(group.ID)
if !found {
return 146, nil, true, errors.New("gacha: equipment fixed design missing")
}
priorSR = s.collection.GachaFixedCount(fixed.ID, 2)
priorUR = s.collection.GachaFixedCount(fixed.ID, 3)
}
@@ -857,6 +858,11 @@ func (s *Service) buyEquipmentWithIdentity(seq, buyType uint64, tickets []player
for _, fixed := range grant.GachaFixed {
response = wire.AppendBytes(response, 3, gachaFixedWire(fixed))
}
if !already && s.onDraw != nil {
if err := s.onDraw(uint64(design.Count)); err != nil {
return 146, nil, true, fmt.Errorf("gacha: equipment draw mission: %w", err)
}
}
return 146, response, true, nil
}
@@ -885,7 +891,17 @@ func costumeFixedStates(fixed gamedata.GachaFixedDesign, result gamedata.GachaFi
}
func (s *Service) completeSharedFixedStates(fixedID uint64, updates []player.GachaFixedState) []player.GachaFixedState {
if fixedID == 0 || s.equipmentCatalog == nil || s.equipmentCatalog.Fixed().ID != fixedID {
if fixedID == 0 || s.equipmentCatalog == nil {
return updates
}
known := false
for _, fixed := range s.equipmentCatalog.FixedDesigns() {
if fixed.ID == fixedID {
known = true
break
}
}
if !known {
return updates
}
states := make([]player.GachaFixedState, 4)
@@ -1056,18 +1072,18 @@ func (s *Service) preview(request []byte) (int, []byte, bool, error) {
if s.first != nil && id == s.first.GachaID {
return s.previewFirstGacha(request)
}
if _, bought := s.collection.Grant(infiniteGrant); bought {
if _, bought := s.collection.Grant(s.infiniteGrant()); bought {
return 175, nil, true, errors.New("gacha: infinite product already purchased")
}
if id != gamedata.InfiniteGachaID {
if id != s.design.GachaID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview id %d", id)
}
group, found, err := wire.Varint(request, 3)
if err != nil || !found || group != gamedata.InfiniteProductGroupID {
if err != nil || !found || group != s.design.ProductGroupID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview product group %d", group)
}
product, found, err := wire.Varint(request, 4)
if err != nil || !found || product != gamedata.InfiniteProductID {
if err != nil || !found || product != s.design.ProductID {
return 0, nil, true, fmt.Errorf("gacha: unsupported preview product %d", product)
}
if s.previewEventIndex == 0 {
@@ -1127,22 +1143,30 @@ func (s *Service) confirm(request []byte) (int, []byte, bool, error) {
if err != nil {
return 0, nil, true, err
}
if saleGroup != 0 || buyCount != 1 {
if buyCount != 1 {
return 0, nil, true, fmt.Errorf("gacha: unsupported product %d sale %d count %d", productID, saleGroup, buyCount)
}
if group == moonriseProductGroupID && productID == moonriseProductID {
for _, cashGroup := range s.regular.Groups() {
if group != cashGroup.CashProductGroupID || productID != cashGroup.CashProductID || saleGroup != cashGroup.CashSalesGroup {
continue
}
if len(cashGroup.CashRewards) == 0 {
return 61, nil, true, errors.New("gacha: cash product rewards unavailable")
}
productGrant := fmt.Sprintf("cash-product:%d:%d", group, productID)
ticketGrant := fmt.Sprintf("cash-product-reward:%d:%d:0", group, productID)
if s.inventory == nil {
return 61, nil, true, errors.New("gacha: inventory not attached")
}
items, err := s.inventory.GrantOnce(moonriseTicketGrant, []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}})
items, err := s.inventory.GrantOnce(ticketGrant, cashGroup.CashRewards)
if err != nil {
return 61, nil, true, err
}
if err := s.collection.RecordGrantMarker(moonriseProductGrant); err != nil {
if err := s.collection.RecordGrantMarker(productGrant); err != nil {
return 61, nil, true, err
}
if len(items) == 0 {
items = s.inventory.GrantedItems(moonriseTicketGrant)
items = s.inventory.GrantedItems(ticketGrant)
}
var bundle []byte
for _, item := range items {
@@ -1150,14 +1174,14 @@ func (s *Service) confirm(request []byte) (int, []byte, bool, error) {
}
return 61, wire.AppendBytes(nil, 1, bundle), true, nil
}
if group != gamedata.InfiniteProductGroupID || productID != gamedata.InfiniteProductID {
if group != s.design.ProductGroupID || productID != s.design.ProductID || saleGroup != s.design.SaleGroup {
return 0, nil, true, fmt.Errorf("gacha: unsupported cash product group=%d product=%d", group, productID)
}
grant, err := s.collection.ConfirmInfinite(infiniteGrant, s.design)
grant, err := s.collection.ConfirmInfinite(s.infiniteGrant(), s.design)
if err != nil {
return 0, nil, true, err
}
if err := s.creditOverflow(infiniteGrant, grant); err != nil {
if err := s.creditOverflow(s.infiniteGrant(), grant); err != nil {
return 61, nil, true, err
}
return 61, wire.AppendBytes(nil, 1, s.rewardBundle(grant)), true, nil
@@ -1255,8 +1279,7 @@ func (s *Service) rewardBundle(grant player.CollectionGrant) []byte {
func (s *Service) gachaInfo() []byte {
var out []byte
_, infinitePurchased := s.collection.Grant(infiniteGrant)
_, moonriseDrawn := s.collection.Grant(moonriseDrawGrant)
_, infinitePurchased := s.collection.Grant(s.infiniteGrant())
if s.schedule != nil {
for _, entry := range s.schedule.Schedules {
// The versioned seed is the public schedule, while availability of
@@ -1264,12 +1287,14 @@ func (s *Service) gachaInfo() []byte {
// grant exists, publishing this group makes the client reopen the
// preview flow even though the authoritative preview endpoint must
// reject a second purchase.
if infinitePurchased && entry.GroupID == infiniteScheduleGroupID {
if infinitePurchased && entry.GroupID == s.design.GroupID {
continue
}
if moonriseDrawn && entry.GroupID == paidTwelvePickGroupID {
if special, ok := s.regular.Group(entry.GroupID); ok && special.GachaSubType == 1 && special.CashProductID != 0 {
if _, drawn := s.collection.Grant(fmt.Sprintf("special-gacha:%d:%d", special.ID, special.TenTimeGachaID)); drawn {
continue
}
}
schedule := wire.AppendVarint(nil, 1, entry.GroupID)
schedule = wire.AppendVarint(schedule, 2, entry.StartTime)
schedule = wire.AppendVarint(schedule, 3, entry.EndTime)
+197 -65
View File
@@ -23,15 +23,15 @@ func TestTicketOnlyEquipmentDrawUsesGameDataAndNoScheduleAccounting(t *testing.T
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
const version = "20260923193640"
infinite, err := gamedata.LoadInfiniteGacha(root, version)
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.LoadRegularCostumeGacha(root, version)
regular, err := gamedata.LoadRegularCostumeGachaGroups(root, version, []uint64{10001, 135, 205, 121}, []uint64{29})
if err != nil {
t.Fatal(err)
}
equipmentCatalog, err := gamedata.LoadEquipmentGacha(root, version)
equipmentCatalog, err := gamedata.LoadEquipmentGachaGroups(root, version, []uint64{10002, 9, 133, 206})
if err != nil {
t.Fatal(err)
}
@@ -131,7 +131,7 @@ func fixtureCharacter(id, hp uint64) gamedata.CharacterDesign {
}
func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
60901: fixtureCharacter(6090, 253),
})
if err != nil {
@@ -142,7 +142,7 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
@@ -171,9 +171,9 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
t.Fatal(err)
}
preview := wire.AppendVarint(nil, 1, 1)
preview = wire.AppendVarint(preview, 2, gamedata.InfiniteGachaID)
preview = wire.AppendVarint(preview, 3, gamedata.InfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, gamedata.InfiniteProductID)
preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID)
preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID)
code, response, ok, err := service.Handle("/GachaBuyPreview", preview)
if err != nil || !ok || code != 175 {
t.Fatalf("preview code=%d ok=%v err=%v", code, ok, err)
@@ -213,8 +213,8 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
}
buy := wire.AppendVarint(nil, 1, 3)
buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID)
buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID)
product = wire.AppendVarint(product, 3, 1)
buy = wire.AppendBytes(buy, 4, product)
code, response, ok, err = service.Handle("/CashShopBuy", buy)
@@ -259,7 +259,7 @@ func TestInfinitePreviewAndFreeConfirmationPersist(t *testing.T) {
productID, productFound, productErr := wire.Varint(countInfo, 2)
_, saleGroupFound, saleGroupErr := wire.Varint(countInfo, 3)
count, countFound, countErr := wire.Varint(countInfo, 4)
if err != nil || parseErr != nil || groupErr != nil || productErr != nil || saleGroupErr != nil || countErr != nil || !ok || code != 432 || countFields(response, 1) != 1 || !found || !groupFound || !productFound || saleGroupFound || !countFound || productGroup != gamedata.InfiniteProductGroupID || productID != gamedata.InfiniteProductID || count != 1 {
if err != nil || parseErr != nil || groupErr != nil || productErr != nil || saleGroupErr != nil || countErr != nil || !ok || code != 432 || countFields(response, 1) != 1 || !found || !groupFound || !productFound || saleGroupFound || !countFound || productGroup != fixtureInfiniteProductGroupID || productID != fixtureInfiniteProductID || count != 1 {
t.Fatalf("purchase count code=%d group=%d product=%d saleGroupFound=%v count=%d found=%v/%v/%v/%v ok=%v err=%v/%v/%v/%v/%v/%v", code, productGroup, productID, saleGroupFound, count, found, groupFound, productFound, countFound, ok, err, parseErr, groupErr, productErr, saleGroupErr, countErr)
}
if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err == nil {
@@ -292,11 +292,11 @@ func scheduleGroupIDs(windows []ScheduleWindow) []uint64 {
}
func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
@@ -317,9 +317,9 @@ func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
t.Fatal(err)
}
preview := wire.AppendVarint(nil, 1, 1)
preview = wire.AppendVarint(preview, 2, gamedata.InfiniteGachaID)
preview = wire.AppendVarint(preview, 3, gamedata.InfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, gamedata.InfiniteProductID)
preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID)
preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID)
if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil {
t.Fatal(err)
}
@@ -328,8 +328,8 @@ func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
t.Fatalf("first preview event=%d locked=%v", firstEvent, locked)
}
buy := wire.AppendVarint(nil, 1, 2)
buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID)
buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID)
product = wire.AppendVarint(product, 3, 1)
buy = wire.AppendBytes(buy, 4, product)
if _, _, _, err := service.Handle("/CashShopBuy", buy); err == nil {
@@ -349,11 +349,11 @@ func TestInfinitePreviewMustBeLockedAndRerollClearsLock(t *testing.T) {
}
func TestRegularGachaBuySpendsWalletAndPersistsCostume(t *testing.T) {
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
@@ -395,11 +395,11 @@ func TestRegularGachaBuySpendsWalletAndPersistsCostume(t *testing.T) {
}
func TestRegularGachaSequenceReuseAcrossLoginIsANewPurchase(t *testing.T) {
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
10100084: {ID: 10100084, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
@@ -445,11 +445,11 @@ func TestRegularGachaSequenceReuseAcrossLoginIsANewPurchase(t *testing.T) {
}
func TestPaidRegularGachaBuySpendsPaidJewelry(t *testing.T) {
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
8100118: {ID: 8100118, Count: 10, PriceType: 2, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
@@ -488,11 +488,11 @@ func TestPaidRegularGachaBuySpendsPaidJewelry(t *testing.T) {
func TestPriceType19IsRejectedWithoutChargingDiamonds(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
@@ -528,11 +528,11 @@ func TestPriceType19IsRejectedWithoutChargingDiamonds(t *testing.T) {
func TestUnverifiedGachaRPCsRemainUnhandled(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
@@ -563,11 +563,11 @@ func newMultiBuyTestService(t *testing.T, storage stateio.Store) *Service {
t.Helper()
character := fixtureCharacter(6090, 253)
characters := map[uint64]gamedata.CharacterDesign{60901: character}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, characters)
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
11: {ID: 11, Count: 1, FreeCountDay: 2, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
12: {ID: 12, Count: 1, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
13: {ID: 13, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
@@ -788,7 +788,7 @@ func TestGachaMultiBuyFailureRollsBackWholeSQLiteOperation(t *testing.T) {
if err := service.collection.EnsurePersisted(); err != nil {
t.Fatal(err)
}
service.AttachPreviewMission(func() error { return errors.New("injected mission failure after reward") })
service.AttachDrawMission(func(uint64) error { return errors.New("injected mission failure after reward") })
operation, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
@@ -901,7 +901,7 @@ func TestEquipmentPointExchangeUsesGameDataAndIsIdempotent(t *testing.T) {
if _, err := os.Stat(filepath.Join(root, version, "release", "common-dbdata.bin")); os.IsNotExist(err) {
t.Skip("installed 2.35.10 GameData archive is unavailable")
}
infinite, err := gamedata.LoadInfiniteGacha(root, version)
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
@@ -974,7 +974,7 @@ func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
const stepUpGroupID = 29
stepUpGachaIDs := []uint64{8100118, 8100119, 8100120, 8100121}
character := fixtureCharacter(6090, 253)
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
@@ -982,7 +982,7 @@ func TestCompletedStepUpPersistsAndRemainsVisible(t *testing.T) {
for _, id := range stepUpGachaIDs {
gachas[id] = gamedata.RegularGacha{ID: id, Count: 1, PriceType: 2, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}
}
regular, err := gamedata.NewRegularGachaCatalog(gachas, map[uint64]gamedata.CharacterDesign{60901: character})
regular, err := fixtureRegularCatalog(gachas, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
@@ -1064,7 +1064,7 @@ func TestInstalledStepUpGroups29And30UseGenericIndependentProgress(t *testing.T)
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
}
const version = "20260923193640"
design, err := gamedata.LoadInfiniteGacha(root, version)
design, err := gamedata.LoadInfiniteGachaForSchedules(root, version, []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
@@ -1135,11 +1135,11 @@ func TestInstalledStepUpGroups29And30UseGenericIndependentProgress(t *testing.T)
func TestRegularTenPullContainsTenClientDisplayEntriesAtMaxLevel(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
11000084: {ID: 11000084, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
@@ -1201,7 +1201,7 @@ func TestRegularTenPullContainsTenClientDisplayEntriesAtMaxLevel(t *testing.T) {
func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing.T) {
character := fixtureCharacter(6090, 253)
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
}
@@ -1211,8 +1211,8 @@ func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
{Weight: 1, Children: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
11000145: {ID: 11000145, Count: 10, PriceType: 3, Price: 2000, Pool: pool},
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
11000145: {Grades: map[uint64]uint64{60901: 5}, ID: 11000145, Count: 10, PriceType: 3, Price: 2000, Pool: pool},
}, map[uint64]gamedata.CharacterDesign{60901: character})
if err != nil {
t.Fatal(err)
@@ -1294,11 +1294,11 @@ func TestGachaPointManualExchangePersistsAndRetriesWithoutDoubleGrant(t *testing
}
func TestCashShopPurchaseCountStartsEmpty(t *testing.T) {
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{60901: fixtureCharacter(6090, 253)})
if err != nil {
t.Fatal(err)
}
@@ -1331,14 +1331,17 @@ func TestTwelvePickSelectionSavePersistsAndReturnsInGachaInfo(t *testing.T) {
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}}, characters)
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}}}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, SelectCount: 12}, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
@@ -1380,11 +1383,11 @@ func TestTwelvePickSelectionSavePersistsAndReturnsInGachaInfo(t *testing.T) {
func TestDailyFreeAndPaidSingleDrawsResetByUTCDate(t *testing.T) {
characters := map[uint64]gamedata.CharacterDesign{5001: fixtureCharacter(500, 100)}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{5001}, characters)
design, err := fixtureInfiniteGachaDesign(10, []uint64{5001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
101: {ID: 101, Count: 1, DailyPayGachaCount: 1, DailyPayGachaPriceCount: 90, FreeCountDay: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}},
}, characters)
if err != nil {
@@ -1495,7 +1498,7 @@ func TestMoonriseSelectionCashProductAndOneTimeTicketDraw(t *testing.T) {
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
@@ -1508,11 +1511,134 @@ func TestMoonriseSelectionCashProductAndOneTimeTicketDraw(t *testing.T) {
}}},
},
}}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters)
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, BuyLimitCount: 10, CashProductGroupID: moonriseProductGroupID, CashProductID: moonriseProductID, TenTimeGachaID: moonriseProductID, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); err != nil {
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, BuyLimitCount: 10, CashProductGroupID: moonriseProductGroupID, CashProductID: moonriseProductID, CashRewards: []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}}, TenTimeGachaID: moonriseProductID, SelectCount: 12, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
service, err := NewService(design, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.AttachInventory(inventory)
if err := service.AttachSchedule(&ScheduleSeed{ClientVersion: "test", Schedules: []ScheduleWindow{{GroupID: paidTwelvePickGroupID, StartTime: 1, EndTime: 2}}, StepUps: []ScheduleWindow{{GroupID: 30, StartTime: 1, EndTime: 2}}}); err != nil {
t.Fatal(err)
}
product := wire.AppendVarint(nil, 1, moonriseProductID)
product = wire.AppendVarint(product, 3, 1)
buyProduct := wire.AppendVarint(nil, 1, 1)
buyProduct = wire.AppendVarint(buyProduct, 3, moonriseProductGroupID)
buyProduct = wire.AppendBytes(buyProduct, 4, product)
if code, _, handled, err := service.Handle("/CashShopBuy", buyProduct); err != nil || !handled || code != 61 {
t.Fatalf("cash product code=%d handled=%v err=%v", code, handled, err)
}
if !inventory.WasGranted(moonriseTicketGrant) {
t.Fatal("moonrise cash product purchase was not persisted")
}
if _, bought := collection.Grant(moonriseProductGrant); !bought {
t.Fatal("moonrise cash purchase marker was not persisted")
}
selection := wire.AppendVarint(nil, 1, 2)
for slot, itemID := range fiveStars {
entry := wire.AppendVarint(nil, 1, paidTwelvePickGroupID)
if slot != 0 {
entry = wire.AppendVarint(entry, 2, uint64(slot))
}
entry = wire.AppendVarint(entry, 3, itemID)
selection = wire.AppendBytes(selection, 2, entry)
}
if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil {
t.Fatal(err)
}
draw := wire.AppendVarint(nil, 1, 3)
draw = wire.AppendVarint(draw, 2, moonriseProductID)
draw = wire.AppendVarint(draw, 3, 3)
if code, response, handled, err := service.Handle("/GachaBuy", draw); err != nil || !handled || code != 146 || countFields(response, 4) != 3 {
t.Fatalf("moonrise draw code=%d handled=%v err=%v", code, handled, err)
}
grant, found := collection.Grant(moonriseDrawGrant)
if !found || len(grant.ViewCostumeIDs) != 0 {
// The completion grant is deliberately an empty marker; the concrete
// result remains keyed by the request identity for retry replay.
if !found {
t.Fatal("moonrise completion marker missing")
}
}
if got := collection.GachaUser(paidTwelvePickGroupID).TotalBuyCount; got != 10 {
t.Fatalf("moonrise total buy count=%d want=10", got)
}
if _, _, _, err := service.Handle("/GachaBuy", wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 4), 2, moonriseProductID), 3, 3)); err == nil {
t.Fatal("second moonrise draw was accepted")
}
_, info, _, err := service.Handle("/GachaInfo", wire.AppendVarint(nil, 1, 5))
if err != nil || len(collectScheduleWindows(t, info, 1)) != 0 || !sameScheduleGroups(collectScheduleWindows(t, info, 7), 30) {
t.Fatalf("post-moonrise schedules=%v step=%v err=%v", scheduleGroupIDs(collectScheduleWindows(t, info, 1)), scheduleGroupIDs(collectScheduleWindows(t, info, 7)), err)
}
counts := service.PurchaseCountDBInfos()
if len(counts) != 1 {
t.Fatalf("purchase counts=%d want=1", len(counts))
}
group, _, _ := wire.Varint(counts[0], 1)
id, _, _ := wire.Varint(counts[0], 2)
if group != moonriseProductGroupID || id != moonriseProductID {
t.Fatalf("purchase count group=%d id=%d", group, id)
}
}
func TestSpecialSelectionUsesDifferentProductGroupTicketAndChoiceCount(t *testing.T) {
const (
moonriseProductID = 890001
moonriseProductGroupID = 770001
moonriseTicketID = 660003
paidTwelvePickGroupID = 550004
moonriseTicketType = 19
moonriseTicketGrant = "cash-product-reward:770001:890001:0"
moonriseProductGrant = "cash-product:770001:890001"
moonriseDrawGrant = "special-gacha:550004:890001"
)
fiveStars := make([]uint64, 4)
characters := make(map[uint64]gamedata.CharacterDesign, 14)
choices := make([]gamedata.CostumeRewardEntry, 4)
for i := range fiveStars {
fiveStars[i] = uint64(5001 + i)
characters[fiveStars[i]] = fixtureCharacter(uint64(500+i), 100)
choices[i] = gamedata.CostumeRewardEntry{ItemType: 11, ItemID: fiveStars[i], Count: 1, Weight: 1}
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
moonrise := gamedata.RegularGacha{ID: moonriseProductID, Count: 10, PriceType: moonriseTicketType, PriceID: moonriseTicketID, Price: 1, RewardGroup: &gamedata.CostumeRewardGroup{
ID: moonriseProductID, DropCount: 1, DropType: 1, Entries: []gamedata.CostumeRewardEntry{
{ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}},
{ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &gamedata.CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []gamedata.CostumeRewardEntry{
{ItemType: 11, ItemID: 4001, Count: 1, Weight: 1443},
{ItemType: 11, ItemID: 3001, Count: 1, Weight: 8557},
}}},
},
}}
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{moonriseProductID: moonrise}, characters)
if err != nil {
t.Fatal(err)
}
if err := regular.AddGroupDesign(gamedata.GachaGroupDesign{ID: paidTwelvePickGroupID, PointCount: 1, BuyLimitCount: 10, CashProductGroupID: moonriseProductGroupID, CashProductID: moonriseProductID, CashRewards: []gamedata.BattleReward{{Type: moonriseTicketType, ID: moonriseTicketID, Count: 1}}, TenTimeGachaID: moonriseProductID, SelectCount: 4, SelectionChoiceRate: 100, GachaSubType: 1}, gamedata.GachaFixedDesign{}); err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
@@ -1605,11 +1731,11 @@ func TestSelectionChangeCountPersistsEnforcesLimitAndReturnsInGachaInfo(t *testi
4001: fixtureCharacter(400, 100),
3001: fixtureCharacter(300, 100),
}
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
101: {ID: 101, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 5001, Weight: 1}}},
}, characters)
if err != nil {
@@ -1689,7 +1815,7 @@ func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing
}
characters[4001] = fixtureCharacter(400, 100)
characters[3001] = fixtureCharacter(300, 100)
design, err := gamedata.NewInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
design, err := fixtureInfiniteGachaDesignWithRates(10, fiveStars, []uint64{4001}, []uint64{3001}, characters)
if err != nil {
t.Fatal(err)
}
@@ -1698,7 +1824,7 @@ func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing
{Weight: 1400, Children: []gamedata.WeightedCostume{{ID: 4001, Weight: 1}}},
{Weight: 8300, Children: []gamedata.WeightedCostume{{ID: 3001, Weight: 1}}},
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
101: {ID: 101, Count: 10, PriceType: 3, Price: 2000, Pool: pool, TicketIDs: []uint64{1108, 1000}},
}, characters)
if err != nil {
@@ -1761,7 +1887,7 @@ func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testi
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
infinite, err := gamedata.LoadInfiniteGacha(root, "20260923193640")
infinite, err := gamedata.LoadInfiniteGachaForSchedules(root, "20260923193640", []uint64{infiniteScheduleGroupID})
if err != nil {
t.Fatal(err)
}
@@ -1813,24 +1939,30 @@ func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testi
if err != nil || point != 0 {
t.Fatalf("draw %d point=%d err=%v", draw+1, point, err)
}
applySort, found, err := wire.Varint(response, 4)
if err != nil || !found || applySort != 9 {
t.Fatalf("draw %d selected guarantee sort=%d found=%v err=%v response=%x", draw+1, applySort, found, err, response)
}
grant, ok := collection.Grant(service.requestIdentity(31, draw+2))
if !ok || len(grant.ViewCostumeIDs) != 10 || !selected[grant.ViewCostumeIDs[9]] {
t.Fatalf("draw %d grant=%+v", draw+1, grant)
if !ok || len(grant.ViewCostumeIDs) != 10 {
t.Fatalf("grant=%+v", grant)
}
for _, sortID := range grant.SelectionApplySortIDs {
if sortID >= uint64(len(grant.ViewCostumeIDs)) || !selected[grant.ViewCostumeIDs[sortID]] {
t.Fatalf("invalid selected guarantee sort=%d grant=%+v", sortID, grant)
}
}
foundLast := false
for _, sortID := range grant.SelectionApplySortIDs {
if sortID == 9 {
foundLast = true
}
}
if !foundLast || !selected[grant.ViewCostumeIDs[9]] {
t.Fatalf("nonresetting newbie tenth-slot guarantee missing: %+v", grant)
}
}
user := collection.GachaUser(1009)
if user.TotalBuyCount != 30 || user.Point != 0 || wallet.Snapshot().Jewelry != 0 {
t.Fatalf("newbie state user=%+v wallet=%+v", user, wallet.Snapshot())
}
for _, fixed := range collection.GachaFixedStates() {
if fixed.FixedID == 3 && fixed.Type == 1 && fixed.Count != 0 {
t.Fatalf("newbie five-star fixed count accumulated: %+v", fixed)
}
}
before := wallet.Snapshot()
fourth := wire.AppendVarint(nil, 1, 5)
fourth = wire.AppendVarint(fourth, 2, 31)
@@ -0,0 +1,74 @@
package gacha
import "bd2server/internal/server/gamedata"
const (
moonriseProductGroupID = 1500001
moonriseProductID = 9100037
moonriseTicketType = 19
moonriseTicketID = 450030
moonriseProductGrant = "cash-product:1500001:9100037"
moonriseTicketGrant = "cash-product-reward:1500001:9100037:0"
moonriseDrawGrant = "special-gacha:30011:9100037"
)
const (
infiniteScheduleGroupID = 30010
twelvePickGroupID = 10001
paidTwelvePickGroupID = 30011
)
const infiniteGrant = "cash-product:1100001:9100033"
const (
fixtureInfiniteGachaID = 9100033
fixtureInfiniteProductGroupID = 1100001
fixtureInfiniteProductID = 9100033
)
func fixtureInfiniteGachaDesign(count int, ids []uint64, characters map[uint64]gamedata.CharacterDesign) (*gamedata.InfiniteGachaDesign, error) {
return fixtureInfiniteGachaDesignWithRates(count, ids, ids, ids, characters)
}
func fixtureInfiniteGachaDesignWithRates(count int, five, four, three []uint64, characters map[uint64]gamedata.CharacterDesign) (*gamedata.InfiniteGachaDesign, error) {
d, e := gamedata.NewInfiniteGachaDesignWithRates(count, five, four, three, characters)
if e == nil {
d.GroupID = infiniteScheduleGroupID
d.GachaID = fixtureInfiniteGachaID
d.ProductGroupID = fixtureInfiniteProductGroupID
d.ProductID = fixtureInfiniteProductID
}
return d, e
}
func fixtureRegularCatalog(gachas map[uint64]gamedata.RegularGacha, characters map[uint64]gamedata.CharacterDesign) (*gamedata.RegularGachaCatalog, error) {
for id, g := range gachas {
if len(g.Grades) == 0 {
g.Grades = map[uint64]uint64{}
var collect func([]gamedata.WeightedCostume, uint64)
collect = func(p []gamedata.WeightedCostume, grade uint64) {
for _, w := range p {
if w.ID != 0 {
g.Grades[w.ID] = grade
} else {
collect(w.Children, grade)
}
}
}
for i, w := range g.Pool {
grade := uint64(5 - i)
if len(g.Pool) == 4 {
grade = uint64(6 - i)
if i == 0 {
grade = 5
}
}
collect([]gamedata.WeightedCostume{w}, grade)
}
if g.FixedCostumeID != 0 {
g.Grades[g.FixedCostumeID] = 5
}
gachas[id] = g
}
}
return gamedata.NewRegularGachaCatalog(gachas, characters)
}
@@ -18,13 +18,13 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) {
t.Fatal(err)
}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
design, err := fixtureInfiniteGachaDesign(10, []uint64{60901}, map[uint64]gamedata.CharacterDesign{
60901: {ID: 6090, HP: 253, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2},
})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(
regular, err := fixtureRegularCatalog(
map[uint64]gamedata.RegularGacha{
1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 60901, Weight: 1}}},
},
@@ -52,9 +52,9 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) {
}
preview := wire.AppendVarint(nil, 1, 1)
preview = wire.AppendVarint(preview, 2, gamedata.InfiniteGachaID)
preview = wire.AppendVarint(preview, 3, gamedata.InfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, gamedata.InfiniteProductID)
preview = wire.AppendVarint(preview, 2, fixtureInfiniteGachaID)
preview = wire.AppendVarint(preview, 3, fixtureInfiniteProductGroupID)
preview = wire.AppendVarint(preview, 4, fixtureInfiniteProductID)
if _, _, _, err := service.Handle("/GachaBuyPreview", preview); err != nil {
t.Fatal(err)
}
@@ -64,8 +64,8 @@ func TestLoginPurchaseCountsRestoredFromSQLiteGrant(t *testing.T) {
t.Fatal(err)
}
buy := wire.AppendVarint(nil, 1, 3)
buy = wire.AppendVarint(buy, 3, gamedata.InfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, gamedata.InfiniteProductID)
buy = wire.AppendVarint(buy, 3, fixtureInfiniteProductGroupID)
product := wire.AppendVarint(nil, 1, fixtureInfiniteProductID)
product = wire.AppendVarint(product, 3, 1)
buy = wire.AppendBytes(buy, 4, product)
if _, _, _, err := service.Handle("/CashShopBuy", buy); err != nil {
@@ -137,7 +137,7 @@ func assertInfinitePurchaseCount(t *testing.T, data []byte) {
count, countFound, countErr := wire.Varint(data, 4)
if groupErr != nil || idErr != nil || saleGroupErr != nil || countErr != nil ||
!groupFound || !idFound || saleGroupFound || !countFound ||
group != gamedata.InfiniteProductGroupID || id != gamedata.InfiniteProductID || saleGroup != 0 || count != 1 {
group != fixtureInfiniteProductGroupID || id != fixtureInfiniteProductID || saleGroup != 0 || count != 1 {
t.Fatalf("PurchaseCountDBInfo group=%d id=%d saleGroup=%d count=%d found=%v/%v/%v/%v errors=%v/%v/%v/%v",
group, id, saleGroup, count, groupFound, idFound, saleGroupFound, countFound,
groupErr, idErr, saleGroupErr, countErr)
@@ -21,11 +21,11 @@ func TestGachaPointExchangeGrantsUpgradesOverflowsAndRetries(t *testing.T) {
rollID: {ID: 6090, HP: 253, CostumeMaxLevel: 100, OverflowItemType: 20, OverflowItemCount: 2},
pickupID: {ID: 2120, HP: 300, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2},
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{rollID}, characters)
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{rollID}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
gachaID: {ID: gachaID, Count: 10, PriceType: 3, Price: 2000, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}},
}, characters)
if err != nil {
@@ -141,11 +141,11 @@ func TestGachaPointExchangeUsesOnlySavedSingleSelection(t *testing.T) {
rollID: fixtureCharacter(6090, 253),
selected: fixtureCharacter(6490, 300),
}
infinite, err := gamedata.NewInfiniteGachaDesign(10, []uint64{rollID}, characters)
infinite, err := fixtureInfiniteGachaDesign(10, []uint64{rollID}, characters)
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
gachaID: {ID: gachaID, Count: 1, PriceType: 3, Price: 200, Pool: []gamedata.WeightedCostume{{ID: rollID, Weight: 1}}},
}, characters)
if err != nil {
+3 -3
View File
@@ -60,7 +60,7 @@ func TestScheduleSeedStrictValidation(t *testing.T) {
func TestActivePickupCostumesUsesHalfOpenScheduleWindows(t *testing.T) {
character := gamedata.CharacterDesign{ID: 1, HP: 1, CostumeMaxLevel: 5}
catalog, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{
catalog, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{
11: {ID: 11, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 101, Weight: 1}}},
12: {ID: 12, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 102, Weight: 1}}},
}, map[uint64]gamedata.CharacterDesign{101: character, 102: character})
@@ -93,11 +93,11 @@ func TestGachaInfoUsesInjectedScheduleAndEmptyAccountHasNoPreview(t *testing.T)
t.Fatal(err)
}
character := gamedata.CharacterDesign{ID: 1, HP: 1, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 1}
design, err := gamedata.NewInfiniteGachaDesign(10, []uint64{11}, map[uint64]gamedata.CharacterDesign{11: character})
design, err := fixtureInfiniteGachaDesign(10, []uint64{11}, map[uint64]gamedata.CharacterDesign{11: character})
if err != nil {
t.Fatal(err)
}
regular, err := gamedata.NewRegularGachaCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 11, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{11: character})
regular, err := fixtureRegularCatalog(map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 11, Weight: 1}}}}, map[uint64]gamedata.CharacterDesign{11: character})
if err != nil {
t.Fatal(err)
}
@@ -0,0 +1,37 @@
package gamedata
import "fmt"
type GachaContentTicketDesign struct{ IDs map[uint64]bool }
func LoadGachaContentTicketDesign(root, version string) (*GachaContentTicketDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
rows, err := db.Query("SELECT id,ProtoBuf FROM ContentTicketTable")
if err != nil {
return nil, err
}
defer rows.Close()
d := &GachaContentTicketDesign{IDs: map[uint64]bool{}}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
return nil, err
}
typ, err := optionalScalar(raw, 8)
if err != nil {
return nil, err
}
if typ == 4 {
if id == 0 {
return nil, fmt.Errorf("gamedata: invalid gacha content ticket")
}
d.IDs[id] = true
}
}
return d, rows.Err()
}
@@ -0,0 +1,46 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
)
// CookingRecipeDesign describes valid recipe identities. Learning a recipe is
// player state, so listing this table never grants all of its recipes.
type CookingRecipeDesign struct{ IDs map[uint64]bool }
func LoadCookingRecipeDesign(root, version string) (*CookingRecipeDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadCookingRecipeDesign(db)
}
func loadCookingRecipeDesign(db *sql.DB) (*CookingRecipeDesign, error) {
rows, err := db.Query("SELECT id FROM CookingTable ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
design := &CookingRecipeDesign{IDs: map[uint64]bool{}}
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
if id <= 0 || id > math.MaxInt32 || design.IDs[uint64(id)] {
return nil, fmt.Errorf("gamedata: invalid cooking recipe identity")
}
design.IDs[uint64(id)] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(design.IDs) == 0 {
return nil, fmt.Errorf("gamedata: empty cooking recipe catalog")
}
return design, nil
}
@@ -0,0 +1,27 @@
package gamedata
import (
"database/sql"
"testing"
)
func TestCookingRecipesReadCatalogIdentities(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE CookingTable(id INTEGER); INSERT INTO CookingTable VALUES(503),(707)"); err != nil {
t.Fatal(err)
}
design, err := loadCookingRecipeDesign(db)
if err != nil || len(design.IDs) != 2 || !design.IDs[503] || !design.IDs[707] || design.IDs[101] {
t.Fatalf("design=%+v err=%v", design, err)
}
if _, err := db.Exec("INSERT INTO CookingTable VALUES(0)"); err != nil {
t.Fatal(err)
}
if _, err := loadCookingRecipeDesign(db); err == nil {
t.Fatal("invalid recipe identity accepted")
}
}
+3 -8
View File
@@ -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) {
+5 -1
View File
@@ -53,7 +53,11 @@ func TestReadQuestDatabase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), questDatabaseEntry, encrypted)
member, err := DatabaseName("common")
if err != nil {
t.Fatal(err)
}
writeDatabaseTestArchive(t, filepath.Join(release, ArchiveName), member, encrypted)
got, err := ReadQuestDatabase(root, "123")
if err != nil {
t.Fatal(err)
@@ -36,10 +36,86 @@ func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error
if c == nil || c.equipment == nil {
return fmt.Errorf("gamedata: missing equipment catalog")
}
g, ok := c.Gachas[71200001]
if !ok || !g.TicketOnly || len(g.TicketIDs) != 1 || g.TicketIDs[0] != 1104 || len(g.Pool) != 3 {
return fmt.Errorf("gamedata: missing or malformed UR equipment ticket pool")
// Select guaranteed weapon products from their existing equipment definitions,
// rather than tying eligibility to a product or resource-ticket number.
var targets []uint64
for id, g := range c.Gachas {
if !g.TicketOnly || len(g.TicketIDs) == 0 || len(g.Pool) == 0 {
continue
}
eligible := true
for _, branch := range g.Pool {
if branch.ID != 0 || branch.Weight == 0 || len(branch.Children) == 0 {
eligible = false
break
}
for _, item := range branch.Children {
if item.ID == 0 || len(item.Children) != 0 {
eligible = false
break
}
}
}
if !eligible {
continue
}
var visit func([]WeightedEquipment) error
visit = func(pool []WeightedEquipment) error {
for _, entry := range pool {
if entry.ID == 0 {
if len(entry.Children) == 0 {
eligible = false
}
if err := visit(entry.Children); err != nil {
return err
}
continue
}
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentTable WHERE id=?", entry.ID).Scan(&raw); err != nil {
return err
}
grade, e1 := packedInts(raw, 3)
quality, e2 := packedInts(raw, 18)
owner, e3 := packedInts(raw, 16)
if e1 != nil || e2 != nil || e3 != nil {
return fmt.Errorf("gamedata: malformed ticket equipment %d", entry.ID)
}
if len(grade) != 1 || grade[0] != 4 || len(quality) != 1 || quality[0] != 3 || len(owner) != 1 || owner[0] == 0 {
eligible = false
}
}
return nil
}
if err := visit(g.Pool); err != nil {
return err
}
if eligible {
targets = append(targets, id)
}
}
sort.Slice(targets, func(i, j int) bool { return targets[i] < targets[j] })
// Publish only after all selected products validate successfully.
clone := *c
clone.Gachas = make(map[uint64]EquipmentGacha, len(c.Gachas))
for id, g := range c.Gachas {
clone.Gachas[id] = g
}
clone.equipment = make(map[uint64]EquipmentDesign, len(c.equipment))
for id, d := range c.equipment {
clone.equipment[id] = d
}
for _, id := range targets {
if err := clone.includeCollaborationWeaponPool(db, id); err != nil {
return err
}
}
c.Gachas, c.equipment = clone.Gachas, clone.equipment
return nil
}
func (c *EquipmentGachaCatalog) includeCollaborationWeaponPool(db *sql.DB, id uint64) error {
g := c.Gachas[id]
readRows := func(query string) (map[uint64][]byte, error) {
rows, err := db.Query(query)
if err != nil {
@@ -110,7 +186,7 @@ func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error
return err
}
grade := grades[owner]
if grade < 3 || grade > 5 || (branchGrade != 0 && branchGrade != grade) {
if grade == 0 || (branchGrade != 0 && branchGrade != grade) {
return fmt.Errorf("gamedata: unknown or mixed UR ticket character tier")
}
branchGrade = grade
@@ -155,6 +231,10 @@ func (c *EquipmentGachaCatalog) includeCollaborationURWeapons(db *sql.DB) error
c.equipment[id] = design
}
g.Pool = pool
g.Grades = make(map[uint64]uint64, len(seen))
for id := range seen {
g.Grades[id] = c.equipment[id].Grade
}
c.Gachas[g.ID] = g
return nil
}
@@ -13,7 +13,7 @@ func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) {
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
c, err := LoadEquipmentGacha(root, "20260923193640")
c, err := LoadEquipmentGachaGroups(root, "20260923193640", []uint64{10002, 9, 133, 206})
if err != nil {
t.Fatal(err)
}
@@ -54,10 +54,16 @@ func TestIncludeCollaborationURWeaponsInstalled(t *testing.T) {
t.Fatal("original pool mutated")
}
for id, old := range before {
if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) {
if id != g.ID && !reflect.DeepEqual(old, c.Gachas[id]) && !old.TicketOnly {
t.Fatalf("other product %d changed", id)
}
}
// The same UR weapon ticket design is available in a one-item product.
// Its semantic eligibility cannot depend on one versus ten draw IDs.
one := c.Gachas[70200001]
if len(one.Pool) == 0 || len(one.Pool[0].Children) != len(g.Pool[0].Children) {
t.Fatal("equivalent one-ticket UR pool not extended")
}
for _, branch := range g.Pool {
for _, item := range branch.Children {
if _, _, _, err := c.RollOptions(item.ID); err != nil {
@@ -112,11 +118,11 @@ func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) {
t.Fatal(err)
}
pool := []WeightedEquipment{{Weight: 150, Children: []WeightedEquipment{{ID: 101, Weight: 1}}}, {Weight: 350, Children: []WeightedEquipment{{ID: 102, Weight: 1}}}, {Weight: 500, Children: []WeightedEquipment{{ID: 103, Weight: 1}}}}
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{71200001: {ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}}
c := &EquipmentGachaCatalog{Gachas: map[uint64]EquipmentGacha{880077: {ID: 880077, TicketOnly: true, TicketIDs: []uint64{990055}, Pool: pool}, 77: {ID: 77, Pool: pool}}, equipment: map[uint64]EquipmentDesign{}}
if err := c.includeCollaborationURWeapons(db); err != nil {
t.Fatal(err)
}
if len(c.Gachas[71200001].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 {
if len(c.Gachas[880077].Pool[0].Children) != 2 || len(c.Gachas[77].Pool[0].Children) != 1 {
t.Fatal("extension or isolation failed")
}
if _, ok := c.equipment[104]; !ok {
@@ -125,25 +131,145 @@ func TestIncludeCollaborationURWeaponsSynthetic(t *testing.T) {
if err := c.includeCollaborationURWeapons(db); err != nil {
t.Fatal(err)
}
if len(c.Gachas[71200001].Pool[0].Children) != 2 {
if len(c.Gachas[880077].Pool[0].Children) != 2 {
t.Fatal("duplicate owner or repeated inclusion duplicated equipment")
}
// Eligibility follows the product definitions even when there are fewer
// character rarity branches than the current installed three-tier pool.
c.Gachas[990088] = EquipmentGacha{ID: 990088, TicketOnly: true, TicketIDs: []uint64{554433}, Pool: pool[:1]}
if err := c.includeCollaborationURWeapons(db); err != nil {
t.Fatal(err)
}
if len(c.Gachas[990088].Pool[0].Children) != 2 {
t.Fatal("changed product/ticket/branch count ignored")
}
delete(c.Gachas, 990088)
if _, err := db.Exec("UPDATE CharTable SET ProtoBuf=? WHERE id=4", proto(20, 4, 9, 2)); err != nil {
t.Fatal(err)
}
delete(c.Gachas, 71200001)
c.Gachas[71200001] = EquipmentGacha{ID: 71200001, TicketOnly: true, TicketIDs: []uint64{1104}, Pool: pool}
delete(c.Gachas, 880077)
c.Gachas[880077] = EquipmentGacha{ID: 880077, TicketOnly: true, TicketIDs: []uint64{990055}, Pool: pool}
if err := c.includeCollaborationURWeapons(db); err == nil {
t.Fatal("unknown tier accepted")
}
if len(c.Gachas[71200001].Pool[0].Children) != 1 {
if len(c.Gachas[880077].Pool[0].Children) != 1 {
t.Fatal("failed inclusion published partial pool")
}
invalid := c.Gachas[71200001]
invalid := c.Gachas[880077]
invalid.Pool = append([]WeightedEquipment(nil), pool...)
invalid.Pool[0].Children = []WeightedEquipment{{ID: 101, Weight: 1}, {ID: 101, Weight: 1}}
c.Gachas[71200001] = invalid
c.Gachas[880077] = invalid
if err := c.includeCollaborationURWeapons(db); err == nil {
t.Fatal("duplicate existing candidate accepted")
}
}
func TestEquipmentFixedUsesChangedIDThresholdsAndResetDefault(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE GachaFixedTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
var raw []byte
for _, v := range []uint64{8, 7, 24, 43} {
raw = binary.AppendUvarint(raw, v)
}
if _, err := db.Exec("INSERT INTO GachaFixedTable VALUES(?,?)", 987, raw); err != nil {
t.Fatal(err)
}
fixed, err := loadEquipmentFixed(db, 987)
if err != nil || fixed.ID != 987 || fixed.SRCount != 7 || fixed.URCount != 43 || fixed.Reset {
t.Fatalf("dynamic fixed=%+v err=%v", fixed, err)
}
raw = append(raw, 48, 1)
if _, err := db.Exec("UPDATE GachaFixedTable SET ProtoBuf=?", raw); err != nil {
t.Fatal(err)
}
fixed, err = loadEquipmentFixed(db, 987)
if err != nil || !fixed.Reset {
t.Fatalf("reset=%+v err=%v", fixed, err)
}
}
func TestEquipmentFixedProgramsFollowEachGroup(t *testing.T) {
c := &EquipmentGachaCatalog{groups: map[uint64]EquipmentGachaGroup{71: {ID: 71, FixedID: 901}, 72: {ID: 72, FixedID: 902}}, fixedDesigns: map[uint64]EquipmentFixedDesign{901: {ID: 901, SRCount: 7, URCount: 43}, 902: {ID: 902, SRCount: 2, URCount: 5, Reset: true}}}
for _, pair := range []struct{ group, fixed, threshold uint64 }{{71, 901, 43}, {72, 902, 5}} {
fixed, ok := c.FixedForGroup(pair.group)
if !ok || fixed.ID != pair.fixed || fixed.URCount != pair.threshold {
t.Fatalf("group%d fixed=%+v found=%v", pair.group, fixed, ok)
}
}
if _, ok := c.FixedForGroup(73); ok {
t.Fatal("unknown group inherited another fixed program")
}
programs := c.FixedDesigns()
if len(programs) != 2 || programs[0].ID != 901 || programs[1].ID != 902 {
t.Fatalf("fixed designs=%+v", programs)
}
}
func TestEquipmentPityUsesActualGradesAndReorderedNestedPool(t *testing.T) {
g := EquipmentGacha{Count: 1, Grades: map[uint64]uint64{90: 2, 80: 4, 70: 3}, Pool: []WeightedEquipment{{ID: 90, Weight: 60}, {Weight: 30, Children: []WeightedEquipment{{ID: 80, Weight: 10}, {ID: 70, Weight: 20}}}}}
fixed := EquipmentFixedDesign{ID: 99, SRCount: 7, URCount: 43, Reset: true}
draw := func(limit uint64) (uint64, error) { return 0, nil }
got, state, err := g.rollWith(6, 42, fixed, draw)
if err != nil || len(got) != 1 || got[0] != 80 || state.URSort != 0 || state.SRCount != 0 || state.URCount != 0 {
t.Fatalf("UR actualgrade got=%v state=%+v err=%v", got, state, err)
}
got, state, err = g.rollWith(6, 0, fixed, draw)
if err != nil || got[0] != 70 || state.SRSort != 0 || state.SRCount != 0 || state.URCount != 1 {
t.Fatalf("SR actualgrade got=%v state=%+v err=%v", got, state, err)
}
fixed.Reset = false
got, state, err = g.rollWith(6, 42, fixed, draw)
if err != nil || got[0] != 80 || state.SRCount != 0 || state.URCount != 0 {
t.Fatalf("forced nonreset pity=%v state=%+v err=%v", got, state, err)
}
g.Pool = []WeightedEquipment{{ID: 80, Weight: 1}}
got, state, err = g.rollWith(2, 3, fixed, draw)
if err != nil || got[0] != 80 || state.SRCount != 3 || state.URCount != 4 {
t.Fatalf("natural UR nonreset=%v state=%+v err=%v", got, state, err)
}
}
func TestEquipmentConditionalGradePreservesMixedBranchProbability(t *testing.T) {
g := EquipmentGacha{Grades: map[uint64]uint64{1: 4, 2: 2, 3: 4}, Pool: []WeightedEquipment{{Weight: 1, Children: []WeightedEquipment{{ID: 1, Weight: 1}, {ID: 2, Weight: 3}}}, {Weight: 1, Children: []WeightedEquipment{{ID: 3, Weight: 1}}}}}
pool, err := g.gradePool(4)
if err != nil || len(pool) != 2 || pool[0].ID != 1 || pool[1].ID != 3 || pool[1].Weight != 4*pool[0].Weight {
t.Fatalf("mixed conditional distribution=%+v err=%v", pool, err)
}
}
func TestEquipmentGachaLoaderAcceptsChangedPriceAndPoolLength(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, table := range []string{"GachaTable", "RewardGroupTable"} {
if _, err := db.Exec("CREATE TABLE " + table + "(id INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
}
proto := func(values ...uint64) []byte {
var raw []byte
for i := 0; i < len(values); i += 2 {
raw = binary.AppendUvarint(raw, values[i]<<3)
raw = binary.AppendUvarint(raw, values[i+1])
}
return raw
}
if _, err := db.Exec("INSERT INTO GachaTable VALUES(?,?)", 808, proto(5, 2, 7, 909, 10, 666, 12, 3)); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 909, proto(5, 707, 6, 10, 8, 123)); err != nil {
t.Fatal(err)
}
g, err := loadEquipmentGacha(db, 808)
if err != nil || g.Count != 2 || g.Price != 666 || len(g.Pool) != 1 || g.Pool[0].Weight != 123 {
t.Fatalf("changed price/pool=%+v err=%v", g, err)
}
}
+91 -79
View File
@@ -20,6 +20,7 @@ type EquipmentGachaCatalog struct {
groups map[uint64]EquipmentGachaGroup
byGacha map[uint64]uint64
fixed EquipmentFixedDesign
fixedDesigns map[uint64]EquipmentFixedDesign
equipment map[uint64]EquipmentDesign
}
type EquipmentGacha struct {
@@ -30,6 +31,7 @@ type EquipmentGacha struct {
TicketIDs []uint64
Pool []WeightedEquipment
TicketOnly bool
Grades map[uint64]uint64
}
type EquipmentGachaGroup struct {
ID, FixedID, PointCount, OneTimeGachaID, TenTimeGachaID uint64
@@ -57,10 +59,6 @@ type OptionGroup struct {
type WeightedOption struct{ ID, Weight uint64 }
type EquipmentOptionChoice struct{ GroupID, ID uint64 }
func LoadEquipmentGacha(root, version string) (*EquipmentGachaCatalog, error) {
return LoadEquipmentGachaGroups(root, version, []uint64{10002, 9, 133, 206})
}
// LoadEquipmentGachaGroups loads only groups selected by a captured dynamic
// schedule. Presence in GameData alone does not mean a banner is open.
func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*EquipmentGachaCatalog, error) {
@@ -96,7 +94,7 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
pickupEnabled, _ := packedInts(raw, 20)
pickupCosts, _ := packedInts(raw, 25)
pickupItems, _ := packedInts(raw, 26)
if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] != 1 || len(points) != 1 || points[0] != 1 ||
if len(one) != 1 || len(ten) != 1 || len(fixed) != 1 || fixed[0] == 0 || len(points) != 1 || points[0] == 0 ||
len(gachaTypes) != 1 || gachaTypes[0] != 2 || len(pickupEnabled) > 1 || len(pickupCosts) > 1 || len(pickupItems) > 1 {
return nil, fmt.Errorf("gamedata: equipment group %d malformed", groupID)
}
@@ -190,20 +188,52 @@ func LoadEquipmentGachaGroups(root, version string, groupIDs []uint64) (*Equipme
if err := rows.Close(); err != nil {
return nil, err
}
var fixed []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=1").Scan(&fixed); err != nil {
for id, g := range c.Gachas {
g.Grades = map[uint64]uint64{}
var visit func([]WeightedEquipment)
visit = func(pool []WeightedEquipment) {
for _, entry := range pool {
if entry.ID != 0 {
g.Grades[entry.ID] = c.equipment[entry.ID].Grade
} else {
visit(entry.Children)
}
}
}
visit(g.Pool)
c.Gachas[id] = g
}
c.fixedDesigns = map[uint64]EquipmentFixedDesign{}
for _, group := range c.groups {
if _, ok := c.fixedDesigns[group.FixedID]; ok {
continue
}
fixed, err := loadEquipmentFixed(db, group.FixedID)
if err != nil {
return nil, err
}
sr, _ := packedInts(fixed, 1)
ur, _ := packedInts(fixed, 3)
reset, _ := packedInts(fixed, 6)
if len(sr) != 1 || sr[0] != 10 || len(ur) != 1 || ur[0] != 100 || len(reset) != 1 || reset[0] != 1 {
return nil, fmt.Errorf("gamedata: malformed equipment fixed table")
c.fixedDesigns[fixed.ID] = fixed
if c.fixed.ID == 0 || fixed.ID < c.fixed.ID {
c.fixed = fixed
}
}
c.fixed = EquipmentFixedDesign{ID: 1, SRCount: sr[0], URCount: ur[0], Reset: true}
return c, nil
}
func loadEquipmentFixed(db *sql.DB, id uint64) (EquipmentFixedDesign, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GachaFixedTable WHERE id=?", id).Scan(&raw); err != nil {
return EquipmentFixedDesign{}, err
}
sr, e1 := packedInts(raw, 1)
ur, e2 := packedInts(raw, 3)
reset, e3 := packedInts(raw, 6)
if e1 != nil || e2 != nil || e3 != nil || len(sr) != 1 || sr[0] == 0 || len(ur) != 1 || ur[0] == 0 || len(reset) > 1 || (len(reset) == 1 && reset[0] > 1) {
return EquipmentFixedDesign{}, fmt.Errorf("gamedata: malformed equipment fixed table %d", id)
}
return EquipmentFixedDesign{ID: id, SRCount: sr[0], URCount: ur[0], Reset: len(reset) == 1 && reset[0] == 1}, nil
}
func classifyEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, bool, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", groupID).Scan(&raw); err != nil {
@@ -259,27 +289,15 @@ func loadEquipmentGacha(db *sql.DB, id uint64) (EquipmentGacha, error) {
if len(freeCounts) == 1 {
freeCount = freeCounts[0]
}
if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || price[0] != uint64(count[0])*200 {
// Partial ticket consumption bills remaining draws individually; the local
// billing path currently requires an integral price per draw.
if len(count) != 1 || len(reward) != 1 || len(price) != 1 || len(kind) != 1 || kind[0] != 3 || count[0] == 0 || price[0] == 0 || price[0]%count[0] != 0 {
return EquipmentGacha{}, fmt.Errorf("gamedata: malformed equipment gacha %d", id)
}
pool, err := loadEquipmentRewardPool(db, reward[0])
if err != nil {
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d: %w", id, err)
}
var want []uint64
switch len(pool) {
case 8: // permanent draw 200/201: one 3% five-star UR branch.
want = []uint64{300, 200, 250, 850, 1700, 400, 1600, 4700}
case 9: // pickup groups: UP and non-UP five-star UR branches.
want = []uint64{150, 150, 200, 250, 850, 1700, 400, 1600, 4700}
default:
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d has %d branches", id, len(pool))
}
for i := range pool {
if pool[i].Weight != want[i] {
return EquipmentGacha{}, fmt.Errorf("gamedata: equipment gacha %d branch %d rate=%d", id, i, pool[i].Weight)
}
}
return EquipmentGacha{ID: id, Count: int(count[0]), FreeCountDay: freeCount, PriceType: kind[0], Price: price[0], TicketIDs: append([]uint64(nil), tickets...), Pool: pool}, nil
}
func loadEquipmentRewardPool(db *sql.DB, groupID uint64) ([]WeightedEquipment, error) {
@@ -421,6 +439,28 @@ func (c *EquipmentGachaCatalog) Groups() []EquipmentGachaGroup {
return out
}
func (c *EquipmentGachaCatalog) Fixed() EquipmentFixedDesign { return c.fixed }
func (c *EquipmentGachaCatalog) FixedForGroup(groupID uint64) (EquipmentFixedDesign, bool) {
if c == nil {
return EquipmentFixedDesign{}, false
}
group, ok := c.groups[groupID]
if !ok {
return EquipmentFixedDesign{}, false
}
fixed, ok := c.fixedDesigns[group.FixedID]
return fixed, ok
}
func (c *EquipmentGachaCatalog) FixedDesigns() []EquipmentFixedDesign {
if c == nil {
return nil
}
out := make([]EquipmentFixedDesign, 0, len(c.fixedDesigns))
for _, fixed := range c.fixedDesigns {
out = append(out, fixed)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
func (c *EquipmentGachaCatalog) RollOptions(id uint64) (main, sub []EquipmentOptionChoice, private *EquipmentOptionChoice, err error) {
d, ok := c.equipment[id]
if !ok {
@@ -485,21 +525,29 @@ type EquipmentRoll struct {
func (g EquipmentGacha) rollWith(sr, ur uint64, fixed EquipmentFixedDesign, draw func(uint64) (uint64, error)) ([]uint64, EquipmentRoll, error) {
state := EquipmentRoll{SRCount: sr, URCount: ur, SRSort: -1, URSort: -1}
if g.Count <= 0 || (len(g.Pool) != 8 && len(g.Pool) != 9) {
if g.Count <= 0 || len(g.Pool) == 0 || len(g.Grades) == 0 {
return nil, state, errors.New("gamedata: invalid equipment gacha")
}
out := make([]uint64, g.Count)
for i := range out {
forceUR := state.URCount+1 >= fixed.URCount
forceSR := !forceUR && state.SRCount+1 >= fixed.SRCount
forceUR := fixed.URCount != 0 && state.URCount+1 >= fixed.URCount
forceSR := !forceUR && fixed.SRCount != 0 && state.SRCount+1 >= fixed.SRCount
var id uint64
var err error
switch {
case forceUR:
id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, true), draw)
var pool []WeightedEquipment
pool, err = g.gradePool(4)
if err == nil {
id, err = rollEquipmentChoiceWith(pool, draw)
}
state.URSort = i
case forceSR:
id, err = rollEquipmentChoiceWith(equipmentGuaranteedPool(g.Pool, false), draw)
var pool []WeightedEquipment
pool, err = g.gradePool(3)
if err == nil {
id, err = rollEquipmentChoiceWith(pool, draw)
}
state.SRSort = i
default:
id, err = rollEquipmentChoiceWith(g.Pool, draw)
@@ -508,59 +556,23 @@ func (g EquipmentGacha) rollWith(sr, ur uint64, fixed EquipmentFixedDesign, draw
return nil, state, err
}
out[i] = id
tier := equipmentTier(g.Pool, id)
switch tier {
case 2:
state.SRCount = 0
state.URCount = 0
case 1:
state.SRCount = 0
state.URCount++
default:
grade, known := g.Grades[id]
if !known || grade == 0 || grade > 4 {
return nil, state, errors.New("gamedata: equipment grade unavailable")
}
state.SRCount++
state.URCount++
if forceSR || forceUR || fixed.Reset && grade >= 3 {
state.SRCount = 0
}
if forceUR || fixed.Reset && grade == 4 {
state.URCount = 0
}
}
return out, state, nil
}
func equipmentGuaranteedPool(pool []WeightedEquipment, ur bool) []WeightedEquipment {
if len(pool) == 8 {
if ur {
return []WeightedEquipment{{Weight: 15, Children: pool[0:1]}, {Weight: 35, Children: pool[2:3]}, {Weight: 50, Children: pool[5:6]}}
}
return []WeightedEquipment{{Weight: 15, Children: pool[1:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}}
}
if ur {
return []WeightedEquipment{{Weight: 15, Children: pool[0:2]}, {Weight: 35, Children: pool[3:4]}, {Weight: 50, Children: pool[6:7]}}
}
return []WeightedEquipment{{Weight: 15, Children: pool[2:3]}, {Weight: 35, Children: pool[4:5]}, {Weight: 50, Children: pool[7:8]}}
}
func equipmentTier(pool []WeightedEquipment, id uint64) int {
for i, item := range pool {
if containsEquipment(item, id) {
if len(pool) == 8 {
switch i {
case 0, 2, 5:
return 2
case 1, 3, 6:
return 1
default:
return 0
}
}
switch i {
case 0, 1, 3, 6:
return 2
case 2, 4, 7:
return 1
default:
return 0
}
}
}
return -1
}
func containsEquipment(item WeightedEquipment, id uint64) bool {
if item.ID != 0 {
return item.ID == id
@@ -0,0 +1,74 @@
package gamedata
import (
"errors"
"math/big"
)
// Local pity policy: no separate official equipment pity pool is available.
// gradePool conditions the actual nested weighted distribution on one grade.
// Exact rational path weights preserve branches with different child totals.
func (g EquipmentGacha) gradePool(grade uint64) ([]WeightedEquipment, error) {
weights := map[uint64]*big.Rat{}
var walk func([]WeightedEquipment, *big.Rat) error
walk = func(pool []WeightedEquipment, path *big.Rat) error {
total := new(big.Int)
for _, item := range pool {
if item.Weight == 0 {
return errors.New("gamedata: zero equipment weight")
}
total.Add(total, new(big.Int).SetUint64(item.Weight))
}
if total.Sign() == 0 {
return errors.New("gamedata: empty equipment pool")
}
for _, item := range pool {
prob := new(big.Rat).Mul(path, new(big.Rat).SetFrac(new(big.Int).SetUint64(item.Weight), total))
if item.ID == 0 {
if err := walk(item.Children, prob); err != nil {
return err
}
continue
}
actual, ok := g.Grades[item.ID]
if !ok || actual == 0 || actual > 4 {
return errors.New("gamedata: equipment grade unavailable")
}
if actual == grade {
if weights[item.ID] == nil {
weights[item.ID] = new(big.Rat)
}
weights[item.ID].Add(weights[item.ID], prob)
}
}
return nil
}
if err := walk(g.Pool, new(big.Rat).SetInt64(1)); err != nil {
return nil, err
}
denom := big.NewInt(1)
for _, weight := range weights {
gcd := new(big.Int).GCD(nil, nil, denom, weight.Denom())
denom.Mul(new(big.Int).Quo(denom, gcd), weight.Denom())
}
var ids []uint64
for id := range weights {
ids = append(ids, id)
}
// Stable table-independent ordering makes deterministic draw sources reproducible.
for i := 1; i < len(ids); i++ {
for j := i; j > 0 && ids[j] < ids[j-1]; j-- {
ids[j], ids[j-1] = ids[j-1], ids[j]
}
}
var out []WeightedEquipment
for _, id := range ids {
w := weights[id]
count := new(big.Int).Mul(w.Num(), new(big.Int).Quo(new(big.Int).Set(denom), w.Denom()))
if !count.IsUint64() {
return nil, errors.New("gamedata: conditioned equipment weight overflow")
}
out = append(out, WeightedEquipment{ID: id, Weight: count.Uint64()})
}
return out, nil
}
@@ -10,7 +10,7 @@ func TestEquipmentTicketOnlyGachaAgainstInstalledVersion23510(t *testing.T) {
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
catalog, err := LoadEquipmentGacha(root, "20260923193640")
catalog, err := LoadEquipmentGachaGroups(root, "20260923193640", []uint64{10002, 9, 133, 206})
if err != nil {
t.Fatal(err)
}
+56 -13
View File
@@ -1,17 +1,18 @@
package gamedata
import (
"crypto/rand"
"database/sql"
"fmt"
"math/big"
"os"
"path/filepath"
)
// FieldObjectDesign retains the client table identities, including unsupported
// reward graphs, so callers can reject them without inventing a reward.
type FieldObjectDesign struct{ Objects map[int]FieldRewardObject }
type FieldObjectDesign struct {
Objects map[int]FieldRewardObject
Equipment *EquipmentGachaCatalog
}
type FieldRewardObject struct {
ID, MapID, GroupID, Type, ResetType, QuestID, BuffID, MonsterID int
Rewards []BattleReward
@@ -46,7 +47,7 @@ func LoadFieldObjects(root, version string, pack int) (FieldObjectDesign, error)
return loadFieldObjects(db, common)
}
func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}}
design := FieldObjectDesign{Objects: map[int]FieldRewardObject{}, Equipment: &EquipmentGachaCatalog{equipment: map[uint64]EquipmentDesign{}}}
groups := map[int]FieldRewardObject{}
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldRewardObjectGroupTable")
if err != nil {
@@ -91,17 +92,28 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
rows.Close()
return design, fmt.Errorf("gamedata: malformed field loot %d", group[0])
}
if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) == 1 && drop[0] > 0 && len(dropType) <= 1 {
if len(ids) > 0 && len(ids) == len(types) && len(ids) == len(counts) && len(ratios) == len(ids) && len(drop) <= 1 && len(dropType) <= 1 {
if len(drop) == 1 {
obj.DropCount = drop[0]
}
obj.Ratios = ratios
if len(dropType) > 0 {
obj.DropType = dropType[0]
}
for i := range ids {
obj.Rewards = append(obj.Rewards, BattleReward{ID: ids[i], Type: types[i], Count: counts[i]})
if types[i] == 10 {
if e := design.Equipment.loadEquipmentTree(common, WeightedEquipment{ID: ids[i]}); e != nil {
rows.Close()
return design, e
}
}
}
} else {
rows.Close()
return design, fmt.Errorf("gamedata: malformed field loot entries %d", group[0])
}
}
groups[id] = obj
}
if err = rows.Err(); err != nil {
@@ -138,29 +150,60 @@ func loadFieldObjects(db, common *sql.DB) (FieldObjectDesign, error) {
return design, rows.Err()
}
// Draw uses the table's normalized ratio distribution for DropType 0.
// Independent DropType 1 uses a different ratio unit and needs separate evidence.
// DropType 0 makes DropCount weighted selections. DropType 1 checks each
// component's percentage independently; its DropCount may be proto default zero.
func (o FieldRewardObject) Draw() ([]BattleReward, error) {
if o.DropType != 0 || o.DropCount == 0 || o.DropCount > 100 || len(o.Rewards) != len(o.Ratios) {
return o.draw(cryptoDraw)
}
func (o FieldRewardObject) draw(draw func(uint64) (uint64, error)) ([]BattleReward, error) {
if o.DropType > 1 || (o.DropType == 0 && o.DropCount == 0) || o.DropCount > 100 || len(o.Rewards) == 0 || len(o.Rewards) != len(o.Ratios) {
return nil, fmt.Errorf("gamedata: unsupported field reward draw")
}
var total uint64
for _, r := range o.Ratios {
for i, r := range o.Ratios {
if o.Rewards[i].Count == 0 || o.Rewards[i].Count > uint64(^uint32(0)>>1) || (o.DropType == 1 && r > 100) {
return nil, fmt.Errorf("gamedata: malformed field reward component")
}
if ^uint64(0)-total < r {
return nil, fmt.Errorf("gamedata: reward ratio overflow")
}
total += r
}
if total == 0 {
if total == 0 && o.DropType == 0 {
return nil, fmt.Errorf("gamedata: empty field reward distribution")
}
var result []BattleReward
for n := uint64(0); n < o.DropCount; n++ {
value, err := rand.Int(rand.Reader, new(big.Int).SetUint64(total))
if o.DropType == 1 {
for i, ratio := range o.Ratios {
if ratio == 0 {
continue
}
if ratio < 100 {
value, err := draw(100)
if err != nil {
return nil, err
}
pick := value.Uint64()
if value >= 100 {
return nil, fmt.Errorf("gamedata: field reward random out of range")
}
if value >= ratio {
continue
}
}
result = append(result, o.Rewards[i])
}
return result, nil
}
for n := uint64(0); n < o.DropCount; n++ {
value, err := draw(total)
if err != nil {
return nil, err
}
if value >= total {
return nil, fmt.Errorf("gamedata: field reward random out of range")
}
pick := value
for i, ratio := range o.Ratios {
if pick < ratio {
result = append(result, o.Rewards[i])
@@ -1,10 +1,80 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
"time"
)
func TestFieldObjectLoadIndependentEquipmentAndRandomBox(t *testing.T) {
pack, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer pack.Close()
common, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer common.Close()
for _, table := range []string{"FieldRewardObjectGroupTable", "FieldRewardObjectTable"} {
if _, err := pack.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
}
for _, table := range []string{"RewardGroupTable", "EquipmentTable"} {
if _, err := common.Exec("CREATE TABLE " + table + "(id INTEGER, ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
}
if _, err := common.Exec("CREATE TABLE EquipmentOptionTable(GroupId INTEGER, id INTEGER, ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
group := wire.AppendVarint(nil, 10, 66)
group = wire.AppendVarint(group, 12, 8) // A newly introduced object kind must not need an allowlist.
if _, err := pack.Exec("INSERT INTO FieldRewardObjectGroupTable VALUES(?,?)", 55, group); err != nil {
t.Fatal(err)
}
object := wire.AppendVarint(nil, 3, 55)
object = wire.AppendVarint(object, 6, 44)
if _, err := pack.Exec("INSERT INTO FieldRewardObjectTable VALUES(?,?)", 33, object); err != nil {
t.Fatal(err)
}
loot := wire.AppendVarint(nil, 2, 1)
for _, r := range []BattleReward{{Type: 10, ID: 77, Count: 2}, {Type: 9, ID: 88, Count: 1}} {
loot = wire.AppendVarint(loot, 4, r.Count)
loot = wire.AppendVarint(loot, 5, r.ID)
loot = wire.AppendVarint(loot, 6, r.Type)
loot = wire.AppendVarint(loot, 8, 100)
}
if _, err := common.Exec("INSERT INTO RewardGroupTable VALUES(?,?)", 66, loot); err != nil {
t.Fatal(err)
}
equip := wire.AppendVarint(nil, 3, 3)
equip = wire.AppendVarint(equip, 12, 99)
if _, err := common.Exec("INSERT INTO EquipmentTable VALUES(?,?)", 77, equip); err != nil {
t.Fatal(err)
}
option := wire.AppendVarint(nil, 5, 7)
option = wire.AppendVarint(option, 2, 100)
if _, err := common.Exec("INSERT INTO EquipmentOptionTable VALUES(?,?,?)", 99, 7, option); err != nil {
t.Fatal(err)
}
d, err := loadFieldObjects(pack, common)
if err != nil {
t.Fatal(err)
}
got, err := d.Objects[33].Draw()
if err != nil || len(got) != 2 || got[0].ID != 77 || got[0].Count != 2 || got[1].Type != 9 || got[1].ID != 88 {
t.Fatalf("rewards=%+v err=%v", got, err)
}
main, _, _, err := d.Equipment.RollOptions(77)
if err != nil || len(main) != 1 || main[0].GroupID != 99 || main[0].ID != 7 {
t.Fatalf("options=%+v err=%v", main, err)
}
}
func TestFieldResetUsesConfiguredBoundary(t *testing.T) {
s := FieldResetSchedule{DailyReset: 9 * time.Hour, WeeklyDay: time.Monday}
before := time.Date(2026, 10, 4, 23, 59, 59, 0, time.UTC)
@@ -39,7 +109,39 @@ func TestFieldWeightedDrawCountAndZeroWeight(t *testing.T) {
}
}
o.DropType = 1
o.DropCount = 0
got, err := o.Draw()
if err != nil || len(got) != 1 || got[0].ID != 2 {
t.Fatalf("independent draw=%+v err=%v", got, err)
}
}
func TestFieldDrawRejectsMalformedUnselectedBranch(t *testing.T) {
o := FieldRewardObject{DropCount: 1, Rewards: []BattleReward{{Type: 5, ID: 1, Count: 1}, {Type: 5, ID: 2}}, Ratios: []uint64{100, 0}}
if _, err := o.Draw(); err == nil {
t.Fatal("unsupported independent distribution accepted")
t.Fatal("zero-weight malformed reward accepted")
}
o.Rewards[1].Count = 1
o.DropType = 1
o.Ratios[1] = 101
if _, err := o.Draw(); err == nil {
t.Fatal("independent percentage above 100 accepted")
}
}
func TestFieldIndependentDrawUsesEveryPercentageOnce(t *testing.T) {
o := FieldRewardObject{DropType: 1, DropCount: 99, Ratios: []uint64{0, 25, 25, 100}, Rewards: []BattleReward{{ID: 1, Count: 1}, {ID: 2, Count: 1}, {ID: 3, Count: 1}, {ID: 4, Count: 1}}}
values := []uint64{24, 25}
calls := 0
got, err := o.draw(func(limit uint64) (uint64, error) {
if limit != 100 || calls >= len(values) {
t.Fatalf("unexpected draw limit=%d calls=%d", limit, calls)
}
v := values[calls]
calls++
return v, nil
})
if err != nil || calls != 2 || len(got) != 2 || got[0].ID != 2 || got[1].ID != 4 {
t.Fatalf("independent percentage boundary: got=%+v calls=%d err=%v", got, calls, err)
}
}
+134 -149
View File
@@ -18,9 +18,6 @@ import (
)
const (
InfiniteGachaID = 9100033
InfiniteProductGroupID = 1100001
InfiniteProductID = 9100033
officialRateScale = 10000
officialFiveStarRate = 300
officialFourStarRate = 1400
@@ -30,6 +27,9 @@ const (
// "infinite reroll" product. The local server deliberately makes final
// confirmation free, but still reads the official result pool from GameData.
type InfiniteGachaDesign struct {
GachaID, ProductGroupID, ProductID, SaleGroup uint64
rewardProgram *CostumeRewardGroup
GroupID uint64
Count int
CostumeIDs []uint64 // five-star compatibility view
FiveStarIDs []uint64
@@ -54,6 +54,7 @@ type WeightedCostume struct {
}
type RegularGacha struct {
Grades map[uint64]uint64
ID uint64
Count int
DailyPayGachaCount uint64
@@ -75,6 +76,7 @@ type GachaGroupDesign struct {
CashProductGroupID uint64
CashProductID uint64
CashSalesGroup uint64
CashRewards []BattleReward
FixedID uint64
PointCount uint64
PickUpExchangeCost uint64
@@ -89,6 +91,10 @@ type GachaGroupDesign struct {
IsSelectedFromPity bool
}
// CostumeRewardGroup preserves the audited costume reward program.
// DropType 1 is the current fixed combination mode with ratio=1 per child;
// this is distinct from field-object independent percentage rolls. Unsupported
// combination ratios fail loading rather than silently discarding probability.
// CostumeRewardGroup preserves RewardGroupTable's execution structure. A
// zero DropType repeats one weighted choice DropCount times; DropType 1 emits
// every entry once. Keeping that distinction is required for selection and
@@ -366,24 +372,8 @@ func (c *RegularGachaCatalog) CostumeGrade(costumeID uint64) (uint64, bool) {
}
func (c *RegularGachaCatalog) recordPoolGrades(gacha RegularGacha) {
if gacha.FixedCostumeID != 0 {
c.grades[gacha.FixedCostumeID] = 5
}
for branch, item := range gacha.Pool {
grade := uint64(0)
switch len(gacha.Pool) {
case 4:
if branch < 2 {
grade = 5
} else {
grade = uint64(6 - branch) // branch 2/3 => grade 4/3
}
case 3:
grade = uint64(5 - branch)
}
if grade != 0 {
recordCostumeGrade(c.grades, item, grade)
}
for id, grade := range gacha.Grades {
c.grades[id] = grade
}
}
@@ -580,12 +570,16 @@ func (g RegularGacha) RollWithCostumeFixedSelection(previous4, previous5 uint64,
func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed GachaFixedDesign, normalSelectedFive, pitySelectedFive []uint64, draw func(uint64) (uint64, error)) ([]uint64, GachaFixedRoll, error) {
state := GachaFixedRoll{CostumeGrade4Count: previous4, CostumeGrade5Count: previous5, CostumeGrade4Sort: -1, CostumeGrade5Sort: -1}
if g.Count <= 0 || (len(g.Pool) != 3 && len(g.Pool) != 4) || fixed.ID == 0 || (fixed.CostumeGrade4Count == 0 && fixed.CostumeGrade5Count == 0) || draw == nil {
if g.Count <= 0 || len(g.Pool) == 0 || fixed.ID == 0 || (fixed.CostumeGrade4Count == 0 && fixed.CostumeGrade5Count == 0) || draw == nil {
return nil, state, errors.New("gamedata: invalid costume fixed gacha")
}
fiveBranches := 1
if len(g.Pool) == 4 {
fiveBranches = 2
fivePool, err := g.gradePool(5)
if err != nil {
return nil, state, err
}
fourPool, err := g.gradePool(4)
if err != nil {
return nil, state, err
}
result := make([]uint64, g.Count)
for i := range result {
@@ -607,18 +601,21 @@ func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed Ga
state.SelectionSorts = append(state.SelectionSorts, i)
}
} else {
id, err = rollCostumeChoiceWith(g.Pool[:fiveBranches], draw)
id, err = rollCostumeChoiceWith(fivePool, draw)
}
grade = 5
state.CostumeGrade5Sort = i
case force4:
id, err = rollCostumeChoiceWith([]WeightedCostume{g.Pool[fiveBranches]}, draw)
id, err = rollCostumeChoiceWith(fourPool, draw)
grade = 4
state.CostumeGrade4Sort = i
default:
var branch int
branch, id, err = rollCostumeBranchWith(g.Pool, draw)
if branch < fiveBranches {
_, id, err = rollCostumeBranchWith(g.Pool, draw)
grade = g.Grades[id]
if grade < 3 || grade > 5 {
return nil, state, errors.New("gamedata: missing costume grade")
}
if grade == 5 {
grade = 5
if err == nil && len(normalSelectedFive) != 0 {
var selected uint64
@@ -631,25 +628,24 @@ func (g RegularGacha) rollWithCostumeFixed(previous4, previous5 uint64, fixed Ga
state.SelectionSorts = append(state.SelectionSorts, i)
}
}
} else {
grade = uint64(4 + fiveBranches - branch)
}
}
if err != nil {
return nil, state, err
}
result[i] = id
switch grade {
case 5:
state.CostumeGrade4Count = 0
state.CostumeGrade5Count = 0
case 4:
state.CostumeGrade4Count = 0
state.CostumeGrade5Count++
default:
if state.CostumeGrade4Count == math.MaxUint64 || state.CostumeGrade5Count == math.MaxUint64 {
return nil, state, errors.New("gamedata: costume fixed counter overflow")
}
state.CostumeGrade4Count++
state.CostumeGrade5Count++
if force4 || force5 || fixed.ResetOnMatchingGrade && grade >= 4 {
state.CostumeGrade4Count = 0
}
if force5 || fixed.ResetOnMatchingGrade && grade == 5 {
state.CostumeGrade5Count = 0
}
}
return result, state, nil
}
@@ -666,21 +662,24 @@ func (g RegularGacha) rollWith(draw func(uint64) (uint64, error)) ([]uint64, err
}
hasFourOrFive := g.FixedCostumeID != 0
for i := start; i < len(result); i++ {
branch, id, err := rollCostumeBranchWith(g.Pool, draw)
_, id, err := rollCostumeBranchWith(g.Pool, draw)
if err != nil {
return nil, err
}
if branch < len(g.Pool)-1 {
if g.Grades[id] >= 4 {
hasFourOrFive = true
}
result[i] = id
}
// Official costume ten-pulls guarantee grade four or above. The ordinary
// four-branch pools are ordered as pickup-5, other-5, grade-4, grade-3.
// If all ten normal rolls landed in grade 3, replace the final slot with
// an equal-weight draw from the real grade-4 branch.
if g.FixedCostumeID == 0 && g.Count == 10 && len(g.Pool) == 4 && !hasFourOrFive {
id, err := rollCostumeChoiceWith([]WeightedCostume{g.Pool[2]}, draw)
// Ordinary costume ten-pulls guarantee grade four or above. If all
// natural results are grade three, condition the actual weighted pool on
// grade four for the last slot, irrespective of branch order or count.
if g.FixedCostumeID == 0 && g.Count == 10 && len(g.Grades) != 0 && !hasFourOrFive {
fourPool, err := g.gradePool(4)
if err != nil || len(fourPool) == 0 {
return nil, errors.New("gamedata: grade four guarantee pool unavailable")
}
id, err := rollCostumeChoiceWith(fourPool, draw)
if err != nil {
return nil, err
}
@@ -820,6 +819,9 @@ func rollCostumeBranch(pool []WeightedCostume) (int, uint64, error) {
}
func rollCostumeBranchWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (int, uint64, error) {
if len(pool) == 0 || draw == nil {
return 0, 0, errors.New("gamedata: empty costume choice")
}
var total uint64
for _, item := range pool {
if item.Weight == 0 || math.MaxUint64-total < item.Weight {
@@ -855,6 +857,9 @@ func rollCostumeChoice(pool []WeightedCostume) (uint64, error) {
}
func rollCostumeChoiceWith(pool []WeightedCostume, draw func(uint64) (uint64, error)) (uint64, error) {
if len(pool) == 0 || draw == nil {
return 0, errors.New("gamedata: empty costume choice")
}
var total uint64
for _, item := range pool {
if item.Weight == 0 || math.MaxUint64-total < item.Weight {
@@ -884,14 +889,6 @@ func rollCostumeChoiceWith(pool []WeightedCostume, draw func(uint64) (uint64, er
return 0, errors.New("gamedata: regular gacha selection failed")
}
// LoadRegularCostumeGacha retains the historical catalog used by existing
// callers. New schedule code should pass the captured active group IDs to
// LoadRegularCostumeGachaGroups; GameData defines a group but does not say that
// it is currently open.
func LoadRegularCostumeGacha(root, version string) (*RegularGachaCatalog, error) {
return LoadRegularCostumeGachaGroups(root, version, []uint64{10001, 135, 205, 121}, []uint64{29})
}
func LoadActiveGacha(root, version string, costumeGroupIDs, equipmentGroupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, *EquipmentGachaCatalog, error) {
costumes, err := LoadRegularCostumeGachaGroups(root, version, costumeGroupIDs, stepUpGroupIDs)
if err != nil {
@@ -1029,10 +1026,6 @@ func ClassifyActiveGachaGroups(root, version string, groupIDs []uint64) (costume
}
func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, error) {
infinite, err := LoadInfiniteGacha(root, version)
if err != nil {
return nil, err
}
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, err
@@ -1069,9 +1062,6 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID
return nil, err
}
}
for id, character := range infinite.characters {
catalog.characters[id] = character
}
gachaIDs := make(map[uint64]struct{})
for _, groupID := range groupIDs {
var raw []byte
@@ -1169,16 +1159,6 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID
return nil, err
}
}
if len(pool) == 4 {
err = validateOfficialPickupRates(pool)
} else if len(pool) == 3 {
err = validateRegularCostumeRates(pool)
} else if reward.DropType == 0 {
err = fmt.Errorf("expected three or four rarity branches, got %d", len(pool))
}
if err != nil {
return nil, fmt.Errorf("gamedata: regular gacha %d probability: %w", id, err)
}
var program *CostumeRewardGroup
if reward.DropType != 0 {
program = reward
@@ -1202,7 +1182,7 @@ func LoadRegularCostumeGachaGroups(root, version string, groupIDs, stepUpGroupID
if (dailyPayCount == 0) != (dailyPayPrice == 0) {
return nil, fmt.Errorf("gamedata: regular gacha %d has incomplete daily paid design", id)
}
catalog.Gachas[id] = RegularGacha{ID: id, Count: int(counts[0]), DailyPayGachaCount: dailyPayCount, DailyPayGachaPriceCount: dailyPayPrice, FreeCountDay: freeCount, PriceType: priceTypes[0], PriceID: priceID, Price: prices[0], Pool: pool, RewardGroup: program, FixedCostumeID: fixedCostumeID, TicketIDs: ticketIDs}
catalog.Gachas[id] = RegularGacha{Grades: catalog.grades, ID: id, Count: int(counts[0]), DailyPayGachaCount: dailyPayCount, DailyPayGachaPriceCount: dailyPayPrice, FreeCountDay: freeCount, PriceType: priceTypes[0], PriceID: priceID, Price: prices[0], Pool: pool, RewardGroup: program, FixedCostumeID: fixedCostumeID, TicketIDs: ticketIDs}
catalog.recordPoolGrades(catalog.Gachas[id])
}
if err := catalog.loadGroupsAndFixed(db); err != nil {
@@ -1276,11 +1256,11 @@ func loadCostumeRewardGroup(db *sql.DB, id uint64, visiting map[uint64]bool) (*C
}
return 0, false
}
dropType, _ := one(2)
dropCount, ok := one(1)
if !ok || dropCount == 0 {
if dropType > 1 || dropType == 0 && (!ok || dropCount == 0) {
return nil, errors.New("reward group has invalid drop count")
}
dropType, _ := one(2)
ids, _ := packedInts(raw, 5)
types, _ := packedInts(raw, 6)
counts, _ := packedInts(raw, 4)
@@ -1291,7 +1271,7 @@ func loadCostumeRewardGroup(db *sql.DB, id uint64, visiting map[uint64]bool) (*C
group := &CostumeRewardGroup{ID: id, DropCount: dropCount, DropType: dropType, Entries: make([]CostumeRewardEntry, len(ids))}
for i := range ids {
entry := CostumeRewardEntry{ItemType: types[i], ItemID: ids[i], Count: counts[i], Weight: weights[i]}
if entry.Count == 0 || entry.Weight == 0 {
if entry.Count == 0 || entry.Weight == 0 || dropType == 1 && entry.Weight != 1 {
return nil, errors.New("reward group has zero count or weight")
}
switch entry.ItemType {
@@ -1373,8 +1353,12 @@ func collectCostumeRewardIDs(group *CostumeRewardGroup, out *[]uint64) {
}
func loadCostumeGrade(db *sql.DB, costumeID uint64) (uint64, error) {
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
if err != nil {
return 0, err
}
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", costumeID/10).Scan(&raw); err != nil {
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&raw); err != nil {
return 0, err
}
grades, _ := packedInts(raw, 9)
@@ -1384,19 +1368,6 @@ func loadCostumeGrade(db *sql.DB, costumeID uint64) (uint64, error) {
return grades[0], nil
}
func validateRegularCostumeRates(pool []WeightedCostume) error {
if len(pool) != 3 {
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
}
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
for i, item := range pool {
if item.Weight != want[i] {
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
}
}
return nil
}
func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable")
if err != nil {
@@ -1427,6 +1398,13 @@ func (c *RegularGachaCatalog) loadGroupsAndFixed(db *sql.DB) error {
if !oneLoaded && !tenLoaded {
continue
}
if group.CashProductGroupID != 0 && group.CashProductID != 0 {
var err error
group.CashRewards, err = loadGachaCashRewards(db, group)
if err != nil {
return err
}
}
c.groups[id] = group
if oneLoaded {
c.byGacha[group.OneTimeGachaID] = id
@@ -1514,35 +1492,6 @@ func (c *RegularGachaCatalog) loadStepFixed(db *sql.DB) error {
return nil
}
func validateFixedPickupRemainderRates(pool []WeightedCostume) error {
if len(pool) != 3 {
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
}
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
for i, item := range pool {
if item.Weight != want[i] {
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
}
}
return nil
}
// validateOfficialPickupRates prevents a GameData/schema regression from
// silently changing the published costume pickup rates. The two five-star
// branches are pickup 1.5% plus the ordinary five-star pool 1.5%.
func validateOfficialPickupRates(pool []WeightedCostume) error {
if len(pool) != 4 {
return fmt.Errorf("expected four rarity branches, got %d", len(pool))
}
want := [...]uint64{150, 150, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
for i, item := range pool {
if item.Weight != want[i] {
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
}
}
return nil
}
func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) {
for _, item := range pool {
if item.ID != 0 {
@@ -1554,7 +1503,10 @@ func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) {
}
func loadGachaCharacterDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) {
characterID := costumeID / 10
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
if err != nil {
return CharacterDesign{}, err
}
var character []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil {
return CharacterDesign{}, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err)
@@ -1680,8 +1632,8 @@ func NewInfiniteGachaDesign(count int, costumeIDs []uint64, characters map[uint6
return NewInfiniteGachaDesignWithRates(count, costumeIDs, costumeIDs, costumeIDs, characters)
}
// NewInfiniteGachaDesignWithRates builds the local reroll design. Its first
// Count-1 slots use the official costume base rates (3%/14%/83%), while the
// NewInfiniteGachaDesignWithRates builds a synthetic reroll policy for tests.
// Its first Count-1 slots use an explicit 3%/14%/83% synthetic policy, while the
// last slot is a five-star guarantee chosen uniformly from the real eligible
// five-star pool.
func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeStarIDs []uint64, characters map[uint64]CharacterDesign) (*InfiniteGachaDesign, error) {
@@ -1705,7 +1657,7 @@ func NewInfiniteGachaDesignWithRates(count int, fiveStarIDs, fourStarIDs, threeS
return design, nil
}
func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
func LoadInfiniteGachaForSchedules(root, version string, groups []uint64) (*InfiniteGachaDesign, error) {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, err
@@ -1725,30 +1677,41 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
}
defer db.Close()
infiniteGroupID, infiniteGachaID, err := loadInfiniteGachaIdentity(db, groups)
if err != nil {
return nil, err
}
var gacha []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", InfiniteGachaID).Scan(&gacha); err != nil {
if err := db.QueryRow("SELECT ProtoBuf FROM GachaTable WHERE id=?", infiniteGachaID).Scan(&gacha); err != nil {
return nil, fmt.Errorf("gamedata: infinite gacha: %w", err)
}
count, _ := packedInts(gacha, 5)
reward, _ := packedInts(gacha, 7)
priceCount, _ := packedInts(gacha, 10)
if len(count) != 1 || count[0] != 10 || len(reward) != 1 || reward[0] != InfiniteGachaID || len(priceCount) != 0 {
if len(count) != 1 || count[0] == 0 || len(reward) != 1 || reward[0] == 0 || len(priceCount) != 0 {
return nil, fmt.Errorf("gamedata: unexpected infinite gacha definition count=%v reward=%v price=%v", count, reward, priceCount)
}
var cash []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CashProductTable WHERE id=? AND GroupId=?", InfiniteProductID, InfiniteProductGroupID).Scan(&cash); err != nil {
return nil, fmt.Errorf("gamedata: infinite product: %w", err)
productGroupID, productID, saleGroup, err := loadInfiniteCashIdentity(db, reward[0])
if err != nil {
return nil, err
}
productID, _ := packedInts(cash, 6)
groupID, _ := packedInts(cash, 5)
if len(productID) != 1 || productID[0] != InfiniteProductID || len(groupID) != 1 || groupID[0] != InfiniteProductGroupID {
return nil, fmt.Errorf("gamedata: unexpected infinite product identity")
program, err := loadCostumeRewardGroup(db, reward[0], map[uint64]bool{})
if err != nil {
return nil, err
}
programCount, err := costumeRewardCount(program)
if err != nil || programCount != count[0] {
return nil, fmt.Errorf("gamedata: infinite reward count does not match gacha")
}
// GachaTable.FixedGachaRewardId=9100035 is the official costume pool used
// by the observed infinite previews. RewardGroup item type 11 is Costume.
fixedPool, err := optionalScalar(gacha, 3)
if err != nil || fixedPool == 0 {
return nil, errors.New("gamedata: infinite fixed reward missing")
}
var pool []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=9100035").Scan(&pool); err != nil {
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", fixedPool).Scan(&pool); err != nil {
return nil, fmt.Errorf("gamedata: infinite costume pool: %w", err)
}
ids, err := packedInts(pool, 5)
@@ -1793,7 +1756,10 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
if types[i] != 11 || costumeID == 0 {
return nil, fmt.Errorf("gamedata: infinite pool entry %d has type %d", costumeID, types[i])
}
characterID := costumeID / 10
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
if err != nil {
return nil, err
}
var character []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", characterID).Scan(&character); err != nil {
return nil, fmt.Errorf("gamedata: costume %d character %d: %w", costumeID, characterID, err)
@@ -1833,20 +1799,30 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
costume.HP = uint64(hp)
characters[costumeID] = costume
}
fourStarPool, err := loadCostumeRewardPool(db, 70008)
if err != nil {
return nil, fmt.Errorf("gamedata: infinite four-star pool: %w", err)
}
threeStarPool, err := loadCostumeRewardPool(db, 70004)
if err != nil {
return nil, fmt.Errorf("gamedata: infinite three-star pool: %w", err)
}
var allIDs []uint64
collectCostumeRewardIDs(program, &allIDs)
var fourStarIDs, threeStarIDs []uint64
collectCostumeIDs(fourStarPool, &fourStarIDs)
collectCostumeIDs(threeStarPool, &threeStarIDs)
if len(ids) != 129 || len(fourStarIDs) != 13 || len(threeStarIDs) != 16 {
return nil, fmt.Errorf("gamedata: unexpected infinite rarity pools five=%d four=%d three=%d", len(ids), len(fourStarIDs), len(threeStarIDs))
seen := map[uint64]bool{}
for _, costumeID := range allIDs {
if seen[costumeID] {
continue
}
seen[costumeID] = true
grade, err := loadCostumeGrade(db, costumeID)
if err != nil {
return nil, err
}
switch grade {
case 4:
fourStarIDs = append(fourStarIDs, costumeID)
case 3:
threeStarIDs = append(threeStarIDs, costumeID)
case 5:
default:
return nil, fmt.Errorf("gamedata: unsupported infinite costume grade %d", grade)
}
}
for _, costumeID := range append(append([]uint64(nil), fourStarIDs...), threeStarIDs...) {
if _, exists := characters[costumeID]; exists {
continue
@@ -1857,7 +1833,10 @@ func LoadInfiniteGacha(root, version string) (*InfiniteGachaDesign, error) {
}
characters[costumeID] = character
}
return NewInfiniteGachaDesignWithRates(int(count[0]), ids, fourStarIDs, threeStarIDs, characters)
design := &InfiniteGachaDesign{Count: int(count[0]), CostumeIDs: append([]uint64(nil), ids...), FiveStarIDs: ids, FourStarIDs: fourStarIDs, ThreeStarIDs: threeStarIDs, characters: characters}
design.GroupID, design.GachaID, design.ProductGroupID, design.ProductID, design.SaleGroup = infiniteGroupID, infiniteGachaID, productGroupID, productID, saleGroup
design.rewardProgram = program
return design, nil
}
func fixed64Double(proto []byte, number int) (float64, bool, error) {
@@ -1888,9 +1867,15 @@ func (d *InfiniteGachaDesign) Roll() ([]uint64, error) {
}
func (d *InfiniteGachaDesign) rollWith(draw func(uint64) (uint64, error)) ([]uint64, error) {
if d == nil || d.Count <= 0 || len(d.FiveStarIDs) == 0 || len(d.FourStarIDs) == 0 || len(d.ThreeStarIDs) == 0 || draw == nil {
if d == nil || d.Count <= 0 || draw == nil {
return nil, fmt.Errorf("gamedata: invalid infinite gacha design")
}
if d.rewardProgram != nil {
return rollCostumeRewardGroup(d.rewardProgram, draw)
}
if len(d.FiveStarIDs) == 0 || len(d.FourStarIDs) == 0 || len(d.ThreeStarIDs) == 0 {
return nil, errors.New("gamedata: incomplete synthetic infinite pools")
}
result := make([]uint64, d.Count)
for i := 0; i < d.Count-1; i++ {
rate, err := draw(officialRateScale)
@@ -31,7 +31,7 @@ func TestSingleSidedCostumeFixedThresholdsDoNotTriggerZeroSide(t *testing.T) {
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002},
{Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
}
gacha := RegularGacha{ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: pool}
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 1, PriceType: 3, Price: 200, Pool: pool}
chooseThree := func(limit uint64) (uint64, error) {
if limit == officialRateScale {
return limit - 1, nil
@@ -59,7 +59,7 @@ func TestCompositeCostumeRewardGroupExecutesAllChildren(t *testing.T) {
if count, err := costumeRewardCount(program); err != nil || count != 10 {
t.Fatalf("count=%d err=%v", count, err)
}
gacha := RegularGacha{ID: 1, Count: 10, PriceType: 2, Price: 500, RewardGroup: program}
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 10, PriceType: 2, Price: 500, RewardGroup: program}
roll, err := gacha.rollRewardGroup(func(limit uint64) (uint64, error) { return 0, nil })
if err != nil || len(roll) != 10 || roll[0] != 5001 {
t.Fatalf("roll=%v err=%v", roll, err)
@@ -78,7 +78,7 @@ func TestMoonriseSpecialSelectionUsesThreeChoicesAndGameDataRemainder(t *testing
selected[i] = uint64(5001 + i)
choices[i] = CostumeRewardEntry{ItemType: 11, ItemID: selected[i], Count: 1, Weight: 1}
}
gacha := RegularGacha{ID: 9100037, Count: 10, PriceType: 19, PriceID: 450030, Price: 1, RewardGroup: &CostumeRewardGroup{
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 9100037, Count: 10, PriceType: 19, PriceID: 450030, Price: 1, RewardGroup: &CostumeRewardGroup{
ID: 9100037, DropCount: 1, DropType: 1, Entries: []CostumeRewardEntry{
{ItemType: 9, ItemID: 9100038, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100038, DropCount: 3, Entries: choices}},
{ItemType: 9, ItemID: 9100039, Count: 1, Weight: 1, Group: &CostumeRewardGroup{ID: 9100039, DropCount: 7, Entries: []CostumeRewardEntry{
@@ -108,7 +108,7 @@ func TestMoonriseSpecialSelectionUsesThreeChoicesAndGameDataRemainder(t *testing
func TestAddAndQueryStepUpDesign(t *testing.T) {
characters := map[uint64]CharacterDesign{5001: {ID: 500, HP: 100}}
catalog, err := NewRegularGachaCatalog(map[uint64]RegularGacha{
catalog, err := fixtureRegularCatalog(map[uint64]RegularGacha{
10: {ID: 10, Count: 1, PriceType: 2, Price: 1, Pool: []WeightedCostume{{ID: 5001, Weight: 1}}},
}, characters)
if err != nil {
@@ -210,7 +210,7 @@ func TestLegacyRegularGachaLoaderAgainstInstalledVersion23510(t *testing.T) {
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
catalog, err := LoadRegularCostumeGacha(root, "20260923193640")
catalog, err := LoadRegularCostumeGachaGroups(root, "20260923193640", []uint64{10001, 135, 205, 121}, []uint64{29})
if err != nil {
t.Fatal(err)
}
+104
View File
@@ -0,0 +1,104 @@
package gamedata
import (
"database/sql"
"fmt"
)
// Cash product tickets are the deterministic CashProductTable -> RandomBoxTable
// -> RewardGroupTable grant. Their IDs and quantities are version facts.
func loadGachaCashRewards(db *sql.DB, group GachaGroupDesign) ([]BattleReward, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CashProductTable WHERE GroupId=? AND id=? AND saleGroup=?", group.CashProductGroupID, group.CashProductID, group.CashSalesGroup).Scan(&raw); err != nil {
return nil, err
}
box, err := optionalScalar(raw, 14)
if err != nil || box == 0 {
return nil, fmt.Errorf("gamedata: gacha cash product has no random box")
}
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&raw); err != nil {
return nil, err
}
rewardID, err := optionalScalar(raw, 9)
if err != nil || rewardID == 0 {
return nil, fmt.Errorf("gamedata: gacha cash box has no reward group")
}
return deterministicGachaCashRewards(db, rewardID, map[uint64]bool{})
}
func deterministicGachaCashRewards(db *sql.DB, id uint64, visiting map[uint64]bool) ([]BattleReward, error) {
if visiting[id] {
return nil, fmt.Errorf("gamedata: cyclic cash reward group %d", id)
}
visiting[id] = true
defer delete(visiting, id)
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", id).Scan(&raw); err != nil {
return nil, err
}
dropType, err := optionalScalar(raw, 2)
if err != nil {
return nil, err
}
ids, err := packedInts(raw, 5)
if err != nil {
return nil, err
}
types, err := packedInts(raw, 6)
if err != nil {
return nil, err
}
counts, err := packedInts(raw, 4)
if err != nil {
return nil, err
}
ratios, err := packedInts(raw, 8)
if err != nil || len(ratios) != len(ids) {
return nil, fmt.Errorf("gamedata: malformed cash reward ratio")
}
for _, ratio := range ratios {
if dropType == 0 && ratio == 0 || dropType == 1 && ratio != 100 {
return nil, fmt.Errorf("gamedata: cash reward is not deterministic")
}
}
if len(ids) == 0 || len(ids) != len(types) || len(ids) != len(counts) || dropType > 1 || dropType == 0 && len(ids) != 1 {
return nil, fmt.Errorf("gamedata: cash reward %d is not deterministic", id)
}
dropCount, err := optionalScalar(raw, 1)
if err != nil || dropType == 0 && dropCount == 0 {
return nil, fmt.Errorf("gamedata: cash reward has invalid drop count")
}
if dropType == 0 {
for i := range counts {
if counts[i] > ^uint64(0)/dropCount {
return nil, fmt.Errorf("gamedata: cash reward count overflow")
}
counts[i] *= dropCount
}
}
var out []BattleReward
for i, itemID := range ids {
if counts[i] == 0 {
return nil, fmt.Errorf("gamedata: cash reward %d has zero count", id)
}
if types[i] == 9 {
children, err := deterministicGachaCashRewards(db, itemID, visiting)
if err != nil {
return nil, err
}
for _, child := range children {
if child.Count > ^uint64(0)/counts[i] {
return nil, fmt.Errorf("gamedata: cash reward overflow")
}
child.Count *= counts[i]
out = append(out, child)
}
} else {
if (!itemDBInfoRewardType(types[i]) && types[i] != 19) || itemID == 0 {
return nil, fmt.Errorf("gamedata: unsupported cash reward type %d", types[i])
}
out = append(out, BattleReward{Type: types[i], ID: itemID, Count: counts[i]})
}
}
return out, nil
}
@@ -0,0 +1,54 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestCashRewardsUseItemCountAndRejectRandomIndependentRolls(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE RewardGroupTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
makeRaw := func(dropType, dropCount, count, ratio, typ uint64) []byte {
raw := wire.AppendVarint(nil, 1, dropCount)
raw = wire.AppendVarint(raw, 2, dropType)
for _, field := range []struct {
n int
v uint64
}{{4, count}, {5, 9876}, {6, typ}, {8, ratio}} {
raw = wire.AppendVarint(raw, field.n, field.v)
}
return raw
}
for _, tc := range []struct {
id, typ, dropType, dropCount, count, ratio, want uint64
valid bool
}{
{1, 19, 0, 2, 7, 1, 14, true}, {2, 8, 1, 0, 7, 100, 7, true}, {3, 8, 1, 0, 7, 35, 0, false}, {4, 10, 0, 1, 1, 1, 0, false}, {5, 4, 0, 1, 1, 1, 0, false},
} {
if _, err := db.Exec("INSERT INTO RewardGroupTable VALUES (?,?)", tc.id, makeRaw(tc.dropType, tc.dropCount, tc.count, tc.ratio, tc.typ)); err != nil {
t.Fatal(err)
}
got, err := deterministicGachaCashRewards(db, tc.id, map[uint64]bool{})
if (err == nil) != tc.valid {
t.Fatalf("id=%d valid=%v err=%v", tc.id, tc.valid, err)
}
if tc.valid && (len(got) != 1 || got[0].Count != tc.want) {
t.Fatalf("id=%d count=%+v want=%d", tc.id, got, tc.want)
}
}
}
func TestInfiniteRewardProgramDoesNotRequireEveryRarity(t *testing.T) {
d := &InfiniteGachaDesign{Count: 2, rewardProgram: &CostumeRewardGroup{ID: 1, DropCount: 2, Entries: []CostumeRewardEntry{{ItemType: 11, ItemID: 5001, Count: 1, Weight: 1}}}}
got, err := d.rollWith(func(uint64) (uint64, error) { return 0, nil })
if err != nil || len(got) != 2 || got[0] != 5001 {
t.Fatalf("roll=%v err=%v", got, err)
}
}
@@ -0,0 +1,55 @@
package gamedata
import (
"database/sql"
"fmt"
)
// Costume ownership refers to a unique character, not a numeric costume ID
// prefix. The initial growth grade is the base character, excluding pack-only
// temporary copies; ambiguous rows are rejected instead of selecting an ID.
func loadCostumeBaseCharacterID(db *sql.DB, costumeID uint64) (uint64, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&raw); err != nil {
return 0, err
}
unique, err := optionalScalar(raw, 27)
if err != nil || unique == 0 {
return 0, fmt.Errorf("gamedata: costume %d unique character unavailable", costumeID)
}
rows, err := db.Query("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId=?", unique)
if err != nil {
return 0, err
}
defer rows.Close()
var result uint64
for rows.Next() {
var id uint64
var row []byte
if err := rows.Scan(&id, &row); err != nil {
return 0, err
}
growth, err := optionalScalar(row, 10)
if err != nil {
return 0, err
}
temporary, err := optionalScalar(row, 21)
if err != nil {
return 0, err
}
if growth != 1 || temporary != 0 {
continue
}
if result != 0 {
return 0, fmt.Errorf("gamedata: costume %d has ambiguous base character", costumeID)
}
result = id
}
if err := rows.Err(); err != nil {
return 0, err
}
if result == 0 {
return 0, fmt.Errorf("gamedata: costume %d has no base character", costumeID)
}
return result, nil
}
@@ -0,0 +1,31 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestCostumeCharacterRelationshipDoesNotUseNumericPrefix(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE CostumeTable(id INTEGER,ProtoBuf BLOB);CREATE TABLE CharTable(id INTEGER,uniqueCharId INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
db.Exec("INSERT INTO CostumeTable VALUES (?,?)", 76543, wire.AppendVarint(nil, 27, 88))
for _, c := range []struct{ id, growth, temp uint64 }{{999, 1, 0}, {1000, 2, 0}, {222, 1, 4}} {
raw := wire.AppendVarint(wire.AppendVarint(nil, 10, c.growth), 21, c.temp)
db.Exec("INSERT INTO CharTable VALUES (?,?,?)", c.id, 88, raw)
}
got, err := loadCostumeBaseCharacterID(db, 76543)
if err != nil || got != 999 {
t.Fatalf("character=%d err=%v", got, err)
}
db.Exec("INSERT INTO CharTable VALUES (?,?,?)", 777, 88, wire.AppendVarint(nil, 10, 1))
if _, err := loadCostumeBaseCharacterID(db, 76543); err == nil {
t.Fatal("ambiguous base guessed")
}
}
@@ -18,7 +18,7 @@ func TestCostumeGuaranteedFourAndFiveCountsAcrossOneAndTen(t *testing.T) {
}
// The first of a ten-pull hits the shared four-star boundary after nine
// unsuccessful singles, and the next nine slots continue the new count.
gacha := RegularGacha{ID: 1, Count: 10, PriceType: 3, Price: 2000, Pool: pool}
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 1, Count: 10, PriceType: 3, Price: 2000, Pool: pool}
roll, state, err := gacha.rollWithCostumeFixed(9, 25, fixed, nil, nil, chooseThree)
if err != nil {
t.Fatal(err)
@@ -52,7 +52,7 @@ func TestTwelvePickSelectionAppliesToNaturalAndPityFiveStar(t *testing.T) {
{Weight: 1400, Children: []WeightedCostume{{ID: 4001, Weight: 1}}},
{Weight: 8300, Children: []WeightedCostume{{ID: 3001, Weight: 1}}},
}
gacha := RegularGacha{ID: 101, Count: 1, PriceType: 3, Price: 200, Pool: pool}
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, ID: 101, Count: 1, PriceType: 3, Price: 200, Pool: pool}
fixed := GachaFixedDesign{ID: 8, CostumeGrade4Count: 10, CostumeGrade5Count: 100, ResetOnMatchingGrade: true}
selected := []uint64{5012, 5013}
chooseFirst := func(limit uint64) (uint64, error) { return 0, nil }
@@ -0,0 +1,73 @@
package gamedata
import (
"errors"
"math/big"
)
// gradePool conditions the actual nested weighted distribution on one grade.
// Exact rational path weights preserve branches with different child totals.
func (g RegularGacha) gradePool(grade uint64) ([]WeightedCostume, error) {
weights := map[uint64]*big.Rat{}
var walk func([]WeightedCostume, *big.Rat) error
walk = func(pool []WeightedCostume, path *big.Rat) error {
total := new(big.Int)
for _, item := range pool {
if item.Weight == 0 {
return errors.New("gamedata: zero costume weight")
}
total.Add(total, new(big.Int).SetUint64(item.Weight))
}
if total.Sign() == 0 {
return errors.New("gamedata: empty costume pool")
}
for _, item := range pool {
prob := new(big.Rat).Mul(path, new(big.Rat).SetFrac(new(big.Int).SetUint64(item.Weight), total))
if item.ID == 0 {
if err := walk(item.Children, prob); err != nil {
return err
}
continue
}
actual, ok := g.Grades[item.ID]
if !ok || actual < 3 || actual > 5 {
return errors.New("gamedata: costume grade unavailable")
}
if actual == grade {
if weights[item.ID] == nil {
weights[item.ID] = new(big.Rat)
}
weights[item.ID].Add(weights[item.ID], prob)
}
}
return nil
}
if err := walk(g.Pool, new(big.Rat).SetInt64(1)); err != nil {
return nil, err
}
denom := big.NewInt(1)
for _, weight := range weights {
gcd := new(big.Int).GCD(nil, nil, denom, weight.Denom())
denom.Mul(new(big.Int).Quo(denom, gcd), weight.Denom())
}
var ids []uint64
for id := range weights {
ids = append(ids, id)
}
// Stable table-independent ordering makes deterministic draw sources reproducible.
for i := 1; i < len(ids); i++ {
for j := i; j > 0 && ids[j] < ids[j-1]; j-- {
ids[j], ids[j-1] = ids[j-1], ids[j]
}
}
var out []WeightedCostume
for _, id := range ids {
w := weights[id]
count := new(big.Int).Mul(w.Num(), new(big.Int).Quo(new(big.Int).Set(denom), w.Denom()))
if !count.IsUint64() {
return nil, errors.New("gamedata: conditioned costume weight overflow")
}
out = append(out, WeightedCostume{ID: id, Weight: count.Uint64()})
}
return out, nil
}
@@ -0,0 +1,46 @@
package gamedata
import "testing"
func TestNonResettingFixedGuaranteeKeepsNaturalFiveStarProgress(t *testing.T) {
g := RegularGacha{Count: 10, Grades: map[uint64]uint64{55: 5}, Pool: []WeightedCostume{{ID: 55, Weight: 1}}}
_, state, err := g.rollWithCostumeFixed(0, 0, GachaFixedDesign{ID: 3, CostumeGrade5Count: 10}, nil, []uint64{55}, func(uint64) (uint64, error) { return 0, nil })
if err != nil || state.CostumeGrade5Sort != 9 || state.CostumeGrade5Count != 0 || len(state.SelectionSorts) != 1 || state.SelectionSorts[0] != 9 {
t.Fatalf("state=%+v err=%v", state, err)
}
}
func TestCostumeFixedUsesActualGradesAcrossMixedAndReorderedBranches(t *testing.T) {
g := RegularGacha{Count: 1, Grades: map[uint64]uint64{99: 3, 77: 5, 66: 4, 55: 5}, Pool: []WeightedCostume{
{Weight: 10, Children: []WeightedCostume{{ID: 99, Weight: 3}, {ID: 77, Weight: 1}}},
{ID: 66, Weight: 7}, {ID: 55, Weight: 5},
}}
fixed := GachaFixedDesign{ResetOnMatchingGrade: true, ID: 1, CostumeGrade4Count: 10, CostumeGrade5Count: 100}
first := func(uint64) (uint64, error) { return 0, nil }
roll, state, err := g.rollWithCostumeFixed(9, 99, fixed, nil, nil, first)
if err != nil || roll[0] != 55 || state.CostumeGrade5Count != 0 {
t.Fatalf("five=%v state=%+v err=%v", roll, state, err)
}
roll, state, err = g.rollWithCostumeFixed(9, 0, fixed, nil, nil, first)
if err != nil || roll[0] != 66 || state.CostumeGrade5Count != 1 {
t.Fatalf("four=%v state=%+v err=%v", roll, state, err)
}
// Last branch is five-star despite the historical assumption of grade three.
last := func(limit uint64) (uint64, error) { return limit - 1, nil }
roll, state, err = g.rollWithCostumeFixed(2, 3, fixed, nil, nil, last)
if err != nil || roll[0] != 55 || state.CostumeGrade4Count != 0 || state.CostumeGrade5Count != 0 {
t.Fatalf("natural=%v state=%+v err=%v", roll, state, err)
}
pool, err := g.gradePool(5)
if err != nil || len(pool) != 2 || pool[0].ID != 55 || pool[0].Weight != 10 || pool[1].ID != 77 || pool[1].Weight != 5 {
t.Fatalf("conditioned=%+v err=%v", pool, err)
}
}
func TestTenDrawGradeFourGuaranteeWorksWithTwoReorderedBranches(t *testing.T) {
g := RegularGacha{Count: 10, Grades: map[uint64]uint64{99: 3, 66: 4}, Pool: []WeightedCostume{{ID: 99, Weight: 9}, {ID: 66, Weight: 1}}}
roll, err := g.rollWith(func(uint64) (uint64, error) { return 0, nil })
if err != nil || roll[9] != 66 {
t.Fatalf("roll=%v err=%v", roll, err)
}
}
@@ -0,0 +1,107 @@
package gamedata
import (
"database/sql"
"fmt"
)
func loadInfiniteGachaIdentity(db *sql.DB, selectedGroups []uint64) (groupID, gachaID uint64, err error) {
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable ORDER BY id")
if err != nil {
return 0, 0, err
}
defer rows.Close()
for rows.Next() {
var id uint64
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
return
}
typ, e := optionalScalar(raw, 17)
if e != nil {
return 0, 0, e
}
sub, e := optionalScalar(raw, 16)
if e != nil {
return 0, 0, e
}
selected := false
for _, wanted := range selectedGroups {
if wanted == id {
selected = true
}
}
if !selected || typ != 1 || sub != 5 {
continue
}
if groupID != 0 {
return 0, 0, fmt.Errorf("gamedata: multiple infinite gacha groups require schedule selection")
}
groupID = id
gachaID, err = optionalScalar(raw, 33)
if err != nil {
return
}
}
if err = rows.Err(); err != nil {
return
}
if groupID == 0 || gachaID == 0 {
err = fmt.Errorf("gamedata: infinite gacha group missing")
}
return
}
func loadInfiniteCashIdentity(db *sql.DB, rewardID uint64) (groupID, productID, saleGroup uint64, err error) {
rows, err := db.Query("SELECT ProtoBuf FROM CashProductTable ORDER BY GroupId,id,saleGroup")
if err != nil {
return 0, 0, 0, err
}
defer rows.Close()
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
return
}
box, e := optionalScalar(raw, 14)
if e != nil {
return 0, 0, 0, e
}
if box == 0 {
continue
}
var boxRaw []byte
if e = db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", box).Scan(&boxRaw); e != nil {
return 0, 0, 0, e
}
reward, e := optionalScalar(boxRaw, 9)
if e != nil {
return 0, 0, 0, e
}
if reward != rewardID {
continue
}
if productID != 0 {
return 0, 0, 0, fmt.Errorf("gamedata: ambiguous infinite cash product")
}
groupID, err = optionalScalar(raw, 5)
if err != nil {
return
}
productID, err = optionalScalar(raw, 6)
if err != nil {
return
}
saleGroup, err = optionalScalar(raw, 15)
if err != nil {
return
}
}
if err = rows.Err(); err != nil {
return
}
if groupID == 0 || productID == 0 {
err = fmt.Errorf("gamedata: infinite cash product missing")
}
return
}
@@ -59,7 +59,7 @@ func TestOfficialPickupRateDefinition(t *testing.T) {
}
func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T) {
gacha := RegularGacha{Count: 10, Pool: []WeightedCostume{
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, Count: 10, Pool: []WeightedCostume{
{Weight: 150, ID: 5001}, {Weight: 150, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
}}
roll, err := gacha.rollWith(func(limit uint64) (uint64, error) {
@@ -82,7 +82,7 @@ func TestTenPullGuaranteesGradeFourWhenAllNormalRollsAreGradeThree(t *testing.T)
}
func TestFixedPickupTenPullAlwaysStartsWithPickupCostume(t *testing.T) {
gacha := RegularGacha{Count: 10, FixedCostumeID: 21201, Pool: []WeightedCostume{
gacha := RegularGacha{Grades: map[uint64]uint64{5001: 5, 5002: 5, 4001: 4, 3001: 3, 21201: 5}, Count: 10, FixedCostumeID: 21201, Pool: []WeightedCostume{
{Weight: 300, ID: 5002}, {Weight: 1400, ID: 4001}, {Weight: 8300, ID: 3001},
}}
roll, err := gacha.rollWith(func(limit uint64) (uint64, error) { return limit - 1, nil })
+98
View File
@@ -0,0 +1,98 @@
package gamedata
import "fmt"
// ImmortalDesign resolves exactly the current character talent and skill level.
// Full restoration is evidenced for value 10000; other values require official
// server evidence of their rounding/scaling semantics before being enabled.
type ImmortalDesign struct {
Characters map[uint64]uint64
FullRestore map[[2]uint64]bool
}
func LoadImmortalDesign(root, version string) (*ImmortalDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
d := &ImmortalDesign{Characters: map[uint64]uint64{}, FullRestore: map[[2]uint64]bool{}}
talents := map[uint64]uint64{}
rows, err := db.Query("SELECT id,ProtoBuf FROM TalentTable")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
group, err := optionalScalar(raw, 18)
if err != nil {
rows.Close()
return nil, err
}
talents[id] = group
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
talent, err := optionalScalar(raw, 18)
if err != nil {
rows.Close()
return nil, err
}
if group := talents[talent]; group != 0 {
d.Characters[id] = group
}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var group, level uint64
var raw []byte
if err := rows.Scan(&group, &level, &raw); err != nil {
return nil, err
}
class, err := optionalScalar(raw, 2)
if err != nil {
return nil, err
}
if class != 14 {
continue
}
values, err := fixed32Floats(raw, 14)
if err != nil || len(values) == 0 {
return nil, fmt.Errorf("gamedata: immortal skill missing value")
}
d.FullRestore[[2]uint64{group, level}] = values[0] == 10000
}
return d, rows.Err()
}
func (d *ImmortalDesign) CanRestore(characterID, level uint64) bool {
return d != nil && d.FullRestore[[2]uint64{d.Characters[characterID], level}]
}
@@ -83,7 +83,7 @@ func loadInventorySlotDesign(db *sql.DB) (*InventorySlotDesign, error) {
return InventorySlotRule{}, err
}
rule := InventorySlotRule{Default: initial, Maximum: maximum, PriceType: priceType, BasePrice: basePrice, MaxPrice: maxPrice}
if rule.Default > rule.Maximum || rule.PriceType != 4 {
if rule.Default > rule.Maximum || (rule.PriceType != 2 && rule.PriceType != 3 && rule.PriceType != 4) {
return InventorySlotRule{}, errors.New("gamedata: unsupported inventory slot rule")
}
return rule, nil
@@ -0,0 +1,78 @@
package gamedata
import "fmt"
func validateRegularCostumeRates(pool []WeightedCostume) error {
if len(pool) != 3 {
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
}
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
for i, item := range pool {
if item.Weight != want[i] {
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
}
}
return nil
}
func validateFixedPickupRemainderRates(pool []WeightedCostume) error {
if len(pool) != 3 {
return fmt.Errorf("expected three rarity branches, got %d", len(pool))
}
want := [...]uint64{officialFiveStarRate, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
for i, item := range pool {
if item.Weight != want[i] {
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
}
}
return nil
}
// validateOfficialPickupRates prevents a GameData/schema regression from
// silently changing the published costume pickup rates. The two five-star
// branches are pickup 1.5% plus the ordinary five-star pool 1.5%.
func validateOfficialPickupRates(pool []WeightedCostume) error {
if len(pool) != 4 {
return fmt.Errorf("expected four rarity branches, got %d", len(pool))
}
want := [...]uint64{150, 150, officialFourStarRate, officialRateScale - officialFiveStarRate - officialFourStarRate}
for i, item := range pool {
if item.Weight != want[i] {
return fmt.Errorf("branch %d weight=%d want=%d", i, item.Weight, want[i])
}
}
return nil
}
func fixtureRegularCatalog(gachas map[uint64]RegularGacha, characters map[uint64]CharacterDesign) (*RegularGachaCatalog, error) {
for id, g := range gachas {
if len(g.Grades) == 0 {
g.Grades = map[uint64]uint64{}
var collect func([]WeightedCostume, uint64)
collect = func(p []WeightedCostume, grade uint64) {
for _, w := range p {
if w.ID != 0 {
g.Grades[w.ID] = grade
} else {
collect(w.Children, grade)
}
}
}
for i, w := range g.Pool {
grade := uint64(5 - i)
if len(g.Pool) == 4 {
grade = uint64(6 - i)
if i == 0 {
grade = 5
}
}
collect([]WeightedCostume{w}, grade)
}
if g.FixedCostumeID != 0 {
g.Grades[g.FixedCostumeID] = 5
}
gachas[id] = g
}
}
return NewRegularGachaCatalog(gachas, characters)
}
+6 -1
View File
@@ -32,6 +32,7 @@ type MissionDesign struct {
type MissionCondition struct {
Type uint64
SubType uint64
SubTypeComparison uint64
Params []uint64
TargetValue uint64
UnlockPack uint64
@@ -154,7 +155,11 @@ func loadMissionRows(db *sql.DB, design *MissionDesign) error {
if err != nil {
return fmt.Errorf("gamedata: MissionTable unlock quest: %w", err)
}
design.Conditions[key] = MissionCondition{Type: conditionType, SubType: conditionSubType, Params: params, TargetValue: target, UnlockPack: unlockPack, UnlockQuest: unlockQuest}
subTypeMore, err := optionalScalar(proto, 9)
if err != nil {
return fmt.Errorf("gamedata: MissionTable subtype comparison invalid")
}
design.Conditions[key] = MissionCondition{SubTypeComparison: subTypeMore, Type: conditionType, SubType: conditionSubType, Params: params, TargetValue: target, UnlockPack: unlockPack, UnlockQuest: unlockQuest}
}
return rows.Err()
}
+16 -1
View File
@@ -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
}
+1 -1
View File
@@ -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)
}
+83
View File
@@ -0,0 +1,83 @@
package gamedata
import (
"database/sql"
"fmt"
"os"
"path/filepath"
)
// PresetDesign is the normal party preset configuration in GameDefaultTable.
type PresetDesign struct {
BaseCount, Maximum, PriceType, PriceID, Price uint64
Icons map[uint64]bool
}
func (d PresetDesign) Validate() error {
if d.BaseCount == 0 || d.Maximum < d.BaseCount || d.Price == 0 || d.PriceID != 0 {
return fmt.Errorf("gamedata: invalid preset configuration")
}
switch d.PriceType {
case 2, 3, 4, 12:
default:
return fmt.Errorf("gamedata: unsupported preset currency %d", d.PriceType)
}
return nil
}
func LoadPresetDesign(root, version string) (*PresetDesign, error) {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-presets-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
return loadPresetDesign(db)
}
func loadPresetDesign(db *sql.DB) (*PresetDesign, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
return nil, err
}
d := &PresetDesign{}
for field, target := range map[int]*uint64{91: &d.BaseCount, 92: &d.Price, 93: &d.PriceID, 94: &d.PriceType, 95: &d.Maximum} {
v, err := optionalScalar(raw, field)
if err != nil {
return nil, err
}
*target = v
}
if err := d.Validate(); err != nil {
return nil, err
}
rows, err := db.Query("SELECT id FROM PresetTable ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
d.Icons = map[uint64]bool{}
for rows.Next() {
var id uint64
if err := rows.Scan(&id); err != nil {
return nil, err
}
d.Icons[id] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
return d, nil
}
@@ -0,0 +1,35 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestPresetDesignReadsChangedLimitsAndPrice(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec("CREATE TABLE GameDefaultTable(id INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
if _, err = db.Exec("CREATE TABLE PresetTable(id INTEGER)"); err != nil {
t.Fatal(err)
}
if _, err = db.Exec("INSERT INTO PresetTable VALUES(79)"); err != nil {
t.Fatal(err)
}
var raw []byte
for field, value := range map[int]uint64{91: 3, 92: 73, 94: 3, 95: 9} {
raw = wire.AppendVarint(raw, field, value)
}
if _, err = db.Exec("INSERT INTO GameDefaultTable VALUES(0,?)", raw); err != nil {
t.Fatal(err)
}
got, err := loadPresetDesign(db)
if err != nil || got.BaseCount != 3 || got.Maximum != 9 || got.Price != 73 || got.PriceType != 3 || !got.Icons[79] {
t.Fatalf("design=%+v err=%v", got, err)
}
}
@@ -0,0 +1,44 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"path/filepath"
"testing"
)
func TestQuestCollectionsFollowChangedPackQuestAndItemIDs(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "quest.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
for _, sqlText := range []string{"CREATE TABLE QuestTable77(id INTEGER,ProtoBuf BLOB)", "CREATE TABLE CollectionTable(id INTEGER,ProtoBuf BLOB)"} {
if _, err := db.Exec(sqlText); err != nil {
t.Fatal(err)
}
}
quest := wire.AppendVarint(wire.AppendVarint(nil, 6, 801), 6, 802)
if _, err := db.Exec("INSERT INTO QuestTable77 VALUES(49,?)", quest); err != nil {
t.Fatal(err)
}
for _, collectionID := range []uint64{801, 802} {
if _, err := db.Exec("INSERT INTO CollectionTable VALUES(?,?)", collectionID, wire.AppendVarint(nil, 3, collectionID)); err != nil {
t.Fatal(err)
}
}
got, err := loadQuestDesignDB(db, 77)
if err != nil {
t.Fatal(err)
}
rewards := got[49].CollectionRewards
if len(rewards) != 2 || rewards[0] != (Reward{Type: 17, ID: 801, Count: 1}) || rewards[1] != (Reward{Type: 17, ID: 802, Count: 1}) {
t.Fatalf("rewards=%+v", rewards)
}
if _, err := db.Exec("DELETE FROM CollectionTable WHERE id=802"); err != nil {
t.Fatal(err)
}
if _, err := loadQuestDesignDB(db, 77); err == nil {
t.Fatal("missing collection design accepted")
}
}
@@ -33,8 +33,8 @@ type QuestCharacterDesign struct {
// QuestCostumeDesign is one row from StoryCharGroupTable. UniqueCharacterID
// comes from CostumeTable.UseUniqueCharId and is useful for matching a story
// costume to its character design. Costume 996000 is the official placeholder
// used for an account-owned party slot; callers must not treat it as owned.
// costume to its character design. Versioned world configuration identifies
// player-controlled placeholders; this loader preserves their authored IDs.
type QuestCostumeDesign struct {
Order uint64
CostumeID uint64
+19
View File
@@ -18,6 +18,7 @@ type QuestDesign struct {
PriorQuestID int
NextQuestID int
GiveQuestItemIDs []uint64
CollectionRewards []Reward
Rewards [5][]Reward
}
@@ -66,6 +67,24 @@ func loadQuestDesignDB(db *sql.DB, packID int) (map[int]QuestDesign, error) {
continue
}
entry := QuestDesign{ID: id}
collectionIDs, err := packedInts(proto, 6)
if err != nil {
return nil, fmt.Errorf("gamedata: quest%d collections: %w", id, err)
}
for _, collectionID := range collectionIDs {
var collection []byte
if err := db.QueryRow("SELECT ProtoBuf FROM CollectionTable WHERE id=?", collectionID).Scan(&collection); err != nil {
return nil, fmt.Errorf("gamedata: quest%d collection%d: %w", id, collectionID, err)
}
itemID, err := requiredScalar(collection, 3)
if err != nil {
return nil, err
}
if itemID != collectionID {
return nil, fmt.Errorf("gamedata: quest%d collection%d identity mismatch%d", id, collectionID, itemID)
}
entry.CollectionRewards = append(entry.CollectionRewards, Reward{Type: 17, ID: collectionID, Count: 1})
}
entry.GiveQuestItemIDs, err = packedInts(proto, 29)
if err != nil {
return nil, fmt.Errorf("gamedata: quest%d give items: %w", id, err)
@@ -13,7 +13,14 @@ type StoryCharacterCatalog struct {
characters map[[2]int][]StoryCharacterDesign
}
func LoadStoryCharacterCatalog(root, version string, packs []int) (*StoryCharacterCatalog, error) {
func LoadStoryCharacterCatalog(root, version string, packs []int, placeholderCostumes ...uint64) (*StoryCharacterCatalog, error) {
var placeholder uint64
if len(placeholderCostumes) > 1 {
return nil, fmt.Errorf("gamedata: multiple story placeholders")
}
if len(placeholderCostumes) == 1 {
placeholder = placeholderCostumes[0]
}
db, cleanup, err := openStatDatabase(root, version)
if err != nil {
return nil, err
@@ -60,7 +67,7 @@ func LoadStoryCharacterCatalog(root, version string, packs []int) (*StoryCharact
return nil, err
}
for _, story := range formation.StoryCostumes {
if story.UniqueCharacterID == unique && story.CostumeID != 996000 {
if story.UniqueCharacterID == unique && (placeholder == 0 || story.CostumeID != placeholder) {
costume = story.CostumeID
break
}
@@ -0,0 +1,21 @@
package mail
import (
"bd2server/internal/server/gamedata"
"testing"
)
func TestContentTicketsUseCatalogAndRequireSingleUse(t *testing.T) {
s := &Service{}
if err := s.AttachContentTickets(&gamedata.GachaContentTicketDesign{IDs: map[uint64]bool{660003: true}}); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
id, count uint64
want bool
}{{660003, 1, true}, {450030, 1, false}, {660003, 2, false}, {660003, 0, false}} {
if got := s.supportedItemDBInfoReward(gamedata.Reward{Type: 19, ID: tc.id, Count: tc.count}); got != tc.want {
t.Fatalf("ticket=%+v supported=%v", tc, got)
}
}
}
+5 -1
View File
@@ -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)
}
+19 -8
View File
@@ -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 {
+1 -1
View File
@@ -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},
+121
View File
@@ -0,0 +1,121 @@
package missions
import (
"errors"
"sort"
"bd2server/internal/server/gamedata"
)
// These values are Define_MissionConditionType protocol enums, not mission IDs.
const (
ConditionConnect uint64 = 0
ConditionMonsterKill uint64 = 2
ConditionGachaBuy uint64 = 9
)
// RecordEvent applies an authoritative gameplay count to every matching regular
// mission. The caller supplies quest unlock state; locked and scheduled event
// missions cannot become eligible merely because they share the condition.
func (s *Service) RecordEvent(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
return err
}
return s.recordEventLocked(conditionType, subType, count, unlocked)
}
// RecordLogin records at most one connection per daily period. The existing
// progress map stores the period marker, so reconnects and restarts cannot
// inflate weekly connection missions and no additional save format is needed.
func (s *Service) RecordLogin(unlocked func(uint64, uint64) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
return err
}
if s.state.Progress["0/event/connect"] != 0 {
return nil
}
return s.recordEventLocked(ConditionConnect, 0, 1, unlocked)
}
// CompleteSingleTargetEvent covers evidence of at least one occurrence, without
// inventing a count or subtype for richer missions (e.g. a monster encounter win).
func (s *Service) CompleteSingleTargetEvent(conditionType uint64, unlocked func(uint64, uint64) bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.rolloverLocked(); err != nil {
return err
}
next := cloneSnapshot(s.state)
for key, c := range s.design.Conditions {
if key.GroupType > 1 || c.Type != conditionType || c.SubType != 0 || c.TargetValue != 1 || len(c.Params) != 0 {
continue
}
if (c.UnlockPack != 0 || c.UnlockQuest != 0) && (unlocked == nil || !unlocked(c.UnlockPack, c.UnlockQuest)) {
continue
}
name := missionName(key)
if next.Progress[name] >= 1 {
continue
}
next.Progress[name] = 1
s.applyCompletionDependencies(&next, key)
}
return s.commit(next)
}
func (s *Service) recordEventLocked(conditionType, subType, count uint64, unlocked func(uint64, uint64) bool) error {
if count == 0 {
return errors.New("missions: event count is zero")
}
next := cloneSnapshot(s.state)
var keys []gamedata.MissionKey
for key, condition := range s.design.Conditions {
if key.GroupType > 1 || condition.Type != conditionType || len(condition.Params) != 0 {
continue
}
if condition.SubType != 0 && ((condition.SubTypeComparison == 0 && condition.SubType != subType) || (condition.SubTypeComparison == 1 && subType < condition.SubType)) {
continue
}
if (condition.UnlockPack != 0 || condition.UnlockQuest != 0) && (unlocked == nil || !unlocked(condition.UnlockPack, condition.UnlockQuest)) {
continue
}
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.GroupType != b.GroupType {
return a.GroupType < b.GroupType
}
if a.GroupID != b.GroupID {
return a.GroupID < b.GroupID
}
return a.ID < b.ID
})
for _, key := range keys {
name := missionName(key)
target := s.design.Conditions[key].TargetValue
if target == 0 {
target = 1
}
before := next.Progress[name]
if before >= target {
continue
}
increment := count
if increment > target-before {
increment = target - before
}
next.Progress[name] = before + increment
if next.Progress[name] == target {
s.applyCompletionDependencies(&next, key)
}
}
if conditionType == ConditionConnect {
next.Progress["0/event/connect"] = 1
}
return s.commit(next)
}
@@ -0,0 +1,86 @@
package missions
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/mail"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"testing"
"time"
)
func TestEventsFollowConditionsAndLoginPersistsAcrossRestart(t *testing.T) {
daily := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 9876}
weekly := gamedata.MissionKey{GroupType: 1, GroupID: 66, ID: 7654}
gacha := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 8765}
locked := gamedata.MissionKey{GroupType: 0, GroupID: 42, ID: 8654}
design := &gamedata.MissionDesign{Missions: map[gamedata.MissionKey][]gamedata.Reward{daily: nil, weekly: nil, gacha: nil, locked: nil}, Conditions: map[gamedata.MissionKey]gamedata.MissionCondition{
daily: {Type: ConditionConnect, TargetValue: 1}, weekly: {Type: ConditionConnect, TargetValue: 3}, gacha: {Type: ConditionGachaBuy, SubType: 5, SubTypeComparison: 1, TargetValue: 7}, locked: {Type: ConditionGachaBuy, UnlockPack: 21, UnlockQuest: 4, TargetValue: 1},
}}
store := stateio.NewMemory()
inv, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s, err := Open(store, design, inv)
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
s.now = func() time.Time { return now }
if err := s.RecordLogin(nil); err != nil {
t.Fatal(err)
}
s, err = Open(store, design, inv)
if err != nil {
t.Fatal(err)
}
s.now = func() time.Time { return now }
if err := s.RecordLogin(nil); err != nil {
t.Fatal(err)
}
if s.state.Progress[missionName(weekly)] != 1 {
t.Fatal("reconnect incremented weekly login")
}
if err := s.RecordEvent(ConditionGachaBuy, 4, 20, nil); err != nil {
t.Fatal(err)
}
if s.state.Progress[missionName(gacha)] != 0 || s.state.Progress[missionName(locked)] != 0 {
t.Fatal("subtype/locked mission matched")
}
if err := s.RecordEvent(ConditionGachaBuy, 6, 3, nil); err != nil {
t.Fatal(err)
}
if s.state.Progress[missionName(gacha)] != 3 {
t.Fatal("event did not use count")
}
if err := s.RecordEvent(ConditionGachaBuy, 6, 100, func(pack, quest uint64) bool { return pack == 21 && quest == 4 }); err != nil {
t.Fatal(err)
}
if s.state.Progress[missionName(gacha)] != 7 || s.state.Progress[missionName(locked)] != 1 {
t.Fatal("target cap/unlock did not apply")
}
wallet, err := player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
mailbox, err := mail.OpenService(store, &mail.Starter{Version: "2.35.10", MaxMailID: 100, MailCount: 1}, inv, wallet)
if err != nil {
t.Fatal(err)
}
if err := s.AttachMail(mailbox); err != nil {
t.Fatal(err)
}
now = now.AddDate(0, 0, 1)
if err := s.RecordLogin(nil); err != nil {
t.Fatal(err)
}
// Monday resets weekly; other days increment its connection count.
want := uint64(2)
if now.Weekday() == time.Monday {
want = 1
}
if s.state.Progress[missionName(weekly)] != want {
t.Fatalf("weekly progress=%d want=%d", s.state.Progress[missionName(weekly)], want)
}
}
+3 -3
View File
@@ -431,8 +431,8 @@ func (s *CollectionStore) GachaSelectionChangeCounts() []GachaSelectionChangeCou
}
func (s *CollectionStore) SetPreview(eventIndex uint64, costumeIDs []uint64) error {
if eventIndex == 0 || len(costumeIDs) != 10 {
return errors.New("player: infinite preview requires an event index and ten costumes")
if eventIndex == 0 || len(costumeIDs) == 0 {
return errors.New("player: infinite preview requires an event index and costumes")
}
s.mu.Lock()
defer s.mu.Unlock()
@@ -452,7 +452,7 @@ func (s *CollectionStore) LockPreview(eventIndex uint64) error {
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.data.LatestPreview) != 10 || s.data.PreviewEventIndex == 0 {
if len(s.data.LatestPreview) == 0 || s.data.PreviewEventIndex == 0 {
return errors.New("player: no complete infinite gacha preview to lock")
}
if eventIndex != s.data.PreviewEventIndex {
@@ -307,6 +307,9 @@ func TestCurrentHealthGrowthAndImmortalClearPersistedInjury(t *testing.T) {
t.Fatal(err)
}
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77)
target, _, _ := wire.Varint(request, 2)
candidate, _ := characters.Find(target)
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}})
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
t.Fatal(err)
}
@@ -387,6 +390,9 @@ func TestCurrentHealthRetainsSavedCharacterHPWithoutSeparateEntryAcrossReopen(t
// Revival must write the restored value, since deleting the entry alone
// would expose the zero HP in the owned character record again.
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 78)
target, _, _ := wire.Varint(request, 2)
candidate, _ := characters.Find(target)
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{candidate.ID: 42}, FullRestore: map[[2]uint64]bool{{42, candidate.TalentLevel}: true}})
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
t.Fatal(err)
}
+37
View File
@@ -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
}
+4
View File
@@ -27,6 +27,8 @@ func TestCharImmortalReturnsFullOwnedSnapshot(t *testing.T) {
if err != nil {
t.Fatal(err)
}
characters.SetCurrentHealth(owned[0].InvenIndex, 0)
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{6010: 42}, FullRestore: map[[2]uint64]bool{{42, 1}: true}})
packed := binary.AppendUvarint(nil, owned[0].InvenIndex)
request := wire.AppendBytes(wire.AppendVarint(nil, 1, 23), 2, packed)
code, response, ok, err := characters.Handle("/CharImmortal", request)
@@ -167,6 +169,8 @@ func TestGrowthAndImmortalShareDynamicMaximumHealth(t *testing.T) {
if hp, _, _ := wire.Varint(grown, 3); hp != 513 {
t.Fatalf("grown HP=%d want 513", hp)
}
characters.SetCurrentHealth(77, 0)
characters.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{350: 42}, FullRestore: map[[2]uint64]bool{{42, 0}: true}})
immortal := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77)
_, response, _, err = characters.Handle("/CharImmortal", immortal)
if err != nil {
@@ -0,0 +1,41 @@
package player
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"testing"
)
func TestImmortalRequiresDesignedDeadTalentAndReplaysSameSequence(t *testing.T) {
store := stateio.NewMemory()
inv, err := OpenInventory(store, &Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 999, HP: 0, Level: 1, TalentLevel: 3}}, inv, "", "")
if err != nil {
t.Fatal(err)
}
s.maxHealth = func(Character) (uint64, error) { return 700, nil }
s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 3}: true}})
s.BeginSession("a")
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 77)
_, body, _, err := s.Handle("/CharImmortal", req)
if err != nil {
t.Fatal(err)
}
_, again, _, err := s.Handle("/CharImmortal", req)
if err != nil || !bytes.Equal(body, again) {
t.Fatalf("replay err=%v", err)
}
if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 13), 2, 77)); err == nil {
t.Fatal("alive new request restored")
}
s.SetCurrentHealth(77, 0)
s.AttachImmortalDesign(&gamedata.ImmortalDesign{Characters: map[uint64]uint64{999: 88}, FullRestore: map[[2]uint64]bool{{88, 2}: true}})
if _, _, _, err := s.Handle("/CharImmortal", wire.AppendVarint(wire.AppendVarint(nil, 1, 14), 2, 77)); err == nil {
t.Fatal("wrong talent level revived")
}
}
@@ -0,0 +1,49 @@
package player
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"testing"
)
func TestInventorySlotChargesDesignedCurrencyAndReplays(t *testing.T) {
for _, typ := range []uint64{2, 3, 4} {
t.Run(string(rune('0'+typ)), func(t *testing.T) {
store := stateio.NewMemory()
wallet, err := OpenWallet(store, Currency{Jewelry: 1000, FreeJewelry: 1000, Gold: 1000})
if err != nil {
t.Fatal(err)
}
rule := gamedata.InventorySlotRule{Default: 10, Maximum: 20, PriceType: typ, BasePrice: 30, MaxPrice: 100}
s, err := OpenInventorySlots(store, &gamedata.InventorySlotDesign{Items: rule, Storage: rule, Equipment: rule, EquipmentStorage: rule}, InventorySlotCounts{10, 10, 10, 10}, wallet)
if err != nil {
t.Fatal(err)
}
s.BeginSession("test")
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 12), 2, 2)
if _, _, _, err = s.Handle("/InvenAddSlot", req); err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/InvenAddSlot", req); err != nil {
t.Fatal(err)
}
got := wallet.Snapshot()
want := Currency{Jewelry: 1000, FreeJewelry: 1000, Gold: 1000}
switch typ {
case 2:
want.Jewelry -= 90
case 3:
want.FreeJewelry -= 90
case 4:
want.Gold -= 90
}
if got != want {
t.Fatalf("currency=%+v want=%+v", got, want)
}
if s.state.Items != 12 {
t.Fatal("replay expanded slots twice")
}
})
}
}
+13 -2
View File
@@ -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
+8
View File
@@ -63,6 +63,7 @@ type Character struct {
type Starter struct {
Version string `json:"version"`
CookingRecipes []uint64 `json:"cooking_recipes,omitempty"`
Items []Item `json:"items"`
Costumes []Costume `json:"costumes"`
Characters []Character `json:"characters"`
@@ -89,6 +90,13 @@ func (s *Starter) Validate() error {
if s == nil || s.Version != versionconfig.State() {
return errors.New("player: wrong starter version")
}
seenRecipes := map[uint64]bool{}
for _, id := range s.CookingRecipes {
if id == 0 || id > 0x7fffffff || seenRecipes[id] {
return errors.New("player: invalid initial cooking recipe")
}
seenRecipes[id] = true
}
for _, item := range s.Items {
if item.ID == 0 || item.Count == 0 {
return errors.New("player: invalid item")
@@ -0,0 +1,32 @@
package readonly
import (
"path/filepath"
"testing"
"bd2server/internal/server/wire"
)
func TestRootSortUsesVersionedServerOrdering(t *testing.T) {
seed := &Seed{Version: StateVersion(), Responses: map[string]Response{"/RootSortIdInfo": {PacketCode: 367, Fields: []Field{
{Number: 1, Type: 2, Fields: []Field{{Number: 1, Type: 0, Varint: 4}, {Number: 2, Type: 0, Varint: 907}, {Number: 3, Type: 0, Varint: 8}}},
}}}}
path := filepath.Join(t.TempDir(), "readonly.json")
if err := seed.Write(path); err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
code, payload, handled, err := loaded.Handle("/RootSortIdInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled || code != 367 {
t.Fatalf("response: %d %v %v", code, handled, err)
}
entry, _, err := wire.Bytes(payload, 1)
id, _, _ := wire.Varint(entry, 2)
order, _, _ := wire.Varint(entry, 3)
if err != nil || id != 907 || order != 8 {
t.Fatalf("ordering id=%d sort=%d err=%v", id, order, err)
}
}
+66 -27
View File
@@ -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)
}
+44 -2
View File
@@ -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)
}
}
+53 -1
View File
@@ -131,7 +131,7 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
if position, ok := s.state.Position(); ok && (position.PackID != pack || position.Position.MapID != obj.MapID) {
return nil, fmt.Errorf("%w: field object outside current map", ErrInvalidRequest)
}
if obj.Type < 1 || obj.Type > 3 || obj.BuffID != 0 || obj.MonsterID != 0 || obj.QuestID != 0 || len(obj.Rewards) == 0 {
if obj.BuffID != 0 || obj.MonsterID != 0 || obj.QuestID != 0 || len(obj.Rewards) == 0 {
return nil, fmt.Errorf("%w: unsupported field object reward graph/reset", ErrInvalidRequest)
}
@@ -151,6 +151,28 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
}
var rewards []gamedata.Reward
var itemRewards []gamedata.BattleReward
var equipmentRewards []player.Equipment
// Validate every branch's type and quantity before drawing or writing a
// receipt. LoadFieldObjects validates every equipment option tree, including
// branches with zero weight, before installing the catalog.
for _, r := range obj.Rewards {
if r.Count == 0 || r.Count > uint64(^uint32(0)>>1) {
return nil, fmt.Errorf("world: invalid field reward count")
}
switch r.Type {
case 2, 3, 4, 12, 20:
case 5, 7, 8, 9, 13, 14, 17, 19, 27, 29:
if r.ID == 0 {
return nil, fmt.Errorf("world: invalid field item")
}
case 10:
if r.ID == 0 || r.Count > 100 || s.equipment == nil || design.Equipment == nil {
return nil, fmt.Errorf("world: invalid field equipment")
}
default:
return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type)
}
}
selected, err := obj.Draw()
if err != nil {
return nil, err
@@ -167,6 +189,27 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
return nil, fmt.Errorf("world: invalid field item")
}
itemRewards = append(itemRewards, r)
case 10:
if s.equipment == nil || design.Equipment == nil || r.Count > 100 || len(equipmentRewards)+int(r.Count) > 100 {
return nil, fmt.Errorf("world: field equipment reward unavailable")
}
for n := uint64(0); n < r.Count; n++ {
main, sub, private, e := design.Equipment.RollOptions(r.ID)
if e != nil {
return nil, e
}
entry := player.Equipment{ID: r.ID, Rank: []uint64{0, 0, 0}}
for _, option := range main {
entry.MainOption = append(entry.MainOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
for _, option := range sub {
entry.SubOption = append(entry.SubOption, player.EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
if private != nil {
entry.PrivateOption = &player.EquipmentOption{GroupID: private.GroupID, ID: private.ID}
}
equipmentRewards = append(equipmentRewards, entry)
}
default:
return nil, fmt.Errorf("%w: unsupported field reward type %d", ErrInvalidRequest, r.Type)
}
@@ -179,6 +222,12 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
if err != nil {
return nil, err
}
for i, entry := range equipmentRewards {
equipmentRewards[i], err = s.equipment.GrantGeneratedOnce(fmt.Sprintf("%s:equipment:%d", identity, i), entry)
if err != nil {
return nil, err
}
}
if err = s.state.MarkFieldRewardOpened(pack, id, period); err != nil {
return nil, err
}
@@ -189,5 +238,8 @@ func (s *Service) openFieldObject(pack, group, id int) ([]byte, error) {
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, player.ItemWire(item))
}
for _, entry := range equipmentRewards {
bundle = wire.AppendBytes(bundle, 4, player.EquipmentWire(entry))
}
return bundle, nil
}
@@ -9,9 +9,199 @@ import (
"bd2server/internal/server/wire"
"errors"
"path/filepath"
"reflect"
"testing"
)
func fieldEquipmentCatalog(t *testing.T) *gamedata.EquipmentGachaCatalog {
t.Helper()
options := []gamedata.OptionGroup{{ID: 31, Choices: []gamedata.WeightedOption{{ID: 4, Weight: 1}}}}
d, err := gamedata.NewFirstGachaDesign(gamedata.GachaGroupDesign{ID: 1, GachaSubType: 3, TenTimeGachaID: 2}, 2, 1,
&gamedata.FirstGachaRewardGroup{ID: 3, DropType: 1, DropCount: 1, Entries: []gamedata.FirstGachaRewardEntry{{ItemType: 10, ItemID: 77, Count: 1, Weight: 1}}}, nil,
map[uint64]gamedata.EquipmentDesign{77: {ID: 77, Grade: 3, Main: options, Sub: options, Private: options}})
if err != nil {
t.Fatal(err)
}
return d.EquipmentCatalog()
}
func TestFieldEquipmentTransactionRollbackAndRetry(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer func() { repo.Close() }()
store := &fieldRewardFailStore{Repository: repo}
s := testService()
s.state, err = progress.OpenStore(store)
if err != nil {
t.Fatal(err)
}
if err := s.state.SetActivePackID(21); err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(store, player.Currency{FreeJewelry: 100})
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
s.equipment, err = player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
if _, err := s.wallet.GrantQuestOnce("baseline", nil); err != nil {
t.Fatal(err)
}
obj := gamedata.FieldRewardObject{ID: 1001, MapID: 211, GroupID: 101, ResetType: 1, DropType: 1, Ratios: []uint64{100, 100, 100}, Rewards: []gamedata.BattleReward{{Type: 3, Count: 50}, {Type: 9, ID: 88, Count: 1}, {Type: 10, ID: 77, Count: 2}}}
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: obj}, Equipment: fieldEquipmentCatalog(t)}})
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
store.fail = true
if _, err := s.openFieldObject(21, 101, 1001); err == nil {
t.Fatal("failed marker accepted")
}
if err := op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatal(err)
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store.Repository = repo
store.fail = false
s.state, err = progress.OpenStore(store)
if err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
s.equipment, err = player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
opened, err := s.state.FieldRewardOpened(21, 1001, "once")
if err != nil || opened || len(s.equipment.All()) != 0 || len(s.inventory.All()) != 0 || s.wallet.Snapshot().FreeJewelry != 100 {
t.Fatalf("partial transaction: opened=%v equipment=%+v items=%+v wallet=%+v err=%v", opened, s.equipment.All(), s.inventory.All(), s.wallet.Snapshot(), err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, err := s.openFieldObject(21, 101, 1001); err != nil {
op.Rollback()
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if len(s.equipment.All()) != 2 || len(s.inventory.All()) != 1 || s.wallet.Snapshot().FreeJewelry != 150 {
t.Fatal("retry lost grants or grant receipts")
}
}
func TestFieldIndependentEquipmentAndBoxPersistAcrossRestart(t *testing.T) {
store := stateio.NewMemory()
s := testService()
var err error
s.state, err = progress.OpenStore(store)
if err != nil {
t.Fatal(err)
}
if err := s.state.SetActivePackID(21); err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(store, player.Currency{})
if err != nil {
t.Fatal(err)
}
s.inventory, err = player.OpenInventory(store, s.starter)
if err != nil {
t.Fatal(err)
}
s.equipment, err = player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
obj := gamedata.FieldRewardObject{ID: 1001, MapID: 211, GroupID: 101, Type: 8, ResetType: 1, DropType: 1, Ratios: []uint64{100, 100, 100}, Rewards: []gamedata.BattleReward{{Type: 10, ID: 77, Count: 2}, {Type: 9, ID: 88, Count: 1}, {Type: 3, Count: 50}}}
s.WithFieldObjects(map[int]gamedata.FieldObjectDesign{21: {Objects: map[int]gamedata.FieldRewardObject{1001: obj}, Equipment: fieldEquipmentCatalog(t)}})
bundle, err := s.openFieldObject(21, 101, 1001)
if err != nil {
t.Fatal(err)
}
entries := s.equipment.All()
if len(entries) != 2 || entries[0].InvenIndex == entries[1].InvenIndex {
t.Fatalf("equipment=%+v", entries)
}
for _, entry := range entries {
if len(entry.MainOption) != 1 || len(entry.SubOption) != 1 || entry.PrivateOption == nil || !reflect.DeepEqual(entry.Rank, []uint64{0, 0, 0}) {
t.Fatalf("missing equipment metadata=%+v", entry)
}
}
var eqCount, boxCount int
if err := wire.Walk(bundle, func(f wire.Field) error {
if f.Number == 4 {
eqCount++
if !reflect.DeepEqual(f.Value, player.EquipmentWire(entries[eqCount-1])) {
t.Error("equipment wire lost metadata/index")
}
}
if f.Number == 1 {
kind, _, _ := wire.Varint(f.Value, 3)
if kind == 9 {
boxCount++
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if eqCount != 2 || boxCount != 1 || s.wallet.Snapshot().FreeJewelry != 50 {
t.Fatalf("equipment=%d box=%d wallet=%+v", eqCount, boxCount, s.wallet.Snapshot())
}
s.state, err = progress.OpenStore(store)
if err != nil {
t.Fatal(err)
}
s.equipment, err = player.OpenEquipmentInventory(store)
if err != nil {
t.Fatal(err)
}
if err := s.state.SelectQuest(21, progress.QuestSelection{QuestID: 1, Difficulty: 2}); err != nil {
t.Fatal(err)
}
bundle, err = s.openFieldObject(21, 101, 1001)
if err != nil || len(bundle) != 0 || !reflect.DeepEqual(entries, s.equipment.All()) {
t.Fatalf("duplicate after restart: bundle=%x err=%v", bundle, err)
}
obj.ID = 1002
obj.DropType = 0
obj.DropCount = 1
obj.Rewards = []gamedata.BattleReward{{Type: 3, Count: 50}, {Type: 12345, ID: 1, Count: 1}}
obj.Ratios = []uint64{100, 0}
s.fieldObjects[21].Objects[1002] = obj
if _, err := s.openFieldObject(21, 101, 1002); err == nil {
t.Fatal("malformed unselected branch accepted")
}
if s.wallet.Snapshot().FreeJewelry != 50 || len(s.equipment.All()) != 2 {
t.Fatal("malformed branch changed stores")
}
}
type fieldRewardFailStore struct {
*accountstate.Repository
fail bool
@@ -0,0 +1,18 @@
package world
import "math"
// MissionsUnlocked uses the same pack authorization as map entry. A quest
// condition requires a committed normal-difficulty clear in that pack.
func (s *Service) MissionsUnlocked(pack, quest uint64) bool {
if pack == 0 {
return quest == 0
}
if pack > math.MaxInt32 || quest > math.MaxInt32 {
return false
}
if quest == 0 {
return s.packUnlocked(int(pack))
}
return s.state.QuestCleared(int(quest), int(pack), 0)
}
@@ -0,0 +1,58 @@
package world
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"testing"
)
func TestMissionPackOnlyUnlockUsesTicketAndLevelAuthorization(t *testing.T) {
s := testService()
s.squadLevel = func() (uint64, error) { return 3, nil }
s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, Open: &gamedata.ContentOpenRule{SquadLevel: 4}}
if s.MissionsUnlocked(709, 0) || s.MissionsUnlocked(999, 0) || s.MissionsUnlocked(0, 1) {
t.Fatal("locked pack/invalid condition opened")
}
s.squadLevel = func() (uint64, error) { return 4, nil }
if !s.MissionsUnlocked(709, 0) || !s.MissionsUnlocked(0, 0) {
t.Fatal("authorized pack not opened")
}
s.storyCatalog.Packs[710] = gamedata.StoryPack{ID: 710, Open: &gamedata.ContentOpenRule{TicketID: 901}}
var err error
s.inventory, err = player.OpenInventory(stateio.NewMemory(), &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
if s.MissionsUnlocked(710, 0) {
t.Fatal("missing ticket opened")
}
if _, err := s.inventory.GrantOnce("ticket", []gamedata.BattleReward{{Type: 19, ID: 901, Count: 1}}); err != nil {
t.Fatal(err)
}
if !s.MissionsUnlocked(710, 0) {
t.Fatal("ticket authorization ignored")
}
}
func TestCollectionRewardsGrantChangedQuestAndPackOnlyOnNormal(t *testing.T) {
s := testService()
s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, Quests: map[int]gamedata.QuestDesign{49: {ID: 49, CollectionRewards: []gamedata.Reward{{Type: 17, ID: 9901, Count: 1}}}}}
storage := stateio.NewMemory()
var err error
s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
items, _, err := s.grantQuestRewards(709, 49, nil)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || items[0].ID != 9901 || items[0].Type != 17 {
t.Fatalf("items=%+v", items)
}
}
+1 -1
View File
@@ -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 {
+10 -1
View File
@@ -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")
}
}
+29 -6
View File
@@ -76,7 +76,7 @@ func (s *Service) grantPurchaseRewards(identity string, rewards []gamedata.Rewar
}
var exchanges []gamedata.Reward
for _, x := range grant.Exchanges {
if x.ExchangeItemType != 20 {
if !purchaseCurrency(x.ExchangeItemType) || x.ExchangeItemID != 0 {
return nil, fmt.Errorf("world: unsupported purchase costume exchange")
}
exchanges = append(exchanges, gamedata.Reward{Type: x.ExchangeItemType, ID: x.ExchangeItemID, Count: x.ExchangeCount})
@@ -113,14 +113,29 @@ func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) {
if _, owned := s.collection.Grant(identity); owned {
return []byte{}, nil
}
if !initial && pack.BuyPrice != 0 {
return nil, fmt.Errorf("%w: paid pack purchase type %d is unsupported", ErrInvalidRequest, pack.BuyType)
}
for _, reward := range buyRewards {
if reward.Type != 3 && reward.Type != 4 && reward.Type != 12 && reward.Type != 19 && reward.Type != 11 {
if !purchaseCurrency(reward.Type) && reward.Type != 19 && reward.Type != 11 {
return nil, fmt.Errorf("world: unsupported pack purchase reward type %d", reward.Type)
}
}
if !initial && pack.BuyPrice != 0 {
var err error
switch pack.BuyType {
case 2:
_, err = s.wallet.SpendJewelryOnce(identity+":price", pack.BuyPrice)
case 3:
_, err = s.wallet.SpendFreeJewelryOnce(identity+":price", pack.BuyPrice)
case 4:
_, err = s.wallet.SpendGoldOnce(identity+":price", pack.BuyPrice)
case 12:
_, err = s.wallet.SpendCatalystOnce(identity+":price", pack.BuyPrice)
default:
return nil, fmt.Errorf("%w: unsupported pack purchase currency%d", ErrInvalidRequest, pack.BuyType)
}
if err != nil {
return nil, err
}
}
items, err := s.grantPurchaseRewards(identity, buyRewards)
if err != nil {
return nil, err
@@ -133,7 +148,7 @@ func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) {
bundle = append(bundle, player.CollectionRewardBundle(s.collection, grant)...)
}
for _, reward := range buyRewards {
if reward.Type == 3 || reward.Type == 4 || reward.Type == 12 {
if purchaseCurrency(reward.Type) {
currency := wire.AppendVarint(wire.AppendVarint(nil, 3, reward.Type), 4, reward.Count)
bundle = wire.AppendBytes(bundle, 1, currency)
}
@@ -143,3 +158,11 @@ func (s *Service) purchaseStoryPack(id int, initial bool) ([]byte, error) {
}
return bundle, nil
}
func purchaseCurrency(typ uint64) bool {
switch typ {
case 2, 3, 4, 12, 20:
return true
}
return false
}
@@ -11,6 +11,41 @@ import (
"testing"
)
func TestPaidPackUsesDesignCostAndCurrenciesOnce(t *testing.T) {
storage := stateio.NewMemory()
s := testService()
var err error
s.inventory, err = player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
s.collection, err = player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
s.wallet, err = player.OpenWallet(storage, player.Currency{FreeJewelry: 101})
if err != nil {
t.Fatal(err)
}
s.storyCatalog.Packs[709] = gamedata.StoryPack{ID: 709, BuyType: 3, BuyPrice: 37, BuyRewards: []gamedata.Reward{{Type: 2, Count: 8}, {Type: 20, Count: 11}}}
for attempt := 0; attempt < 2; attempt++ {
if _, err := s.purchaseStoryPack(709, false); err != nil {
t.Fatal(err)
}
}
got := s.wallet.Snapshot()
if got.FreeJewelry != 64 || got.Jewelry != 8 || got.Mileage != 11 {
t.Fatalf("currency=%+v", got)
}
s.storyCatalog.Packs[710] = gamedata.StoryPack{ID: 710, BuyType: 3, BuyPrice: 37, BuyRewards: []gamedata.Reward{{Type: 8, ID: 4, Count: 1}}}
if _, err := s.purchaseStoryPack(710, false); err == nil {
t.Fatal("invalid reward accepted")
}
if s.wallet.Snapshot().FreeJewelry != 64 {
t.Fatal("invalid reward charged")
}
}
type purchaseFailStore struct {
*accountstate.Repository
fail bool
+2 -2
View File
@@ -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
+58 -24
View File
@@ -23,7 +23,23 @@ import (
var ErrInvalidRequest = errors.New("world: invalid request")
type InitialReputation struct {
GroupID uint64 `json:"group_id"`
State uint64 `json:"state"`
ElapsedSeconds uint64 `json:"elapsed_seconds,omitempty"`
}
type InitialRankStatue struct {
ID uint64 `json:"id"`
Season uint64 `json:"season"`
Error bool `json:"error"`
}
type Seed struct {
SquareSceneID uint64 `json:"square_scene_id,omitempty"`
InitialReputations []InitialReputation `json:"initial_reputations,omitempty"`
InitialRankStatues []InitialRankStatue `json:"initial_rank_statues,omitempty"`
// Versioned story slot placeholder; confirmed current CostumeTable row, slot semantics await current capture.
PlaceholderCostumeID uint64 `json:"placeholder_costume_id,omitempty"`
Version string `json:"version"`
PackID int `json:"pack_id"`
StartQuestID int `json:"start_quest_id"`
@@ -115,7 +131,7 @@ func Load(seedPath, gameDataRoot, gameDataVersion string, storage stateio.Store,
for id := range packs {
storyPackIDs = append(storyPackIDs, id)
}
service.storyRoster, err = gamedata.LoadStoryCharacterCatalog(gameDataRoot, gameDataVersion, storyPackIDs)
service.storyRoster, err = gamedata.LoadStoryCharacterCatalog(gameDataRoot, gameDataVersion, storyPackIDs, seed.PlaceholderCostumeID)
if err != nil {
return nil, err
}
@@ -502,8 +518,12 @@ func (s *Service) grantQuestRewards(packID, quest int, designRewards []gamedata.
}
}
}
if packID == s.seed.PackID && s.questDifficultyFor(packID, quest) == 0 {
for _, reward := range questPictorialItems[quest] {
if s.questDifficultyFor(packID, quest) == 0 {
quests, known := s.questsFor(packID)
if !known {
return nil, nil, fmt.Errorf("world: unknown collection reward pack%d", packID)
}
for _, reward := range quests[quest].CollectionRewards {
itemRewards = append(itemRewards, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
}
}
@@ -586,17 +606,32 @@ func (s *Service) packInfoFor(packID int) ([]byte, error) {
visit := wire.AppendVarint(nil, 5, uint64(packID))
return wire.AppendBytes(out, 12, visit), nil
}
open := wire.AppendVarint(nil, 1, 1)
open = wire.AppendVarint(open, 2, 1)
out = wire.AppendBytes(out, 9, open)
for _, state := range s.seed.InitialReputations {
row := wire.AppendVarint(nil, 1, state.GroupID)
row = wire.AppendVarint(row, 2, state.State)
if state.ElapsedSeconds != 0 {
row = wire.AppendVarint(row, 3, state.ElapsedSeconds)
}
out = wire.AppendBytes(out, 9, row)
}
visit := wire.AppendVarint(nil, 5, uint64(packID))
out = wire.AppendBytes(out, 12, visit)
stat := wire.AppendVarint(nil, 1, 3)
stat = wire.AppendVarint(stat, 2, 77)
stat = wire.AppendVarint(stat, 3, 1)
out = wire.AppendBytes(out, 14, stat)
reward := wire.AppendVarint(nil, 3, 3)
reward = wire.AppendVarint(reward, 4, 150)
for _, state := range s.seed.InitialRankStatues {
row := wire.AppendVarint(nil, 1, state.ID)
row = wire.AppendVarint(row, 2, state.Season)
if state.Error {
row = wire.AppendVarint(row, 3, 1)
}
out = wire.AppendBytes(out, 14, row)
}
if s.packJamDesign == nil {
return out, nil
}
if err := s.packJamDesign.ValidateReward(); err != nil {
return nil, err
}
reward := wire.AppendVarint(nil, 3, s.packJamDesign.Reward.Type)
reward = wire.AppendVarint(reward, 4, s.packJamDesign.Reward.Count)
group := wire.AppendBytes(nil, 1, reward)
group = wire.AppendBytes(group, 6, reward)
return wire.AppendBytes(out, 16, group), nil
@@ -653,12 +688,8 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
}
if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 {
rewardCharacter := encodeCharacter(s.seed.RewardCharacter)
// Pictorial state: acquired level 1, progress 60.
rewardCharacter = wire.AppendBytes(rewardCharacter, 12, wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 60))
rewards = wire.AppendBytes(rewards, 2, rewardCharacter)
costume := encodeCostume(s.seed.RewardCostume)
// Pictorial state observed for the starter costume.
costume = wire.AppendBytes(costume, 5, wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 116))
rewards = wire.AppendBytes(rewards, 3, costume)
for _, character := range s.seed.StoryCharacters {
view := wire.AppendVarint(nil, 2, character.ID)
@@ -705,7 +736,13 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
}
}
if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 {
deckIDs := []uint64{s.seed.RewardCharacter.InvenIndex, s.seed.StoryCharacters[0].InvenIndex, s.seed.StoryCharacters[1].InvenIndex, s.seed.StoryCharacters[2].InvenIndex, s.starter.Characters[0].InvenIndex}
deckIDs := []uint64{s.seed.RewardCharacter.InvenIndex}
for _, character := range s.seed.StoryCharacters {
deckIDs = append(deckIDs, character.InvenIndex)
}
for _, character := range s.starter.Characters {
deckIDs = append(deckIDs, character.InvenIndex)
}
for index, characterID := range deckIDs {
deck := wire.AppendVarint(nil, 1, characterID)
deck = wire.AppendVarint(deck, 2, ^uint64(0))
@@ -776,13 +813,10 @@ func (s *Service) accountPackInfo() []byte {
}
}
}
return wire.AppendVarint(out, 5, 3)
}
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
+8 -4
View File
@@ -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)
+26
View File
@@ -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
}
]
}
]
}
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"version": "2.35.10",
"calculate_milliseconds": 32400000,
"contents": [
{
"id": 1,
"current": {
"id": 160,
"start_milliseconds": 1789344000000,
"end_milliseconds": 1789916399000,
"rank_reward_group_id": 1
},
"next": {
"id": 161,
"start_milliseconds": 1789948800000,
"end_milliseconds": 1790521199000,
"rank_reward_group_id": 1
}
},
{
"id": 2,
"current": {
"id": 58,
"start_milliseconds": 1755129600000,
"end_milliseconds": 253370678399000,
"rank_reward_group_id": 1
},
"next": {
"id": 58,
"start_milliseconds": 1755129600000,
"end_milliseconds": 253370678399000,
"rank_reward_group_id": 1
}
},
{
"id": 3,
"current": {
"id": 129,
"start_milliseconds": 1789657200000,
"end_milliseconds": 1790262000000,
"rank_reward_group_id": 1
},
"next": {
"id": 999999,
"start_milliseconds": 253370764800000,
"end_milliseconds": 253370764800000,
"rank_reward_group_id": 1
}
},
{
"id": 4,
"current": {
"id": 862,
"start_milliseconds": 1789830000000,
"end_milliseconds": 1789916400000,
"rank_reward_group_id": 1
},
"next": {
"id": 999999,
"start_milliseconds": 253370764800000,
"end_milliseconds": 253370764800000,
"rank_reward_group_id": 1
}
},
{
"id": 5,
"current": {
"id": 29,
"start_milliseconds": 1787788800000,
"end_milliseconds": 1790089199000,
"rank_reward_group_id": 1
},
"next": {
"id": 30,
"start_milliseconds": 1790121600000,
"end_milliseconds": 1791385199000,
"rank_reward_group_id": 1
}
},
{
"id": 6,
"current": {
"id": 26,
"start_milliseconds": 1787788800000,
"end_milliseconds": 1788361199000,
"rank_reward_group_id": 1
},
"next": {
"id": 27,
"start_milliseconds": 1790121600000,
"end_milliseconds": 1790693999000,
"rank_reward_group_id": 1
}
},
{
"id": 7,
"current": {
"id": 14,
"start_milliseconds": 1788220800000,
"end_milliseconds": 1790812800000,
"rank_reward_group_id": 1
},
"next": {
"id": 999999,
"start_milliseconds": 253370764800000,
"end_milliseconds": 253370764800000,
"rank_reward_group_id": 1
}
},
{
"id": 8,
"current": {
"id": 42,
"start_milliseconds": 1789603200000,
"end_milliseconds": 1790089199000,
"rank_reward_group_id": 1
},
"next": {
"id": 43,
"start_milliseconds": 1790121600000,
"end_milliseconds": 1790780399000,
"rank_reward_group_id": 1
}
},
{
"id": 9,
"current": {
"id": 2,
"start_milliseconds": 1787788800000,
"end_milliseconds": 1792594799000,
"rank_reward_group_id": 1
},
"next": {
"id": 999999,
"start_milliseconds": 253370764800000,
"end_milliseconds": 253370764800000,
"rank_reward_group_id": 1
}
}
],
"regular": [
{
"content_id": 1,
"season": 5
},
{
"content_id": 2,
"season": 6
},
{
"content_id": 5,
"season": 5
},
{
"content_id": 8,
"season": 0
},
{
"content_id": 9,
"season": 0
}
]
}
+1
View File
@@ -1,5 +1,6 @@
{
"version": "2.35.10",
"cooking_recipes": [101],
"items": [
{
"inven_index": 853258187,
+4
View File
@@ -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": {