feat: add resilient server recovery and talent upgrades
This commit is contained in:
@@ -469,6 +469,21 @@ func (s *CollectionStore) GrantRegular(identity string, costumeIDs []uint64, des
|
||||
if identity == "" || len(costumeIDs) == 0 || design == nil {
|
||||
return CollectionGrant{}, errors.New("player: invalid regular gacha grant")
|
||||
}
|
||||
return s.GrantCostumes(identity, costumeIDs, design)
|
||||
}
|
||||
|
||||
type CostumeDesignSource interface {
|
||||
Character(costumeID uint64) (gamedata.CharacterDesign, bool)
|
||||
}
|
||||
|
||||
// GrantCostumes grants a deterministic costume-copy sequence from any
|
||||
// GameData-backed reward catalog. It is shared by gacha and new-player mail;
|
||||
// the identity makes retry return the original characters, costumes and
|
||||
// upgrade records without applying the copies twice.
|
||||
func (s *CollectionStore) GrantCostumes(identity string, costumeIDs []uint64, design CostumeDesignSource) (CollectionGrant, error) {
|
||||
if identity == "" || len(costumeIDs) == 0 || design == nil {
|
||||
return CollectionGrant{}, errors.New("player: invalid costume reward grant")
|
||||
}
|
||||
return s.grantCostumes(identity, costumeIDs, design.Character, nil)
|
||||
}
|
||||
|
||||
@@ -516,6 +531,41 @@ func (s *CollectionStore) GrantGachaPointCostume(identity string, group gamedata
|
||||
})
|
||||
}
|
||||
|
||||
// GrantGachaPointEquipment records an equipment pickup exchange in the same
|
||||
// collection ledger used by costume pickup exchanges. The equipment instance
|
||||
// itself belongs to EquipmentInventory; the request transaction makes both
|
||||
// domain writes atomic.
|
||||
func (s *CollectionStore) GrantGachaPointEquipment(identity string, groupID, exchangeCost uint64) (CollectionGrant, error) {
|
||||
if identity == "" || groupID == 0 || exchangeCost == 0 {
|
||||
return CollectionGrant{}, errors.New("player: invalid gacha point equipment exchange")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if grant, ok := s.data.Grants[identity]; ok {
|
||||
return cloneGrant(grant), nil
|
||||
}
|
||||
next := cloneCollection(s.data)
|
||||
key := strconv.FormatUint(groupID, 10)
|
||||
user, ok := next.GachaUsers[key]
|
||||
if !ok || user.Point < exchangeCost {
|
||||
return CollectionGrant{}, errors.New("player: insufficient gacha point")
|
||||
}
|
||||
if user.ExchangeItemCount == ^uint64(0) {
|
||||
return CollectionGrant{}, errors.New("player: gacha item exchange count overflow")
|
||||
}
|
||||
user.GroupID = groupID
|
||||
user.Point -= exchangeCost
|
||||
user.ExchangeItemCount++
|
||||
next.GachaUsers[key] = user
|
||||
next.GachaPointExchange[identity] = GachaPointExchange{GroupID: groupID, Count: exchangeCost}
|
||||
grant := CollectionGrant{}
|
||||
next.Grants[identity] = grant
|
||||
if err := s.commit(next); err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) GrantRegularPurchase(identity string, costumeIDs []uint64, design *gamedata.RegularGachaCatalog, purchase GachaPurchase) (CollectionGrant, error) {
|
||||
if identity == "" || len(costumeIDs) == 0 || design == nil || purchase.Group.ID == 0 {
|
||||
return CollectionGrant{}, errors.New("player: invalid regular gacha purchase")
|
||||
|
||||
@@ -133,6 +133,9 @@ func (s *EquipmentInventory) AttachOptionReroll(design *gamedata.EquipmentOption
|
||||
func (s *EquipmentInventory) BeginSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id == "" || id == s.sessionID {
|
||||
return
|
||||
}
|
||||
s.sessionID = id
|
||||
s.smeltCache = make(map[string]smeltingReply)
|
||||
}
|
||||
@@ -789,7 +792,7 @@ func (s *EquipmentInventory) upgradeOnce(request []byte) (int, []byte, bool, err
|
||||
return errors.New("player: EquipUpgrade invalid material")
|
||||
}
|
||||
var item Item
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) {
|
||||
@@ -1199,7 +1202,7 @@ func equipmentRequestItems(request []byte, number int, operation string) ([]Item
|
||||
return fmt.Errorf("player: %s invalid material", operation)
|
||||
}
|
||||
var item Item
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
if err := decodeVarints(field.Value, map[int]*uint64{1: &item.InvenIndex, 2: &item.ID, 3: &item.Type, 4: &item.Count, 5: &item.KeepFlag, 6: &item.TimeValue, 8: &item.ExpiryTime, 9: &item.SortID, 10: &item.UseCount}); err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Type == 0 || item.Count == 0 || (item.Type == 4 && (item.ID != 0 || item.InvenIndex != 0)) || (item.Type != 4 && (item.ID == 0 || item.InvenIndex == 0)) {
|
||||
|
||||
@@ -2,9 +2,12 @@ package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
@@ -36,12 +39,14 @@ type CharacterStore struct {
|
||||
promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error)
|
||||
talentGrowth *gamedata.TalentGrowthDesign
|
||||
sessionID string
|
||||
talentReplies map[string]talentUpgradeReply
|
||||
talentReplies map[string]map[string]talentUpgradeReply
|
||||
talentApplied map[string]talentUpgradeReply
|
||||
}
|
||||
|
||||
type talentUpgradeReply struct {
|
||||
code int
|
||||
body []byte
|
||||
Digest string `json:"digest"`
|
||||
Code int `json:"code"`
|
||||
Body []byte `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
func (s *CharacterStore) AttachWallet(wallet *Wallet) error {
|
||||
@@ -70,8 +75,24 @@ func (s *CharacterStore) AttachTalentGrowth(design *gamedata.TalentGrowthDesign)
|
||||
func (s *CharacterStore) BeginSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id == "" || id == s.sessionID {
|
||||
return
|
||||
}
|
||||
s.sessionID = id
|
||||
s.talentReplies = make(map[string]talentUpgradeReply)
|
||||
if s.talentReplies == nil {
|
||||
s.talentReplies = make(map[string]map[string]talentUpgradeReply)
|
||||
}
|
||||
if s.talentReplies[id] == nil {
|
||||
if len(s.talentReplies) >= 1024 {
|
||||
for oldID := range s.talentReplies {
|
||||
if oldID != id {
|
||||
delete(s.talentReplies, oldID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
s.talentReplies[id] = make(map[string]talentUpgradeReply)
|
||||
}
|
||||
}
|
||||
|
||||
// AttachMaxHealth makes growth and post-battle revival consume the same
|
||||
@@ -104,7 +125,7 @@ func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Invent
|
||||
if !ok {
|
||||
return nil, errors.New("player: character store requires atomic entries")
|
||||
}
|
||||
s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, talentReplies: make(map[string]talentUpgradeReply)}
|
||||
s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, talentReplies: make(map[string]map[string]talentUpgradeReply), talentApplied: make(map[string]talentUpgradeReply)}
|
||||
s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
|
||||
return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials)
|
||||
}
|
||||
@@ -123,6 +144,13 @@ func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Invent
|
||||
if len(orphaned) != 0 {
|
||||
return nil, errors.New("player: character entries exist without core")
|
||||
}
|
||||
applied, err := entries.ListEntries("characters", "talent_upgrades")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(applied) != 0 {
|
||||
return nil, errors.New("player: talent upgrade entries exist without character core")
|
||||
}
|
||||
return s, validateCharacters(s.characters)
|
||||
}
|
||||
saved, loaded, err := loadCharacterEntries(entries, data)
|
||||
@@ -142,12 +170,40 @@ func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Invent
|
||||
return nil, fmt.Errorf("player: current character state omits seeded inventory index %d", character.InvenIndex)
|
||||
}
|
||||
}
|
||||
if err := s.loadTalentUpgradeLedger(entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCharacters(s.characters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) loadTalentUpgradeLedger(entries stateio.AtomicEntryStore) error {
|
||||
rows, err := entries.ListEntries("characters", "talent_upgrades")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, payload := range rows {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("player: invalid talent upgrade ledger key %q", key)
|
||||
}
|
||||
index, indexErr := strconv.ParseUint(parts[0], 10, 64)
|
||||
level, levelErr := strconv.ParseUint(parts[1], 10, 64)
|
||||
var reply talentUpgradeReply
|
||||
if indexErr != nil || levelErr != nil || index == 0 || level < 2 || json.Unmarshal(payload, &reply) != nil ||
|
||||
reply.Code != talentSkillUpgradePacketCode || len(reply.Digest) != 64 {
|
||||
return fmt.Errorf("player: invalid talent upgrade ledger entry %q", key)
|
||||
}
|
||||
if _, err := hex.DecodeString(reply.Digest); err != nil {
|
||||
return fmt.Errorf("player: invalid talent upgrade digest %q", key)
|
||||
}
|
||||
s.talentApplied[key] = reply
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CharacterStore) EnsurePersisted() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -65,6 +65,10 @@ func loadCharacterEntries(store stateio.AtomicEntryStore, core []byte) (characte
|
||||
}
|
||||
|
||||
func (s *CharacterStore) persist(next []Character) error {
|
||||
return s.persistWithChanges(next, nil)
|
||||
}
|
||||
|
||||
func (s *CharacterStore) persistWithChanges(next []Character, additional []stateio.EntryMutation) error {
|
||||
if err := validateCharacters(next); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -74,7 +78,8 @@ func (s *CharacterStore) persist(next []Character) error {
|
||||
}
|
||||
current := make(map[uint64]bool, len(next))
|
||||
order := make([]uint64, 0, len(next))
|
||||
changes := make([]stateio.EntryMutation, 0)
|
||||
changes := make([]stateio.EntryMutation, 0, len(additional)+len(next))
|
||||
changes = append(changes, additional...)
|
||||
for _, character := range next {
|
||||
index := character.InvenIndex
|
||||
current[index] = true
|
||||
|
||||
@@ -103,6 +103,9 @@ func (s *InventorySlots) AttachDevelopmentSettings(path string) {
|
||||
func (s *InventorySlots) BeginSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if id == "" || id == s.sessionID {
|
||||
return
|
||||
}
|
||||
s.sessionID = id
|
||||
s.replies = map[string]inventorySlotReply{}
|
||||
}
|
||||
|
||||
@@ -475,6 +475,9 @@ func ItemWire(item Item) []byte {
|
||||
b = wire.AppendVarint(b, f.n, f.v)
|
||||
}
|
||||
}
|
||||
if item.ExpiryTime != 0 {
|
||||
b = wire.AppendVarint(b, 8, item.ExpiryTime)
|
||||
}
|
||||
if item.SortID != 0 {
|
||||
b = wire.AppendVarint(b, 9, item.SortID)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Item struct {
|
||||
Count uint64 `json:"count"`
|
||||
KeepFlag uint64 `json:"keep_flag,omitempty"`
|
||||
TimeValue uint64 `json:"time_value,omitempty"`
|
||||
ExpiryTime uint64 `json:"expiry_time,omitempty"`
|
||||
Pictorialbook *Pictorial `json:"pictorialbook,omitempty"`
|
||||
SortID uint64 `json:"sort_id,omitempty"`
|
||||
UseCount uint64 `json:"use_count,omitempty"`
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
@@ -14,23 +18,43 @@ const talentSkillUpgradePacketCode = 44
|
||||
|
||||
func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool, error) {
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 {
|
||||
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: TalentSkillUpgrade missing sequence")
|
||||
}
|
||||
index, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || index == 0 {
|
||||
if err != nil || !found || index == 0 || index > math.MaxInt64 {
|
||||
return 0, nil, true, errors.New("player: TalentSkillUpgrade missing character")
|
||||
}
|
||||
materials, err := equipmentRequestItems(request, 3, "TalentSkillUpgrade")
|
||||
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: TalentSkillUpgrade material exceeds protocol range")
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cacheKey := "talent-upgrade:" + s.sessionID + ":seq:" + strconv.FormatUint(seq, 10)
|
||||
if reply, ok := s.talentReplies[cacheKey]; ok {
|
||||
return reply.code, append([]byte(nil), reply.body...), true, nil
|
||||
digestBytes := sha256.Sum256(request)
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
sessionID := s.sessionID
|
||||
if sessionID == "" {
|
||||
sessionID = "__direct__"
|
||||
}
|
||||
if s.talentReplies[sessionID] == nil {
|
||||
s.talentReplies[sessionID] = make(map[string]talentUpgradeReply)
|
||||
}
|
||||
replies := s.talentReplies[sessionID]
|
||||
cacheKey := "seq:" + strconv.FormatUint(seq, 10)
|
||||
if reply, ok := replies[cacheKey]; ok {
|
||||
if reply.Digest != digest {
|
||||
return 0, nil, true, errors.New("player: TalentSkillUpgrade sequence reused with different request")
|
||||
}
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
if s.talentGrowth == nil || s.wallet == nil || s.inventory == nil {
|
||||
return 0, nil, true, errors.New("player: talent upgrade unavailable")
|
||||
@@ -51,6 +75,11 @@ func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool,
|
||||
if position < 0 && !fromCollection {
|
||||
return 0, nil, true, fmt.Errorf("player: unknown talent character inventory index %d", index)
|
||||
}
|
||||
previousLedgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10)
|
||||
if reply, ok := s.talentApplied[previousLedgerKey]; ok && reply.Digest == digest {
|
||||
replies[cacheKey] = reply
|
||||
return reply.Code, append([]byte(nil), reply.Body...), true, nil
|
||||
}
|
||||
rule, err := s.talentGrowth.UpgradeRule(current.ID, current.TalentLevel)
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: resolve talent upgrade: %w", err)
|
||||
@@ -74,12 +103,16 @@ func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool,
|
||||
|
||||
previousID := current.ID
|
||||
current.TalentLevel++
|
||||
ledgerKey := strconv.FormatUint(index, 10) + ":" + strconv.FormatUint(current.TalentLevel, 10)
|
||||
if _, exists := s.talentApplied[ledgerKey]; exists {
|
||||
return 0, nil, true, fmt.Errorf("player: talent upgrade ledger already contains target %s", ledgerKey)
|
||||
}
|
||||
if fromCollection {
|
||||
if err := s.collection.CanUpdateCharacter(previousID, current); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: validate collection talent upgrade: %w", err)
|
||||
}
|
||||
}
|
||||
identity := cacheKey + ":character:" + strconv.FormatUint(index, 10) + ":level:" + strconv.FormatUint(rule.CurrentLevel, 10)
|
||||
identity := "talent-upgrade:" + sessionID + ":" + cacheKey + ":character:" + strconv.FormatUint(index, 10) + ":level:" + strconv.FormatUint(rule.CurrentLevel, 10)
|
||||
if gold != 0 {
|
||||
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: consume talent upgrade gold: %w", err)
|
||||
@@ -90,21 +123,31 @@ func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool,
|
||||
return 0, nil, true, fmt.Errorf("player: consume talent upgrade items: %w", err)
|
||||
}
|
||||
}
|
||||
reply := talentUpgradeReply{Digest: digest, Code: talentSkillUpgradePacketCode}
|
||||
ledgerPayload, err := json.Marshal(reply)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
ledgerChange := stateio.EntryMutation{Bucket: "talent_upgrades", Key: ledgerKey, Payload: ledgerPayload}
|
||||
if fromCollection {
|
||||
if err := s.collection.UpdateCharacter(previousID, current); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist collection talent upgrade: %w", err)
|
||||
}
|
||||
if err := s.store.SaveWithEntries("characters", nil, []stateio.EntryMutation{ledgerChange}); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist talent upgrade replay ledger: %w", err)
|
||||
}
|
||||
} else {
|
||||
next := append([]Character(nil), s.characters...)
|
||||
next[position] = current
|
||||
if err := s.persist(next); err != nil {
|
||||
if err := s.persistWithChanges(next, []stateio.EntryMutation{ledgerChange}); err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist talent upgrade: %w", err)
|
||||
}
|
||||
s.characters = next
|
||||
}
|
||||
// TalentSkillUpgradeResponse.item_info is an optional grant list. Current
|
||||
// GameData defines no refund, so the correct protobuf response is empty.
|
||||
s.talentReplies[cacheKey] = talentUpgradeReply{code: talentSkillUpgradePacketCode}
|
||||
s.talentApplied[ledgerKey] = reply
|
||||
replies[cacheKey] = reply
|
||||
return talentSkillUpgradePacketCode, nil, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
@@ -73,6 +75,9 @@ func TestTalentSkillUpgradeConsumesExactCostsPersistsAndReplays(t *testing.T) {
|
||||
if err := inventory.CanConsume([]Item{book}); err != nil {
|
||||
t.Fatalf("one talent book should remain: %v", err)
|
||||
}
|
||||
// Session dispatch activates SessionAware handlers before every request.
|
||||
// Re-activating the same session must retain the sequence replay result.
|
||||
characters.BeginSession("login-a")
|
||||
code, response, handled, err = characters.Handle("/TalentSkillUpgrade", request)
|
||||
if err != nil || !handled || code != talentSkillUpgradePacketCode || len(response) != 0 {
|
||||
t.Fatalf("replay code=%d handled=%v response=%x err=%v", code, handled, response, err)
|
||||
@@ -90,6 +95,253 @@ func TestTalentSkillUpgradeConsumesExactCostsPersistsAndReplays(t *testing.T) {
|
||||
if got, found := reloaded.Find(77); !found || got.TalentLevel != 2 || got.TalentExp != 14 {
|
||||
t.Fatalf("reloaded character=%+v found=%v", got, found)
|
||||
}
|
||||
if err := reloaded.AttachWallet(wallet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reloaded.AttachTalentGrowth(testTalentGrowthDesign()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded.BeginSession("login-after-restart")
|
||||
if code, body, handled, err := reloaded.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
|
||||
t.Fatalf("restart replay code=%d handled=%v body=%x err=%v", code, handled, body, err)
|
||||
}
|
||||
if got, _ := reloaded.Find(77); got.TalentLevel != 2 || wallet.Snapshot().Gold != 4000 {
|
||||
t.Fatalf("restart replay duplicated upgrade: character=%+v gold=%d", got, wallet.Snapshot().Gold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentSkillUpgradeRejectsSameSessionSequenceWithDifferentRequest(t *testing.T) {
|
||||
store := testStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
books, err := inventory.GrantOnce("books", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{Gold: 5000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = characters.AttachWallet(wallet)
|
||||
_ = characters.AttachTalentGrowth(testTalentGrowthDesign())
|
||||
characters.BeginSession("login-a")
|
||||
book := books[0]
|
||||
book.Count = 1
|
||||
request := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 1000})
|
||||
if _, _, _, err := characters.Handle("/TalentSkillUpgrade", request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
different := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 999})
|
||||
if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", different); err == nil || !handled {
|
||||
t.Fatalf("different request replay handled=%v err=%v", handled, err)
|
||||
}
|
||||
if got, _ := characters.Find(77); got.TalentLevel != 2 || wallet.Snapshot().Gold != 4000 {
|
||||
t.Fatalf("different replay mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentSkillUpgradeRetainsSequenceDigestAcrossInterleavedSessions(t *testing.T) {
|
||||
store := testStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
books, err := inventory.GrantOnce("interleaved-books", []gamedata.BattleReward{
|
||||
{Type: 8, ID: 3, Count: 1},
|
||||
{Type: 8, ID: 4, Count: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{Gold: 5000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 42}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = characters.AttachWallet(wallet)
|
||||
_ = characters.AttachTalentGrowth(testTalentGrowthDesign())
|
||||
characters.BeginSession("login-a")
|
||||
first := talentUpgradeRequest(9, 77, books[0], Item{Type: 4, Count: 1000})
|
||||
if _, _, _, err := characters.Handle("/TalentSkillUpgrade", first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters.BeginSession("login-b")
|
||||
characters.BeginSession("login-a")
|
||||
second := talentUpgradeRequest(9, 77, books[1], Item{Type: 4, Count: 2000})
|
||||
if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", second); err == nil || !handled {
|
||||
t.Fatalf("interleaved session forgot sequence digest: handled=%v err=%v", handled, err)
|
||||
}
|
||||
if got, _ := characters.Find(77); got.TalentLevel != 2 || got.TalentExp != 42 || wallet.Snapshot().Gold != 4000 {
|
||||
t.Fatalf("reused sequence mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold)
|
||||
}
|
||||
if err := inventory.CanConsume([]Item{books[1]}); err != nil {
|
||||
t.Fatalf("reused sequence consumed second-rank material: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentSkillUpgradeRejectsOutOfRangeItemMetadata(t *testing.T) {
|
||||
store := testStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
books, err := inventory.GrantOnce("range-book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = characters.AttachWallet(wallet)
|
||||
_ = characters.AttachTalentGrowth(testTalentGrowthDesign())
|
||||
book := books[0]
|
||||
book.Count = 1
|
||||
book.ExpiryTime = math.MaxInt64 + 1
|
||||
request := talentUpgradeRequest(1, 77, book, Item{Type: 4, Count: 1000})
|
||||
if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", request); err == nil || !handled {
|
||||
t.Fatalf("out-of-range metadata accepted: handled=%v err=%v", handled, err)
|
||||
}
|
||||
if got, _ := characters.Find(77); got.TalentLevel != 1 || wallet.Snapshot().Gold != 1000 {
|
||||
t.Fatalf("out-of-range request mutated state: character=%+v gold=%d", got, wallet.Snapshot().Gold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentSkillUpgradeConsumesOneMaterialAcrossMultipleStacks(t *testing.T) {
|
||||
store := testStore(filepath.Join(t.TempDir(), "state.json"))
|
||||
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
books, err := inventory.GrantOnce("split-books", []gamedata.BattleReward{
|
||||
{Type: 8, ID: 3, Count: 1},
|
||||
{Type: 8, ID: 3, Count: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{Gold: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
design := testTalentGrowthDesign()
|
||||
level := design.Levels[[2]uint64{904, 1}]
|
||||
level.Costs = []gamedata.PromotionCost{{Type: 8, ID: 3, Count: 2}, {Type: 4, Count: 1000}}
|
||||
design.Levels[[2]uint64{904, 1}] = level
|
||||
_ = characters.AttachWallet(wallet)
|
||||
_ = characters.AttachTalentGrowth(design)
|
||||
request := talentUpgradeRequest(1, 77, books[0], books[1], Item{Type: 4, Count: 1000})
|
||||
if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
|
||||
t.Fatalf("split-stack upgrade code=%d handled=%v body=%x err=%v", code, handled, body, err)
|
||||
}
|
||||
if got, _ := characters.Find(77); got.TalentLevel != 2 || got.TalentExp != 14 || wallet.Snapshot().Gold != 0 {
|
||||
t.Fatalf("split-stack result character=%+v gold=%d", got, wallet.Snapshot().Gold)
|
||||
}
|
||||
if err := inventory.CanConsume([]Item{books[0]}); err == nil {
|
||||
t.Fatal("first material stack survived consumption")
|
||||
}
|
||||
if err := inventory.CanConsume([]Item{books[1]}); err == nil {
|
||||
t.Fatal("second material stack survived consumption")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentSkillUpgradeLedgerSurvivesSQLiteCloseAndReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repository, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventory, err := OpenInventory(repository, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
books, err := inventory.GrantOnce("sqlite-talent-book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(repository, Currency{Gold: 5000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(repository, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ensure := range []func() error{inventory.EnsurePersisted, wallet.EnsurePersisted, characters.EnsurePersisted} {
|
||||
if err := ensure(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = characters.AttachWallet(wallet)
|
||||
_ = characters.AttachTalentGrowth(testTalentGrowthDesign())
|
||||
characters.BeginSession("before-restart")
|
||||
request := talentUpgradeRequest(9, 77, books[0], Item{Type: 4, Count: 1000})
|
||||
operation, err := repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
|
||||
_ = operation.Rollback()
|
||||
t.Fatalf("upgrade code=%d handled=%v body=%x err=%v", code, handled, body, err)
|
||||
}
|
||||
if err := operation.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repository.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repository, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
inventory, err = OpenInventory(repository, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err = OpenWallet(repository, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err = OpenCharacterStore(repository, nil, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = characters.AttachWallet(wallet)
|
||||
_ = characters.AttachTalentGrowth(testTalentGrowthDesign())
|
||||
characters.BeginSession("after-restart")
|
||||
operation, err = repository.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, body, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode || len(body) != 0 {
|
||||
_ = operation.Rollback()
|
||||
t.Fatalf("replay code=%d handled=%v body=%x err=%v", code, handled, body, err)
|
||||
}
|
||||
if err := operation.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current, found := characters.Find(77); !found || current.TalentLevel != 2 || current.TalentExp != 14 || wallet.Snapshot().Gold != 4000 {
|
||||
t.Fatalf("restarted state character=%+v found=%v gold=%d", current, found, wallet.Snapshot().Gold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentSkillUpgradeRejectsInvalidStateWithoutCharging(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user