fix(server): preserve current health when loading character stats

This commit is contained in:
2026-10-04 11:52:07 +08:00
parent faca42e85a
commit 39e0a99201
8 changed files with 350 additions and 59 deletions
+54
View File
@@ -1,11 +1,65 @@
package battle
import (
"strconv"
"testing"
"bd2server/internal/server/wire"
)
func TestBattleDefeatAndRunCommitZeroAndInjuredHealth(t *testing.T) {
// EBattleResultType: RED_WIN=2, CONDITION_FAIL=3, RUN=4. A dead
// CharDBInfo omits hp because proto3's default is zero; it must be committed
// rather than ignored and replaced with an earlier full-health value.
for _, result := range []uint64{2, 3, 4} {
t.Run(strconv.FormatUint(result, 10), func(t *testing.T) {
s := &Service{}
calls := 0
var saved map[uint64]uint64
s.AttachCommittedHealth(func(health map[uint64]uint64) error { calls++; saved = health; return nil })
s.BeginSession("settlement")
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
start := wire.AppendVarint(request(2), 2, 11)
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 100))
start = wire.AppendBytes(start, 5, wire.AppendVarint(wire.AppendVarint(nil, 2, 200), 4, 100))
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
end := wire.AppendVarint(request(3), 2, result)
end = wire.AppendBytes(end, 3, wire.AppendVarint(nil, 1, 100))
end = wire.AppendBytes(end, 3, wire.AppendVarint(wire.AppendVarint(nil, 1, 200), 3, 21))
_, response, _, err := s.Handle("/BattleEnd", end)
if err != nil {
t.Fatal(err)
}
if calls != 1 || len(saved) != 2 || saved[100] != 0 || saved[200] != 21 {
t.Fatalf("result=%d saved health=%v calls=%d", result, saved, calls)
}
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 3 {
index, _, _ := wire.Varint(field.Value, 1)
hp, _, _ := wire.Varint(field.Value, 3)
if hp != saved[index] {
t.Fatal("response differs from settled HP")
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleExit", request(4)); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatal("exit wrote health after settlement")
}
})
}
}
func TestBattleHealthCommitsOnlyAtEndAndChecksParticipants(t *testing.T) {
s := &Service{}
calls := 0
+26 -14
View File
@@ -188,29 +188,38 @@ func (s *CharacterStore) MaxHealth(index uint64) (uint64, error) {
return character.HP, nil
}
// CurrentHealth resolves persisted current HP independently from maximum HP.
// A missing current_hp entry retains the owned character record's HP, including
// zero. Recomputing equipment or other maximum-health stats must never heal it.
func (s *CharacterStore) CurrentHealth(index uint64) (uint64, error) {
maximum, err := s.MaxHealth(index)
var saved Character
for _, character := range s.RawAll() {
if character.InvenIndex == index {
saved = character
break
}
}
if saved.InvenIndex == 0 {
return 0, fmt.Errorf("player: unknown health character %d", index)
}
return s.savedCurrentHealth(saved)
}
func (s *CharacterStore) savedCurrentHealth(saved Character) (uint64, error) {
if s.store == nil {
return saved.HP, nil
}
data, found, err := s.store.LoadEntry("characters", "current_hp", strconv.FormatUint(saved.InvenIndex, 10))
if err != nil {
return 0, err
}
return s.currentHealthAtMaximum(index, maximum)
}
func (s *CharacterStore) currentHealthAtMaximum(index, maximum uint64) (uint64, error) {
if s.store == nil {
return maximum, nil
}
data, found, err := s.store.LoadEntry("characters", "current_hp", strconv.FormatUint(index, 10))
if err != nil || !found {
return maximum, err
if !found {
return saved.HP, nil
}
var hp uint64
if json.Unmarshal(data, &hp) != nil {
return 0, errors.New("player: invalid saved current health")
}
if hp > maximum {
hp = maximum
}
return hp, nil
}
@@ -381,6 +390,9 @@ func (s *FoodService) recoverCharacter(index uint64, items []Item) (Character, e
}
total += value
}
if current > maximum {
current = maximum
}
if total >= maximum-current {
character.HP = maximum
} else {
@@ -315,6 +315,104 @@ func TestCurrentHealthGrowthAndImmortalClearPersistedInjury(t *testing.T) {
}
}
func TestCurrentHealthRetainsSavedCharacterHPWithoutSeparateEntryAcrossReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
seed := []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 17}, {InvenIndex: 78, ID: 360, Level: 1, HP: 0}}
open := func(store stateio.Store) *CharacterStore {
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
characters, err := OpenCharacterStore(store, seed, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err = characters.AttachMaxHealth(func(Character) (uint64, error) { return 500, nil }); err != nil {
t.Fatal(err)
}
if err = characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
return characters
}
check := func(characters *CharacterStore) {
for _, saved := range seed {
hp, err := characters.CurrentHealth(saved.InvenIndex)
if err != nil || hp != saved.HP {
t.Fatalf("current %d=%d want=%d err=%v", saved.InvenIndex, hp, saved.HP, err)
}
character, found := characters.Find(saved.InvenIndex)
if !found || character.HP != saved.HP {
t.Fatalf("Find=%+v found=%v", character, found)
}
}
all := characters.All()
if len(all) != 2 || all[0].HP != 17 || all[1].HP != 0 {
t.Fatalf("All=%+v", all)
}
maximum, err := characters.MaxHealth(77)
if err != nil || maximum != 500 {
t.Fatalf("maximum=%d err=%v", maximum, err)
}
}
characters := open(repo)
check(characters)
rows, err := repo.ListEntries("characters", "current_hp")
if err != nil || len(rows) != 0 {
t.Fatalf("reads created entries=%v err=%v", rows, err)
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
characters = open(repo)
check(characters)
if err = characters.SetCurrentHealth(77, 300); err != nil {
t.Fatal(err)
}
if err = characters.SetCurrentHealth(78, 0); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 300 {
t.Fatalf("explicit health entry lost=%d", hp)
}
// Revival must write the restored value, since deleting the entry alone
// would expose the zero HP in the owned character record again.
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 78)
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(78); hp != 500 {
t.Fatalf("revived health=%d", hp)
}
}
func TestEatFoodRecoversSavedHPWithoutSeparateCurrentHealthEntry(t *testing.T) {
food, inventory, characters := foodTestService(t, stateio.NewMemory())
// The seed has current HP 7 and maximum 100, with no current_hp entry.
stacks, err := inventory.GrantOnce("saved-health-food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}})
if err != nil {
t.Fatal(err)
}
if _, _, _, err = food.Handle("/EatFood", foodRequest(1, 77, 21, stacks[0])); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 22 {
t.Fatalf("recovered saved health=%d want22", hp)
}
if len(inventory.All()) != 0 {
t.Fatal("food was not consumed")
}
}
type failFoodStore struct {
stateio.AtomicEntryStore
fail bool
@@ -269,9 +269,13 @@ func TestEquipmentMainOptionChangeEquippedCharacterHealthReadsEquipment(t *testi
t.Fatalf("equipped character response missing: found=%v err=%v", found, err)
}
hp, found, err := wire.Varint(character, 3)
if err != nil || !found || hp != 123 {
if err != nil || found || hp != 0 {
t.Fatalf("updated health=%d found=%v err=%v", hp, found, err)
}
maximum, err := characters.MaxHealth(characterIndex)
if err != nil || maximum != 123 {
t.Fatalf("updated maximum=%d err=%v", maximum, err)
}
case <-time.After(2 * time.Second):
t.Fatal("equipped main option change deadlocked while resolving character health")
}
@@ -27,7 +27,8 @@ func TestEquipmentStatContributionsUseOnlyEquippedOwnerAndAllHealthOptions(t *te
if hp := gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, got).Health; hp != 121 {
t.Fatalf("equipped health=%v", hp)
}
// A normal Find callback must safely enter equipment snapshot resolution.
// Maximum health must safely enter equipment snapshot resolution without
// changing the persisted current HP returned by Find.
_, _, characters := foodTestService(t, stateio.NewMemory())
if err := characters.AttachMaxHealth(func(c Character) (uint64, error) {
contributions, err := s.StatContributions(c)
@@ -36,10 +37,17 @@ func TestEquipmentStatContributionsUseOnlyEquippedOwnerAndAllHealthOptions(t *te
t.Fatal(err)
}
done := make(chan Character, 1)
go func() { c, _ := characters.Find(77); done <- c }()
go func() {
maximum, err := characters.MaxHealth(77)
if err != nil || maximum != 121 {
t.Errorf("maximum=%d err=%v", maximum, err)
}
c, _ := characters.Find(77)
done <- c
}()
select {
case c := <-done:
if c.HP != 121 {
if c.HP != 7 {
t.Fatalf("Find health=%d", c.HP)
}
case <-time.After(time.Second):
+10 -39
View File
@@ -223,18 +223,8 @@ func validateCharacters(characters []Character) error {
func (s *CharacterStore) All() []Character {
characters := s.RawAll()
s.mu.Lock()
maxHealth := s.maxHealth
s.mu.Unlock()
if maxHealth != nil {
for i := range characters {
if hp, err := maxHealth(characters[i]); err == nil {
characters[i].HP = hp
}
}
}
for i := range characters {
if hp, err := s.currentHealthAtMaximum(characters[i].InvenIndex, characters[i].HP); err == nil {
if hp, err := s.savedCurrentHealth(characters[i]); err == nil {
characters[i].HP = hp
}
}
@@ -255,38 +245,14 @@ func (s *CharacterStore) RawAll() []Character {
}
func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) {
s.mu.Lock()
for _, character := range s.characters {
for _, character := range s.RawAll() {
if character.InvenIndex == inventoryIndex {
maxHealth := s.maxHealth
s.mu.Unlock()
if maxHealth != nil {
if hp, err := maxHealth(character); err == nil {
character.HP = hp
}
}
if hp, err := s.currentHealthAtMaximum(character.InvenIndex, character.HP); err == nil {
if hp, err := s.savedCurrentHealth(character); err == nil {
character.HP = hp
}
return character, true
}
}
collection := s.collection
s.mu.Unlock()
if collection != nil {
character, found := collection.FindCharacter(inventoryIndex)
if found && s.maxHealth != nil {
if hp, err := s.maxHealth(character); err == nil {
character.HP = hp
}
}
if found {
if hp, err := s.currentHealthAtMaximum(character.InvenIndex, character.HP); err == nil {
character.HP = hp
}
}
return character, found
}
return Character{}, false
}
@@ -508,7 +474,8 @@ func (s *CharacterStore) promoteCharacter(current Character, position int, fromC
// 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
// HP is recomputed by Find from the same calculator as character growth.
// 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 {
@@ -557,7 +524,11 @@ func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error)
response = wire.AppendBytes(response, 1, CharacterWire(character))
}
for _, index := range indices {
if err := s.resetCurrentHealth(index); err != nil {
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
}
}
+130
View File
@@ -0,0 +1,130 @@
package world
import (
"strconv"
"testing"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestTutorialCharInfoRestoresFieldHealthWithoutExposingStoryRoster(t *testing.T) {
for _, hp := range []uint64{0, 17} {
t.Run(strconv.FormatUint(hp, 10), func(t *testing.T) {
storage := stateio.NewMemory()
starter := &player.Starter{Version: "2.35.10", Characters: []player.Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100}}}
inventory, err := player.OpenInventory(storage, starter)
if err != nil {
t.Fatal(err)
}
all := append(append([]player.Character(nil), starter.Characters...), player.Character{InvenIndex: 88, ID: 650, HP: 100, Level: 1})
characters, err := player.OpenCharacterStore(storage, all, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil {
t.Fatal(err)
}
if err := characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if err := characters.SetCurrentHealth(77, hp); err != nil {
t.Fatal(err)
}
characters, err = player.OpenCharacterStore(storage, all, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil {
t.Fatal(err)
}
s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26}, state: progress.NewStore(), starter: starter, characters: characters}
code, response, handled, err := s.Handle("/CharInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || code != 9 || !handled {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
count := 0
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 {
count++
index, _, _ := wire.Varint(field.Value, 1)
got, _, _ := wire.Varint(field.Value, 3)
if index != 77 || got != hp {
t.Fatalf("tutorial character=%d hp=%d want=%d", index, got, hp)
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("tutorial exposed %d characters", count)
}
if _, _, handled, err := s.Handle("/CharInfo", nil); !handled || err == nil {
t.Fatal("invalid character request accepted")
}
})
}
}
func TestPackInfoStoryCharactersUsePersistedDeadAndInjuredHP(t *testing.T) {
storage := stateio.NewMemory()
starter := &player.Starter{Version: "2.35.10"}
inventory, err := player.OpenInventory(storage, starter)
if err != nil {
t.Fatal(err)
}
all := []player.Character{{InvenIndex: 77, ID: 350, HP: 100, Level: 1}, {InvenIndex: 88, ID: 650, HP: 100, Level: 1}}
characters, err := player.OpenCharacterStore(storage, all, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil {
t.Fatal(err)
}
if err := characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
if err := characters.SetCurrentHealth(77, 0); err != nil {
t.Fatal(err)
}
if err := characters.SetCurrentHealth(88, 23); err != nil {
t.Fatal(err)
}
characters, err = player.OpenCharacterStore(storage, all, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err := characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil }); err != nil {
t.Fatal(err)
}
state := progress.NewStore()
if err := state.ClearQuest(26, 21); err != nil {
t.Fatal(err)
}
s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: all[0], StoryCharacters: all[1:]}, state: state, starter: starter, characters: characters}
response, err := s.packInfoFor(21)
if err != nil {
t.Fatal(err)
}
count := 0
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 {
count++
index, _, _ := wire.Varint(field.Value, 1)
hp, _, _ := wire.Varint(field.Value, 3)
want := map[uint64]uint64{77: 0, 88: 23}
if expected, exists := want[index]; !exists || hp != expected {
t.Fatalf("pack character %d hp=%d", index, hp)
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("pack story roster count=%d", count)
}
}
+16 -2
View File
@@ -157,11 +157,25 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
}
return 4, s.accountPackInfo(), true, nil
case "/CharInfo":
if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
return 0, nil, false, nil
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, nil, true, errors.New("world: CharInfo missing sequence")
}
var response []byte
characters := s.characters.All()
if !s.state.QuestCleared(s.seed.BattleUnlockQuestID, s.seed.PackID) {
// The tutorial roster still contains only starter identities, but
// their field HP must come from persisted state rather than falling
// through to Starter.Handle's immutable new-account HP.
characters = make([]player.Character, 0, len(s.starter.Characters))
for _, seeded := range s.starter.Characters {
character, exists := s.characters.Find(seeded.InvenIndex)
if !exists {
return 0, nil, true, fmt.Errorf("world: missing starter character %d", seeded.InvenIndex)
}
characters = append(characters, character)
}
}
slog.Info("team trace: deliver owned characters", "characters", characters)
for _, character := range characters {
response = wire.AppendBytes(response, 1, encodeCharacter(character))