fix(server): deliver prestige skin gifts once to existing and new accounts

This commit is contained in:
2026-10-05 19:32:27 +08:00
parent d1af8423d2
commit fe659999ba
3 changed files with 95 additions and 8 deletions
+4 -2
View File
@@ -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,
+7 -6
View File
@@ -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
}
+84
View File
@@ -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