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)
|
||||
}
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user