fix(server): deliver prestige skin gifts once to existing and new accounts
This commit is contained in:
@@ -1173,11 +1173,13 @@ func serve(args []string) (serveErr error) {
|
|||||||
return fmt.Errorf("ensure account limited-costume entitlement: %w", err)
|
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 {
|
if err := mailService.EnsureStarterPrestigeSkins(prestigeIDs, time.Now().UTC()); err != nil {
|
||||||
return fmt.Errorf("ensure account prestige-skin entitlement: %w", err)
|
return fmt.Errorf("ensure account prestige-skin entitlement: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if initializeAccount {
|
||||||
if err := ensureAccountStateInitialized(
|
if err := ensureAccountStateInitialized(
|
||||||
progressState, deckStateStore, ownedItems, ownedEquipment,
|
progressState, deckStateStore, ownedItems, ownedEquipment,
|
||||||
worldService.CharacterService(), collection, wallet, inventorySlots, mailService, missionService,
|
worldService.CharacterService(), collection, wallet, inventorySlots, mailService, missionService,
|
||||||
|
|||||||
@@ -49,16 +49,17 @@ var itemDBInfoTypes = map[uint64]bool{
|
|||||||
19: true, // audited one-use content ticket; restricted below
|
19: true, // audited one-use content ticket; restricted below
|
||||||
27: true, // my-room item
|
27: true, // my-room item
|
||||||
29: true, // instant-use 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
|
// Content tickets are one-use dictionary entries; only Gacha semantics are
|
||||||
// supported by this mailbox, with IDs read from the current table.
|
// supported by this mailbox, with IDs read from the current table.
|
||||||
func supportedItemDBInfoReward(reward gamedata.Reward) bool {
|
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 {
|
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{
|
var currencyRewardTypes = map[uint64]bool{
|
||||||
@@ -950,9 +951,9 @@ func validateStarterLimitedCostumeMail(entry MailDBInfo, design player.CostumeDe
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureStarterPrestigeSkins gives a newly initialized account the prestige
|
// EnsureStarterPrestigeSkins gives an account its prestige-skin gift if it has
|
||||||
// skins that are not on a currently purchasable cash page. The durable issued
|
// never been issued. Existing and new accounts use the same durable identity;
|
||||||
// identity makes this safe to retry across startup and transaction replay.
|
// unclaimed or claimed mail never causes a second gift on restart.
|
||||||
func (s *Service) EnsureStarterPrestigeSkins(designIDs []uint64, sentAt time.Time) error {
|
func (s *Service) EnsureStarterPrestigeSkins(designIDs []uint64, sentAt time.Time) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return errors.New("mail: unavailable starter prestige-skin service")
|
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
|
seen[designID] = true
|
||||||
rewards = append(rewards, gamedata.Reward{Type: 45, ID: designID, Count: 1})
|
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 {
|
if err := grant.validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
func historyEntries(t *testing.T, response []byte) [][]byte {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var entries [][]byte
|
var entries [][]byte
|
||||||
|
|||||||
Reference in New Issue
Block a user