fix(server): restore portraits and batch costume choices and verify equipment persistence

This commit is contained in:
2026-10-06 16:35:35 +08:00
parent 68003bfb6b
commit 9e9e86a658
8 changed files with 347 additions and 21 deletions
+3
View File
@@ -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)
+11
View File
@@ -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
+14
View File
@@ -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
}
@@ -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)
}
}
+66
View File
@@ -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)
}
@@ -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)
}
}
}
+1 -21
View File
@@ -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 {
@@ -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")
}
}
}