diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index e455a76..046077c 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -1173,11 +1173,13 @@ func serve(args []string) (serveErr error) { return fmt.Errorf("ensure account limited-costume entitlement: %w", err) } } - if initializeAccount { - prestigeIDs := prestigeCatalog.Giftable(cashService.IsAvailable) + prestigeIDs := prestigeCatalog.Giftable(cashService.IsAvailable) + if len(prestigeIDs) != 0 { if err := mailService.EnsureStarterPrestigeSkins(prestigeIDs, time.Now().UTC()); err != nil { return fmt.Errorf("ensure account prestige-skin entitlement: %w", err) } + } + if initializeAccount { if err := ensureAccountStateInitialized( progressState, deckStateStore, ownedItems, ownedEquipment, worldService.CharacterService(), collection, wallet, inventorySlots, mailService, missionService, diff --git a/go/internal/server/mail/mail.go b/go/internal/server/mail/mail.go index dc4d8c0..bde1538 100644 --- a/go/internal/server/mail/mail.go +++ b/go/internal/server/mail/mail.go @@ -49,16 +49,17 @@ var itemDBInfoTypes = map[uint64]bool{ 19: true, // audited one-use content ticket; restricted below 27: true, // my-room item 29: true, // instant-use item + 45: true, // prestige skin ownership, one copy per mail attachment } // 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.Count == 1) + return itemDBInfoTypes[reward.Type] && reward.ID != 0 && ((reward.Type != 19 && reward.Type != 45) || 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]) + return supportedItemDBInfoReward(reward) && (reward.Type != 19 || s.contentTickets != nil && s.contentTickets.IDs[reward.ID]) } var currencyRewardTypes = map[uint64]bool{ @@ -950,9 +951,9 @@ func validateStarterLimitedCostumeMail(entry MailDBInfo, design player.CostumeDe return nil } -// EnsureStarterPrestigeSkins gives a newly initialized account the prestige -// skins that are not on a currently purchasable cash page. The durable issued -// identity makes this safe to retry across startup and transaction replay. +// EnsureStarterPrestigeSkins gives an account its prestige-skin gift if it has +// never been issued. Existing and new accounts use the same durable identity; +// unclaimed or claimed mail never causes a second gift on restart. func (s *Service) EnsureStarterPrestigeSkins(designIDs []uint64, sentAt time.Time) error { if s == nil { return errors.New("mail: unavailable starter prestige-skin service") @@ -974,7 +975,7 @@ func (s *Service) EnsureStarterPrestigeSkins(designIDs []uint64, sentAt time.Tim seen[designID] = true rewards = append(rewards, gamedata.Reward{Type: 45, ID: designID, Count: 1}) } - grant := compensation{identity: starterPrestigeSkinIdentity, title: "New Player Prestige Skins", body: "Prestige skins not currently available on the cash purchase page.", rewards: rewards, sentAt: sentAt} + grant := compensation{identity: starterPrestigeSkinIdentity, title: "Prestige Skin Gift", body: "Prestige skins not currently available on the cash purchase page.", rewards: rewards, sentAt: sentAt} if err := grant.validate(); err != nil { return err } diff --git a/go/internal/server/mail/mail_test.go b/go/internal/server/mail/mail_test.go index 86c993a..39cd026 100644 --- a/go/internal/server/mail/mail_test.go +++ b/go/internal/server/mail/mail_test.go @@ -362,6 +362,90 @@ func TestEnsureStarterLimitedCostumesIsDurablyIdempotent(t *testing.T) { } } +func TestStarterPrestigeSkinMailClaimsAndPersistsOwnershipOnce(t *testing.T) { + storage := stateio.NewMemory() + service, _, _ := spoolTestService(t, storage) + sentAt := time.Date(2026, 10, 5, 8, 0, 0, 0, time.UTC) + skins := []uint64{101, 202} + if err := service.EnsureStarterPrestigeSkins(skins, sentAt); err != nil { + t.Fatal(err) + } + id := service.issued[starterPrestigeSkinIdentity] + if id == 0 || len(service.dynamic) != 1 { + t.Fatalf("gift missing: issued=%v dynamic=%v", service.issued, service.dynamic) + } + original := append([]byte(nil), service.dynamic[id].encode()...) + if err := service.EnsureStarterPrestigeSkins(skins, sentAt.Add(time.Hour)); err != nil { + t.Fatal(err) + } + service, inventory, _ := spoolTestService(t, storage) + if service.issued[starterPrestigeSkinIdentity] != id || len(service.dynamic) != 1 || !bytes.Equal(original, service.dynamic[id].encode()) { + t.Fatal("retry or restart changed the unclaimed gift") + } + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, id) + code, response, handled, err := service.Handle("/MailOpen", request) + if err != nil || !handled || code != 132 { + t.Fatalf("MailOpen: code=%d handled=%v err=%v", code, handled, err) + } + bundle, found, err := wire.Bytes(response, 1) + if err != nil || !found { + t.Fatalf("reward bundle: found=%v err=%v", found, err) + } + // Check the actual client-visible ItemDBInfo, including the inventory + // indices which must remain stable when this mail is claimed again. + assertSkins := func(payload []byte) map[uint64]uint64 { + t.Helper() + indices := make(map[uint64]uint64) + for _, item := range historyEntries(t, payload) { + index, _, _ := wire.Varint(item, 1) + design, _, _ := wire.Varint(item, 2) + typ, _, _ := wire.Varint(item, 3) + count, _, _ := wire.Varint(item, 4) + if index == 0 || typ != 45 || count != 1 || (design != skins[0] && design != skins[1]) || indices[design] != 0 { + t.Fatalf("invalid or duplicate skin ItemDBInfo: index=%d design=%d type=%d count=%d", index, design, typ, count) + } + indices[design] = index + } + if len(indices) != len(skins) { + t.Fatalf("skin ownership=%v", indices) + } + return indices + } + granted := assertSkins(bundle) + assertOwnership := func(inv *player.Inventory) { + t.Helper() + _, info, handled, err := inv.Handle("/ItemInfo", wire.AppendVarint(nil, 1, 2)) + if err != nil || !handled { + t.Fatalf("ItemInfo: handled=%v err=%v", handled, err) + } + owned := assertSkins(info) + for design, index := range granted { + if owned[design] != index { + t.Fatalf("skin %d inventory index changed: got=%d want=%d", design, owned[design], index) + } + } + } + assertOwnership(inventory) + if _, _, _, err := service.Handle("/MailOpen", request); err != nil { + t.Fatal(err) + } + assertOwnership(inventory) + if err := service.EnsureStarterPrestigeSkins([]uint64{303}, sentAt.Add(24*time.Hour)); err != nil { + t.Fatal(err) + } + service, inventory, _ = spoolTestService(t, storage) + if err := service.EnsureStarterPrestigeSkins([]uint64{404}, sentAt.Add(48*time.Hour)); err != nil { + t.Fatal(err) + } + if service.issued[starterPrestigeSkinIdentity] != id || len(service.dynamic) != 1 || !bytes.Equal(original, service.dynamic[id].encode()) || !containsID(service.state.Opened, id) { + t.Fatal("restart lost the claimed gift ledger or replaced its frozen attachments") + } + if _, _, _, err := service.Handle("/MailOpen", request); err != nil { + t.Fatal(err) + } + assertOwnership(inventory) +} + func historyEntries(t *testing.T, response []byte) [][]byte { t.Helper() var entries [][]byte