feat: add game SDK and harden client login and game rules
- Add costume burst upgrades with durable replay and fix permanent SR equipment gacha. - Add server game.json rules for optional collaboration UR weapons, with packaging and documentation. - Add GameNames/GameSdk NuGet tooling, readable types, reobfuscation, complete source/PDB navigation and shared-runtime installation. - Split plugin responsibilities, prefer typeof/nameof, centralize MSBuild settings and ignore local paths; remove the unused LoginUI test project. - Fix login recovery and TLS through native platform HTTP and a private game relay; read device region directly from the OS time zone. Validation: go test ./... -count=1, go vet ./..., plugin Release builds, SDK/runtime self-tests, Roslyn embedded-source navigation, gofmt check and git diff --check.
This commit is contained in:
@@ -1,17 +1,21 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type CostumeUpgrade struct {
|
||||
@@ -106,32 +110,46 @@ type CharAwakeProgress struct {
|
||||
IsAwake bool `json:"is_awake"`
|
||||
}
|
||||
|
||||
// CostumeBurstUpgradeRecord is the durable result of one costume burst level
|
||||
// transition. Digest identifies the exact protobuf request whose successful
|
||||
// response is stored in Body, allowing a committed request to be replayed
|
||||
// after a process restart without charging its materials again.
|
||||
type CostumeBurstUpgradeRecord struct {
|
||||
CostumeID uint64 `json:"costume_id"`
|
||||
Level uint64 `json:"level"`
|
||||
Digest string `json:"digest"`
|
||||
Code int `json:"code"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
// FirstGachaCompletedIdentity is the persisted account flag represented in
|
||||
// the existing collection grant ledger. It is written atomically with the
|
||||
// official GachaSubType=3 first-pick transaction.
|
||||
const FirstGachaCompletedIdentity = "account:first-gacha-completed"
|
||||
|
||||
type collectionSnapshot struct {
|
||||
Version string `json:"version"`
|
||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||
NextCostumeIndex uint64 `json:"next_costume_index"`
|
||||
LatestPreview []uint64 `json:"latest_preview"`
|
||||
PreviewEventIndex uint64 `json:"preview_event_index"`
|
||||
PreviewLocked bool `json:"preview_locked"`
|
||||
Characters []Character `json:"characters,omitempty"`
|
||||
Costumes []Costume `json:"costumes,omitempty"`
|
||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
|
||||
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
|
||||
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
|
||||
GachaCountCorrected bool `json:"gacha_count_corrected"`
|
||||
Grants map[string]CollectionGrant `json:"grants,omitempty"`
|
||||
Version string `json:"version"`
|
||||
NextCharacterIndex uint64 `json:"next_character_index"`
|
||||
NextCostumeIndex uint64 `json:"next_costume_index"`
|
||||
LatestPreview []uint64 `json:"latest_preview"`
|
||||
PreviewEventIndex uint64 `json:"preview_event_index"`
|
||||
PreviewLocked bool `json:"preview_locked"`
|
||||
Characters []Character `json:"characters,omitempty"`
|
||||
Costumes []Costume `json:"costumes,omitempty"`
|
||||
BaseCostumeLevels map[string]uint64 `json:"base_costume_levels"`
|
||||
CostumePotential map[string][]uint64 `json:"costume_potential,omitempty"`
|
||||
CostumeBurstLevels map[string]uint64 `json:"-"`
|
||||
CostumeBurstUpgrades map[string]CostumeBurstUpgradeRecord `json:"-"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
GachaUsers map[string]GachaUserState `json:"gacha_users,omitempty"`
|
||||
GachaFixed map[string]GachaFixedState `json:"gacha_fixed,omitempty"`
|
||||
GachaApplied map[string]bool `json:"gacha_applied,omitempty"`
|
||||
GachaPointExchange map[string]GachaPointExchange `json:"gacha_point_exchanges,omitempty"`
|
||||
GachaCountCorrected bool `json:"gacha_count_corrected"`
|
||||
Grants map[string]CollectionGrant `json:"grants,omitempty"`
|
||||
}
|
||||
|
||||
// CollectionStore owns non-stackable character/costume rewards as one atomic
|
||||
@@ -157,9 +175,10 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
s := &CollectionStore{store: entries, base: append([]Costume(nil), base...), data: collectionSnapshot{
|
||||
Version: versionconfig.State(), NextCharacterIndex: 920000001, NextCostumeIndex: 930000001,
|
||||
BaseCostumeLevels: map[string]uint64{}, GachaSelections: map[string][]GachaSelection{}, GachaSelectionChanges: map[string]uint64{},
|
||||
CostumePotential: map[string][]uint64{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||
CostumePotential: map[string][]uint64{},
|
||||
CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
|
||||
}}
|
||||
b, err := store.Load("collection")
|
||||
@@ -193,6 +212,9 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
if s.data.CharAwake == nil {
|
||||
return nil, errors.New("player: collection save requires char_awake; migrate the development save")
|
||||
}
|
||||
if err := s.validateCostumeBurstStorage(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, progress := range s.data.CharAwake {
|
||||
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
|
||||
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
|
||||
@@ -811,20 +833,191 @@ func (s *CollectionStore) UpdateCharacter(oldID uint64, character Character) err
|
||||
func (s *CollectionStore) Costumes() []Costume {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.costumesLocked()
|
||||
}
|
||||
|
||||
func (s *CollectionStore) costumesLocked() []Costume {
|
||||
result := append([]Costume(nil), s.base...)
|
||||
for i := range result {
|
||||
if level := s.data.BaseCostumeLevels[strconv.FormatUint(result[i].InvenIndex, 10)]; level > result[i].Level {
|
||||
key := strconv.FormatUint(result[i].InvenIndex, 10)
|
||||
if level := s.data.BaseCostumeLevels[key]; level > result[i].Level {
|
||||
result[i].Level = level
|
||||
}
|
||||
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(result[i].InvenIndex, 10)]...)
|
||||
if level, found := s.data.CostumeBurstLevels[key]; found {
|
||||
result[i].BurstLevel = level
|
||||
}
|
||||
result[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
|
||||
}
|
||||
collection := append([]Costume(nil), s.data.Costumes...)
|
||||
for i := range collection {
|
||||
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[strconv.FormatUint(collection[i].InvenIndex, 10)]...)
|
||||
key := strconv.FormatUint(collection[i].InvenIndex, 10)
|
||||
if level, found := s.data.CostumeBurstLevels[key]; found {
|
||||
collection[i].BurstLevel = level
|
||||
}
|
||||
collection[i].PotentialIDs = append([]uint64(nil), s.data.CostumePotential[key]...)
|
||||
}
|
||||
return append(result, collection...)
|
||||
}
|
||||
|
||||
// CostumeByID finds the single owned costume with the logical CostumeTable ID.
|
||||
// A duplicate logical ID is invalid ownership state and is deliberately not
|
||||
// resolved by choosing an arbitrary inventory instance.
|
||||
func (s *CollectionStore) CostumeByID(id uint64) (Costume, bool) {
|
||||
if id == 0 {
|
||||
return Costume{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var result Costume
|
||||
found := false
|
||||
for _, costume := range s.costumesLocked() {
|
||||
if costume.ID != id {
|
||||
continue
|
||||
}
|
||||
if found {
|
||||
return Costume{}, false
|
||||
}
|
||||
result, found = costume, true
|
||||
}
|
||||
return result, found
|
||||
}
|
||||
|
||||
// CostumeBurstReplay returns an owned copy of the durable response recorded
|
||||
// for one inventory instance and target burst level.
|
||||
func (s *CollectionStore) CostumeBurstReplay(invenIndex, target uint64) (CostumeBurstUpgradeRecord, bool) {
|
||||
if invenIndex == 0 || target == 0 {
|
||||
return CostumeBurstUpgradeRecord{}, false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
record, found := s.data.CostumeBurstUpgrades[costumeBurstUpgradeKey(invenIndex, target)]
|
||||
record.Body = append([]byte(nil), record.Body...)
|
||||
return record, found
|
||||
}
|
||||
|
||||
// ApplyCostumeBurst atomically advances one owned costume and stores the exact
|
||||
// successful reply. expectedCurrent is a compare-and-swap guard against a
|
||||
// stale request overwriting a newer level.
|
||||
func (s *CollectionStore) ApplyCostumeBurst(invenIndex, expectedCurrent, target uint64, record CostumeBurstUpgradeRecord) error {
|
||||
if invenIndex == 0 || expectedCurrent == math.MaxUint64 || target != expectedCurrent+1 {
|
||||
return errors.New("player: invalid costume burst transition")
|
||||
}
|
||||
if record.Level != target || record.CostumeID == 0 {
|
||||
return errors.New("player: inconsistent costume burst record")
|
||||
}
|
||||
if err := validateCostumeBurstRecord(record); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
costume, found := s.costumeByIndexLocked(invenIndex)
|
||||
if !found {
|
||||
return fmt.Errorf("player: costume %d not found", invenIndex)
|
||||
}
|
||||
if costume.ID != record.CostumeID {
|
||||
return errors.New("player: costume burst record design mismatch")
|
||||
}
|
||||
if costume.BurstLevel != expectedCurrent {
|
||||
return errors.New("player: stale costume burst level")
|
||||
}
|
||||
ledgerKey := costumeBurstUpgradeKey(invenIndex, target)
|
||||
if _, exists := s.data.CostumeBurstUpgrades[ledgerKey]; exists {
|
||||
return errors.New("player: costume burst transition already recorded")
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
next.CostumeBurstLevels[strconv.FormatUint(invenIndex, 10)] = target
|
||||
record.Body = append([]byte(nil), record.Body...)
|
||||
next.CostumeBurstUpgrades[ledgerKey] = record
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) costumeByIndexLocked(index uint64) (Costume, bool) {
|
||||
for _, costume := range s.costumesLocked() {
|
||||
if costume.InvenIndex == index {
|
||||
return costume, true
|
||||
}
|
||||
}
|
||||
return Costume{}, false
|
||||
}
|
||||
|
||||
func costumeBurstUpgradeKey(invenIndex, target uint64) string {
|
||||
return strconv.FormatUint(invenIndex, 10) + ":" + strconv.FormatUint(target, 10)
|
||||
}
|
||||
|
||||
func parseCostumeBurstUpgradeKey(key string) (uint64, uint64, bool) {
|
||||
left, right, found := strings.Cut(key, ":")
|
||||
if !found || strings.Contains(right, ":") {
|
||||
return 0, 0, false
|
||||
}
|
||||
index, indexErr := strconv.ParseUint(left, 10, 64)
|
||||
level, levelErr := strconv.ParseUint(right, 10, 64)
|
||||
return index, level, indexErr == nil && levelErr == nil && index != 0 && level != 0 && key == costumeBurstUpgradeKey(index, level)
|
||||
}
|
||||
|
||||
func validateCostumeBurstRecord(record CostumeBurstUpgradeRecord) error {
|
||||
if record.CostumeID == 0 || record.Level == 0 || record.Code != 578 || len(record.Digest) != costumeBurstDigestHexSize {
|
||||
return errors.New("player: invalid costume burst upgrade record")
|
||||
}
|
||||
if _, err := hex.DecodeString(record.Digest); err != nil {
|
||||
return errors.New("player: invalid costume burst upgrade digest")
|
||||
}
|
||||
level, found, err := wire.Varint(record.Body, 1)
|
||||
if err != nil || !found || level != record.Level {
|
||||
return errors.New("player: invalid costume burst upgrade response")
|
||||
}
|
||||
fieldCount := 0
|
||||
if err := wire.Walk(record.Body, func(field wire.Field) error {
|
||||
if field.Number != 1 || field.Type != 0 {
|
||||
return errors.New("unexpected costume burst response field")
|
||||
}
|
||||
fieldCount++
|
||||
return nil
|
||||
}); err != nil || fieldCount != 1 {
|
||||
return errors.New("player: invalid costume burst upgrade response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const costumeBurstDigestHexSize = 64
|
||||
|
||||
func (s *CollectionStore) validateCostumeBurstStorage() error {
|
||||
if s.data.CostumeBurstLevels == nil || s.data.CostumeBurstUpgrades == nil {
|
||||
return errors.New("player: collection save requires costume burst ledgers")
|
||||
}
|
||||
owned := make(map[uint64]Costume, len(s.base)+len(s.data.Costumes))
|
||||
for _, costume := range append(append([]Costume(nil), s.base...), s.data.Costumes...) {
|
||||
if costume.InvenIndex == 0 || costume.ID == 0 {
|
||||
return errors.New("player: invalid costume burst ownership")
|
||||
}
|
||||
if _, exists := owned[costume.InvenIndex]; exists {
|
||||
return errors.New("player: duplicate costume burst inventory index")
|
||||
}
|
||||
owned[costume.InvenIndex] = costume
|
||||
}
|
||||
for key, level := range s.data.CostumeBurstLevels {
|
||||
index, err := strconv.ParseUint(key, 10, 64)
|
||||
costume, found := owned[index]
|
||||
if err != nil || index == 0 || key != strconv.FormatUint(index, 10) || level == 0 || !found || level < costume.BurstLevel {
|
||||
return fmt.Errorf("player: invalid costume burst level entry %q", key)
|
||||
}
|
||||
}
|
||||
for key, record := range s.data.CostumeBurstUpgrades {
|
||||
index, level, valid := parseCostumeBurstUpgradeKey(key)
|
||||
costume, found := owned[index]
|
||||
current := costume.BurstLevel
|
||||
if overlay, exists := s.data.CostumeBurstLevels[strconv.FormatUint(index, 10)]; exists {
|
||||
current = overlay
|
||||
}
|
||||
if !valid || !found || record.CostumeID != costume.ID || record.Level != level || level > current {
|
||||
return fmt.Errorf("player: invalid costume burst upgrade entry %q", key)
|
||||
}
|
||||
if err := validateCostumeBurstRecord(record); err != nil {
|
||||
return fmt.Errorf("player: invalid costume burst upgrade entry %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) ValidateCostumePotentialActivation(costumeIndex uint64, nodes []uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -948,12 +1141,9 @@ func (s *CollectionStore) CharacterByIndex(index uint64) (Character, bool) {
|
||||
}
|
||||
|
||||
func (s *CollectionStore) CostumeByIndex(index uint64) (Costume, bool) {
|
||||
for _, costume := range s.Costumes() {
|
||||
if costume.InvenIndex == index {
|
||||
return costume, true
|
||||
}
|
||||
}
|
||||
return Costume{}, false
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.costumeByIndexLocked(index)
|
||||
}
|
||||
|
||||
func findCostume(costumes []Costume, id uint64) (int, bool) {
|
||||
@@ -1005,6 +1195,15 @@ func cloneCollection(in collectionSnapshot) collectionSnapshot {
|
||||
for k, v := range in.CostumePotential {
|
||||
out.CostumePotential[k] = append([]uint64(nil), v...)
|
||||
}
|
||||
out.CostumeBurstLevels = make(map[string]uint64, len(in.CostumeBurstLevels))
|
||||
for k, v := range in.CostumeBurstLevels {
|
||||
out.CostumeBurstLevels[k] = v
|
||||
}
|
||||
out.CostumeBurstUpgrades = make(map[string]CostumeBurstUpgradeRecord, len(in.CostumeBurstUpgrades))
|
||||
for k, v := range in.CostumeBurstUpgrades {
|
||||
v.Body = append([]byte(nil), v.Body...)
|
||||
out.CostumeBurstUpgrades[k] = v
|
||||
}
|
||||
out.CharAwake = make(map[string]CharAwakeProgress, len(in.CharAwake))
|
||||
for k, v := range in.CharAwake {
|
||||
out.CharAwake[k] = v
|
||||
|
||||
@@ -15,7 +15,8 @@ const collectionDomain = "collection"
|
||||
var collectionEntryBuckets = [...]string{
|
||||
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
|
||||
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential", "char_awake",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential",
|
||||
"costume_burst_levels", "costume_burst_upgrades", "char_awake",
|
||||
}
|
||||
|
||||
func rejectInlineCollectionEntries(raw []byte) error {
|
||||
@@ -88,6 +89,12 @@ func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) e
|
||||
if data.CostumePotential, err = loadEntryMap[[]uint64](store, "costume_potential"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CostumeBurstLevels, err = loadEntryMap[uint64](store, "costume_burst_levels"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CostumeBurstUpgrades, err = loadEntryMap[CostumeBurstUpgradeRecord](store, "costume_burst_upgrades"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.CharAwake, err = loadEntryMap[CharAwakeProgress](store, "char_awake"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,6 +134,8 @@ func collectionCore(in collectionSnapshot) collectionSnapshot {
|
||||
in.GachaSelections = nil
|
||||
in.GachaSelectionChanges = nil
|
||||
in.CostumePotential = nil
|
||||
in.CostumeBurstLevels = nil
|
||||
in.CostumeBurstUpgrades = nil
|
||||
in.CharAwake = nil
|
||||
return in
|
||||
}
|
||||
@@ -226,6 +235,12 @@ func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMut
|
||||
if err := diffEntryMap("costume_potential", before.CostumePotential, after.CostumePotential, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("costume_burst_levels", before.CostumeBurstLevels, after.CostumeBurstLevels, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("costume_burst_upgrades", before.CostumeBurstUpgrades, after.CostumeBurstUpgrades, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := diffEntryMap("char_awake", before.CharAwake, after.CharAwake, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
const costumeBurstPacketCode = 578
|
||||
|
||||
type costumeBurstReply struct {
|
||||
Digest string
|
||||
Code int
|
||||
Body []byte
|
||||
}
|
||||
|
||||
// CostumeBurstService applies one adjacent costume-burst level from the
|
||||
// authoritative GameData row. The account request transaction makes the
|
||||
// wallet, inventory, and collection writes one atomic SQLite operation.
|
||||
type CostumeBurstService struct {
|
||||
mu sync.Mutex
|
||||
design *gamedata.CostumeBurstDesign
|
||||
collection *CollectionStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
sessionID string
|
||||
replies map[string]map[uint64]costumeBurstReply
|
||||
}
|
||||
|
||||
func NewCostumeBurstService(design *gamedata.CostumeBurstDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*CostumeBurstService, error) {
|
||||
if design == nil || collection == nil || inventory == nil || wallet == nil {
|
||||
return nil, errors.New("player: incomplete costume burst service")
|
||||
}
|
||||
return &CostumeBurstService{
|
||||
design: design, collection: collection, inventory: inventory, wallet: wallet,
|
||||
replies: make(map[string]map[uint64]costumeBurstReply),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BeginSession scopes protobuf sequence replay without forgetting older live
|
||||
// sessions. Session dispatch calls it before each authenticated request.
|
||||
func (s *CostumeBurstService) BeginSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id == "" || id == s.sessionID {
|
||||
return
|
||||
}
|
||||
s.sessionID = id
|
||||
if s.replies[id] != nil {
|
||||
return
|
||||
}
|
||||
if len(s.replies) >= 1024 {
|
||||
for oldID := range s.replies {
|
||||
if oldID != id {
|
||||
delete(s.replies, oldID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.replies[id] = make(map[uint64]costumeBurstReply)
|
||||
}
|
||||
|
||||
func (s *CostumeBurstService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/CostumeBurst" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst missing sequence")
|
||||
}
|
||||
costumeID, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || costumeID == 0 || costumeID > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst missing costume")
|
||||
}
|
||||
materials, err := equipmentRequestItems(request, 3, "CostumeBurst")
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
for _, material := range materials {
|
||||
if material.InvenIndex > math.MaxInt64 || material.ID > math.MaxInt32 || material.Type > math.MaxInt32 || material.Count > math.MaxInt32 ||
|
||||
material.KeepFlag > math.MaxInt32 || material.TimeValue > math.MaxInt64 || material.ExpiryTime > math.MaxInt64 ||
|
||||
material.SortID > math.MaxInt32 || material.UseCount > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst material exceeds protocol range")
|
||||
}
|
||||
}
|
||||
|
||||
digestBytes := sha256.Sum256(request)
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sessionID := s.sessionID
|
||||
if sessionID == "" {
|
||||
sessionID = "__direct__"
|
||||
}
|
||||
if s.replies[sessionID] == nil {
|
||||
s.replies[sessionID] = make(map[uint64]costumeBurstReply)
|
||||
}
|
||||
if reply, ok := s.replies[sessionID][seq]; ok {
|
||||
if reply.Digest != digest {
|
||||
return 0, nil, true, errors.New("player: CostumeBurst sequence reused with different request")
|
||||
}
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
|
||||
costume, found := s.collection.CostumeByID(costumeID)
|
||||
if !found {
|
||||
return 0, nil, true, fmt.Errorf("player: CostumeBurst costume %d is not owned", costumeID)
|
||||
}
|
||||
if costume.BurstLevel != 0 {
|
||||
if applied, ok := s.collection.CostumeBurstReplay(costume.InvenIndex, costume.BurstLevel); ok && applied.Digest == digest {
|
||||
reply := costumeBurstReply{Digest: digest, Code: applied.Code, Body: append([]byte(nil), applied.Body...)}
|
||||
s.replies[sessionID][seq] = reply
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
}
|
||||
rule, err := s.design.UpgradeRule(costume.ID, costume.BurstLevel)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: resolve CostumeBurst rule: %w", err)
|
||||
}
|
||||
items, gold, err := validateCostumeBurstMaterials(rule.Costs, materials)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if gold != 0 && !s.wallet.CanSpendGold(gold) {
|
||||
return 0, nil, true, errors.New("player: insufficient gold for CostumeBurst")
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate CostumeBurst items: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
identity := "costume-burst:" + strconv.FormatUint(costume.InvenIndex, 10) + ":" + strconv.FormatUint(rule.NextLevel, 10)
|
||||
if gold != 0 {
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume CostumeBurst gold: %w", err)
|
||||
}
|
||||
}
|
||||
if len(items) != 0 {
|
||||
if err := s.inventory.Consume(items); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume CostumeBurst items: %w", err)
|
||||
}
|
||||
}
|
||||
body := wire.AppendVarint(nil, 1, rule.NextLevel)
|
||||
record := CostumeBurstUpgradeRecord{CostumeID: costume.ID, Level: rule.NextLevel, Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
|
||||
if err := s.collection.ApplyCostumeBurst(costume.InvenIndex, costume.BurstLevel, rule.NextLevel, record); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist CostumeBurst: %w", err)
|
||||
}
|
||||
reply := costumeBurstReply{Digest: digest, Code: costumeBurstPacketCode, Body: append([]byte(nil), body...)}
|
||||
s.replies[sessionID][seq] = reply
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
|
||||
func validateCostumeBurstMaterials(costs []gamedata.PromotionCost, materials []Item) ([]Item, uint64, error) {
|
||||
if len(costs) == 0 || len(materials) == 0 {
|
||||
return nil, 0, errors.New("player: CostumeBurst has no material")
|
||||
}
|
||||
want := make(map[[2]uint64]uint64, len(costs))
|
||||
for _, cost := range costs {
|
||||
if cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type == 8 && cost.ID == 0) || (cost.Type != 4 && cost.Type != 8) {
|
||||
return nil, 0, errors.New("player: invalid CostumeBurst GameData cost")
|
||||
}
|
||||
key := [2]uint64{cost.Type, cost.ID}
|
||||
if want[key] > math.MaxUint64-cost.Count {
|
||||
return nil, 0, errors.New("player: CostumeBurst GameData cost overflow")
|
||||
}
|
||||
want[key] += cost.Count
|
||||
}
|
||||
got := make(map[[2]uint64]uint64, len(materials))
|
||||
items := make([]Item, 0, len(materials))
|
||||
var gold uint64
|
||||
for _, material := range materials {
|
||||
if material.Type != 4 && material.Type != 8 {
|
||||
return nil, 0, fmt.Errorf("player: unsupported CostumeBurst material type %d", material.Type)
|
||||
}
|
||||
key := [2]uint64{material.Type, material.ID}
|
||||
if got[key] > math.MaxUint64-material.Count {
|
||||
return nil, 0, errors.New("player: CostumeBurst submitted material overflow")
|
||||
}
|
||||
got[key] += material.Count
|
||||
if material.Type == 4 {
|
||||
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
|
||||
return nil, 0, errors.New("player: invalid CostumeBurst currency")
|
||||
}
|
||||
gold = material.Count
|
||||
} else {
|
||||
items = append(items, material)
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
return nil, 0, errors.New("player: CostumeBurst material kinds mismatch")
|
||||
}
|
||||
for key, count := range want {
|
||||
if got[key] != count {
|
||||
return nil, 0, fmt.Errorf("player: CostumeBurst material %d/%d=%d want %d", key[0], key[1], got[key], count)
|
||||
}
|
||||
}
|
||||
return items, gold, nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func testCostumeBurstRecord(costumeID, level uint64) CostumeBurstUpgradeRecord {
|
||||
return CostumeBurstUpgradeRecord{
|
||||
CostumeID: costumeID,
|
||||
Level: level,
|
||||
Digest: strings.Repeat("ab", 32),
|
||||
Code: 578,
|
||||
Body: wire.AppendVarint(nil, 1, level),
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionCostumeBurstPersistsOverlayAndReplayForEveryCostumeSource(t *testing.T) {
|
||||
storage := &collectionWriteSpy{Memory: stateio.NewMemory()}
|
||||
base := []Costume{{InvenIndex: 11, ID: 101, BurstLevel: 0}}
|
||||
collection, err := OpenCollectionStore(storage, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := collection.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next := cloneCollection(collection.data)
|
||||
next.Costumes = []Costume{{InvenIndex: 22, ID: 202}}
|
||||
if err := collection.commit(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
baseRecord := testCostumeBurstRecord(101, 1)
|
||||
before := len(storage.mutations)
|
||||
if err := collection.ApplyCostumeBurst(11, 0, 1, baseRecord); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(storage.mutations) != before+1 || len(storage.mutations[before]) != 2 {
|
||||
t.Fatalf("burst level and replay were not one collection write: %+v", storage.mutations[before:])
|
||||
}
|
||||
if storage.mutations[before][0].Bucket != "costume_burst_levels" || storage.mutations[before][1].Bucket != "costume_burst_upgrades" {
|
||||
t.Fatalf("unexpected burst mutations: %+v", storage.mutations[before])
|
||||
}
|
||||
if err := collection.ApplyCostumeBurst(22, 0, 1, testCostumeBurstRecord(202, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := collection.ApplyCostumeBurst(22, 1, 2, testCostumeBurstRecord(202, 2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got, found := collection.CostumeByID(101); !found || got.InvenIndex != 11 || got.BurstLevel != 1 {
|
||||
t.Fatalf("base costume overlay=%+v found=%v", got, found)
|
||||
}
|
||||
if got, found := collection.CostumeByIndex(22); !found || got.ID != 202 || got.BurstLevel != 2 {
|
||||
t.Fatalf("collection costume overlay=%+v found=%v", got, found)
|
||||
}
|
||||
replay, found := collection.CostumeBurstReplay(22, 2)
|
||||
if !found || replay.CostumeID != 202 || replay.Level != 2 || replay.Code != 578 {
|
||||
t.Fatalf("replay=%+v found=%v", replay, found)
|
||||
}
|
||||
replay.Body[0] = 0
|
||||
if again, _ := collection.CostumeBurstReplay(22, 2); len(again.Body) == 0 || again.Body[0] == 0 {
|
||||
t.Fatal("CostumeBurstReplay returned an aliased response body")
|
||||
}
|
||||
|
||||
core, err := storage.Load("collection")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(core), "costume_burst") {
|
||||
t.Fatalf("burst entries leaked into collection core: %s", core)
|
||||
}
|
||||
restarted, err := OpenCollectionStore(storage, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := restarted.CostumeByIndex(11); got.BurstLevel != 1 {
|
||||
t.Fatalf("restarted base burst level=%d", got.BurstLevel)
|
||||
}
|
||||
if got, _ := restarted.CostumeByID(202); got.BurstLevel != 2 {
|
||||
t.Fatalf("restarted collection burst level=%d", got.BurstLevel)
|
||||
}
|
||||
if got, ok := restarted.CostumeBurstReplay(22, 1); !ok || got.Level != 1 {
|
||||
t.Fatalf("restarted first replay=%+v found=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionCostumeBurstCASAndRecordValidation(t *testing.T) {
|
||||
collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record := testCostumeBurstRecord(101, 1)
|
||||
if err := collection.ApplyCostumeBurst(11, 0, 1, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, call := range map[string]func() error{
|
||||
"stale current": func() error { return collection.ApplyCostumeBurst(11, 0, 1, record) },
|
||||
"skipped level": func() error { return collection.ApplyCostumeBurst(11, 1, 3, testCostumeBurstRecord(101, 3)) },
|
||||
"wrong costume": func() error { return collection.ApplyCostumeBurst(11, 1, 2, testCostumeBurstRecord(102, 2)) },
|
||||
"unknown instance": func() error { return collection.ApplyCostumeBurst(99, 0, 1, record) },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := call(); err == nil {
|
||||
t.Fatal("invalid costume burst transition was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
if got, _ := collection.CostumeByIndex(11); got.BurstLevel != 1 {
|
||||
t.Fatalf("rejected transitions mutated level to %d", got.BurstLevel)
|
||||
}
|
||||
if _, found := collection.CostumeBurstReplay(11, 2); found {
|
||||
t.Fatal("rejected transition wrote a replay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionCostumeByIDRejectsAmbiguousOwnership(t *testing.T) {
|
||||
collection, err := OpenCollectionStore(stateio.NewMemory(), []Costume{{InvenIndex: 11, ID: 101}, {InvenIndex: 12, ID: 101}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if costume, found := collection.CostumeByID(101); found || costume.InvenIndex != 0 || costume.ID != 0 {
|
||||
t.Fatalf("ambiguous logical ID resolved to %+v", costume)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionRejectsMalformedCostumeBurstEntries(t *testing.T) {
|
||||
validRecord, err := json.Marshal(testCostumeBurstRecord(101, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
bucket string
|
||||
key string
|
||||
payload []byte
|
||||
seed Costume
|
||||
prepare func(*stateio.Memory)
|
||||
}{
|
||||
{name: "noncanonical level key", bucket: "costume_burst_levels", key: "011", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "orphan level", bucket: "costume_burst_levels", key: "99", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "zero level", bucket: "costume_burst_levels", key: "11", payload: []byte("0"), seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "level decreases seed", bucket: "costume_burst_levels", key: "11", payload: []byte("1"), seed: Costume{InvenIndex: 11, ID: 101, BurstLevel: 2}},
|
||||
{name: "noncanonical replay key", bucket: "costume_burst_upgrades", key: "011:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
|
||||
{name: "replay above current", bucket: "costume_burst_upgrades", key: "11:1", payload: validRecord, seed: Costume{InvenIndex: 11, ID: 101}},
|
||||
{name: "invalid replay digest", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
|
||||
r := testCostumeBurstRecord(101, 1)
|
||||
r.Digest = strings.Repeat("zz", 32)
|
||||
b, _ := json.Marshal(r)
|
||||
return b
|
||||
}(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
|
||||
{name: "mismatched response", bucket: "costume_burst_upgrades", key: "11:1", payload: func() []byte {
|
||||
r := testCostumeBurstRecord(101, 1)
|
||||
r.Body = wire.AppendVarint(nil, 1, 2)
|
||||
b, _ := json.Marshal(r)
|
||||
return b
|
||||
}(), seed: Costume{InvenIndex: 11, ID: 101}, prepare: func(s *stateio.Memory) { _ = s.PutEntry("collection", "costume_burst_levels", "11", []byte("1")) }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
collection, err := OpenCollectionStore(storage, []Costume{test.seed})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := collection.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if test.prepare != nil {
|
||||
test.prepare(storage)
|
||||
}
|
||||
if err := storage.PutEntry("collection", test.bucket, test.key, test.payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := OpenCollectionStore(storage, []Costume{test.seed}); err == nil {
|
||||
t.Fatal("malformed costume burst storage was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func testCostumeBurstDesign() *gamedata.CostumeBurstDesign {
|
||||
return &gamedata.CostumeBurstDesign{Levels: map[uint64]map[uint64]gamedata.CostumeBurstLevel{
|
||||
4202: {
|
||||
1: {CostumeID: 4202, Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 100}, {Type: 8, ID: 710, Count: 2}}},
|
||||
2: {CostumeID: 4202, Level: 2, Costs: []gamedata.PromotionCost{{Type: 4, Count: 200}, {Type: 8, ID: 710, Count: 2}}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func costumeBurstRequest(seq, costumeID uint64, materials ...Item) []byte {
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, costumeID)
|
||||
for _, material := range materials {
|
||||
request = wire.AppendBytes(request, 3, ItemWire(material))
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
func newCostumeBurstFixture(t *testing.T, storePath string, stacks []gamedata.BattleReward) (*CostumeBurstService, *CollectionStore, *Inventory, *Wallet, []Item) {
|
||||
t.Helper()
|
||||
store := testStore(storePath)
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(store, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := inventory.GrantOnce("costume-burst-materials", stacks)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return service, collection, inventory, wallet, items
|
||||
}
|
||||
|
||||
func TestCostumeBurstConsumesExactSplitCostsPersistsAndReplays(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, path, []gamedata.BattleReward{
|
||||
{Type: 8, ID: 710, Count: 1},
|
||||
{Type: 8, ID: 710, Count: 1},
|
||||
})
|
||||
service.BeginSession("login-a")
|
||||
request := costumeBurstRequest(7, 4202, stacks[1], Item{Type: 4, Count: 100}, stacks[0])
|
||||
code, body, handled, err := service.Handle("/CostumeBurst", request)
|
||||
if err != nil || !handled || code != costumeBurstPacketCode {
|
||||
t.Fatalf("CostumeBurst code=%d handled=%v body=%x err=%v", code, handled, body, err)
|
||||
}
|
||||
level, found, err := wire.Varint(body, 1)
|
||||
if err != nil || !found || level != 1 {
|
||||
t.Fatalf("response level=%d found=%v err=%v body=%x", level, found, err, body)
|
||||
}
|
||||
got, found := collection.CostumeByID(4202)
|
||||
if !found || got.InvenIndex != 88 || got.DesignID != 9911 || got.BurstLevel != 1 {
|
||||
t.Fatalf("upgraded costume=%+v found=%v", got, found)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 900 {
|
||||
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
|
||||
}
|
||||
for _, stack := range stacks {
|
||||
if err := inventory.CanConsume([]Item{stack}); err == nil {
|
||||
t.Fatalf("material stack %d survived", stack.InvenIndex)
|
||||
}
|
||||
}
|
||||
|
||||
code2, body2, handled, err := service.Handle("/CostumeBurst", request)
|
||||
if err != nil || !handled || code2 != code || string(body2) != string(body) {
|
||||
t.Fatalf("same-session replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
|
||||
}
|
||||
if wallet.Snapshot().Gold != 900 || collection.Costumes()[0].BurstLevel != 1 {
|
||||
t.Fatal("same-session replay mutated state")
|
||||
}
|
||||
|
||||
store := testStore(path)
|
||||
reloadedInventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedWallet, err := OpenWallet(store, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedCollection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 88, ID: 4202, DesignID: 9911}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted, err := NewCostumeBurstService(testCostumeBurstDesign(), reloadedCollection, reloadedInventory, reloadedWallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restarted.BeginSession("after-restart")
|
||||
code2, body2, handled, err = restarted.Handle("/CostumeBurst", request)
|
||||
if err != nil || !handled || code2 != code || string(body2) != string(body) {
|
||||
t.Fatalf("durable replay code=%d handled=%v body=%x err=%v", code2, handled, body2, err)
|
||||
}
|
||||
if reloadedWallet.Snapshot().Gold != 900 || reloadedCollection.Costumes()[0].BurstLevel != 1 {
|
||||
t.Fatal("durable replay charged or advanced again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstRequiresLogicalCostumeIDAndProtectsSessionSequence(t *testing.T) {
|
||||
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: 4}})
|
||||
material := stacks[0]
|
||||
material.Count = 2
|
||||
for _, wrong := range []uint64{88, 9911} {
|
||||
request := costumeBurstRequest(wrong, wrong, material, Item{Type: 4, Count: 100})
|
||||
if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
|
||||
t.Fatalf("non-logical costume ID %d accepted: handled=%v err=%v", wrong, handled, err)
|
||||
}
|
||||
}
|
||||
service.BeginSession("login-a")
|
||||
request := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 100})
|
||||
if _, _, _, err := service.Handle("/CostumeBurst", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
different := costumeBurstRequest(9, 4202, material, Item{Type: 4, Count: 200})
|
||||
if _, _, handled, err := service.Handle("/CostumeBurst", different); err == nil || !handled {
|
||||
t.Fatalf("sequence reuse accepted: handled=%v err=%v", handled, err)
|
||||
}
|
||||
if collection.Costumes()[0].BurstLevel != 1 || wallet.Snapshot().Gold != 900 {
|
||||
t.Fatal("rejected sequence reuse mutated state")
|
||||
}
|
||||
remaining := material
|
||||
remaining.Count = 2
|
||||
if err := inventory.CanConsume([]Item{remaining}); err != nil {
|
||||
t.Fatalf("rejected request consumed remaining items: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstRejectsInvalidMaterialsAndProtocolRanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
materials func(Item) []Item
|
||||
}{
|
||||
{"short", func(stack Item) []Item { stack.Count = 1; return []Item{stack, {Type: 4, Count: 100}} }},
|
||||
{"extra", func(stack Item) []Item {
|
||||
stack.Count = 2
|
||||
return []Item{stack, {Type: 4, Count: 100}, {InvenIndex: 900, Type: 8, ID: 711, Count: 1}}
|
||||
}},
|
||||
{"wrong-gold", func(stack Item) []Item { stack.Count = 2; return []Item{stack, {Type: 4, Count: 99}} }},
|
||||
{"duplicate-overdraw", func(stack Item) []Item { stack.Count = 1; return []Item{stack, stack, {Type: 4, Count: 100}} }},
|
||||
{"range", func(stack Item) []Item {
|
||||
stack.Count = 2
|
||||
stack.ExpiryTime = math.MaxInt64 + 1
|
||||
return []Item{stack, {Type: 4, Count: 100}}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stackCount := uint64(2)
|
||||
if test.name == "duplicate-overdraw" {
|
||||
stackCount = 1
|
||||
}
|
||||
service, collection, inventory, wallet, stacks := newCostumeBurstFixture(t, filepath.Join(t.TempDir(), "state.json"), []gamedata.BattleReward{{Type: 8, ID: 710, Count: stackCount}})
|
||||
request := costumeBurstRequest(1, 4202, test.materials(stacks[0])...)
|
||||
if _, _, handled, err := service.Handle("/CostumeBurst", request); err == nil || !handled {
|
||||
t.Fatalf("invalid request accepted: handled=%v err=%v", handled, err)
|
||||
}
|
||||
if collection.Costumes()[0].BurstLevel != 0 || wallet.Snapshot().Gold != 1000 {
|
||||
t.Fatal("invalid request mutated level or wallet")
|
||||
}
|
||||
if err := inventory.CanConsume([]Item{stacks[0]}); err != nil {
|
||||
t.Fatalf("invalid request consumed material: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeBurstSQLiteRollbackIsAtomic(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
starter := &Starter{Version: "2.35.10"}
|
||||
inventory, err := OpenInventory(repository, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stacks, err := inventory.GrantOnce("rollback-material", []gamedata.BattleReward{{Type: 8, ID: 710, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(repository, Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collection, err := OpenCollectionStore(repository, []Costume{{InvenIndex: 88, ID: 4202}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, collection.EnsurePersisted} {
|
||||
if err := ensure(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
service, _ := NewCostumeBurstService(testCostumeBurstDesign(), collection, inventory, wallet)
|
||||
request := costumeBurstRequest(1, 4202, stacks[0], Item{Type: 4, Count: 100})
|
||||
operation, err := repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, _, handled, err := service.Handle("/CostumeBurst", request); err != nil || !handled || code != costumeBurstPacketCode {
|
||||
t.Fatalf("transactional request code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
if err := operation.Rollback(); err == nil {
|
||||
t.Fatal("dirty request rollback did not fence published in-memory state")
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
reloadedInventory, err := OpenInventory(reopened, starter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedWallet, err := OpenWallet(reopened, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloadedCollection, err := OpenCollectionStore(reopened, []Costume{{InvenIndex: 88, ID: 4202}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloadedWallet.Snapshot().Gold != 1000 || reloadedCollection.Costumes()[0].BurstLevel != 0 {
|
||||
t.Fatalf("rolled back state gold=%d costume=%+v", reloadedWallet.Snapshot().Gold, reloadedCollection.Costumes()[0])
|
||||
}
|
||||
if err := reloadedInventory.CanConsume([]Item{stacks[0]}); err != nil {
|
||||
t.Fatalf("rolled back inventory did not restore materials: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user