feat: add resilient server recovery and talent upgrades

This commit is contained in:
2026-10-03 15:09:31 +08:00
parent b3cd135331
commit b99b3f3451
70 changed files with 3664 additions and 419 deletions
+50
View File
@@ -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")
+5 -2
View File
@@ -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)) {
+61 -5
View File
@@ -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()
+6 -1
View File
@@ -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{}
}
+3
View File
@@ -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)
}
+1
View File
@@ -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"`
+51 -8
View File
@@ -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) {