fix(server): restore portraits and batch costume choices and verify equipment persistence
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user