feat(all): implement prestige skin switching and starter grants
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user