feat(all): implement prestige skin switching and starter grants

This commit is contained in:
2026-10-05 19:11:23 +08:00
parent 8a28dd40fb
commit d1af8423d2
9 changed files with 491 additions and 45 deletions
+10 -2
View File
@@ -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,
+8
View File
@@ -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
+19 -18
View File
@@ -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) {
+5 -1
View File
@@ -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)
}
+135 -3
View File
@@ -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)
}
+135
View File
@@ -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)
}
}
+84 -4
View File
@@ -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
}
+52
View File
@@ -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.
+43 -17
View File
@@ -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":