From 39e0a99201cfd1a45bda798734f6ffe37c8f5085 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Sun, 4 Oct 2026 11:52:07 +0800 Subject: [PATCH] fix(server): preserve current health when loading character stats --- go/internal/server/battle/health_test.go | 54 ++++++++ go/internal/server/player/eat_food.go | 40 ++++-- go/internal/server/player/eat_food_test.go | 98 +++++++++++++ .../player/equipment_option_reroll_test.go | 6 +- .../server/player/equipment_stats_test.go | 14 +- go/internal/server/player/growth.go | 49 ++----- go/internal/server/world/health_test.go | 130 ++++++++++++++++++ go/internal/server/world/world.go | 18 ++- 8 files changed, 350 insertions(+), 59 deletions(-) create mode 100644 go/internal/server/world/health_test.go diff --git a/go/internal/server/battle/health_test.go b/go/internal/server/battle/health_test.go index 32aa6dc..382da98 100644 --- a/go/internal/server/battle/health_test.go +++ b/go/internal/server/battle/health_test.go @@ -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 diff --git a/go/internal/server/player/eat_food.go b/go/internal/server/player/eat_food.go index 2a06da1..7e94761 100644 --- a/go/internal/server/player/eat_food.go +++ b/go/internal/server/player/eat_food.go @@ -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 { diff --git a/go/internal/server/player/eat_food_test.go b/go/internal/server/player/eat_food_test.go index 2c53fcb..7a136bb 100644 --- a/go/internal/server/player/eat_food_test.go +++ b/go/internal/server/player/eat_food_test.go @@ -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 diff --git a/go/internal/server/player/equipment_option_reroll_test.go b/go/internal/server/player/equipment_option_reroll_test.go index 950106b..c266a99 100644 --- a/go/internal/server/player/equipment_option_reroll_test.go +++ b/go/internal/server/player/equipment_option_reroll_test.go @@ -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") } diff --git a/go/internal/server/player/equipment_stats_test.go b/go/internal/server/player/equipment_stats_test.go index ee78d24..71258f1 100644 --- a/go/internal/server/player/equipment_stats_test.go +++ b/go/internal/server/player/equipment_stats_test.go @@ -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): diff --git a/go/internal/server/player/growth.go b/go/internal/server/player/growth.go index 47c4961..c9afe87 100644 --- a/go/internal/server/player/growth.go +++ b/go/internal/server/player/growth.go @@ -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 } } diff --git a/go/internal/server/world/health_test.go b/go/internal/server/world/health_test.go new file mode 100644 index 0000000..40eb30f --- /dev/null +++ b/go/internal/server/world/health_test.go @@ -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) + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 4f0caf8..c89c886 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -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))