feat: split client tooling and add OAuth server login

This commit is contained in:
2026-09-30 17:32:14 +08:00
parent 7cb5e36c2a
commit 2d054f6b72
230 changed files with 9875 additions and 853 deletions
+624
View File
@@ -0,0 +1,624 @@
// Package deck owns local deck, field-party, waypoint, and selected-costume
// state. It stores typed JSON, never captured protobuf/base64 envelopes.
package deck
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"sort"
"sync"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
)
type DeckEntry struct {
CharacterInvenIndex uint64 `json:"character_inven_index"`
// CostumeInvenIndex is retained as the persisted Go/JSON name for the
// development save format. On the wire DeckDBInfo field 2 is Position: a
// zero-based battle-grid cell (or -1 while unassigned), not a costume
// inventory index.
CostumeInvenIndex uint64 `json:"costume_inven_index"`
Slot uint64 `json:"slot"`
}
type FieldEntry struct {
Slot uint64 `json:"slot"`
CharacterInvenIndex uint64 `json:"character_inven_index"`
CostumeInvenIndex uint64 `json:"costume_inven_index"`
}
type Seed struct {
Version string `json:"version"`
FieldDeck []FieldEntry `json:"field_deck"`
FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"`
AutoReviveCatalyst uint64 `json:"auto_revive_catalyst,omitempty"`
}
type state struct {
Version string `json:"version"`
Deck []DeckEntry `json:"deck"`
FieldDeck []FieldEntry `json:"field_deck"`
FieldCharControlDeckType uint64 `json:"field_char_control_deck_type"`
Waypoints map[uint64]uint64 `json:"waypoints"`
Costumes map[uint64]uint64 `json:"costumes"`
Packs map[uint64]uint64 `json:"packs"`
HighestTotalBattlePower uint64 `json:"highest_total_battle_power"`
PortraitCostumeID uint64 `json:"portrait_costume_id"`
AutoReviveCatalyst uint64 `json:"auto_revive_catalyst"`
}
type Store struct {
mu sync.RWMutex
storage stateio.AtomicEntryStore
state state
presets map[uint64]Preset
presetSlots uint64
costumeSettings map[uint64]CostumeSetting
wallet *player.Wallet
characters *player.CharacterStore
equipment *player.EquipmentInventory
collection *player.CollectionStore
sessionID string
replies map[string]deckReply
}
type deckReply struct {
code int
body []byte
}
func (s *Store) CurrentDeck() []DeckEntry {
s.mu.RLock()
defer s.mu.RUnlock()
return append([]DeckEntry(nil), s.state.Deck...)
}
func LoadSeed(path string) (Seed, error) {
b, e := os.ReadFile(path)
if e != nil {
return Seed{}, fmt.Errorf("deck: read seed: %w", e)
}
var s Seed
if e = json.Unmarshal(b, &s); e != nil {
return Seed{}, fmt.Errorf("deck: decode seed: %w", e)
}
if e = s.validate(); e != nil {
return Seed{}, e
}
return s, nil
}
func (s Seed) validate() error {
if s.Version != versionconfig.State() {
return errors.New("deck: wrong seed version")
}
return validField(s.FieldDeck)
}
func validField(entries []FieldEntry) error {
if len(entries) == 0 || len(entries) > 5 {
return errors.New("deck: invalid field deck size")
}
characters := map[uint64]bool{}
costumes := map[uint64]bool{}
sequences := map[uint64]bool{}
for _, e := range entries {
if e.Slot == 0 || e.Slot > 5 || e.CharacterInvenIndex == 0 ||
characters[e.CharacterInvenIndex] || sequences[e.Slot] ||
(e.CostumeInvenIndex != 0 && costumes[e.CostumeInvenIndex]) {
return errors.New("deck: invalid field deck")
}
characters[e.CharacterInvenIndex] = true
sequences[e.Slot] = true
if e.CostumeInvenIndex != 0 {
costumes[e.CostumeInvenIndex] = true
}
}
return nil
}
func validDeck(entries []DeckEntry) error {
if len(entries) == 0 || len(entries) > 5 {
return errors.New("deck: invalid battle deck size")
}
characters := map[uint64]bool{}
positions := map[uint64]bool{}
sequences := map[uint64]bool{}
for _, entry := range entries {
position := entry.CostumeInvenIndex
unassigned := position == ^uint64(0) // int32 -1 sign-extends in protobuf varints.
if entry.CharacterInvenIndex == 0 || (!unassigned && position > 11) || entry.Slot == 0 || entry.Slot > 5 ||
characters[entry.CharacterInvenIndex] || (!unassigned && positions[position]) || sequences[entry.Slot] {
return errors.New("deck: invalid battle deck")
}
characters[entry.CharacterInvenIndex] = true
if !unassigned {
positions[position] = true
}
sequences[entry.Slot] = true
}
return nil
}
func NewStore(seed Seed) (*Store, error) {
if e := seed.validate(); e != nil {
return nil, e
}
return &Store{state: state{Version: versionconfig.State(), FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}, presets: map[uint64]Preset{}, presetSlots: presetBaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil
}
func OpenStore(storage stateio.Store, seed Seed) (*Store, error) {
s, e := NewStore(seed)
if e != nil {
return nil, e
}
entries, ok := storage.(stateio.AtomicEntryStore)
if storage == nil || !ok {
return nil, errors.New("deck: nil storage")
}
s.storage = entries
b, e := storage.Load("deck")
if e != nil {
return nil, fmt.Errorf("deck: load state: %w", e)
}
if b == nil {
if e = stateio.RequireNoEntries(entries, "deck", "presets", "preset_config", "costume_settings"); e != nil {
return nil, fmt.Errorf("deck: invalid entry storage: %w", e)
}
return s, nil
}
if e = stateio.RequireExactJSONObject(b, "version", "deck", "field_deck", "field_char_control_deck_type", "waypoints", "costumes", "packs", "highest_total_battle_power", "portrait_costume_id", "auto_revive_catalyst"); e != nil {
return nil, fmt.Errorf("deck: incompatible state layout: %w", e)
}
var loaded state
if e = json.Unmarshal(b, &loaded); e != nil {
return nil, fmt.Errorf("deck: malformed state: %w", e)
}
if loaded.Version != versionconfig.State() || (len(loaded.Deck) != 0 && validDeck(loaded.Deck) != nil) || validField(loaded.FieldDeck) != nil || loaded.Waypoints == nil || loaded.Costumes == nil || loaded.Packs == nil {
return nil, errors.New("deck: invalid saved state")
}
s.state = loaded
if e = s.loadPresetEntries(); e != nil {
return nil, e
}
return s, nil
}
func (s *Store) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
b, e := s.storage.Load("deck")
if e != nil {
return e
}
if b != nil {
return nil
}
return s.commit(clone(s.state))
}
func (s *Store) commit(next state) error {
if s.storage != nil {
b, e := json.MarshalIndent(next, "", " ")
if e != nil {
return e
}
if e = s.storage.Save("deck", append(b, '\n')); e != nil {
return e
}
}
s.state = next
return nil
}
func clone(x state) state {
y := x
y.Deck = append([]DeckEntry(nil), x.Deck...)
y.FieldDeck = append([]FieldEntry(nil), x.FieldDeck...)
y.Waypoints = map[uint64]uint64{}
for k, v := range x.Waypoints {
y.Waypoints[k] = v
}
y.Costumes = map[uint64]uint64{}
for k, v := range x.Costumes {
y.Costumes[k] = v
}
y.Packs = map[uint64]uint64{}
for k, v := range x.Packs {
y.Packs[k] = v
}
return y
}
func checkSeq(req []byte) error {
v, ok, e := wire.Varint(req, 1)
if e != nil || !ok || v == 0 {
return errors.New("deck: invalid request sequence")
}
return nil
}
func triples(req []byte) ([]DeckEntry, error) {
var out []DeckEntry
e := wire.Walk(req, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 {
return errors.New("deck: deck field")
}
a, aok, e := wire.Varint(f.Value, 1)
if e != nil || !aok || a == 0 {
return errors.New("deck: deck character")
}
position, _, e := wire.Varint(f.Value, 2)
if e != nil || (position > 11 && position != ^uint64(0)) {
return errors.New("deck: invalid deck position")
}
sequence, sequenceOK, e := wire.Varint(f.Value, 3)
if e != nil || !sequenceOK || sequence == 0 || sequence > 5 {
return errors.New("deck: invalid deck sequence")
}
out = append(out, DeckEntry{CharacterInvenIndex: a, CostumeInvenIndex: position, Slot: sequence})
return nil
})
if e != nil {
return nil, e
}
if len(out) == 0 {
return nil, errors.New("deck: empty deck")
}
if e := validDeck(out); e != nil {
return nil, e
}
return out, nil
}
func fieldEntries(req []byte) ([]FieldEntry, error) {
var out []FieldEntry
e := wire.Walk(req, func(f wire.Field) error {
if f.Number != 2 {
return nil
}
if f.Type != 2 {
return errors.New("deck: field deck entry is not a message")
}
slot, slotOK, err := wire.Varint(f.Value, 1)
if err != nil || !slotOK || slot == 0 {
return errors.New("deck: invalid field deck slot")
}
character, characterOK, err := wire.Varint(f.Value, 2)
if err != nil || !characterOK || character == 0 {
return errors.New("deck: invalid field deck character")
}
costume, _, err := wire.Varint(f.Value, 3)
if err != nil {
return errors.New("deck: invalid field deck costume")
}
out = append(out, FieldEntry{Slot: slot, CharacterInvenIndex: character, CostumeInvenIndex: costume})
return nil
})
if e != nil {
return nil, e
}
if len(out) == 0 {
return nil, errors.New("deck: empty field deck")
}
return out, validField(out)
}
func (s *Store) validateOwnedDeckLocked(entries []DeckEntry) error {
if s.characters == nil {
return nil
}
for _, entry := range entries {
if _, found := s.characters.Find(entry.CharacterInvenIndex); !found {
return fmt.Errorf("deck: battle deck references unknown character %d", entry.CharacterInvenIndex)
}
}
return nil
}
func (s *Store) validateOwnedFieldDeckLocked(entries []FieldEntry) error {
if s.characters == nil || s.collection == nil {
return nil
}
for _, entry := range entries {
if _, found := s.characters.Find(entry.CharacterInvenIndex); !found {
return fmt.Errorf("deck: field deck references unknown character %d", entry.CharacterInvenIndex)
}
if entry.CostumeInvenIndex == 0 {
continue
}
costume, found := s.collection.CostumeByIndex(entry.CostumeInvenIndex)
if !found {
return fmt.Errorf("deck: field deck references unknown costume %d", entry.CostumeInvenIndex)
}
if costume.UseChar != entry.CharacterInvenIndex {
return fmt.Errorf("deck: costume %d does not belong to character %d", entry.CostumeInvenIndex, entry.CharacterInvenIndex)
}
}
return nil
}
// Handle implements session.Handler. Every mutation validates its complete
// typed request before committing a replacement JSON state.
func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
switch path {
case "/PresetInfo":
return s.handlePresetInfo(req)
case "/PresetSave":
return s.handlePresetSave(req)
case "/PresetAddSlot":
return s.handlePresetAddSlot(req)
case "/PresetInfoChange":
return s.handlePresetInfoChange(req)
case "/PresetDelete":
return s.handlePresetDelete(req)
case "/PresetUse":
return s.handlePresetUse(req)
case "/DeckCostumeSettingInfo":
return s.handleCostumeSettingInfo(req)
case "/DeckCostumeSettingSave":
return s.handleCostumeSettingSave(req)
case "/DeckInfo":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.RLock()
defer s.mu.RUnlock()
slog.Info("team trace: deliver saved battle deck", "deck", s.state.Deck)
return 8, encodeDeck(s.state.Deck), true, nil
case "/FieldDeckInfo":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.RLock()
defer s.mu.RUnlock()
return 273, encodeField(s.state.FieldDeck), true, nil
case "/DeckCharAutoRevive":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
caster, _, e := wire.Varint(req, 2)
if e != nil {
return 0, nil, true, fmt.Errorf("deck: invalid auto-revive caster: %w", e)
}
if caster != 0 {
// The observed story flow has no caster and only refreshes the
// formation. A nonzero caster would require authoritative revive
// targets, talent experience, and catalyst consumption.
return 0, nil, true, fmt.Errorf("deck: auto-revive caster %d is not verified", caster)
}
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.state.Deck) == 0 {
return 0, nil, true, errors.New("deck: auto-revive has no current battle deck")
}
var response []byte
for _, entry := range s.state.Deck {
deck := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex)
if entry.CostumeInvenIndex != 0 {
deck = wire.AppendVarint(deck, 2, entry.CostumeInvenIndex)
}
deck = wire.AppendVarint(deck, 3, entry.Slot)
response = wire.AppendBytes(response, 1, deck)
}
for _, entry := range s.state.FieldDeck {
field := wire.AppendVarint(nil, 1, entry.Slot)
field = wire.AppendVarint(field, 2, entry.CharacterInvenIndex)
if entry.CostumeInvenIndex != 0 {
field = wire.AppendVarint(field, 3, entry.CostumeInvenIndex)
}
response = wire.AppendBytes(response, 2, field)
}
response = wire.AppendVarint(response, 4, 2) // Define_AutoReviveCharType.CHANGE.
if s.state.AutoReviveCatalyst != 0 {
response = wire.AppendVarint(response, 7, s.state.AutoReviveCatalyst)
}
return 373, response, true, nil
case "/WaypointInfo":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.RLock()
defer s.mu.RUnlock()
return 31, encodeWaypoints(s.state.Waypoints), true, nil
case "/DeckSave":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
x, e := triples(req)
if e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
if e = s.validateOwnedDeckLocked(x); e != nil {
return 0, nil, true, e
}
seq, _, _ := wire.Varint(req, 1)
slog.Info("team trace: client requested battle deck replacement", "seq", seq, "before", s.state.Deck, "after", x)
n := clone(s.state)
n.Deck = x
e = s.commit(n)
if e != nil {
slog.Error("team trace: deck replacement failed", "seq", seq, "error", e)
}
return 10, nil, true, e
case "/FieldDeckSave":
if e := checkSeq(req); e != nil {
return 0, nil, true, e
}
x, e := fieldEntries(req)
if e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
if e = s.validateOwnedFieldDeckLocked(x); e != nil {
return 0, nil, true, e
}
n := clone(s.state)
n.FieldDeck = x
e = s.commit(n)
return 274, nil, true, e
case "/SaveFieldCharControlDeckType":
// Define_FieldCharControllDeckType is a proto3 enum whose valid values
// are BATTLE=0, FIELD=1 and STORY=2. BATTLE is the protobuf default, so
// the generated client deliberately omits field 2 when it switches out
// of story mode after the final quest. An absent field is therefore a
// real value 0, not a malformed request.
v, _, e := wire.Varint(req, 2)
if e != nil || v > 2 {
return 0, nil, true, errors.New("deck: invalid field control type")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
n.FieldCharControlDeckType = v
e = s.commit(n)
return 288, nil, true, e
case "/WaypointSave":
pack, ok, e := wire.Varint(req, 2)
if e != nil || !ok || pack == 0 {
return 0, nil, true, errors.New("deck: invalid waypoint pack")
}
way, ok, e := wire.Varint(req, 3)
if e != nil || !ok || way == 0 {
return 0, nil, true, errors.New("deck: invalid waypoint")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
n.Waypoints[pack] = way
e = s.commit(n)
return 32, nil, true, e
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
case "/PackBuy":
pack, ok, e := wire.Varint(req, 2)
if e != nil || !ok || pack == 0 {
return 0, nil, true, errors.New("deck: invalid pack")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
// Purchasing the story pack is idempotent. Login/bootstrap may repeat
// the request after a client restart before all local state is restored.
n.Packs[pack] = 1
e = s.commit(n)
if e != nil {
return 0, nil, true, e
}
packInfo := wire.AppendVarint(nil, 1, pack)
packInfo = wire.AppendVarint(packInfo, 8, n.Packs[pack])
response := wire.AppendBytes(nil, 1, packInfo)
// Starter pack purchase grants the local story-pack entitlement and
// starter currency. These are semantic RewardDBInfoBundle fields, not
// recorded response bytes.
entitlement := wire.AppendVarint(nil, 2, 1)
entitlement = wire.AppendVarint(entitlement, 3, 19)
entitlement = wire.AppendVarint(entitlement, 4, 1)
entitlement = wire.AppendVarint(entitlement, 8, ^uint64(0)-32400000+1)
currency := wire.AppendVarint(nil, 3, 12)
currency = wire.AppendVarint(currency, 4, 200)
tracking := wire.AppendVarint(nil, 2, 1)
tracking = wire.AppendVarint(tracking, 3, 19)
tracking = wire.AppendVarint(tracking, 4, 1)
bundle := wire.AppendBytes(nil, 1, entitlement)
bundle = wire.AppendBytes(bundle, 1, currency)
bundle = wire.AppendBytes(bundle, 6, tracking)
return 6, wire.AppendBytes(response, 2, bundle), true, nil
case "/SaveTotalBattlePower":
power, ok, e := wire.Varint(req, 2)
if e != nil || !ok || power == 0 {
return 0, nil, true, errors.New("deck: invalid total battle power")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
if power > n.HighestTotalBattlePower {
n.HighestTotalBattlePower = power
}
if e = s.commit(n); e != nil {
return 0, nil, true, e
}
return 258, wire.AppendVarint(nil, 1, n.HighestTotalBattlePower), true, nil
case "/UserPortraitChange":
costumeID, ok, e := wire.Varint(req, 2)
if e != nil || !ok || costumeID == 0 {
return 0, nil, true, errors.New("deck: invalid portrait costume")
}
if e = checkSeq(req); e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
n := clone(s.state)
n.PortraitCostumeID = costumeID
if e = s.commit(n); e != nil {
return 0, nil, true, e
}
return 75, wire.AppendVarint(nil, 1, costumeID), true, nil
}
return 0, nil, false, nil
}
func encodeDeck(xs []DeckEntry) []byte {
var b []byte
for _, x := range xs {
v := wire.AppendVarint(nil, 1, x.CharacterInvenIndex)
v = wire.AppendVarint(v, 2, x.CostumeInvenIndex)
v = wire.AppendVarint(v, 3, x.Slot)
b = wire.AppendBytes(b, 1, v)
}
return b
}
func encodeField(xs []FieldEntry) []byte {
var b []byte
for _, x := range xs {
v := wire.AppendVarint(nil, 1, x.Slot)
v = wire.AppendVarint(v, 2, x.CharacterInvenIndex)
v = wire.AppendVarint(v, 3, x.CostumeInvenIndex)
b = wire.AppendBytes(b, 1, v)
}
return b
}
func encodeWaypoints(xs map[uint64]uint64) []byte {
keys := make([]uint64, 0, len(xs))
for k := range xs {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
var b []byte
for _, k := range keys {
v := wire.AppendVarint(nil, 1, k)
v = wire.AppendVarint(v, 2, xs[k])
b = wire.AppendBytes(b, 1, v)
}
return b
}
+369
View File
@@ -0,0 +1,369 @@
package deck
import (
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/versionconfig"
"bd2server/internal/server/wire"
"path/filepath"
"testing"
)
func seeded(t *testing.T) *Store {
t.Helper()
x, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json"))
if e != nil {
t.Fatal(e)
}
s, e := NewStore(x)
if e != nil {
t.Fatal(e)
}
return s
}
func req(seq uint64, fields ...[]byte) []byte {
b := wire.AppendVarint(nil, 1, seq)
for _, f := range fields {
b = append(b, f...)
}
return b
}
func triple(a, b, c uint64) []byte {
v := wire.AppendVarint(nil, 1, a)
v = wire.AppendVarint(v, 2, b)
v = wire.AppendVarint(v, 3, c)
return wire.AppendBytes(nil, 2, v)
}
func attachFormationOwnership(t *testing.T, store *Store) {
t.Helper()
starter := &player.Starter{
Version: versionconfig.State(),
Characters: []player.Character{
{InvenIndex: 101, ID: 350, Level: 1},
{InvenIndex: 102, ID: 351, Level: 1},
{InvenIndex: 103, ID: 352, Level: 1},
{InvenIndex: 104, ID: 353, Level: 1},
{InvenIndex: 105, ID: 354, Level: 1},
},
Costumes: []player.Costume{
{InvenIndex: 201, ID: 60101, UseChar: 101},
{InvenIndex: 202, ID: 60201, UseChar: 102},
{InvenIndex: 203, ID: 60301, UseChar: 103},
{InvenIndex: 204, ID: 60401, UseChar: 104},
{InvenIndex: 205, ID: 60501, UseChar: 105},
},
}
inventory, err := player.OpenInventory(stateio.NewMemory(), starter)
if err != nil {
t.Fatal(err)
}
characters, err := player.OpenCharacterStore(stateio.NewMemory(), starter.Characters, inventory, "", "")
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(stateio.NewMemory(), starter.Costumes)
if err != nil {
t.Fatal(err)
}
store.characters = characters
store.collection = collection
}
func TestFieldDeckSeedAndSave(t *testing.T) {
s := seeded(t)
code, b, ok, e := s.Handle("/FieldDeckInfo", req(1))
if e != nil || !ok || code != 273 {
t.Fatalf("info %d %t %v", code, ok, e)
}
n := 0
if e = wire.Walk(b, func(f wire.Field) error {
if f.Number == 1 {
n++
}
return nil
}); e != nil || n != 5 {
t.Fatalf("seed field deck: %d %v", n, e)
}
// FieldDeckDBInfo is sequence#1, character#2, costume#3.
field := triple(1, 99, 199)
code, _, ok, e = s.Handle("/FieldDeckSave", req(2, field))
if e != nil || !ok || code != 274 {
t.Fatalf("save: %d %t %v", code, ok, e)
}
_, b, _, _ = s.Handle("/FieldDeckInfo", req(3))
var got uint64
_ = wire.Walk(b, func(f wire.Field) error {
if f.Number == 1 {
got, _, _ = wire.Varint(f.Value, 2)
}
return nil
})
if got != 99 {
t.Fatalf("saved char=%d", got)
}
}
func TestDeckPersistenceAndCommands(t *testing.T) {
seed, e := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json"))
if e != nil {
t.Fatal(e)
}
storage := stateio.NewMemory()
s, e := OpenStore(storage, seed)
if e != nil {
t.Fatal(e)
}
code, _, _, e := s.Handle("/DeckSave", req(1, triple(100, 2, 1)))
if e != nil || code != 10 {
t.Fatalf("deck save: %d %v", code, e)
}
way := wire.AppendVarint(nil, 2, 21)
way = wire.AppendVarint(way, 3, 1)
if code, _, _, e = s.Handle("/WaypointSave", req(2, way)); e != nil || code != 32 {
t.Fatalf("way: %d %v", code, e)
}
use := wire.AppendVarint(nil, 1, 200)
use = wire.AppendVarint(use, 2, 100)
if code, _, _, e = s.Handle("/CostumeUse", req(3, wire.AppendBytes(nil, 2, use))); e != nil || code != 41 {
t.Fatalf("use: %d %v", code, e)
}
pack := wire.AppendVarint(nil, 2, 21)
code, b, _, e := s.Handle("/PackBuy", req(4, pack))
if e != nil || code != 6 {
t.Fatalf("buy: %d %v", code, e)
}
p, _, _ := wire.Bytes(b, 1)
id, _, _ := wire.Varint(p, 1)
if id != 21 {
t.Fatalf("pack response=%d", id)
}
reopened, e := OpenStore(storage, seed)
if e != nil {
t.Fatal(e)
}
_, b, _, e = reopened.Handle("/DeckInfo", req(5))
if e != nil {
t.Fatal(e)
}
entry, _, _ := wire.Bytes(b, 1)
id, _, _ = wire.Varint(entry, 1)
if id != 100 {
t.Fatalf("persist deck=%d", id)
}
}
func TestRejectsInvalidMutations(t *testing.T) {
s := seeded(t)
if _, _, _, e := s.Handle("/DeckSave", req(1, triple(1, 2, 1), triple(3, 4, 1))); e == nil {
t.Fatal("duplicate slots accepted")
}
if _, _, _, e := s.Handle("/WaypointSave", req(1, wire.AppendVarint(nil, 2, 21))); e == nil {
t.Fatal("waypoint missing id accepted")
}
}
func TestDeckSaveValidatesFormationShapeAndOwnership(t *testing.T) {
valid := [][]byte{
triple(101, 0, 1), triple(102, 1, 2), triple(103, 2, 3),
triple(104, 3, 4), triple(105, 4, 5),
}
tests := []struct {
name string
entries [][]byte
}{
{name: "six characters", entries: append(append([][]byte{}, valid...), triple(101, 5, 1))},
{name: "duplicate character", entries: [][]byte{triple(101, 0, 1), triple(101, 1, 2)}},
{name: "duplicate position", entries: [][]byte{triple(101, 0, 1), triple(102, 0, 2)}},
{name: "duplicate sequence", entries: [][]byte{triple(101, 0, 1), triple(102, 1, 1)}},
{name: "position outside twelve cells", entries: [][]byte{triple(101, 12, 1)}},
{name: "sequence outside formation", entries: [][]byte{triple(101, 0, 6)}},
{name: "unknown character", entries: [][]byte{triple(999, 0, 1)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
s := seeded(t)
attachFormationOwnership(t, s)
if _, _, handled, err := s.Handle("/DeckSave", req(1, test.entries...)); err == nil || !handled {
t.Fatalf("invalid deck accepted handled=%v err=%v", handled, err)
}
if len(s.state.Deck) != 0 {
t.Fatalf("invalid deck mutated state: %+v", s.state.Deck)
}
})
}
s := seeded(t)
attachFormationOwnership(t, s)
if code, _, handled, err := s.Handle("/DeckSave", req(2, valid...)); err != nil || !handled || code != 10 || len(s.state.Deck) != 5 {
t.Fatalf("valid deck rejected code=%d handled=%v deck=%+v err=%v", code, handled, s.state.Deck, err)
}
unassigned := [][]byte{
triple(101, ^uint64(0), 1), triple(102, ^uint64(0), 2), triple(103, ^uint64(0), 3),
triple(104, ^uint64(0), 4), triple(105, ^uint64(0), 5),
}
if code, _, handled, err := s.Handle("/DeckSave", req(3, unassigned...)); err != nil || !handled || code != 10 {
t.Fatalf("official unassigned positions rejected code=%d handled=%v err=%v", code, handled, err)
}
}
func TestFieldDeckSaveValidatesFormationShapeAndOwnership(t *testing.T) {
valid := [][]byte{
triple(1, 101, 201), triple(2, 102, 202), triple(3, 103, 203),
triple(4, 104, 204), triple(5, 105, 205),
}
tests := []struct {
name string
entries [][]byte
}{
{name: "six characters", entries: append(append([][]byte{}, valid...), triple(1, 101, 201))},
{name: "duplicate character", entries: [][]byte{triple(1, 101, 201), triple(2, 101, 202)}},
{name: "duplicate costume", entries: [][]byte{triple(1, 101, 201), triple(2, 102, 201)}},
{name: "duplicate sequence", entries: [][]byte{triple(1, 101, 201), triple(1, 102, 202)}},
{name: "sequence outside formation", entries: [][]byte{triple(6, 101, 201)}},
{name: "unknown character", entries: [][]byte{triple(1, 999, 201)}},
{name: "unknown costume", entries: [][]byte{triple(1, 101, 999)}},
{name: "costume owned by another character", entries: [][]byte{triple(1, 101, 202)}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
s := seeded(t)
attachFormationOwnership(t, s)
before := append([]FieldEntry(nil), s.state.FieldDeck...)
if _, _, handled, err := s.Handle("/FieldDeckSave", req(1, test.entries...)); err == nil || !handled {
t.Fatalf("invalid field deck accepted handled=%v err=%v", handled, err)
}
if len(s.state.FieldDeck) != len(before) || s.state.FieldDeck[0] != before[0] {
t.Fatalf("invalid field deck mutated state: %+v", s.state.FieldDeck)
}
})
}
s := seeded(t)
attachFormationOwnership(t, s)
if code, _, handled, err := s.Handle("/FieldDeckSave", req(2, valid...)); err != nil || !handled || code != 274 || len(s.state.FieldDeck) != 5 {
t.Fatalf("valid field deck rejected code=%d handled=%v deck=%+v err=%v", code, handled, s.state.FieldDeck, err)
}
// A zero costume index means to use the character's current costume; the
// client explicitly supports this fallback when resolving its field leader.
if code, _, handled, err := s.Handle("/FieldDeckSave", req(3, triple(1, 101, 0))); err != nil || !handled || code != 274 {
t.Fatalf("current-costume fallback rejected code=%d handled=%v err=%v", code, handled, err)
}
}
func TestDeckSavesRequireRequestSequence(t *testing.T) {
s := seeded(t)
if _, _, handled, err := s.Handle("/DeckSave", triple(101, 0, 1)); err == nil || !handled {
t.Fatalf("DeckSave without seq accepted handled=%v err=%v", handled, err)
}
if _, _, handled, err := s.Handle("/FieldDeckSave", triple(1, 101, 201)); err == nil || !handled {
t.Fatalf("FieldDeckSave without seq accepted handled=%v err=%v", handled, err)
}
}
func TestSaveFieldCharControlDeckTypeAcceptsProtoDefaultBattle(t *testing.T) {
s := seeded(t)
// FCCD_BATTLE=0 is omitted by proto3 serialization. This is the exact
// shape sent when the client leaves story control after the final quest.
code, _, handled, err := s.Handle("/SaveFieldCharControlDeckType", req(1))
if err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != 0 {
t.Fatalf("battle save code=%d handled=%v type=%d err=%v", code, handled, s.state.FieldCharControlDeckType, err)
}
for seq, value := range []uint64{1, 2} {
request := req(uint64(seq+2), wire.AppendVarint(nil, 2, value))
if code, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", request); err != nil || !handled || code != 288 || s.state.FieldCharControlDeckType != value {
t.Fatalf("type %d save code=%d handled=%v stored=%d err=%v", value, code, handled, s.state.FieldCharControlDeckType, err)
}
}
if _, _, handled, err = s.Handle("/SaveFieldCharControlDeckType", req(9, wire.AppendVarint(nil, 2, 3))); !handled || err == nil {
t.Fatalf("unknown enum accepted handled=%v err=%v", handled, err)
}
}
func TestTotalBattlePowerKeepsHighest(t *testing.T) {
s := seeded(t)
for seq, power := range []uint64{1300, 900} {
request := req(uint64(seq+1), wire.AppendVarint(nil, 2, power))
code, response, ok, err := s.Handle("/SaveTotalBattlePower", request)
if err != nil || !ok || code != 258 {
t.Fatalf("save power: code=%d ok=%v err=%v", code, ok, err)
}
highest, found, err := wire.Varint(response, 1)
if err != nil || !found || highest != 1300 {
t.Fatalf("highest=%d found=%v err=%v", highest, found, err)
}
}
}
func TestPortraitChangeEchoesAndStoresCostume(t *testing.T) {
s := seeded(t)
request := req(1, wire.AppendVarint(nil, 2, 3501))
code, response, ok, err := s.Handle("/UserPortraitChange", request)
if err != nil || !ok || code != 75 {
t.Fatalf("portrait: code=%d ok=%v err=%v", code, ok, err)
}
id, found, err := wire.Varint(response, 1)
if err != nil || !found || id != 3501 || s.state.PortraitCostumeID != 3501 {
t.Fatalf("portrait response=%d found=%v stored=%d err=%v", id, found, s.state.PortraitCostumeID, err)
}
}
func TestDeckCharAutoReviveUsesCurrentFormationWithoutInventingRevives(t *testing.T) {
s := seeded(t)
if code, _, handled, err := s.Handle("/DeckSave", req(1, triple(535607162, 10, 1), triple(535604120, 1, 2))); code != 10 || !handled || err != nil {
t.Fatalf("save code=%d handled=%v err=%v", code, handled, err)
}
code, response, handled, err := s.Handle("/DeckCharAutoRevive", req(2))
if err != nil || !handled || code != 373 {
t.Fatalf("auto revive code=%d handled=%v err=%v", code, handled, err)
}
var battle, field, revive int
if err := wire.Walk(response, func(f wire.Field) error {
switch f.Number {
case 1:
battle++
if battle == 1 {
index, _, _ := wire.Varint(f.Value, 1)
if index != 535607162 {
t.Errorf("first battle character %d", index)
}
}
case 2:
field++
case 3, 5:
revive++
}
return nil
}); err != nil || battle != 2 || field != 5 || revive != 0 {
t.Fatalf("formation battle=%d field=%d invented revive=%d err=%v", battle, field, revive, err)
}
mode, _, _ := wire.Varint(response, 4)
catalyst, _, _ := wire.Varint(response, 7)
if mode != 2 || catalyst != 200 {
t.Fatalf("official formation mode=%d catalyst=%d", mode, catalyst)
}
if _, _, handled, err := s.Handle("/DeckCharAutoRevive", req(3, wire.AppendVarint(nil, 2, 535604120))); !handled || err == nil {
t.Fatalf("unverified caster incorrectly accepted handled=%v err=%v", handled, err)
}
if _, _, handled, err := s.Handle("/DeckCharAutoRevive", nil); !handled || err == nil {
t.Fatalf("missing sequence incorrectly accepted handled=%v err=%v", handled, err)
}
}
func TestDeckCharAutoRevivePreservesExplicitZeroCatalyst(t *testing.T) {
seed, err := LoadSeed(filepath.Join("..", "..", "..", "seed", "v2_35_10", "decks.json"))
if err != nil {
t.Fatal(err)
}
storage := stateio.NewMemory()
s, err := OpenStore(storage, seed)
if err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/DeckSave", req(1, triple(88, 11, 1))); err != nil {
t.Fatal(err)
}
s.state.AutoReviveCatalyst = 0
if err := s.commit(s.state); err != nil {
t.Fatal(err)
}
reopened, err := OpenStore(storage, seed)
if err != nil || reopened.state.AutoReviveCatalyst != 0 || reopened.state.Deck[0].CharacterInvenIndex != 88 {
t.Fatalf("reloaded state=%+v err=%v", reopened.state, err)
}
}
+880
View File
@@ -0,0 +1,880 @@
package deck
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
const (
presetBaseCount = 5
presetMaxCount = 12
presetSlotPrice = 2000
)
type Preset struct {
Name string `json:"name"`
ResourceID uint64 `json:"resource_id"`
ResourceColor uint64 `json:"resource_color"`
Slot uint64 `json:"slot"`
Decks []PresetDeck `json:"decks"`
Blesses []PresetBless `json:"blesses"`
}
type PresetDeck struct {
Deck DeckEntry `json:"deck"`
CostumeIndex uint64 `json:"costume_index"`
Equipment []PresetEquipmentItem `json:"equipment"`
Team uint64 `json:"team"`
}
type PresetEquipmentItem struct {
Type uint64 `json:"type"`
Index uint64 `json:"index"`
}
type PresetBless struct {
DeckType uint64 `json:"deck_type"`
IDs []uint64 `json:"ids"`
}
type CostumeSetting struct {
CharacterIndex uint64 `json:"character_index"`
Sequence []CostumeSettingItem `json:"sequence"`
BattleMode uint64 `json:"battle_mode"`
MonsterID uint64 `json:"monster_id"`
}
type CostumeSettingItem struct {
CostumeIndex int64 `json:"costume_index"`
BurstLevel uint64 `json:"burst_level"`
}
func (s *Store) AttachPresetRuntime(wallet *player.Wallet, characters *player.CharacterStore, equipment *player.EquipmentInventory, collection *player.CollectionStore) error {
if wallet == nil || characters == nil || equipment == nil || collection == nil {
return errors.New("deck: incomplete preset runtime")
}
s.mu.Lock()
defer s.mu.Unlock()
s.wallet, s.characters, s.equipment, s.collection = wallet, characters, equipment, collection
return s.validatePresetOwnershipLocked()
}
func (s *Store) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = id
s.replies = map[string]deckReply{}
}
func (s *Store) PresetSlotCount() uint64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.presetSlots
}
func (s *Store) loadPresetEntries() error {
rawConfig, found, err := s.storage.LoadEntry("deck", "preset_config", "slots")
if err != nil {
return err
}
if found {
if err := json.Unmarshal(rawConfig, &s.presetSlots); err != nil || s.presetSlots < presetBaseCount || s.presetSlots > presetMaxCount {
return errors.New("deck: invalid preset slot configuration")
}
}
raw, err := s.storage.ListEntries("deck", "presets")
if err != nil {
return err
}
for key, payload := range raw {
slot, err := strconv.ParseUint(key, 10, 64)
if err != nil || key != strconv.FormatUint(slot, 10) {
return fmt.Errorf("deck: invalid preset key %q", key)
}
var preset Preset
if err := json.Unmarshal(payload, &preset); err != nil || preset.Slot != slot {
return fmt.Errorf("deck: invalid preset %q", key)
}
if err := validatePresetShape(preset, s.presetSlots); err != nil {
return fmt.Errorf("deck: invalid preset %q: %w", key, err)
}
s.presets[slot] = preset
}
rawSettings, err := s.storage.ListEntries("deck", "costume_settings")
if err != nil {
return err
}
for key, payload := range rawSettings {
index, err := strconv.ParseUint(key, 10, 64)
if err != nil || index == 0 || key != strconv.FormatUint(index, 10) {
return fmt.Errorf("deck: invalid costume setting key %q", key)
}
var setting CostumeSetting
if err := json.Unmarshal(payload, &setting); err != nil || setting.CharacterIndex != index {
return fmt.Errorf("deck: invalid costume setting %q", key)
}
s.costumeSettings[index] = setting
}
return nil
}
func (s *Store) validatePresetOwnershipLocked() error {
if s.characters == nil || s.collection == nil || s.equipment == nil {
return nil
}
for _, preset := range s.presets {
if err := s.validatePresetOwnedLocked(preset); err != nil {
return fmt.Errorf("deck: saved preset %d: %w", preset.Slot, err)
}
}
for _, setting := range s.costumeSettings {
if _, found := s.characters.Find(setting.CharacterIndex); !found {
return fmt.Errorf("deck: costume setting references unknown character %d", setting.CharacterIndex)
}
for _, item := range setting.Sequence {
if item.CostumeIndex > 0 {
if _, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex)); !found {
return fmt.Errorf("deck: costume setting references unknown costume %d", item.CostumeIndex)
}
}
}
}
return nil
}
func validatePresetShape(p Preset, slotCount uint64) error {
if p.Slot >= slotCount || p.ResourceID > 21 || p.ResourceColor > 5 || !validPresetName(p.Name) || len(p.Decks) > 5 {
return errors.New("invalid metadata or deck count")
}
characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{}
for _, deck := range p.Decks {
if deck.Deck.CharacterInvenIndex == 0 || deck.Deck.CostumeInvenIndex > 11 || deck.Deck.Slot == 0 || deck.Deck.Slot > 5 || deck.Team != 0 ||
characters[deck.Deck.CharacterInvenIndex] || positions[deck.Deck.CostumeInvenIndex] || sequences[deck.Deck.Slot] {
return errors.New("invalid deck entry")
}
characters[deck.Deck.CharacterInvenIndex] = true
positions[deck.Deck.CostumeInvenIndex] = true
sequences[deck.Deck.Slot] = true
if len(deck.Equipment) != 5 {
return errors.New("preset deck requires five equipment slots")
}
seen := map[uint64]bool{}
for _, item := range deck.Equipment {
if item.Type >= 5 || seen[item.Type] {
return errors.New("invalid preset equipment slot")
}
seen[item.Type] = true
}
}
return nil
}
func validPresetName(value string) bool {
if !utf8.ValidString(value) || utf8.RuneCountInString(value) < 1 || utf8.RuneCountInString(value) > 16 || strings.Contains(value, "<") || strings.Contains(value, ">") {
return false
}
for _, r := range value {
if unicode.IsControl(r) {
return false
}
}
return true
}
func (s *Store) validatePresetOwnedLocked(p Preset) error {
if err := validatePresetShape(p, s.presetSlots); err != nil {
return err
}
ownedEquipment := make(map[uint64]player.Equipment)
for _, item := range s.equipment.All() {
ownedEquipment[item.InvenIndex] = item
}
seenEquipment := map[uint64]bool{}
for _, deck := range p.Decks {
if _, found := s.characters.Find(deck.Deck.CharacterInvenIndex); !found {
return fmt.Errorf("unknown character %d", deck.Deck.CharacterInvenIndex)
}
if deck.CostumeIndex != 0 {
costume, found := s.collection.CostumeByIndex(deck.CostumeIndex)
if !found || costume.UseChar != deck.Deck.CharacterInvenIndex {
return fmt.Errorf("unknown costume %d", deck.CostumeIndex)
}
}
for _, reference := range deck.Equipment {
if reference.Index == 0 {
continue
}
if seenEquipment[reference.Index] || ownedEquipment[reference.Index].InvenIndex == 0 {
return fmt.Errorf("invalid or repeated equipment %d", reference.Index)
}
seenEquipment[reference.Index] = true
}
binding := player.PresetEquipmentBinding{CharacterIndex: deck.Deck.CharacterInvenIndex, Equipment: make([]uint64, 5)}
for _, reference := range deck.Equipment {
binding.Equipment[reference.Type] = reference.Index
}
if err := s.equipment.ValidatePresetEquipment([]player.PresetEquipmentBinding{binding}); err != nil {
return err
}
}
return nil
}
func decodePreset(data []byte) (Preset, error) {
var p Preset
name, _, err := wire.Bytes(data, 1)
if err != nil {
return p, err
}
p.Name = string(name)
p.ResourceID, _, err = wire.Varint(data, 2)
if err != nil {
return p, err
}
p.ResourceColor, _, err = wire.Varint(data, 3)
if err != nil {
return p, err
}
p.Slot, _, err = wire.Varint(data, 4)
if err != nil {
return p, err
}
err = wire.Walk(data, func(field wire.Field) error {
if field.Type != 2 {
return nil
}
switch field.Number {
case 5:
deck, err := decodePresetDeck(field.Value)
if err != nil {
return err
}
p.Decks = append(p.Decks, deck)
case 6:
bless, err := decodePresetBless(field.Value)
if err != nil {
return err
}
p.Blesses = append(p.Blesses, bless)
}
return nil
})
return p, err
}
func decodePresetDeck(data []byte) (PresetDeck, error) {
var result PresetDeck
base, found, err := wire.Bytes(data, 1)
if err != nil || !found {
return result, errors.New("deck: preset missing deck base")
}
character, ok, err := wire.Varint(base, 1)
if err != nil || !ok || character == 0 {
return result, errors.New("deck: invalid preset character")
}
position, _, err := wire.Varint(base, 2)
if err != nil {
return result, err
}
sequence, ok, err := wire.Varint(base, 3)
if err != nil || !ok || sequence == 0 {
return result, errors.New("deck: invalid preset sequence")
}
result.Deck = DeckEntry{CharacterInvenIndex: character, CostumeInvenIndex: position, Slot: sequence}
result.CostumeIndex, _, err = wire.Varint(data, 2)
if err != nil {
return result, err
}
result.Team, _, err = wire.Varint(data, 4)
if err != nil {
return result, err
}
err = wire.Walk(data, func(field wire.Field) error {
if field.Number != 3 {
return nil
}
if field.Type != 2 {
return errors.New("deck: invalid preset equipment")
}
typeID, _, err := wire.Varint(field.Value, 1)
if err != nil {
return err
}
index, _, err := wire.Varint(field.Value, 2)
if err != nil {
return err
}
result.Equipment = append(result.Equipment, PresetEquipmentItem{Type: typeID, Index: index})
return nil
})
sort.Slice(result.Equipment, func(i, j int) bool { return result.Equipment[i].Type < result.Equipment[j].Type })
return result, err
}
func decodePresetBless(data []byte) (PresetBless, error) {
var result PresetBless
result.DeckType, _, _ = wire.Varint(data, 1)
err := wire.Walk(data, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
values, err := repeatedUint64(field)
if err != nil {
return err
}
result.IDs = append(result.IDs, values...)
return nil
})
return result, err
}
func repeatedUint64(field wire.Field) ([]uint64, error) {
if field.Type == 0 {
value, count := binary.Uvarint(field.Value)
if count <= 0 {
return nil, wire.ErrMalformed
}
return []uint64{value}, nil
}
if field.Type != 2 {
return nil, wire.ErrMalformed
}
var out []uint64
for offset := 0; offset < len(field.Value); {
value, count := binary.Uvarint(field.Value[offset:])
if count <= 0 {
return nil, wire.ErrMalformed
}
out = append(out, value)
offset += count
}
return out, nil
}
func presetWire(p Preset) []byte {
var out []byte
if p.Name != "" {
out = wire.AppendString(out, 1, p.Name)
}
if p.ResourceID != 0 {
out = wire.AppendVarint(out, 2, p.ResourceID)
}
if p.ResourceColor != 0 {
out = wire.AppendVarint(out, 3, p.ResourceColor)
}
if p.Slot != 0 {
out = wire.AppendVarint(out, 4, p.Slot)
}
for _, deck := range p.Decks {
out = wire.AppendBytes(out, 5, presetDeckWire(deck))
}
for _, bless := range p.Blesses {
var b []byte
if bless.DeckType != 0 {
b = wire.AppendVarint(b, 1, bless.DeckType)
}
for _, id := range bless.IDs {
b = wire.AppendVarint(b, 2, id)
}
out = wire.AppendBytes(out, 6, b)
}
return out
}
func presetDeckWire(deck PresetDeck) []byte {
base := wire.AppendVarint(nil, 1, deck.Deck.CharacterInvenIndex)
if deck.Deck.CostumeInvenIndex != 0 {
base = wire.AppendVarint(base, 2, deck.Deck.CostumeInvenIndex)
}
base = wire.AppendVarint(base, 3, deck.Deck.Slot)
out := wire.AppendBytes(nil, 1, base)
if deck.CostumeIndex != 0 {
out = wire.AppendVarint(out, 2, deck.CostumeIndex)
}
for _, item := range deck.Equipment {
var b []byte
if item.Type != 0 {
b = wire.AppendVarint(b, 1, item.Type)
}
if item.Index != 0 {
b = wire.AppendVarint(b, 2, item.Index)
}
out = wire.AppendBytes(out, 3, b)
}
if deck.Team != 0 {
out = wire.AppendVarint(out, 4, deck.Team)
}
return out
}
func (s *Store) presetCacheKey(kind string, seq uint64) string {
return kind + ":" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
}
func (s *Store) persistPresetLocked(p Preset) error {
payload, err := json.Marshal(p)
if err != nil {
return err
}
core, err := s.corePayloadLocked()
if err != nil {
return err
}
return s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "presets", Key: strconv.FormatUint(p.Slot, 10), Payload: payload}})
}
func (s *Store) corePayloadLocked() ([]byte, error) {
payload, err := json.MarshalIndent(s.state, "", " ")
if err != nil {
return nil, err
}
return append(payload, '\n'), nil
}
func (s *Store) cachedReplyLocked(kind string, seq uint64) (deckReply, bool) {
reply, found := s.replies[s.presetCacheKey(kind, seq)]
if found {
reply.body = append([]byte(nil), reply.body...)
}
return reply, found
}
func (s *Store) rememberReplyLocked(kind string, seq uint64, code int, body []byte) {
s.replies[s.presetCacheKey(kind, seq)] = deckReply{code: code, body: append([]byte(nil), body...)}
}
func requestSequence(request []byte) (uint64, error) {
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, errors.New("deck: invalid request sequence")
}
return seq, nil
}
func (s *Store) handlePresetInfo(request []byte) (int, []byte, bool, error) {
if _, err := requestSequence(request); err != nil {
return 0, nil, true, err
}
s.mu.RLock()
defer s.mu.RUnlock()
slots := make([]uint64, 0, len(s.presets))
for slot := range s.presets {
if slot < s.presetSlots {
slots = append(slots, slot)
}
}
sort.Slice(slots, func(i, j int) bool { return slots[i] < slots[j] })
var response []byte
for _, slot := range slots {
response = wire.AppendBytes(response, 1, presetWire(s.presets[slot]))
}
return 178, response, true, nil
}
func (s *Store) handlePresetSave(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
raw, found, err := wire.Bytes(request, 2)
if err != nil || !found {
return 0, nil, true, errors.New("deck: PresetSave missing preset")
}
preset, err := decodePreset(raw)
if err != nil {
return 0, nil, true, fmt.Errorf("deck: decode preset: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("save", seq); found {
return reply.code, reply.body, true, nil
}
if s.characters == nil || s.collection == nil || s.equipment == nil {
return 0, nil, true, errors.New("deck: preset runtime unavailable")
}
if err := s.validatePresetOwnedLocked(preset); err != nil {
return 0, nil, true, fmt.Errorf("deck: invalid preset: %w", err)
}
if err := s.persistPresetLocked(preset); err != nil {
return 0, nil, true, fmt.Errorf("deck: persist preset: %w", err)
}
s.presets[preset.Slot] = preset
s.rememberReplyLocked("save", seq, 179, nil)
return 179, nil, true, nil
}
func (s *Store) handlePresetAddSlot(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
count, found, err := wire.Varint(request, 2)
if err != nil || !found || count == 0 {
return 0, nil, true, errors.New("deck: PresetAddSlot invalid count")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("add-slot", seq); found {
return reply.code, reply.body, true, nil
}
if s.wallet == nil {
return 0, nil, true, errors.New("deck: preset wallet unavailable")
}
if s.presetSlots > presetMaxCount || count > presetMaxCount-s.presetSlots {
return 0, nil, true, errors.New("deck: preset slot limit exceeded")
}
if count > ^uint64(0)/presetSlotPrice {
return 0, nil, true, errors.New("deck: preset slot price overflow")
}
identity := "preset-slot:" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
if _, err := s.wallet.SpendGoldOnce(identity, count*presetSlotPrice); err != nil {
return 0, nil, true, fmt.Errorf("deck: buy preset slot: %w", err)
}
next := s.presetSlots + count
payload, err := json.Marshal(next)
if err != nil {
return 0, nil, true, err
}
core, err := s.corePayloadLocked()
if err != nil {
return 0, nil, true, err
}
if err := s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "preset_config", Key: "slots", Payload: payload}}); err != nil {
return 0, nil, true, fmt.Errorf("deck: persist preset slots: %w", err)
}
s.presetSlots = next
s.rememberReplyLocked("add-slot", seq, 180, nil)
return 180, nil, true, nil
}
func (s *Store) handlePresetInfoChange(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
nameBytes, _, err := wire.Bytes(request, 2)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset name")
}
resourceID, _, err := wire.Varint(request, 3)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset icon")
}
color, _, err := wire.Varint(request, 4)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset color")
}
// Ordinary preset slots are zero-based. Proto3 omits slot=0, so an absent
// field 5 is the first slot rather than a malformed request.
slot, _, err := wire.Varint(request, 5)
if err != nil {
return 0, nil, true, errors.New("deck: invalid preset slot")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("info-change", seq); found {
return reply.code, reply.body, true, nil
}
preset, exists := s.presets[slot]
if !exists {
preset = Preset{Slot: slot, Decks: []PresetDeck{}, Blesses: []PresetBless{}}
}
preset.Name, preset.ResourceID, preset.ResourceColor = string(nameBytes), resourceID, color
if err := validatePresetShape(preset, s.presetSlots); err != nil {
return 0, nil, true, fmt.Errorf("deck: invalid preset metadata: %w", err)
}
if err := s.persistPresetLocked(preset); err != nil {
return 0, nil, true, err
}
s.presets[slot] = preset
s.rememberReplyLocked("info-change", seq, 278, nil)
return 278, nil, true, nil
}
func (s *Store) handlePresetDelete(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
var slots []uint64
if err := wire.Walk(request, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
values, err := repeatedUint64(field)
if err != nil {
return err
}
slots = append(slots, values...)
return nil
}); err != nil || len(slots) == 0 {
return 0, nil, true, errors.New("deck: invalid preset delete slots")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("delete", seq); found {
return reply.code, reply.body, true, nil
}
seen := make(map[uint64]bool, len(slots))
changes := make([]stateio.EntryMutation, 0, len(slots))
for _, slot := range slots {
if slot >= s.presetSlots || seen[slot] {
return 0, nil, true, errors.New("deck: invalid or duplicate preset delete slot")
}
seen[slot] = true
changes = append(changes, stateio.EntryMutation{Bucket: "presets", Key: strconv.FormatUint(slot, 10), Delete: true})
}
core, err := s.corePayloadLocked()
if err != nil {
return 0, nil, true, err
}
if err := s.storage.SaveWithEntries("deck", core, changes); err != nil {
return 0, nil, true, err
}
for slot := range seen {
delete(s.presets, slot)
}
// 2.35.10 contains the request/response classes but no PacketCode enum
// member. Code zero is the same compatibility fallback used for other
// unnumbered local endpoints; it must not be treated as an official value.
s.rememberReplyLocked("delete", seq, 0, nil)
return 0, nil, true, nil
}
func (s *Store) handlePresetUse(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
// PresetUse is also zero-based and slot=0 is omitted by proto3.
slot, _, err := wire.Varint(request, 2)
if err != nil {
return 0, nil, true, errors.New("deck: PresetUse missing slot")
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("use", seq); found {
return reply.code, reply.body, true, nil
}
preset, found := s.presets[slot]
if !found || len(preset.Decks) == 0 {
return 0, nil, true, errors.New("deck: PresetUse references an empty slot")
}
if s.characters == nil || s.collection == nil || s.equipment == nil {
return 0, nil, true, errors.New("deck: preset runtime unavailable")
}
if err := s.validatePresetOwnedLocked(preset); err != nil {
return 0, nil, true, fmt.Errorf("deck: stale preset: %w", err)
}
deckEntries := make([]DeckEntry, 0, len(preset.Decks))
assignments := make(map[uint64]uint64, len(preset.Decks))
bindings := make([]player.PresetEquipmentBinding, 0, len(preset.Decks))
for _, entry := range preset.Decks {
deckEntries = append(deckEntries, entry.Deck)
assignments[entry.Deck.CharacterInvenIndex] = entry.CostumeIndex
equipment := make([]uint64, 5)
for _, item := range entry.Equipment {
equipment[item.Type] = item.Index
}
bindings = append(bindings, player.PresetEquipmentBinding{CharacterIndex: entry.Deck.CharacterInvenIndex, Equipment: equipment})
}
if _, err := s.characters.ApplyPresetCostumes(assignments); err != nil {
return 0, nil, true, fmt.Errorf("deck: apply preset costumes: %w", err)
}
affected, err := s.equipment.ApplyPresetEquipment(bindings)
if err != nil {
return 0, nil, true, fmt.Errorf("deck: apply preset equipment: %w", err)
}
next := clone(s.state)
next.Deck = append([]DeckEntry(nil), deckEntries...)
for character, costume := range assignments {
next.Costumes[character] = costume
}
if err := s.commit(next); err != nil {
return 0, nil, true, fmt.Errorf("deck: persist applied preset: %w", err)
}
var response []byte
for _, entry := range deckEntries {
base := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex)
if entry.CostumeInvenIndex != 0 {
base = wire.AppendVarint(base, 2, entry.CostumeInvenIndex)
}
base = wire.AppendVarint(base, 3, entry.Slot)
response = wire.AppendBytes(response, 1, base)
}
characterSet := make(map[uint64]bool, len(assignments)+len(affected))
for index := range assignments {
characterSet[index] = true
}
for _, character := range affected {
characterSet[character.InvenIndex] = true
}
indices := make([]uint64, 0, len(characterSet))
for index := range characterSet {
indices = append(indices, index)
}
sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] })
for _, index := range indices {
if character, found := s.characters.Find(index); found {
response = wire.AppendBytes(response, 2, player.CharacterWire(character))
}
}
for _, binding := range bindings {
info := wire.AppendVarint(nil, 1, binding.CharacterIndex)
for _, index := range binding.Equipment {
info = wire.AppendVarint(info, 2, index)
}
response = wire.AppendBytes(response, 3, info)
}
s.rememberReplyLocked("use", seq, 409, response)
return 409, response, true, nil
}
func costumeSettingWire(setting CostumeSetting) []byte {
out := wire.AppendVarint(nil, 1, setting.CharacterIndex)
for _, item := range setting.Sequence {
entry := wire.AppendVarint(nil, 1, uint64(item.CostumeIndex))
if item.BurstLevel != 0 {
entry = wire.AppendVarint(entry, 2, item.BurstLevel)
}
out = wire.AppendBytes(out, 2, entry)
}
if setting.BattleMode != 0 {
out = wire.AppendVarint(out, 3, setting.BattleMode)
}
if setting.MonsterID != 0 {
out = wire.AppendVarint(out, 4, setting.MonsterID)
}
return out
}
func decodeCostumeSetting(data []byte) (CostumeSetting, error) {
var result CostumeSetting
character, found, err := wire.Varint(data, 1)
if err != nil || !found || character == 0 {
return result, errors.New("deck: costume setting missing character")
}
result.CharacterIndex = character
result.BattleMode, _, err = wire.Varint(data, 3)
if err != nil {
return result, err
}
result.MonsterID, _, err = wire.Varint(data, 4)
if err != nil {
return result, err
}
err = wire.Walk(data, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
if field.Type != 2 {
return errors.New("deck: invalid costume setting sequence")
}
raw, found, err := wire.Varint(field.Value, 1)
if err != nil || !found {
return errors.New("deck: costume setting entry missing costume")
}
burst, _, err := wire.Varint(field.Value, 2)
if err != nil {
return err
}
result.Sequence = append(result.Sequence, CostumeSettingItem{CostumeIndex: int64(raw), BurstLevel: burst})
return nil
})
return result, err
}
func (s *Store) validateCostumeSettingLocked(setting CostumeSetting) error {
if s.characters == nil || s.collection == nil {
return errors.New("deck: costume setting runtime unavailable")
}
if setting.BattleMode != 0 || setting.MonsterID != 0 {
return errors.New("deck: ordinary costume setting requires normal battle mode")
}
if _, found := s.characters.Find(setting.CharacterIndex); !found {
return fmt.Errorf("deck: unknown costume setting character %d", setting.CharacterIndex)
}
for _, item := range setting.Sequence {
if item.CostumeIndex <= 0 {
if item.CostumeIndex != 0 && item.CostumeIndex != -1 {
return fmt.Errorf("deck: invalid costume setting sentinel %d", item.CostumeIndex)
}
continue
}
costume, found := s.collection.CostumeByIndex(uint64(item.CostumeIndex))
if !found || costume.UseChar != setting.CharacterIndex {
return fmt.Errorf("deck: costume %d does not belong to character %d", item.CostumeIndex, setting.CharacterIndex)
}
if item.BurstLevel > costume.BurstLevel {
return fmt.Errorf("deck: costume %d burst level exceeds owned level", item.CostumeIndex)
}
}
return nil
}
func (s *Store) handleCostumeSettingInfo(request []byte) (int, []byte, bool, error) {
if _, err := requestSequence(request); err != nil {
return 0, nil, true, err
}
s.mu.RLock()
defer s.mu.RUnlock()
indices := make([]uint64, 0, len(s.costumeSettings))
for index := range s.costumeSettings {
indices = append(indices, index)
}
sort.Slice(indices, func(i, j int) bool { return indices[i] < indices[j] })
var response []byte
for _, index := range indices {
response = wire.AppendBytes(response, 1, costumeSettingWire(s.costumeSettings[index]))
}
return 397, response, true, nil
}
func (s *Store) handleCostumeSettingSave(request []byte) (int, []byte, bool, error) {
seq, err := requestSequence(request)
if err != nil {
return 0, nil, true, err
}
raw, found, err := wire.Bytes(request, 2)
if err != nil || !found {
return 0, nil, true, errors.New("deck: DeckCostumeSettingSave missing setting")
}
setting, err := decodeCostumeSetting(raw)
if err != nil {
return 0, nil, true, err
}
s.mu.Lock()
defer s.mu.Unlock()
if reply, found := s.cachedReplyLocked("costume-setting-save", seq); found {
return reply.code, reply.body, true, nil
}
if err := s.validateCostumeSettingLocked(setting); err != nil {
return 0, nil, true, err
}
payload, err := json.Marshal(setting)
if err != nil {
return 0, nil, true, err
}
change := stateio.EntryMutation{Bucket: "costume_settings", Key: strconv.FormatUint(setting.CharacterIndex, 10), Payload: payload}
core, err := s.corePayloadLocked()
if err != nil {
return 0, nil, true, err
}
if err := s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{change}); err != nil {
return 0, nil, true, err
}
s.costumeSettings[setting.CharacterIndex] = setting
s.rememberReplyLocked("costume-setting-save", seq, 398, nil)
return 398, nil, true, nil
}
+347
View File
@@ -0,0 +1,347 @@
package deck
import (
"reflect"
"sort"
"testing"
"bd2server/internal/server/player"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
type presetFixture struct {
storage *stateio.Memory
seed Seed
deck *Store
wallet *player.Wallet
characters *player.CharacterStore
equipment *player.EquipmentInventory
collection *player.CollectionStore
weapon player.Equipment
}
func newPresetFixture(t *testing.T) *presetFixture {
t.Helper()
storage := stateio.NewMemory()
seed, err := LoadSeed("../../../seed/v2_35_10/decks.json")
if err != nil {
t.Fatal(err)
}
inventory, err := player.OpenInventory(storage, &player.Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
collection, err := player.OpenCollectionStore(storage, []player.Costume{
{InvenIndex: 1001, ID: 3501, UseChar: 100, BurstLevel: 3},
{InvenIndex: 2001, ID: 3601, UseChar: 200, BurstLevel: 1},
})
if err != nil {
t.Fatal(err)
}
characters, err := player.OpenCharacterStore(storage, []player.Character{
{InvenIndex: 100, ID: 350, Level: 20, UseCostume: 1001, CostumeID: 3501},
{InvenIndex: 200, ID: 360, Level: 20, UseCostume: 2001, CostumeID: 3601},
}, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err := characters.AttachCollection(collection); err != nil {
t.Fatal(err)
}
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
if err := equipment.AttachSlots(map[uint64]uint64{10: 0, 11: 1, 12: 2, 13: 3, 14: 4}); err != nil {
t.Fatal(err)
}
if err := equipment.AttachCharacters(characters); err != nil {
t.Fatal(err)
}
weapon, err := equipment.GrantOnce("preset-test-weapon", 10)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{Gold: 20000})
if err != nil {
t.Fatal(err)
}
decks, err := OpenStore(storage, seed)
if err != nil {
t.Fatal(err)
}
if err := decks.AttachPresetRuntime(wallet, characters, equipment, collection); err != nil {
t.Fatal(err)
}
decks.BeginSession("preset-test")
return &presetFixture{storage: storage, seed: seed, deck: decks, wallet: wallet, characters: characters, equipment: equipment, collection: collection, weapon: weapon}
}
func presetRequest(slot, character, costume, equipment uint64, name string) []byte {
base := wire.AppendVarint(nil, 1, character)
base = wire.AppendVarint(base, 2, 0)
base = wire.AppendVarint(base, 3, 1)
deck := wire.AppendBytes(nil, 1, base)
deck = wire.AppendVarint(deck, 2, costume)
for equipmentType := uint64(0); equipmentType < 5; equipmentType++ {
entry := wire.AppendVarint(nil, 1, equipmentType)
if equipmentType == 0 {
entry = wire.AppendVarint(entry, 2, equipment)
}
deck = wire.AppendBytes(deck, 3, entry)
}
preset := wire.AppendString(nil, 1, name)
preset = wire.AppendVarint(preset, 2, 1)
preset = wire.AppendVarint(preset, 4, slot)
preset = wire.AppendBytes(preset, 5, deck)
return wire.AppendBytes(nil, 2, preset)
}
func TestPresetSaveInfoMetadataDeleteAndRestart(t *testing.T) {
f := newPresetFixture(t)
for seq, slot := range []uint64{2, 0} {
request := req(uint64(seq+1), presetRequest(slot, 100, 1001, f.weapon.InvenIndex, "编队"))
if code, _, handled, err := f.deck.Handle("/PresetSave", request); err != nil || !handled || code != 179 {
t.Fatalf("save slot %d code=%d handled=%v err=%v", slot, code, handled, err)
}
}
code, response, handled, err := f.deck.Handle("/PresetInfo", req(10))
if err != nil || !handled || code != 178 {
t.Fatalf("info code=%d handled=%v err=%v", code, handled, err)
}
var slots []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 {
slot, _, err := wire.Varint(field.Value, 4)
if err != nil {
return err
}
slots = append(slots, slot)
}
return nil
}); err != nil || !reflect.DeepEqual(slots, []uint64{0, 2}) {
t.Fatalf("ordered slots=%v err=%v", slots, err)
}
change := wire.AppendString(nil, 2, "主力队")
change = wire.AppendVarint(change, 3, 21)
change = wire.AppendVarint(change, 4, 5)
// slot=0 is absent on the real proto3 wire.
if code, _, _, err := f.deck.Handle("/PresetInfoChange", req(11, change)); err != nil || code != 278 {
t.Fatalf("metadata change code=%d err=%v", code, err)
}
if got := f.deck.presets[0]; got.Name != "主力队" || got.ResourceID != 21 || got.ResourceColor != 5 || len(got.Decks) != 1 {
t.Fatalf("metadata change lost content: %+v", got)
}
restarted, err := OpenStore(f.storage, f.seed)
if err != nil {
t.Fatal(err)
}
if len(restarted.presets) != 2 || restarted.presets[0].Name != "主力队" {
t.Fatalf("restarted presets=%+v", restarted.presets)
}
restarted.BeginSession("restart")
deleteRequest := req(12, wire.AppendVarint(nil, 2, 0), wire.AppendVarint(nil, 2, 2))
if code, _, handled, err := restarted.Handle("/PresetDelete", deleteRequest); err != nil || !handled || code != 0 {
t.Fatalf("delete code=%d handled=%v err=%v", code, handled, err)
}
if len(restarted.presets) != 0 || restarted.presetSlots != 5 {
t.Fatalf("delete changed slots/content: slots=%d presets=%+v", restarted.presetSlots, restarted.presets)
}
}
func TestPresetMetadataCanCreateEmptySlot(t *testing.T) {
f := newPresetFixture(t)
change := wire.AppendString(nil, 2, "备用")
change = wire.AppendVarint(change, 3, 2)
change = wire.AppendVarint(change, 4, 1)
change = wire.AppendVarint(change, 5, 4)
if code, _, _, err := f.deck.Handle("/PresetInfoChange", req(1, change)); err != nil || code != 278 {
t.Fatalf("upsert code=%d err=%v", code, err)
}
if got := f.deck.presets[4]; got.Name != "备用" || len(got.Decks) != 0 {
t.Fatalf("empty metadata preset=%+v", got)
}
}
func TestPresetAddSlotChargesOncePersistsAndCaps(t *testing.T) {
f := newPresetFixture(t)
add := req(20, wire.AppendVarint(nil, 2, 1))
for attempt := 0; attempt < 2; attempt++ {
if code, _, _, err := f.deck.Handle("/PresetAddSlot", add); err != nil || code != 180 {
t.Fatalf("add attempt %d code=%d err=%v", attempt, code, err)
}
}
if f.deck.PresetSlotCount() != 6 || f.wallet.Snapshot().Gold != 18000 {
t.Fatalf("slots=%d gold=%d", f.deck.PresetSlotCount(), f.wallet.Snapshot().Gold)
}
restarted, err := OpenStore(f.storage, f.seed)
if err != nil {
t.Fatal(err)
}
if restarted.PresetSlotCount() != 6 {
t.Fatalf("restarted slots=%d", restarted.PresetSlotCount())
}
if err := restarted.AttachPresetRuntime(f.wallet, f.characters, f.equipment, f.collection); err != nil {
t.Fatal(err)
}
restarted.BeginSession("cap")
if _, _, _, err := restarted.Handle("/PresetAddSlot", req(21, wire.AppendVarint(nil, 2, 6))); err != nil {
t.Fatalf("buy remaining slots: %v", err)
}
if _, _, _, err := restarted.Handle("/PresetAddSlot", req(22, wire.AppendVarint(nil, 2, 1))); err == nil {
t.Fatal("accepted slot beyond maximum")
}
}
func TestPresetUseAppliesDeckCostumeEquipmentWithoutChangingFieldDeck(t *testing.T) {
f := newPresetFixture(t)
wantField := append([]FieldEntry(nil), f.deck.state.FieldDeck...)
if _, _, _, err := f.deck.Handle("/PresetSave", req(1, presetRequest(0, 100, 1001, f.weapon.InvenIndex, "应用"))); err != nil {
t.Fatal(err)
}
code, response, handled, err := f.deck.Handle("/PresetUse", req(2, wire.AppendVarint(nil, 3, 999999999)))
if err != nil || !handled || code != 409 {
t.Fatalf("use code=%d handled=%v err=%v", code, handled, err)
}
if len(f.deck.state.Deck) != 1 || f.deck.state.Deck[0].CharacterInvenIndex != 100 || !reflect.DeepEqual(f.deck.state.FieldDeck, wantField) {
t.Fatalf("deck=%+v field changed=%v", f.deck.state.Deck, !reflect.DeepEqual(f.deck.state.FieldDeck, wantField))
}
owned := f.equipment.All()
if len(owned) != 1 || owned[0].UseChar != 100 {
t.Fatalf("equipment=%+v", owned)
}
var deckCount, characterCount int
var equipmentValues []uint64
if err := wire.Walk(response, func(field wire.Field) error {
switch field.Number {
case 1:
deckCount++
case 2:
characterCount++
case 3:
return wire.Walk(field.Value, func(nested wire.Field) error {
if nested.Number == 2 {
values, err := repeatedUint64(nested)
equipmentValues = append(equipmentValues, values...)
return err
}
return nil
})
}
return nil
}); err != nil {
t.Fatal(err)
}
if deckCount != 1 || characterCount == 0 || !reflect.DeepEqual(equipmentValues, []uint64{f.weapon.InvenIndex, 0, 0, 0, 0}) {
t.Fatalf("response deck=%d chars=%d equipment=%v", deckCount, characterCount, equipmentValues)
}
// The client-supplied power is deliberately ignored; replay returns the
// exact same response and does not repeat cross-domain state transitions.
if replayCode, replay, _, replayErr := f.deck.Handle("/PresetUse", req(2)); replayErr != nil || replayCode != 409 || !reflect.DeepEqual(replay, response) {
t.Fatalf("replay code=%d equal=%v err=%v", replayCode, reflect.DeepEqual(replay, response), replayErr)
}
}
func TestPresetSaveRejectsForgedOwnershipAndEquipmentSlot(t *testing.T) {
f := newPresetFixture(t)
tests := []struct {
name string
character uint64
costume uint64
equipment uint64
}{
{name: "character", character: 999, costume: 1001, equipment: f.weapon.InvenIndex},
{name: "costume owner", character: 100, costume: 2001, equipment: f.weapon.InvenIndex},
{name: "equipment", character: 100, costume: 1001, equipment: 999999},
}
for seq, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, _, err := f.deck.Handle("/PresetSave", req(uint64(seq+1), presetRequest(0, test.character, test.costume, test.equipment, "伪造"))); err == nil {
t.Fatal("forged preset accepted")
}
})
}
if len(f.deck.presets) != 0 {
t.Fatalf("rejected save mutated presets=%+v", f.deck.presets)
}
}
func TestDeckCostumeSettingSentinelsClearAndRestart(t *testing.T) {
f := newPresetFixture(t)
setting := wire.AppendVarint(nil, 1, 100)
for _, item := range []struct {
index int64
burst uint64
}{{1001, 3}, {-1, 0}, {0, 0}} {
entry := wire.AppendVarint(nil, 1, uint64(item.index))
entry = wire.AppendVarint(entry, 2, item.burst)
setting = wire.AppendBytes(setting, 2, entry)
}
if code, _, _, err := f.deck.Handle("/DeckCostumeSettingSave", req(1, wire.AppendBytes(nil, 2, setting))); err != nil || code != 398 {
t.Fatalf("save setting code=%d err=%v", code, err)
}
restarted, err := OpenStore(f.storage, f.seed)
if err != nil {
t.Fatal(err)
}
if err := restarted.AttachPresetRuntime(f.wallet, f.characters, f.equipment, f.collection); err != nil {
t.Fatal(err)
}
restarted.BeginSession("costume-restart")
code, response, _, err := restarted.Handle("/DeckCostumeSettingInfo", req(2))
if err != nil || code != 397 {
t.Fatalf("info setting code=%d err=%v", code, err)
}
var got []int64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 1 {
return nil
}
return wire.Walk(field.Value, func(nested wire.Field) error {
if nested.Number == 2 {
value, _, err := wire.Varint(nested.Value, 1)
got = append(got, int64(value))
return err
}
return nil
})
}); err != nil || !reflect.DeepEqual(got, []int64{1001, -1, 0}) {
t.Fatalf("setting sequence=%v err=%v", got, err)
}
clear := wire.AppendVarint(nil, 1, 100)
if _, _, _, err := restarted.Handle("/DeckCostumeSettingSave", req(3, wire.AppendBytes(nil, 2, clear))); err != nil {
t.Fatal(err)
}
if len(restarted.costumeSettings[100].Sequence) != 0 {
t.Fatalf("setting was not cleared: %+v", restarted.costumeSettings[100])
}
}
func TestCostumeSettingInfoIsSorted(t *testing.T) {
f := newPresetFixture(t)
for seq, character := range []uint64{200, 100} {
setting := wire.AppendVarint(nil, 1, character)
if _, _, _, err := f.deck.Handle("/DeckCostumeSettingSave", req(uint64(seq+1), wire.AppendBytes(nil, 2, setting))); err != nil {
t.Fatal(err)
}
}
_, response, _, err := f.deck.Handle("/DeckCostumeSettingInfo", req(8))
if err != nil {
t.Fatal(err)
}
var indices []uint64
_ = wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 {
index, _, _ := wire.Varint(field.Value, 1)
indices = append(indices, index)
}
return nil
})
if !sort.SliceIsSorted(indices, func(i, j int) bool { return indices[i] < indices[j] }) || !reflect.DeepEqual(indices, []uint64{100, 200}) {
t.Fatalf("setting order=%v", indices)
}
}