From 9e9e86a6588b45011e804b185bafce8445df4850 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Tue, 6 Oct 2026 16:35:35 +0800 Subject: [PATCH] fix(server): restore portraits and batch costume choices and verify equipment persistence --- go/cmd/bd2server/main.go | 3 + go/internal/server/account/login.go | 11 ++ go/internal/server/account/portrait.go | 14 +++ go/internal/server/account/portrait_test.go | 54 +++++++++ go/internal/server/deck/costume_use.go | 66 +++++++++++ go/internal/server/deck/costume_use_test.go | 89 ++++++++++++++ go/internal/server/deck/deck.go | 22 +--- .../server/player/equipment_reconnect_test.go | 109 ++++++++++++++++++ 8 files changed, 347 insertions(+), 21 deletions(-) create mode 100644 go/internal/server/account/portrait.go create mode 100644 go/internal/server/account/portrait_test.go create mode 100644 go/internal/server/deck/costume_use.go create mode 100644 go/internal/server/deck/costume_use_test.go create mode 100644 go/internal/server/player/equipment_reconnect_test.go diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index 9935c60..2467ef2 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -360,6 +360,9 @@ func serve(args []string) (serveErr error) { if err := login.AttachPresetSlots(deckStateStore); err != nil { return fmt.Errorf("attach preset slots to login: %w", err) } + if err := login.AttachPortrait(deckStateStore); err != nil { + return fmt.Errorf("attach persisted portrait to login: %w", err) + } ownedItems, err := player.OpenInventory(stateRepository, starter) if err != nil { return fmt.Errorf("load owned inventory: %w", err) diff --git a/go/internal/server/account/login.go b/go/internal/server/account/login.go index d68664d..fa86288 100644 --- a/go/internal/server/account/login.go +++ b/go/internal/server/account/login.go @@ -42,6 +42,7 @@ type LoginSeed struct { lastPlayedPack LastPlayedPackProvider achievementExp AchievementExperienceProvider autoReviveSettings interface{ AutoReviveSettings() (bool, uint64, error) } + portrait interface{ PortraitCostume() uint64 } levelReward LevelRewardProvider huntingAP HuntingAPProvider monsterHuntSlots PresetSlotProvider @@ -607,6 +608,16 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) { } } user = wire.AppendBytes(user, 3, sessionKey) + if s.portrait != nil { + portrait := s.portrait.PortraitCostume() + if portrait > math.MaxInt32 { + return nil, fmt.Errorf("account: portrait costume overflow") + } + var err error + if user, _, err = wire.ReplaceVarint(user, 14, portrait); err != nil { + return nil, err + } + } projected, projectionErr := s.projectAutoRevive(user) if projectionErr != nil { return nil, projectionErr diff --git a/go/internal/server/account/portrait.go b/go/internal/server/account/portrait.go new file mode 100644 index 0000000..543b8da --- /dev/null +++ b/go/internal/server/account/portrait.go @@ -0,0 +1,14 @@ +package account + +import "fmt" + +// PortraitCostumeProvider supplies the saved UserPortraitChange selection. +type PortraitCostumeProvider interface{ PortraitCostume() uint64 } + +func (s *LoginSeed) AttachPortrait(p PortraitCostumeProvider) error { + if p == nil { + return fmt.Errorf("account: nil portrait provider") + } + s.portrait = p + return nil +} diff --git a/go/internal/server/account/portrait_test.go b/go/internal/server/account/portrait_test.go new file mode 100644 index 0000000..edce6c2 --- /dev/null +++ b/go/internal/server/account/portrait_test.go @@ -0,0 +1,54 @@ +package account + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/deck" + "bd2server/internal/server/wire" + "path/filepath" + "testing" +) + +func TestLoginPortraitUsesSQLiteSelectionAfterRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + r, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + seed, err := deck.LoadSeed("../../../seed/v2_35_10/decks.json") + if err != nil { + t.Fatal(err) + } + d, err := deck.OpenStore(r, seed) + if err != nil { + t.Fatal(err) + } + request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 3601) + if _, _, _, err = d.Handle("/UserPortraitChange", request); err != nil { + t.Fatal(err) + } + if err = r.Close(); err != nil { + t.Fatal(err) + } + r, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer r.Close() + d, err = deck.OpenStore(r, seed) + if err != nil { + t.Fatal(err) + } + s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(wire.AppendVarint(nil, 1, 42), 14, 3501)} + if err = s.AttachPortrait(d); err != nil { + t.Fatal(err) + } + body, err := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")) + if err != nil { + t.Fatal(err) + } + user, _, _ := wire.Bytes(body, 1) + portrait, _, _ := wire.Varint(user, 14) + if portrait != 3601 { + t.Fatalf("reconnected portrait%d, want saved3601", portrait) + } +} diff --git a/go/internal/server/deck/costume_use.go b/go/internal/server/deck/costume_use.go new file mode 100644 index 0000000..c794d51 --- /dev/null +++ b/go/internal/server/deck/costume_use.go @@ -0,0 +1,66 @@ +package deck + +import ( + "bd2server/internal/server/wire" + "fmt" + "math" +) + +func (s *Store) handleCostumeUse(req []byte) (int, []byte, bool, error) { + fail := func(e error) (int, []byte, bool, error) { return 41, nil, true, e } + if e := checkSeq(req); e != nil { + return fail(e) + } + assignments := map[uint64]uint64{} + e := wire.Walk(req, func(f wire.Field) error { + if f.Number != 2 { + return nil + } + if f.Type != 2 { + return fmt.Errorf("deck: invalid costume use entry") + } + cost, _, e := wire.Varint(f.Value, 1) + if e != nil || cost == 0 || cost > math.MaxInt64 { + return fmt.Errorf("deck: invalid costume index") + } + char, _, e := wire.Varint(f.Value, 2) + if e != nil || char == 0 || char > math.MaxInt64 { + return fmt.Errorf("deck: invalid costume character") + } + if _, ok := assignments[char]; ok { + return fmt.Errorf("deck: repeated costume character") + } + assignments[char] = cost + return nil + }) + if e != nil { + return fail(e) + } + if len(assignments) == 0 { + return fail(fmt.Errorf("deck: missing costume assignments")) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.characters != nil { + if s.collection == nil { + return fail(fmt.Errorf("deck: costume collection unavailable")) + } + for char, cost := range assignments { + if _, ok := s.characters.Find(char); !ok { + return fail(fmt.Errorf("deck: unknown costume character")) + } + c, ok := s.collection.CostumeByIndex(cost) + if !ok || c.UseChar != char { + return fail(fmt.Errorf("deck: costume not owned by character")) + } + } + if _, e = s.characters.ApplyPresetCostumes(assignments); e != nil { + return fail(e) + } + } + n := clone(s.state) + for char, cost := range assignments { + n.Costumes[char] = cost + } + return 41, nil, true, s.commit(n) +} diff --git a/go/internal/server/deck/costume_use_test.go b/go/internal/server/deck/costume_use_test.go new file mode 100644 index 0000000..76717bb --- /dev/null +++ b/go/internal/server/deck/costume_use_test.go @@ -0,0 +1,89 @@ +package deck + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/player" + "bd2server/internal/server/wire" + "path/filepath" + "testing" +) + +func TestBatchCostumeUseRestoresCharacterSelectionsFromSQLite(t *testing.T) { + p := filepath.Join(t.TempDir(), "state.db") + r, e := accountstate.Open(p) + if e != nil { + t.Fatal(e) + } + seed, e := LoadSeed("../../../seed/v2_35_10/decks.json") + if e != nil { + t.Fatal(e) + } + initial := []player.Character{{InvenIndex: 100, ID: 350, HP: 80, Level: 1, UseCostume: 1001, CostumeID: 3501, ConnectPotentialCostume: 3501}, {InvenIndex: 200, ID: 360, HP: 60, Level: 1, UseCostume: 2001, CostumeID: 3601, ConnectPotentialCostume: 3601}} + inv, e := player.OpenInventory(r, &player.Starter{Version: "2.35.10"}) + if e != nil { + t.Fatal(e) + } + chars, e := player.OpenCharacterStore(r, initial, inv, "", "") + if e != nil { + t.Fatal(e) + } + coll, e := player.OpenCollectionStore(r, []player.Costume{{InvenIndex: 1001, ID: 3501, UseChar: 100}, {InvenIndex: 1002, ID: 3502, UseChar: 100}, {InvenIndex: 2001, ID: 3601, UseChar: 200}, {InvenIndex: 2002, ID: 3602, UseChar: 200}}) + if e != nil { + t.Fatal(e) + } + if e = chars.AttachCollection(coll); e != nil { + t.Fatal(e) + } + if e = coll.EnsurePersisted(); e != nil { + t.Fatal(e) + } + if e = chars.EnsurePersisted(); e != nil { + t.Fatal(e) + } + d, e := OpenStore(r, seed) + if e != nil { + t.Fatal(e) + } + d.characters = chars + d.collection = coll + invalid := req(1, wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1002), 2, 100)), wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1001), 2, 200))) + if _, _, _, err := d.Handle("/CostumeUse", invalid); err == nil { + t.Fatal("accepted another character's costume") + } + if c, _ := chars.Find(100); c.UseCostume != 1001 { + t.Fatal("invalid batch partially changed first character") + } + request := req(1, wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 1002), 2, 100)), wire.AppendBytes(nil, 2, wire.AppendVarint(wire.AppendVarint(nil, 1, 2002), 2, 200))) + if _, body, _, e := d.Handle("/CostumeUse", request); e != nil || len(body) != 0 { + t.Fatal("empty response protocol", e) + } + if e = r.Close(); e != nil { + t.Fatal(e) + } + r, e = accountstate.Open(p) + if e != nil { + t.Fatal(e) + } + defer r.Close() + inv, e = player.OpenInventory(r, &player.Starter{Version: "2.35.10"}) + if e != nil { + t.Fatal(e) + } + coll, e = player.OpenCollectionStore(r, nil) + if e != nil { + t.Fatal(e) + } + chars, e = player.OpenCharacterStore(r, initial, inv, "", "") + if e != nil { + t.Fatal(e) + } + if e = chars.AttachCollection(coll); e != nil { + t.Fatal(e) + } + for _, v := range []struct{ index, cost, id, hp, connect uint64 }{{100, 1002, 3502, 80, 3501}, {200, 2002, 3602, 60, 3601}} { + c, ok := chars.Find(v.index) + if !ok || c.UseCostume != v.cost || c.CostumeID != v.id || c.HP != v.hp || c.ConnectPotentialCostume != v.connect { + t.Fatalf("reconnected character%+v", c) + } + } +} diff --git a/go/internal/server/deck/deck.go b/go/internal/server/deck/deck.go index 2730bdd..6618765 100644 --- a/go/internal/server/deck/deck.go +++ b/go/internal/server/deck/deck.go @@ -485,27 +485,7 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) { case "/WaypointSave", "/WaypointUse": return s.handleWaypoint(path, req) case "/CostumeUse": - raw, ok, e := wire.Bytes(req, 2) - if e != nil || !ok { - return 0, nil, true, errors.New("deck: invalid costume use") - } - cost, ok, e := wire.Varint(raw, 1) - if e != nil || !ok || cost == 0 { - return 0, nil, true, errors.New("deck: invalid costume") - } - char, ok, e := wire.Varint(raw, 2) - if e != nil || !ok || char == 0 { - return 0, nil, true, errors.New("deck: invalid costume character") - } - if e = checkSeq(req); e != nil { - return 0, nil, true, e - } - s.mu.Lock() - defer s.mu.Unlock() - n := clone(s.state) - n.Costumes[char] = cost - e = s.commit(n) - return 41, nil, true, e + return s.handleCostumeUse(req) case "/SaveTotalBattlePower": power, ok, e := wire.Varint(req, 2) if e != nil || !ok || power == 0 { diff --git a/go/internal/server/player/equipment_reconnect_test.go b/go/internal/server/player/equipment_reconnect_test.go new file mode 100644 index 0000000..4e72eea --- /dev/null +++ b/go/internal/server/player/equipment_reconnect_test.go @@ -0,0 +1,109 @@ +package player + +import ( + "bd2server/internal/server/accountstate" + "bd2server/internal/server/wire" + "path/filepath" + "reflect" + "testing" +) + +// EquipInfo is the client's complete reconstruction source after it clears +// both equipment dictionaries during relogin. Exercise real SQLite writes and +// reopen rather than only the item serializer or an in-memory snapshot. +func TestEquipmentReconnectRestoresEveryCharacterAndClearedReplacement(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + open := func(repo *accountstate.Repository) *EquipmentInventory { + items, e := OpenInventory(repo, &Starter{Version: "2.35.10"}) + if e != nil { + t.Fatal(e) + } + chars, e := OpenCharacterStore(repo, []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100}, {InvenIndex: 78, ID: 360, Level: 1, HP: 100}}, items, "", "") + if e != nil { + t.Fatal(e) + } + chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }) + if e = chars.EnsurePersisted(); e != nil { + t.Fatal(e) + } + eq, e := OpenEquipmentInventory(repo) + if e != nil { + t.Fatal(e) + } + if e = eq.AttachCharacters(chars); e != nil { + t.Fatal(e) + } + if e = eq.AttachSlots(map[uint64]uint64{100: 0, 101: 0, 102: 1, 103: 0}); e != nil { + t.Fatal(e) + } + return eq + } + eq := open(repo) + eq.BeginSession("before-restart") + var entries []Equipment + for i, id := range []uint64{100, 101, 102, 103} { + entry, e := eq.GrantOnce("grant"+string(rune('a'+i)), id) + if e != nil { + t.Fatal(e) + } + entries = append(entries, entry) + } + request := func(seq, equip, char uint64) []byte { + return wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, equip), 3, char) + } + for _, r := range []struct { + path string + seq, index, char uint64 + }{{"/EquipUse", 1, entries[0].InvenIndex, 77}, {"/EquipUse", 2, entries[2].InvenIndex, 77}, {"/EquipUse", 3, entries[3].InvenIndex, 78}, {"/EquipChange", 4, entries[1].InvenIndex, 77}} { + op, e := repo.BeginOperation() + if e != nil { + t.Fatal(e) + } + if _, _, _, e = eq.Handle(r.path, request(r.seq, r.index, r.char)); e != nil { + t.Fatal(e) + } + if e = op.Commit(); e != nil { + t.Fatal(e) + } + } + want := map[uint64]uint64{entries[0].InvenIndex: 0, entries[1].InvenIndex: 77, entries[2].InvenIndex: 77, entries[3].InvenIndex: 78} + if err = repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + eq = open(repo) + eq.BeginSession("after-restart") + code, response, handled, err := eq.Handle("/EquipInfo", wire.AppendVarint(nil, 1, 1)) + if err != nil || !handled || code != 34 { + t.Fatal(err) + } + got := map[uint64]uint64{} + err = wire.Walk(response, func(f wire.Field) error { + if f.Number != 1 { + return nil + } + index, _, e := wire.Varint(f.Value, 1) + if e != nil { + return e + } + owner, _, e := wire.Varint(f.Value, 2) + got[index] = owner + return e + }) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("reconnect ownership got=%v want=%v error=%v", got, want, err) + } + for _, entry := range eq.All() { + if entry.UseChar != want[entry.InvenIndex] { + t.Fatal("serialized ownership differs from saved equipment") + } + } +}