feat(server): implement persistent costume potential connections and stat updates

This commit is contained in:
2026-10-06 00:33:01 +08:00
parent 76a2d72f05
commit 20be684204
7 changed files with 589 additions and 5 deletions
+3
View File
@@ -692,6 +692,9 @@ func serve(args []string) (serveErr error) {
return fmt.Errorf("read initial hunting AP: %w", err)
}
gameplayStore := stateio.EntrySnapshotStore{Entries: stateRepository, Domain: "missions", Bucket: "gameplay"}
if err := costumePotentialService.AttachConnectStore(gameplayStore); err != nil {
return fmt.Errorf("attach costume potential connection state: %w", err)
}
if err := worldService.AttachFieldMonsterState(gameplayStore); err != nil {
return fmt.Errorf("attach field monster state: %w", err)
}
+3
View File
@@ -147,6 +147,9 @@ func preflight(args []string) error {
if _, err := gamedata.LoadCostumeBurstDesign(gameData, versions.GameDataVersion); err != nil {
return err
}
if _, err := gamedata.LoadCostumePotentialDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight costume potential design: %w", err)
}
if _, err := gamedata.LoadTalentUseDesign(gameData, versions.GameDataVersion); err != nil {
return fmt.Errorf("preflight talent skill design: %w", err)
}
@@ -23,6 +23,8 @@ type CostumePotentialDesign struct {
CostumeUnique map[uint64]uint64
CharacterGrade map[uint64]uint64
CharacterUnique map[uint64]uint64
CharacterTypes map[uint64]uint64
CostumeActive map[uint64]bool
}
func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign, error) {
@@ -36,6 +38,31 @@ func LoadCostumePotentialDesign(root, version string) (*CostumePotentialDesign,
func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) {
d := &CostumePotentialDesign{Nodes: map[uint64]map[uint64]CostumePotentialNode{}, CostumeUnique: map[uint64]uint64{}, CharacterGrade: map[uint64]uint64{}, CharacterUnique: map[uint64]uint64{}}
d.CharacterTypes = map[uint64]uint64{}
d.CostumeActive = map[uint64]bool{}
groups, err := db.Query("SELECT id,ProtoBuf FROM CostumeNodeGroupTable")
if err != nil {
return nil, err
}
for groups.Next() {
var id uint64
var raw []byte
if err = groups.Scan(&id, &raw); err != nil {
groups.Close()
return nil, err
}
active, e := optionalScalar(raw, 3)
if e != nil || active > 1 {
groups.Close()
return nil, fmt.Errorf("gamedata: invalid potential active group")
}
d.CostumeActive[id] = active == 1
}
if err = groups.Err(); err != nil {
groups.Close()
return nil, err
}
groups.Close()
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM CostumeNodeTable ORDER BY groupId,id")
if err != nil {
return nil, err
@@ -125,6 +152,12 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) {
return nil, err
}
grade, _ := packedInts(proto, 10)
typeID, e := optionalScalar(proto, 19)
if e != nil {
rows.Close()
return nil, e
}
d.CharacterTypes[id] = typeID
unique, _ := packedInts(proto, 20)
if len(grade) == 1 && len(unique) == 1 {
d.CharacterGrade[id] = grade[0]
@@ -1,11 +1,14 @@
package pictorial
import (
"bytes"
"errors"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestMaximumHealthIncludesOwnedEquipmentPotentialAndAwakening(t *testing.T) {
@@ -35,3 +38,90 @@ func TestMaximumHealthIncludesOwnedEquipmentPotentialAndAwakening(t *testing.T)
t.Fatal("ignored equipment stat failure")
}
}
func TestPotentialConnectionSwitchRecomputesMaximumWithoutHealingOrChangingCostume(t *testing.T) {
store := stateio.NewMemory()
items, err := player.OpenInventory(store, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
characters, err := player.OpenCharacterStore(store, []player.Character{{InvenIndex: 77, ID: 350, HP: 80, Level: 1, UseCostume: 1, CostumeID: 100}}, items, "", "")
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(store, []player.Costume{{InvenIndex: 1, ID: 100, UseChar: 77}, {InvenIndex: 2, ID: 200, UseChar: 77}})
if err != nil {
t.Fatal(err)
}
for _, index := range []uint64{1, 2} {
if err = collection.ActivateCostumePotential(index, []uint64{1, 2}); err != nil {
t.Fatal(err)
}
}
wallet, err := player.OpenWallet(store, player.Currency{Gold: 100})
if err != nil {
t.Fatal(err)
}
d := &gamedata.CostumePotentialDesign{CharacterTypes: map[uint64]uint64{350: 0}, CostumeActive: map[uint64]bool{100: true, 200: true}, CharacterUnique: map[uint64]uint64{350: 9}, CostumeUnique: map[uint64]uint64{100: 9, 200: 9}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{
100: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 10}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 100}},
200: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 5}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 20}},
}}
potential, err := player.NewCostumePotentialService(d, collection, characters, items, wallet)
if err != nil {
t.Fatal(err)
}
if err = potential.AttachConnectStore(store); err != nil {
t.Fatal(err)
}
potential.BeginSession("health")
stats := &Service{Design: &gamedata.PictorialDesign{}, Owned: &ownedState{}, PotentialContributions: potential.Contributions}
stats.baseHealth.Store([2]uint64{350, 1}, float64(100))
if err = characters.AttachMaxHealth(stats.MaxHealth); err != nil {
t.Fatal(err)
}
request := func(seq, costume uint64) []byte {
row := wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, costume)
return wire.AppendBytes(wire.AppendVarint(nil, 1, seq), 2, row)
}
first := request(1, 100)
if _, _, _, err = potential.Handle("/CostumePotentialConnect", first); err != nil {
t.Fatal(err)
}
if maximum, err := characters.MaxHealth(77); err != nil || maximum != 215 {
t.Fatalf("first maximum=%d err=%v", maximum, err)
}
if hp, err := characters.CurrentHealth(77); err != nil || hp != 80 {
t.Fatal("connecting potential healed damage")
}
if err = characters.SetCurrentHealth(77, 200); err != nil {
t.Fatal(err)
}
second := request(2, 200)
code, out, handled, err := potential.Handle("/CostumePotentialConnect", second)
if err != nil || code != 267 || !handled {
t.Fatal(err)
}
c, ok := characters.Find(77)
if !ok || c.ConnectPotentialCostume != 200 || c.UseCostume != 1 || c.CostumeID != 100 || c.HP != 135 {
t.Fatalf("connection changed worn costume or failed HP clamp: %+v", c)
}
if maximum, err := characters.MaxHealth(77); err != nil || maximum != 135 {
t.Fatalf("switched maximum=%d err=%v", maximum, err)
}
_, again, _, err := potential.Handle("/CostumePotentialConnect", second)
if err != nil || !bytes.Equal(out, again) {
t.Fatal("connection replay changed response")
}
if err = characters.SetCurrentHealth(77, 0); err != nil {
t.Fatal(err)
}
if _, _, _, err = potential.Handle("/CostumePotentialConnect", request(3, 100)); err != nil {
t.Fatal(err)
}
if hp, err := characters.CurrentHealth(77); err != nil || hp != 0 {
t.Fatal("potential connection revived dead character")
}
if wallet.Snapshot().Gold != 100 || len(items.All()) != 0 {
t.Fatal("free connection charged materials or currency")
}
}
+13 -5
View File
@@ -8,17 +8,22 @@ import (
"sort"
"strconv"
"strings"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type CostumePotentialService struct {
design *gamedata.CostumePotentialDesign
collection *CollectionStore
characters *CharacterStore
inventory *Inventory
wallet *Wallet
design *gamedata.CostumePotentialDesign
collection *CollectionStore
characters *CharacterStore
inventory *Inventory
wallet *Wallet
connectStore stateio.Store
connectSession string
connectMu sync.Mutex
}
func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collection *CollectionStore, characters *CharacterStore, inventory *Inventory, wallet *Wallet) (*CostumePotentialService, error) {
@@ -104,6 +109,9 @@ func (s *CostumePotentialService) Contributions(character Character) ([]gamedata
}
func (s *CostumePotentialService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path == "/CostumePotentialConnect" {
return s.connect(request)
}
if path != "/CostumeNodeActivation" {
return 0, nil, false, nil
}
@@ -0,0 +1,214 @@
package player
import (
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
)
type potentialConnectReceipt struct {
Digest string
Body []byte
}
func (s *CostumePotentialService) AttachConnectStore(store stateio.Store) error {
if store == nil {
return fmt.Errorf("player: missing potential connect store")
}
s.connectStore = store
return nil
}
func (s *CostumePotentialService) BeginSession(id string) {
s.connectMu.Lock()
defer s.connectMu.Unlock()
s.connectSession = id
}
func (s *CostumePotentialService) connect(request []byte) (int, []byte, bool, error) {
s.connectMu.Lock()
defer s.connectMu.Unlock()
fail := func(e error) (int, []byte, bool, error) { return 267, nil, true, e }
seq, ok, err := wire.Varint(request, 1)
if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.connectStore == nil || s.connectSession == "" {
return fail(fmt.Errorf("player: invalid potential connection session/sequence"))
}
var receipts map[string]potentialConnectReceipt
b, err := s.connectStore.Load("potentialconnect")
if err != nil {
return fail(err)
}
if b != nil {
if err = json.Unmarshal(b, &receipts); err != nil || receipts == nil {
return fail(fmt.Errorf("player: invalid potential connection receipts"))
}
} else {
receipts = map[string]potentialConnectReceipt{}
}
for _, r := range receipts {
hash, e := hex.DecodeString(r.Digest)
if e != nil || len(hash) != sha256.Size {
return fail(fmt.Errorf("player: malformed potential connection receipt digest"))
}
if e = wire.Walk(r.Body, func(wire.Field) error { return nil }); e != nil {
return fail(e)
}
}
if err = wire.Walk(request, func(f wire.Field) error {
if f.Number == 1 {
if f.Type != 0 {
return fmt.Errorf("player: invalid potential sequence wire")
}
}
return nil
}); err != nil {
return fail(err)
}
seqFields := 0
wire.Walk(request, func(f wire.Field) error {
if f.Number == 1 {
seqFields++
}
return nil
})
if seqFields != 1 {
return fail(fmt.Errorf("player: duplicate potential sequence"))
}
key := fmt.Sprintf("%s:%d", s.connectSession, seq)
digest := fmt.Sprintf("%x", sha256.Sum256(request))
if prior, ok := receipts[key]; ok {
if prior.Digest != digest {
return fail(fmt.Errorf("player: changed potential connection replay"))
}
return 267, prior.Body, true, nil
}
var characters []Character
seen := map[uint64]bool{}
oldHP := map[uint64]uint64{}
err = wire.Walk(request, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 || len(characters) >= 4096 {
return fmt.Errorf("player: invalid potential connection list")
}
fields := map[int]bool{}
if e := wire.Walk(f.Value, func(field wire.Field) error {
if field.Number == 1 || field.Number == 2 {
if field.Type != 0 || fields[field.Number] {
return fmt.Errorf("player: duplicate or invalid connection scalar")
}
fields[field.Number] = true
}
return nil
}); e != nil {
return e
}
index, ok, e := wire.Varint(f.Value, 1)
if e != nil || !ok || index == 0 || index > math.MaxInt64 || seen[index] {
return fmt.Errorf("player: duplicate or invalid potential character")
}
costume, _, e := wire.Varint(f.Value, 2)
if e != nil || costume == 0 || costume > math.MaxInt32 {
return fmt.Errorf("player: potential connection requires a costume design ID")
}
c, owned := s.characters.Find(index)
if !owned || IsStoryCharacter(c) || IsCharmCharacter(c) {
return fmt.Errorf("player: potential character is not permanent owned")
}
unique := s.design.CharacterUnique[c.ID]
characterType, knownType := s.design.CharacterTypes[c.ID]
if unique == 0 || !knownType || characterType != 0 || !s.design.CostumeActive[costume] || s.design.CostumeUnique[costume] != unique || len(s.design.Nodes[costume]) == 0 {
return fmt.Errorf("player: incompatible or unavailable potential costume")
}
ownedCostume := false
for _, v := range s.collection.Costumes() {
if v.ID == costume && v.UseChar == index {
ownedCostume = true
for _, id := range v.PotentialIDs {
if _, ok := s.design.Nodes[costume][id]; !ok {
return fmt.Errorf("player: invalid saved potential node")
}
}
break
}
}
if !ownedCostume {
return fmt.Errorf("player: potential costume not owned by requested character")
}
hp, e := s.characters.CurrentHealth(index)
if e != nil {
return e
}
oldHP[index] = hp
c.ConnectPotentialCostume = costume
characters = append(characters, c)
seen[index] = true
return nil
})
if err != nil {
return fail(err)
}
if len(characters) == 0 {
return fail(fmt.Errorf("player: empty potential connection list"))
}
// All links are validated before writes. The parent account transaction
// includes both character ownership domains, HP and the response receipt.
for _, c := range characters {
if err = s.characters.setPotentialConnection(c.InvenIndex, c.ConnectPotentialCostume); err != nil {
return fail(err)
}
}
var out []byte
for _, c := range characters {
maximum, e := s.characters.MaxHealth(c.InvenIndex)
if e != nil {
return fail(e)
}
hp := oldHP[c.InvenIndex]
if hp > maximum {
hp = maximum
}
if e = s.characters.SetCurrentHealth(c.InvenIndex, hp); e != nil {
return fail(e)
}
c.HP = hp
out = wire.AppendBytes(out, 1, CharacterWire(c))
}
receipts[key] = potentialConnectReceipt{Digest: digest, Body: out}
b, err = json.Marshal(receipts)
if err != nil {
return fail(err)
}
if err = s.connectStore.Save("potentialconnect", b); err != nil {
return fail(err)
}
return 267, out, true, nil
}
func (s *CharacterStore) setPotentialConnection(index, costume uint64) error {
s.mu.Lock()
for i, c := range s.characters {
if c.InvenIndex == index {
next := append([]Character(nil), s.characters...)
next[i].ConnectPotentialCostume = costume
if err := s.persist(next); err != nil {
s.mu.Unlock()
return err
}
s.characters = next
s.mu.Unlock()
return nil
}
}
collection := s.collection
s.mu.Unlock()
if collection != nil {
if c, ok := collection.FindCharacter(index); ok {
c.ConnectPotentialCostume = costume
return collection.UpdateCharacter(c.ID, c)
}
}
return fmt.Errorf("player: unknown potential connection character")
}
@@ -0,0 +1,233 @@
package player
import (
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
"bytes"
"errors"
"path/filepath"
"testing"
)
func connectTestService(t *testing.T, store stateio.Store) (*CostumePotentialService, *CharacterStore) {
t.Helper()
inv, e := OpenInventory(store, &Starter{Version: "2.35.10"})
if e != nil {
t.Fatal(e)
}
chars, e := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 91, Level: 1, HP: 80}}, inv, "", "")
if e != nil {
t.Fatal(e)
}
if e = chars.EnsurePersisted(); e != nil {
t.Fatal(e)
}
collection, e := OpenCollectionStore(store, nil)
if e != nil {
t.Fatal(e)
}
if len(collection.Costumes()) == 0 {
v := cloneCollection(collection.data)
v.Costumes = []Costume{{InvenIndex: 88, ID: 100, UseChar: 77}, {InvenIndex: 89, ID: 200, UseChar: 77}, {InvenIndex: 90, ID: 300, UseChar: 77}}
if e = collection.commit(v); e != nil {
t.Fatal(e)
}
}
wallet, e := OpenWallet(store, Currency{Gold: 100})
if e != nil {
t.Fatal(e)
}
d := &gamedata.CostumePotentialDesign{CharacterUnique: map[uint64]uint64{91: 9}, CostumeUnique: map[uint64]uint64{100: 9, 200: 9, 300: 10}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{100: {1: {ID: 1}}, 200: {1: {ID: 1}}, 300: {1: {ID: 1}}}}
s, e := NewCostumePotentialService(d, collection, chars, inv, wallet)
d.CharacterTypes = map[uint64]uint64{91: 0}
d.CostumeActive = map[uint64]bool{100: true, 200: true, 300: true}
if e != nil {
t.Fatal(e)
}
s.AttachConnectStore(store)
s.BeginSession("test")
chars.AttachMaxHealth(func(c Character) (uint64, error) {
if c.ConnectPotentialCostume == 200 {
return 50, nil
}
return 100, nil
})
return s, chars
}
func connectRequest(seq uint64, rows ...[2]uint64) []byte {
b := wire.AppendVarint(nil, 1, seq)
for _, r := range rows {
v := wire.AppendVarint(wire.AppendVarint(nil, 1, r[0]), 2, r[1])
b = wire.AppendBytes(b, 2, v)
}
return b
}
func TestPotentialConnectFreeZeroNodesSwitchClampsAndReplaysAcrossRestart(t *testing.T) {
store := stateio.NewMemory()
s, chars := connectTestService(t, store)
if _, _, _, e := s.Handle("/CostumePotentialConnect", connectRequest(1, [2]uint64{77, 100})); e != nil {
t.Fatal(e)
}
c, _ := chars.Find(77)
if c.ConnectPotentialCostume != 100 || c.HP != 80 || s.wallet.Snapshot().Gold != 100 {
t.Fatal("auto connection charged or healed")
}
b := connectRequest(2, [2]uint64{77, 200})
_, reply, _, e := s.Handle("/CostumePotentialConnect", b)
if e != nil {
t.Fatal(e)
}
c, _ = chars.Find(77)
if c.HP != 50 {
t.Fatal("maxHP decrease not clamped")
}
next, _ := connectTestService(t, store)
_, again, _, e := next.Handle("/CostumePotentialConnect", b)
if e != nil || !bytes.Equal(reply, again) {
t.Fatal("restart lost exact connection replay")
}
if _, _, _, e = next.Handle("/CostumePotentialConnect", connectRequest(3, [2]uint64{77, 100})); e != nil {
t.Fatal(e)
}
c, _ = next.characters.Find(77)
if c.HP != 50 {
t.Fatal("maxHP increase healed player")
}
if e = next.characters.SetCurrentHealth(77, 0); e != nil {
t.Fatal(e)
}
if _, _, _, e = next.Handle("/CostumePotentialConnect", connectRequest(4, [2]uint64{77, 200})); e != nil {
t.Fatal(e)
}
c, _ = next.characters.Find(77)
if c.HP != 0 {
t.Fatal("connection revived dead character")
}
}
func TestPotentialConnectRejectsUnownedOtherCharacterAndDuplicateBatch(t *testing.T) {
s, chars := connectTestService(t, stateio.NewMemory())
for _, b := range [][]byte{connectRequest(1, [2]uint64{77, 300}), connectRequest(2, [2]uint64{77, 88}), connectRequest(3, [2]uint64{77, 0}), connectRequest(4, [2]uint64{77, 100}, [2]uint64{77, 200}), connectRequest(5, [2]uint64{77, 100}, [2]uint64{999, 200})} {
if _, _, _, e := s.Handle("/CostumePotentialConnect", b); e == nil {
t.Fatal("invalid potential connection accepted")
}
}
c, _ := chars.Find(77)
if c.ConnectPotentialCostume != 0 {
t.Fatal("invalid batch partially updated character")
}
s.design.CostumeActive[100] = false
if _, _, _, e := s.Handle("/CostumePotentialConnect", connectRequest(6, [2]uint64{77, 100})); e == nil {
t.Fatal("inactive potential costume connected")
}
s.design.CostumeActive[100] = true
s.design.CharacterTypes[91] = 1
if _, _, _, e := s.Handle("/CostumePotentialConnect", connectRequest(7, [2]uint64{77, 100})); e == nil {
t.Fatal("temporary design character connected")
}
s.design.CharacterTypes[91] = 0
duplicate := wire.AppendVarint(connectRequest(8, [2]uint64{77, 100}), 1, 9)
if _, _, _, e := s.Handle("/CostumePotentialConnect", duplicate); e == nil {
t.Fatal("duplicate sequence accepted")
}
row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 77), 2, 100), 2, 200)
duplicate = wire.AppendBytes(wire.AppendVarint(nil, 1, 10), 2, row)
if _, _, _, e := s.Handle("/CostumePotentialConnect", duplicate); e == nil {
t.Fatal("duplicate nested costume scalar accepted")
}
}
type connectFailStore struct{ stateio.Store }
func (s connectFailStore) Save(name string, b []byte) error {
if name == "potentialconnect" {
return errors.New("receipt failed")
}
return s.Store.Save(name, b)
}
func TestPotentialConnectSQLiteFailureRollsBackLinkAndHealth(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, e := accountstate.Open(path)
if e != nil {
t.Fatal(e)
}
s, _ := connectTestService(t, repo)
s.AttachConnectStore(connectFailStore{repo})
op, e := repo.BeginOperation()
if e != nil {
t.Fatal(e)
}
b := connectRequest(1, [2]uint64{77, 200})
if _, _, _, e = s.Handle("/CostumePotentialConnect", b); e == nil {
t.Fatal("receipt failure ignored")
}
if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) {
t.Fatal(e)
}
repo.Close()
repo, e = accountstate.Open(path)
if e != nil {
t.Fatal(e)
}
defer repo.Close()
next, chars := connectTestService(t, repo)
c, _ := chars.Find(77)
if c.ConnectPotentialCostume != 0 || c.HP != 80 {
t.Fatal("failed connection persisted link/HP")
}
op, e = repo.BeginOperation()
if e != nil {
t.Fatal(e)
}
if _, _, _, e = next.Handle("/CostumePotentialConnect", b); e != nil {
t.Fatal(e)
}
if e = op.Commit(); e != nil {
t.Fatal(e)
}
}
func TestPotentialConnectionCollectionCharacterFallbackPersistsAcrossRestart(t *testing.T) {
store := stateio.NewMemory()
s, chars := connectTestService(t, store)
v := cloneCollection(s.collection.data)
v.Characters = []Character{{InvenIndex: 78, ID: 92, Level: 1, HP: 40, CostumeID: 201, UseCostume: 91}}
v.Costumes = append(v.Costumes, Costume{InvenIndex: 91, ID: 201, UseChar: 78})
if e := s.collection.commit(v); e != nil {
t.Fatal(e)
}
if e := chars.AttachCollection(s.collection); e != nil {
t.Fatal(e)
}
s.design.CharacterUnique[92] = 9
s.design.CharacterTypes[92] = 0
s.design.CostumeUnique[201] = 9
s.design.CostumeActive[201] = true
s.design.Nodes[201] = map[uint64]gamedata.CostumePotentialNode{1: {ID: 1}}
b := connectRequest(1, [2]uint64{78, 201})
_, response, _, e := s.Handle("/CostumePotentialConnect", b)
if e != nil {
t.Fatal(e)
}
c, ok := chars.Find(78)
if !ok || c.ConnectPotentialCostume != 201 || c.UseCostume != 91 || c.CostumeID != 201 || c.HP != 40 {
t.Fatal("collection connection changed appearance or HP")
}
collection, e := OpenCollectionStore(store, nil)
if e != nil {
t.Fatal(e)
}
c, ok = collection.FindCharacter(78)
if !ok || c.ConnectPotentialCostume != 201 {
t.Fatal("collection connection lost after restart")
}
s.collection = collection
chars.AttachCollection(collection)
s.AttachConnectStore(store)
s.BeginSession("test")
_, again, _, e := s.Handle("/CostumePotentialConnect", b)
if e != nil || !bytes.Equal(response, again) {
t.Fatal("collection reconnect replay lost")
}
}