From d1af8423d239d98f95c4ca3c654dc5e65bc24e8a Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Mon, 5 Oct 2026 19:11:23 +0800 Subject: [PATCH] feat(all): implement prestige skin switching and starter grants --- go/cmd/bd2server/main.go | 12 +- go/internal/server/deck/deck.go | 8 ++ go/internal/server/events/economy.go | 37 +++--- go/internal/server/events/skin_handler.go | 6 +- go/internal/server/events/skins.go | 138 ++++++++++++++++++++- go/internal/server/events/skins_test.go | 135 ++++++++++++++++++++ go/internal/server/gamedata/reward_skin.go | 88 ++++++++++++- go/internal/server/mail/mail.go | 52 ++++++++ go/internal/server/world/world.go | 60 ++++++--- 9 files changed, 491 insertions(+), 45 deletions(-) create mode 100644 go/internal/server/events/skins_test.go diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 1cb7d74..e455a76 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -736,11 +736,15 @@ func serve(args []string) (serveErr error) { if err = worldService.AttachResearchRuntime(gameData, *gameDataVersion, eventEconomy); err != nil { return fmt.Errorf("attach field research: %w", err) } - prestigeSkins, err := gamedata.LoadPrestigeSkins(gameData, *gameDataVersion) + prestigeCatalog, err := gamedata.LoadPrestigeSkinCatalog(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load reward prestige skins: %w", err) } - eventEconomy.AttachPrestigeSkins(prestigeSkins) + eventEconomy.AttachPrestigeSkins(prestigeCatalog.Skins) + eventEconomy.AttachPrestigePortrait(deckStateStore.PortraitCostume) + if err := worldService.AttachPrestigeSelections(eventEconomy.PrestigeSkinSelections); err != nil { + return fmt.Errorf("attach prestige skin selections: %w", err) + } ownedEventItems, err := gamedata.LoadOwnedEventItemDesign(gameData, *gameDataVersion) if err != nil { return fmt.Errorf("load event inventory design: %w", err) @@ -1170,6 +1174,10 @@ func serve(args []string) (serveErr error) { } } if initializeAccount { + prestigeIDs := prestigeCatalog.Giftable(cashService.IsAvailable) + if err := mailService.EnsureStarterPrestigeSkins(prestigeIDs, time.Now().UTC()); err != nil { + return fmt.Errorf("ensure account prestige-skin entitlement: %w", err) + } if err := ensureAccountStateInitialized( progressState, deckStateStore, ownedItems, ownedEquipment, worldService.CharacterService(), collection, wallet, inventorySlots, mailService, missionService, diff --git a/go/internal/server/deck/deck.go b/go/internal/server/deck/deck.go index 7fa83a7..e5e79d8 100644 --- a/go/internal/server/deck/deck.go +++ b/go/internal/server/deck/deck.go @@ -49,6 +49,14 @@ type state struct { PortraitCostumeID uint64 `json:"portrait_costume_id"` AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"` } + +// PortraitCostume exposes the current portrait without changing the frozen deck schema. +func (s *Store) PortraitCostume() uint64 { + s.mu.RLock() + defer s.mu.RUnlock() + return s.state.PortraitCostumeID +} + type Store struct { mu sync.RWMutex storage stateio.AtomicEntryStore diff --git a/go/internal/server/events/economy.go b/go/internal/server/events/economy.go index 61391f4..574ada6 100644 --- a/go/internal/server/events/economy.go +++ b/go/internal/server/events/economy.go @@ -38,24 +38,25 @@ type economySnapshot struct { // Economy dispatches verified static rewards to their owning domains. It is // called inside the same account transaction as the gameplay operation. type Economy struct { - ownedDesign map[uint64]map[uint64]bool - avatarRewards *gamedata.AvatarRewardDesign - buffRewards *BuffRewards - mu sync.Mutex - store stateio.Store - items *player.Inventory - wallet *player.Wallet - collection *player.CollectionStore - equipment *player.EquipmentInventory - costumes player.CostumeDesignSource - options equipmentRoller - graph rewardResolver - hunting HuntingAP - prestige map[uint64]uint64 - apCaps map[uint64]uint64 - resetSeconds int64 - now func() time.Time - initial map[uint64]uint64 + ownedDesign map[uint64]map[uint64]bool + avatarRewards *gamedata.AvatarRewardDesign + buffRewards *BuffRewards + mu sync.Mutex + store stateio.Store + items *player.Inventory + wallet *player.Wallet + collection *player.CollectionStore + equipment *player.EquipmentInventory + costumes player.CostumeDesignSource + options equipmentRoller + graph rewardResolver + hunting HuntingAP + prestige map[uint64]uint64 + prestigePortrait func() uint64 + apCaps map[uint64]uint64 + resetSeconds int64 + now func() time.Time + initial map[uint64]uint64 } func NewEconomy(store stateio.Store, items *player.Inventory, wallet *player.Wallet, collection *player.CollectionStore, equipment *player.EquipmentInventory, costumes player.CostumeDesignSource, options equipmentRoller, graph rewardResolver, initial map[uint64]uint64) (*Economy, error) { diff --git a/go/internal/server/events/skin_handler.go b/go/internal/server/events/skin_handler.go index 71d3043..db92679 100644 --- a/go/internal/server/events/skin_handler.go +++ b/go/internal/server/events/skin_handler.go @@ -4,8 +4,12 @@ package events type SkinHandler struct{ Economy *Economy } func (h SkinHandler) Handle(path string, request []byte) (int, []byte, bool, error) { + return h.HandleSession(path, request, "local") +} + +func (h SkinHandler) HandleSession(path string, request []byte, session string) (int, []byte, bool, error) { if code, response, handled, err := h.Economy.OwnedItemInfo(path, request); handled || err != nil { return code, response, handled, err } - return h.Economy.PrestigeSkinInfo(path, request) + return h.Economy.HandleSession(path, request, session) } diff --git a/go/internal/server/events/skins.go b/go/internal/server/events/skins.go index d622827..001ff1b 100644 --- a/go/internal/server/events/skins.go +++ b/go/internal/server/events/skins.go @@ -1,23 +1,152 @@ package events import ( + "bd2server/internal/server/stateio" "bd2server/internal/server/wire" + "bytes" + "encoding/json" "fmt" + "math" + "strconv" ) -func (e *Economy) AttachPrestigeSkins(design map[uint64]uint64) { e.prestige = design } +func (e *Economy) AttachPrestigeSkins(design map[uint64]uint64) { e.prestige = design } +func (e *Economy) AttachPrestigePortrait(portrait func() uint64) { e.prestigePortrait = portrait } + +type prestigeSkinReceipt struct{ Request, Response []byte } +type prestigeSkinState struct { + Selections map[uint64]uint64 `json:"selections"` + Receipts map[string]prestigeSkinReceipt `json:"receipts"` +} + +func (e *Economy) loadPrestigeSkins() (prestigeSkinState, error) { + s := prestigeSkinState{Selections: map[uint64]uint64{}, Receipts: map[string]prestigeSkinReceipt{}} + raw, err := e.store.Load("prestige_skin_sets") + if err != nil || raw == nil { + return s, err + } + if err = stateio.RequireExactJSONObject(raw, "selections", "receipts"); err != nil { + return s, err + } + if err = json.Unmarshal(raw, &s); err != nil { + return s, err + } + if s.Selections == nil || s.Receipts == nil { + return s, fmt.Errorf("events: invalid saved prestige skins") + } + for costume, design := range s.Selections { + if costume == 0 || design == 0 || e.prestige[design] != costume { + return s, fmt.Errorf("events: invalid saved prestige selection") + } + } + return s, nil +} + +// PrestigeSkinSelections returns a detached projection for costume responses. +func (e *Economy) PrestigeSkinSelections() (map[uint64]uint64, error) { + e.mu.Lock() + defer e.mu.Unlock() + s, err := e.loadPrestigeSkins() + return s.Selections, err +} + +func (e *Economy) Handle(path string, request []byte) (int, []byte, bool, error) { + return e.HandleSession(path, request, "local") +} +func (e *Economy) HandleSession(path string, req []byte, session string) (int, []byte, bool, error) { + if path != "/PrestigeSkinSet" { + return e.PrestigeSkinInfo(path, req) + } + e.mu.Lock() + defer e.mu.Unlock() + if err := wire.Walk(req, func(f wire.Field) error { + if (f.Number == 1 && f.Type != 0) || (f.Number == 2 && f.Type != 2) { + return fmt.Errorf("events: invalid prestige request field") + } + return nil + }); err != nil { + return 426, nil, true, err + } + seq, _, err := wire.Varint(req, 1) + info, found, err2 := wire.Bytes(req, 2) + if err != nil || err2 != nil || !found || seq == 0 || seq > math.MaxInt32 || session == "" { + return 426, nil, true, fmt.Errorf("events: invalid prestige set request") + } + if err := wire.Walk(info, func(f wire.Field) error { + if f.Number >= 1 && f.Number <= 4 && f.Type != 0 { + return fmt.Errorf("events: invalid prestige info field") + } + return nil + }); err != nil { + return 426, nil, true, err + } + costume, _, err := wire.Varint(info, 1) + design, _, err2 := wire.Varint(info, 2) + setting, _, err3 := wire.Varint(info, 3) + timeValue, _, err4 := wire.Varint(info, 4) + if err != nil || err2 != nil || err3 != nil || err4 != nil || costume == 0 || costume > math.MaxInt32 || design == 0 || design > math.MaxInt32 || setting > 1 || timeValue > math.MaxInt64 || e.prestige[design] != costume { + return 426, nil, true, fmt.Errorf("events: invalid prestige skin") + } + s, err := e.loadPrestigeSkins() + if err != nil { + return 426, nil, true, err + } + key := session + ":" + strconv.FormatUint(seq, 10) + if receipt, ok := s.Receipts[key]; ok { + if !bytes.Equal(receipt.Request, req) { + return 426, nil, true, fmt.Errorf("events: prestige sequence conflict") + } + return 426, append([]byte(nil), receipt.Response...), true, nil + } + owned := false + for _, item := range e.items.All() { + if item.Type == 45 && item.ID == design && item.Count > 0 { + owned = true + break + } + } + if !owned { + return 426, nil, true, fmt.Errorf("events: prestige skin not owned") + } + if setting == 1 { + s.Selections[costume] = design + } else if s.Selections[costume] == design { + delete(s.Selections, costume) + } + portrait := costume + if e.prestigePortrait != nil { + portrait = e.prestigePortrait() + } + out := wire.AppendVarint(nil, 1, portrait) + out = wire.AppendVarint(out, 2, s.Selections[portrait]) + s.Receipts[key] = prestigeSkinReceipt{Request: append([]byte(nil), req...), Response: append([]byte(nil), out...)} + raw, err := json.Marshal(s) + if err == nil { + err = e.store.Save("prestige_skin_sets", raw) + } + if err != nil { + return 426, nil, true, err + } + return 426, out, true, nil +} func (e *Economy) PrestigeSkinInfo(path string, req []byte) (int, []byte, bool, error) { if path != "/PrestigeSkinInfo" { return 0, nil, false, nil } seq, _, err := wire.Varint(req, 1) - if err != nil || seq == 0 { + if err != nil || seq == 0 || seq > math.MaxInt32 { return 425, nil, true, fmt.Errorf("events: missing sequence") } + e.mu.Lock() + defer e.mu.Unlock() + s, err := e.loadPrestigeSkins() + if err != nil { + return 425, nil, true, err + } var out []byte seen := map[uint64]bool{} for _, item := range e.items.All() { - if item.Type != 45 || seen[item.ID] { + if item.Type != 45 || item.Count == 0 || seen[item.ID] { continue } costume, ok := e.prestige[item.ID] @@ -27,6 +156,9 @@ func (e *Economy) PrestigeSkinInfo(path string, req []byte) (int, []byte, bool, seen[item.ID] = true b := wire.AppendVarint(nil, 1, costume) b = wire.AppendVarint(b, 2, item.ID) + if s.Selections[costume] == item.ID { + b = wire.AppendVarint(b, 3, 1) + } b = wire.AppendVarint(b, 4, item.TimeValue) out = wire.AppendBytes(out, 1, b) } diff --git a/go/internal/server/events/skins_test.go b/go/internal/server/events/skins_test.go new file mode 100644 index 0000000..0ae10fc --- /dev/null +++ b/go/internal/server/events/skins_test.go @@ -0,0 +1,135 @@ +package events + +import ( + "bytes" + "fmt" + "testing" + + "bd2server/internal/server/gamedata" + "bd2server/internal/server/stateio" + "bd2server/internal/server/wire" +) + +func skinSetRequest(seq, costume, design uint64, set bool) []byte { + info := wire.AppendVarint(nil, 1, costume) + info = wire.AppendVarint(info, 2, design) + if set { + info = wire.AppendVarint(info, 3, 1) + } + return wire.AppendBytes(wire.AppendVarint(nil, 1, seq), 2, info) +} + +func TestPrestigeSkinSelectionPersistsAndReplaysWithoutUndoingLaterChanges(t *testing.T) { + store := stateio.NewMemory() + eco, _, _ := economyFixture(t, store, &economyGraph{}) + design := map[uint64]uint64{9901: 60601, 9902: 60601, 9903: 60602} + eco.AttachPrestigeSkins(design) + if _, err := eco.Apply("skins", nil, []gamedata.Reward{{Type: 45, ID: 9901, Count: 1}, {Type: 45, ID: 9902, Count: 1}, {Type: 45, ID: 9903, Count: 1}}); err != nil { + t.Fatal(err) + } + portrait := uint64(60601) + eco.AttachPrestigePortrait(func() uint64 { return portrait }) + handler := SkinHandler{Economy: eco} + first := skinSetRequest(1, 60601, 9901, true) + code, original, handled, err := handler.HandleSession("/PrestigeSkinSet", first, "s1") + if err != nil || code != 426 || !handled { + t.Fatalf("set: %d %v %v", code, handled, err) + } + for _, req := range [][]byte{skinSetRequest(2, 60601, 9902, true), skinSetRequest(3, 60602, 9903, true), skinSetRequest(4, 60601, 9901, false)} { + _, out, _, callErr := handler.HandleSession("/PrestigeSkinSet", req, "s1") + if callErr != nil { + t.Fatal(callErr) + } + id, _, _ := wire.Varint(out, 1) + design, _, _ := wire.Varint(out, 2) + if id != 60601 || design != 9902 { + t.Fatalf("set changed unrelated portrait: %x", out) + } + } + // Disabling an older skin cannot clear the newer choice on the same costume. + sets, err := eco.PrestigeSkinSelections() + if err != nil || sets[60601] != 9902 || sets[60602] != 9903 { + t.Fatalf("selections: %v %v", sets, err) + } + portraitID, _, _ := wire.Varint(original, 1) + portraitDesign, _, _ := wire.Varint(original, 2) + if portraitID != 60601 || portraitDesign != 9901 { + t.Fatalf("portrait: %x", original) + } + _, _, _, err = handler.HandleSession("/PrestigeSkinSet", skinSetRequest(1, 60601, 9902, true), "s1") + if err == nil { + t.Fatal("sequence conflict accepted") + } + reopened, _, _ := economyFixture(t, store, &economyGraph{}) + reopened.AttachPrestigeSkins(design) + _, replay, _, err := reopened.HandleSession("/PrestigeSkinSet", first, "s1") + if err != nil || !bytes.Equal(original, replay) { + t.Fatalf("restart replay %x %v", replay, err) + } + _, info, _, err := reopened.PrestigeSkinInfo("/PrestigeSkinInfo", wire.AppendVarint(nil, 1, 9)) + if err != nil { + t.Fatal(err) + } + selected := map[uint64]bool{} + if err = wire.Walk(info, func(f wire.Field) error { + id, _, _ := wire.Varint(f.Value, 2) + set, _, _ := wire.Varint(f.Value, 3) + selected[id] = set == 1 + return nil + }); err != nil { + t.Fatal(err) + } + if selected[9901] || !selected[9902] || !selected[9903] { + t.Fatalf("info selection: %v", selected) + } + if _, _, _, err = reopened.HandleSession("/PrestigeSkinSet", skinSetRequest(1, 60601, 9902, false), "s2"); err != nil { + t.Fatal(err) + } + sets, err = reopened.PrestigeSkinSelections() + if err != nil || sets[60601] != 0 || sets[60602] != 9903 { + t.Fatalf("unset disturbed another costume: %v %v", sets, err) + } +} + +type failedSkinStore struct { + *stateio.Memory + fail bool +} + +func (s *failedSkinStore) Save(name string, payload []byte) error { + if s.fail && name == "prestige_skin_sets" { + return fmt.Errorf("injected skin save failure") + } + return s.Memory.Save(name, payload) +} + +func TestPrestigeSkinRejectsUnownedAndFailedSaveLeavesSelectionUnchanged(t *testing.T) { + store := &failedSkinStore{Memory: stateio.NewMemory()} + eco, _, _ := economyFixture(t, store, &economyGraph{}) + eco.AttachPrestigeSkins(map[uint64]uint64{9901: 60601, 9902: 60601}) + if _, err := eco.Apply("skin", nil, []gamedata.Reward{{Type: 45, ID: 9901, Count: 1}}); err != nil { + t.Fatal(err) + } + for _, req := range [][]byte{skinSetRequest(1, 60601, 9902, true), skinSetRequest(1, 60602, 9901, true), wire.AppendBytes(wire.AppendVarint(nil, 1, 1), 2, wire.AppendBytes(wire.AppendVarint(wire.AppendVarint(nil, 1, 60601), 2, 9901), 3, nil))} { + if _, _, _, err := eco.HandleSession("/PrestigeSkinSet", req, "session"); err == nil { + t.Fatalf("invalid request accepted: %x", req) + } + } + req := skinSetRequest(1, 60601, 9901, true) + store.fail = true + if _, _, _, err := eco.HandleSession("/PrestigeSkinSet", req, "session"); err == nil { + t.Fatal("save failure succeeded") + } + sets, err := eco.PrestigeSkinSelections() + if err != nil || len(sets) != 0 { + t.Fatalf("failed selection leaked %v %v", sets, err) + } + store.fail = false + if _, _, _, err = eco.HandleSession("/PrestigeSkinSet", req, "session"); err != nil { + t.Fatal(err) + } + sets, err = eco.PrestigeSkinSelections() + if err != nil || sets[60601] != 9901 { + t.Fatalf("retry not committed: %v %v", sets, err) + } +} diff --git a/go/internal/server/gamedata/reward_skin.go b/go/internal/server/gamedata/reward_skin.go index 623c5cf..6fc8730 100644 --- a/go/internal/server/gamedata/reward_skin.go +++ b/go/internal/server/gamedata/reward_skin.go @@ -1,6 +1,23 @@ package gamedata -func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) { +import ( + "fmt" + "sort" +) + +type PrestigeSkinSelling struct { + DesignID, GroupID, ProductID, MagicGroupID, MagicID uint64 +} + +type PrestigeSkinCatalog struct { + Skins map[uint64]uint64 + Selling []PrestigeSkinSelling +} + +// LoadPrestigeSkinCatalog loads the skin ownership mapping and the separate +// selling-page bindings. PrestigeSkinTable has no "limited" flag; availability +// is therefore determined from the selling binding and the live shop window. +func LoadPrestigeSkinCatalog(root, version string) (*PrestigeSkinCatalog, error) { db, done, e := openStatDatabase(root, version) if e != nil { return nil, e @@ -11,7 +28,7 @@ func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) { return nil, e } defer rows.Close() - out := map[uint64]uint64{} + catalog := &PrestigeSkinCatalog{Skins: map[uint64]uint64{}} for rows.Next() { var id uint64 var raw []byte @@ -22,7 +39,70 @@ func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) { if e != nil { return nil, e } - out[id] = costume + if id == 0 || costume == 0 || catalog.Skins[id] != 0 { + return nil, fmt.Errorf("gamedata: invalid prestige skin %d", id) + } + catalog.Skins[id] = costume } - return out, rows.Err() + if e := rows.Err(); e != nil { + return nil, e + } + rows.Close() + rows, e = db.Query("SELECT ProtoBuf FROM SkinSellingTable ORDER BY groupId,id") + if e != nil { + return nil, e + } + for rows.Next() { + var raw []byte + if e = rows.Scan(&raw); e != nil { + return nil, e + } + values := make([]uint64, 6) + for i, field := range []int{1, 2, 3, 5, 6} { + values[i], e = optionalScalar(raw, field) + if e != nil { + return nil, e + } + } + if values[0] == 0 || values[1] == 0 || values[2] == 0 { + return nil, fmt.Errorf("gamedata: invalid prestige selling row") + } + catalog.Selling = append(catalog.Selling, PrestigeSkinSelling{DesignID: values[0], GroupID: values[1], ProductID: values[2], MagicGroupID: values[3], MagicID: values[4]}) + } + if e = rows.Err(); e != nil { + return nil, e + } + sort.Slice(catalog.Selling, func(i, j int) bool { return catalog.Selling[i].DesignID < catalog.Selling[j].DesignID }) + return catalog, nil +} + +// Giftable returns stable design IDs which are not currently represented by a +// cash purchase page. Event-task sources are intentionally not treated as a +// purchase page; their rewards are not purchasable shop goods. +func (c *PrestigeSkinCatalog) Giftable(cashAvailable func(CashProductKey) bool) []uint64 { + if c == nil { + return nil + } + current := map[uint64]bool{} + for _, row := range c.Selling { + if row.MagicGroupID != 0 && cashAvailable != nil && cashAvailable(CashProductKey{GroupID: row.MagicGroupID, ProductID: row.MagicID}) { + current[row.DesignID] = true + } + } + result := make([]uint64, 0, len(c.Skins)) + for design := range c.Skins { + if !current[design] { + result = append(result, design) + } + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + +func LoadPrestigeSkins(root, version string) (map[uint64]uint64, error) { + catalog, err := LoadPrestigeSkinCatalog(root, version) + if err != nil { + return nil, err + } + return catalog.Skins, nil } diff --git a/go/internal/server/mail/mail.go b/go/internal/server/mail/mail.go index ef58133..dc4d8c0 100644 --- a/go/internal/server/mail/mail.go +++ b/go/internal/server/mail/mail.go @@ -29,6 +29,7 @@ const ( mailHistoryPeriod = 30 * 24 * time.Hour mailHistoryPageMax = 100 starterLimitedCostumeIdentity = "starter:limited-costumes:not-current-pickup:v1" + starterPrestigeSkinIdentity = "starter:prestige-skins:not-current-purchase:v1" ) // itemDBInfoTypes are ElementType values whose successful mail claim is @@ -347,6 +348,11 @@ func OpenService(storage stateio.Store, starter *Starter, inventory *player.Inve return nil, fmt.Errorf("mail: invalid persisted starter limited-costume gift: %w", err) } } + if id, exists := s.issued[starterPrestigeSkinIdentity]; exists { + if err := validateStarterPrestigeSkinMail(s.dynamic[id]); err != nil { + return nil, fmt.Errorf("mail: invalid persisted starter prestige-skin gift: %w", err) + } + } rawHistory, err := entries.ListEntries("mail", "history") if err != nil { return nil, err @@ -944,6 +950,52 @@ 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. +func (s *Service) EnsureStarterPrestigeSkins(designIDs []uint64, sentAt time.Time) error { + if s == nil { + return errors.New("mail: unavailable starter prestige-skin service") + } + s.mu.Lock() + defer s.mu.Unlock() + if id, issued := s.issued[starterPrestigeSkinIdentity]; issued { + return validateStarterPrestigeSkinMail(s.dynamic[id]) + } + if len(designIDs) == 0 { + return errors.New("mail: starter prestige-skin gift is empty") + } + seen := make(map[uint64]bool, len(designIDs)) + rewards := make([]gamedata.Reward, 0, len(designIDs)) + for _, designID := range designIDs { + if designID == 0 || seen[designID] { + return fmt.Errorf("mail: invalid starter prestige skin %d", designID) + } + 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} + if err := grant.validate(); err != nil { + return err + } + return s.enqueueCompensations([]compensation{grant}) +} + +func validateStarterPrestigeSkinMail(entry MailDBInfo) error { + if entry.MailID == 0 || len(entry.RewardTypes) == 0 || len(entry.RewardTypes) != len(entry.RewardIDs) || len(entry.RewardTypes) != len(entry.RewardCounts) { + return errors.New("invalid starter prestige-skin reward arrays") + } + seen := make(map[uint64]bool, len(entry.RewardTypes)) + for i, typ := range entry.RewardTypes { + id := entry.RewardIDs[i] + if typ != 45 || id == 0 || entry.RewardCounts[i] != 1 || seen[id] { + return errors.New("starter prestige-skin gift must contain unique Type45 designs") + } + seen[id] = true + } + return nil +} + // enqueueCompensations shares the EnqueueCompensation allocator and durable // identity ledger. The entire batch is prepared before a single atomic write; // neither storage nor memory can retain a partially imported grant spool. diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 494cb6f..e80f8cb 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -190,22 +190,23 @@ type Service struct { researchEconomy interface { Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error) } - squadLevel func() (uint64, error) - seed Seed - state *progress.Store - starter *player.Starter - equipment *player.EquipmentInventory - inventory *player.Inventory - wallet *player.Wallet - characters *player.CharacterStore - collection *player.CollectionStore - decks *deck.Store - quests map[int]gamedata.QuestDesign - transition gamedata.PackTransition - packs map[int]map[int]gamedata.QuestDesign - transitions map[int]gamedata.PackTransition - activePackMu sync.RWMutex - activePack int + squadLevel func() (uint64, error) + seed Seed + state *progress.Store + starter *player.Starter + equipment *player.EquipmentInventory + inventory *player.Inventory + wallet *player.Wallet + characters *player.CharacterStore + collection *player.CollectionStore + decks *deck.Store + quests map[int]gamedata.QuestDesign + transition gamedata.PackTransition + packs map[int]map[int]gamedata.QuestDesign + transitions map[int]gamedata.PackTransition + activePackMu sync.RWMutex + activePack int + prestigeSelections func() (map[uint64]uint64, error) } func (s *Service) AttachCollection(collection *player.CollectionStore) error { @@ -216,6 +217,16 @@ func (s *Service) AttachCollection(collection *player.CollectionStore) error { return s.characters.AttachCollection(collection) } +// AttachPrestigeSelections projects the durable skin choice into CostumeInfo +// responses without changing the frozen collection/deck schemas. +func (s *Service) AttachPrestigeSelections(provider func() (map[uint64]uint64, error)) error { + if provider == nil { + return errors.New("world: nil prestige selection provider") + } + s.prestigeSelections = provider + return nil +} + func (s *Service) AttachDecks(decks *deck.Store) error { if decks == nil { return errors.New("world: nil deck store") @@ -303,11 +314,26 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) costumes = s.collection.Costumes() } slog.Info("team trace: deliver owned costumes", "costumes", costumes, "quest26", s.seed.RewardCostume) + var selections map[uint64]uint64 + if s.prestigeSelections != nil { + var err error + selections, err = s.prestigeSelections() + if err != nil { + return 0, nil, true, err + } + } for _, costume := range costumes { + if design := selections[costume.ID]; design != 0 { + costume.DesignID = design + } response = wire.AppendBytes(response, 1, encodeCostume(costume)) } if s.collection == nil { - response = wire.AppendBytes(response, 1, encodeCostume(s.seed.RewardCostume)) + costume := s.seed.RewardCostume + if design := selections[costume.ID]; design != 0 { + costume.DesignID = design + } + response = wire.AppendBytes(response, 1, encodeCostume(costume)) } return 40, response, true, nil case "/PackInGameInfo":