Files
bd2/go/internal/server/player/growth.go
T

603 lines
20 KiB
Go

package player
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type characterSnapshot struct {
Version string `json:"version"`
CharacterOrder []uint64 `json:"character_order"`
}
// CharacterStore owns mutable character growth separately from immutable
// character design data. The seed supplies the initially owned instances.
type CharacterStore struct {
mu sync.Mutex
store stateio.AtomicEntryStore
characters []Character
persisted map[uint64]bool
persistedOrder []uint64
persistedCore bool
inventory *Inventory
gameDataRoot string
gameDataVersion string
grow func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error)
collection *CollectionStore
wallet *Wallet
maxHealth func(Character) (uint64, error)
promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error)
talentGrowth *gamedata.TalentGrowthDesign
immortal *gamedata.ImmortalDesign
immortalReplies map[string]talentUpgradeReply
sessionID string
talentReplies map[string]map[string]talentUpgradeReply
talentApplied map[string]talentUpgradeReply
}
type talentUpgradeReply struct {
Digest string `json:"digest"`
Code int `json:"code"`
Body []byte `json:"body,omitempty"`
}
func (s *CharacterStore) AttachWallet(wallet *Wallet) error {
if wallet == nil {
return errors.New("player: nil growth wallet")
}
s.mu.Lock()
defer s.mu.Unlock()
s.wallet = wallet
return nil
}
func (s *CharacterStore) AttachImmortalDesign(design *gamedata.ImmortalDesign) error {
if design == nil {
return errors.New("player: nil immortal design")
}
s.immortal = design
return nil
}
func (s *CharacterStore) AttachTalentGrowth(design *gamedata.TalentGrowthDesign) error {
if design == nil {
return errors.New("player: nil talent growth design")
}
s.mu.Lock()
defer s.mu.Unlock()
s.talentGrowth = design
return nil
}
// BeginSession scopes protobuf sequence replay. Network retries reuse the
// exact TalentSkillUpgrade request and must receive success without a second
// level increase or charge.
func (s *CharacterStore) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
if id == "" || id == s.sessionID {
return
}
s.sessionID = id
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
// GameData/collection calculator as AllCharRefresh and BattleEnter.
func (s *CharacterStore) AttachMaxHealth(maxHealth func(Character) (uint64, error)) error {
if maxHealth == nil {
return errors.New("player: nil maximum health calculator")
}
s.mu.Lock()
defer s.mu.Unlock()
s.maxHealth = maxHealth
return nil
}
func (s *CharacterStore) AttachCollection(collection *CollectionStore) error {
if collection == nil {
return errors.New("player: nil collection store")
}
s.mu.Lock()
defer s.mu.Unlock()
s.collection = collection
return nil
}
func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Inventory, gameDataRoot, gameDataVersion string) (*CharacterStore, error) {
if store == nil || inventory == nil {
return nil, errors.New("player: invalid character store configuration")
}
entries, ok := store.(stateio.AtomicEntryStore)
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]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)
}
s.promoteGrowth = func(character Character, submitted []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error) {
return gamedata.CharacterGrowthPromotions(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, submitted)
}
data, err := entries.Load("characters")
if err != nil {
return nil, err
}
if data == nil {
orphaned, err := entries.ListEntries("characters", "characters")
if err != nil {
return nil, fmt.Errorf("player: list character entries: %w", err)
}
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)
if err != nil {
return nil, err
}
s.characters = loaded
s.persistedOrder = append([]uint64{}, saved.CharacterOrder...)
s.persistedCore = true
seen := make(map[uint64]bool, len(s.characters))
for _, character := range s.characters {
seen[character.InvenIndex] = true
s.persisted[character.InvenIndex] = true
}
for _, character := range seed {
if !seen[character.InvenIndex] {
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()
return s.persist(append([]Character(nil), s.characters...))
}
func validateCharacters(characters []Character) error {
seen := make(map[uint64]bool, len(characters))
for _, character := range characters {
if character.InvenIndex == 0 || character.ID == 0 || character.Level == 0 || seen[character.InvenIndex] {
return errors.New("player: invalid saved character")
}
seen[character.InvenIndex] = true
}
return nil
}
func (s *CharacterStore) All() []Character {
characters := s.RawAll()
active := characters[:0]
for _, c := range characters {
if !CharacterExpired(c, time.Now()) {
active = append(active, c)
}
}
characters = active
for i := range characters {
if hp, err := s.savedCurrentHealth(characters[i]); err == nil {
characters[i].HP = hp
}
}
return characters
}
// RawAll is for account-ownership queries used by the stat calculator itself.
// Calling All() from that calculator would recursively calculate its inputs.
func (s *CharacterStore) RawAll() []Character {
s.mu.Lock()
characters := append([]Character(nil), s.characters...)
collection := s.collection
s.mu.Unlock()
if collection != nil {
characters = append(characters, collection.Characters()...)
}
return characters
}
func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) {
for _, character := range s.RawAll() {
if character.InvenIndex == inventoryIndex {
if CharacterExpired(character, time.Now()) {
return Character{}, false
}
if hp, err := s.savedCurrentHealth(character); err == nil {
character.HP = hp
}
return character, true
}
}
return Character{}, false
}
func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool, error) {
if path == "/CharImmortal" {
return s.charImmortal(request)
}
if path == "/TalentSkillUpgrade" {
return s.talentSkillUpgrade(request)
}
if path != "/CharGrowth" {
return 0, nil, false, nil
}
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: CharGrowth missing character")
}
var materials []Item
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 3 {
return nil
}
if field.Type != 2 {
return errors.New("player: invalid growth 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 {
return err
}
if item.Type == 0 || item.Count == 0 || (item.Type != 4 && (item.InvenIndex == 0 || item.ID == 0)) {
return errors.New("player: incomplete growth material")
}
materials = append(materials, item)
return nil
}); err != nil {
return 0, nil, true, err
}
if len(materials) == 0 {
return 0, nil, true, errors.New("player: CharGrowth has no materials")
}
s.mu.Lock()
defer s.mu.Unlock()
position := -1
for i, current := range s.characters {
if current.InvenIndex == index {
position = i
break
}
}
var current Character
fromCollection := false
if position >= 0 {
current = s.characters[position]
} else if s.collection != nil {
current, fromCollection = s.collection.FindCharacter(index)
}
if position < 0 && !fromCollection {
return 0, nil, true, fmt.Errorf("player: unknown character inventory index %d", index)
}
isPromotion := false
for _, material := range materials {
if material.Type == 4 {
isPromotion = true
break
}
}
if isPromotion {
return s.promoteCharacter(current, position, fromCollection, materials)
}
growthMaterials := make([]gamedata.GrowthMaterial, len(materials))
for i, material := range materials {
if material.Type != 8 {
return 0, nil, true, fmt.Errorf("player: unsupported growth material type %d", material.Type)
}
growthMaterials[i] = gamedata.GrowthMaterial{ID: material.ID, Count: material.Count}
}
newLevel, newExp, refunds, err := s.grow(current, growthMaterials)
if err != nil {
return 0, nil, true, fmt.Errorf("player: calculate character growth: %w", err)
}
current.Level = newLevel
current.Exp = newExp
if s.maxHealth != nil {
maxHealth := s.maxHealth
s.mu.Unlock()
hp, healthErr := maxHealth(current)
s.mu.Lock()
current.HP, err = hp, healthErr
if err != nil {
return 0, nil, true, fmt.Errorf("player: calculate grown character maximum health: %w", err)
}
}
returned, err := s.inventory.ConsumeAndRefund(materials, refunds)
if err != nil {
return 0, nil, true, fmt.Errorf("player: consume growth material: %w", err)
}
if fromCollection {
if err := s.collection.UpdateCharacter(current.ID, current); err != nil {
return 0, nil, true, fmt.Errorf("player: persist collection character growth: %w", err)
}
} else {
next := append([]Character(nil), s.characters...)
next[position] = current
if err := s.persist(next); err != nil {
return 0, nil, true, fmt.Errorf("player: persist character growth: %w", err)
}
s.characters = next
}
if err := s.resetCurrentHealth(current.InvenIndex); err != nil {
return 0, nil, true, err
}
response := wire.AppendBytes(nil, 1, CharacterWire(current))
var bundle []byte
for _, item := range returned {
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
}
response = wire.AppendBytes(response, 2, bundle)
return 433, response, true, nil
}
func (s *CharacterStore) promoteCharacter(current Character, position int, fromCollection bool, materials []Item) (int, []byte, bool, error) {
var items []Item
requested := make(map[[2]uint64]uint64)
var gold uint64
for _, material := range materials {
if material.Type == 4 {
if material.InvenIndex != 0 || material.ID != 0 || gold != 0 {
return 0, nil, true, errors.New("player: invalid promotion currency")
}
gold = material.Count
} else if material.Type == 8 {
items = append(items, material)
key := [2]uint64{8, material.ID}
if material.Count > ^uint64(0)-requested[key] {
return 0, nil, true, errors.New("player: promotion material overflow")
}
requested[key] += material.Count
} else {
return 0, nil, true, fmt.Errorf("player: unsupported promotion item type %d", material.Type)
}
}
if gold == 0 {
return 0, nil, true, errors.New("player: promotion has no gold cost")
}
submitted := make([]gamedata.PromotionCost, 0, len(requested)+1)
for key, count := range requested {
submitted = append(submitted, gamedata.PromotionCost{Type: key[0], ID: key[1], Count: count})
}
submitted = append(submitted, gamedata.PromotionCost{Type: 4, Count: gold})
result, err := s.promoteGrowth(current, submitted)
if err != nil {
return 0, nil, true, fmt.Errorf("player: calculate combined character promotion: %w; request=%+v", err, materials)
}
if gold != 0 && (s.wallet == nil || !s.wallet.CanSpendGold(gold)) {
return 0, nil, true, errors.New("player: insufficient gold for promotion")
}
if len(items) == 0 {
return 0, nil, true, errors.New("player: promotion has no item material")
}
previousID := current.ID
current.ID = result.CharacterID
current.Level = result.Level
current.Exp = result.Exp
if fromCollection {
if err := s.collection.CanUpdateCharacter(previousID, current); err != nil {
return 0, nil, true, fmt.Errorf("player: validate promoted collection character: %w", err)
}
}
if err := s.inventory.CanConsume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: validate promotion items: %w", err)
}
if s.maxHealth != nil {
maxHealth := s.maxHealth
s.mu.Unlock()
hp, healthErr := maxHealth(current)
s.mu.Lock()
if healthErr != nil {
return 0, nil, true, fmt.Errorf("player: calculate promoted character health: %w", healthErr)
}
current.HP = hp
}
if gold != 0 {
identity := "char-promote:" + strconv.FormatUint(current.InvenIndex, 10) + ":" + strconv.FormatUint(current.ID, 10)
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
return 0, nil, true, fmt.Errorf("player: consume promotion gold: %w", err)
}
}
returned, err := s.inventory.ConsumeAndRefund(items, result.Refunds)
if err != nil {
return 0, nil, true, fmt.Errorf("player: consume promotion items: %w", err)
}
if fromCollection {
if err := s.collection.UpdateCharacter(previousID, current); err != nil {
return 0, nil, true, fmt.Errorf("player: persist promoted collection character: %w", err)
}
} else {
next := append([]Character(nil), s.characters...)
next[position] = current
if err := s.persist(next); err != nil {
return 0, nil, true, fmt.Errorf("player: persist promoted character: %w", err)
}
s.characters = next
}
if err := s.resetCurrentHealth(current.InvenIndex); err != nil {
return 0, nil, true, err
}
response := wire.AppendBytes(nil, 1, CharacterWire(current))
var bundle []byte
for _, item := range returned {
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
}
if len(bundle) != 0 {
response = wire.AppendBytes(response, 2, bundle)
}
return 433, response, true, nil
}
// charImmortal completes the automatic post-battle revival for characters
// whose TalentSkillTable.ClassType is 14. The story character 6010 has
// ValueList[0]=10000 at every talent level (100%). The authoritative maximum
// Maximum HP is calculated separately from the persisted current HP and the
// restored value is saved explicitly for subsequent character snapshots.
func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error) {
seq, present, err := wire.Varint(request, 1)
if err != nil || !present || seq == 0 {
return 0, nil, true, errors.New("player: CharImmortal missing sequence")
}
digest := fmt.Sprintf("%x", sha256.Sum256(request))
s.mu.Lock()
key := fmt.Sprintf("%s:%d", s.sessionID, seq)
prior, already := s.immortalReplies[key]
s.mu.Unlock()
if already {
if prior.Digest != digest {
return 0, nil, true, errors.New("player: immortal replay payload changed")
}
return prior.Code, append([]byte(nil), prior.Body...), true, nil
}
var indices []uint64
err = wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
if field.Type != 0 && field.Type != 2 {
return errors.New("player: CharImmortal invalid inventory index field")
}
for data := field.Value; len(data) != 0; {
index, count := binary.Uvarint(data)
if count <= 0 || index == 0 {
return errors.New("player: CharImmortal invalid inventory index")
}
indices = append(indices, index)
data = data[count:]
}
return nil
})
if err != nil {
return 0, nil, true, err
}
if len(indices) == 0 || len(indices) > 5 {
return 0, nil, true, fmt.Errorf("player: CharImmortal invalid character count %d", len(indices))
}
seen := make(map[uint64]bool, len(indices))
var response []byte
for _, index := range indices {
if seen[index] {
return 0, nil, true, fmt.Errorf("player: CharImmortal duplicate character %d", index)
}
seen[index] = true
character, found := s.Find(index)
if !found {
return 0, nil, true, fmt.Errorf("player: CharImmortal unknown character %d", index)
}
if character.HP != 0 {
return 0, nil, true, errors.New("player: immortal requires a defeated character")
}
if !s.immortal.CanRestore(character.ID, character.TalentLevel) {
return 0, nil, true, errors.New("player: character has no supported immortal talent at current level")
}
maximum, err := s.MaxHealth(index)
if err != nil {
return 0, nil, true, err
}
character.HP = maximum
response = wire.AppendBytes(response, 1, CharacterWire(character))
}
for _, index := range indices {
maximum, err := s.MaxHealth(index)
if err != nil {
return 0, nil, true, err
}
if err := s.SetCurrentHealth(index, maximum); err != nil {
return 0, nil, true, err
}
}
s.mu.Lock()
if s.immortalReplies == nil {
s.immortalReplies = map[string]talentUpgradeReply{}
}
if len(s.immortalReplies) >= 1024 {
s.immortalReplies = map[string]talentUpgradeReply{}
}
s.immortalReplies[key] = talentUpgradeReply{Digest: digest, Code: 96, Body: append([]byte(nil), response...)}
s.mu.Unlock()
return 96, response, true, nil
}
func CharacterWire(c Character) []byte {
fields := []struct {
n int
v uint64
}{{1, c.InvenIndex}, {2, c.ID}, {3, c.HP}, {4, c.Level}, {5, c.CostumeID}, {6, c.Exp}, {7, c.UseCostume}, {8, c.TalentLevel}, {9, c.TalentExp}, {10, c.SolidarityReward}, {11, c.ExpiryTime}, {13, c.ConnectPotentialCostume}}
var out []byte
for _, field := range fields {
if field.v != 0 {
out = wire.AppendVarint(out, field.n, field.v)
}
}
for _, p := range c.Pictorialbook {
book := wire.AppendVarint(wire.AppendVarint(nil, 1, p.ID), 2, p.GroupID)
out = wire.AppendBytes(out, 12, book)
}
return out
}