Files
bd2/go/internal/server/deck/deck.go
T

536 lines
16 KiB
Go

// 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"
"maps"
"os"
"sync"
"bd2server/internal/server/gamedata"
"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"`
}
// PortraitCostume exposes the current portrait without changing the frozen deck schema.
func (s *Store) PortraitCostume() uint64 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.state.PortraitCostumeID
}
type Store struct {
autoRecovery func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error)
autoRecoveryAllowed func() (bool, error)
fieldSettingsDesign *gamedata.FieldSettingsDesign
fieldSettingsPack func() (int, error)
mu sync.RWMutex
storage stateio.AtomicEntryStore
state state
presets map[uint64]Preset
presetSlots uint64
presetDesign gamedata.PresetDesign
costumeSettings map[uint64]CostumeSetting
wallet *player.Wallet
characters *player.CharacterStore
equipment *player.EquipmentInventory
collection *player.CollectionStore
sessionID string
replies map[string]deckReply
waypointDesign func(uint64) (gamedata.WaypointPack, error)
waypointPack func(uint64, bool) error
}
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
}
}
for slot := uint64(1); slot <= uint64(len(entries)); slot++ {
if !sequences[slot] {
return errors.New("deck: field deck has a missing sequence")
}
}
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, designs ...gamedata.PresetDesign) (*Store, error) {
if e := seed.validate(); e != nil {
return nil, e
}
var design gamedata.PresetDesign
if len(designs) > 1 {
return nil, errors.New("deck: multiple preset designs")
}
if len(designs) == 1 {
design = designs[0]
if err := design.Validate(); err != nil {
return nil, err
}
}
return &Store{presetDesign: design, 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: design.BaseCount, costumeSettings: map[uint64]CostumeSetting{}, replies: map[string]deckReply{}}, nil
}
func OpenStore(storage stateio.Store, seed Seed, designs ...gamedata.PresetDesign) (*Store, error) {
s, e := NewStore(seed, designs...)
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", "field_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")
}
if e = validWaypointState(loaded.Waypoints); e != nil {
return nil, e
}
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] = append([]uint64(nil), v...)
}
y.Costumes = map[uint64]uint64{}
maps.Copy(y.Costumes, x.Costumes)
y.Packs = map[uint64]uint64{}
maps.Copy(y.Packs, x.Packs)
return y
}
func checkSeq(req []byte) error {
v, ok, e := wire.Varint(req, 1)
if e != nil || !ok || v == 0 || v > 2147483647 {
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 {
character, found := s.characters.Find(entry.CharacterInvenIndex)
if !found {
return fmt.Errorf("deck: field deck references unknown character %d", entry.CharacterInvenIndex)
}
if player.IsStoryCharacter(character) && !s.temporaryAllowed(character) {
return fmt.Errorf("deck: field character unavailable in this pack")
}
if entry.CostumeInvenIndex == 0 {
continue
}
if (player.IsStoryCharacter(character) || player.IsCharmCharacter(character)) && character.UseCostume == entry.CostumeInvenIndex {
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 "/TalentSlotSave", "/CharAutoReviveSet":
return s.handleFieldSettings(path, req)
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()
out := encodeDeck(s.state.Deck)
if s.fieldSettingsDesign != nil {
v, e := s.loadFieldSettings()
if e != nil {
return 0, nil, true, e
}
for _, id := range s.projectTalentSlots(v.TalentIDs) {
out = wire.AppendVarint(out, 2, id)
}
}
return 8, out, 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.visibleFieldDeckLocked()), true, nil
case "/DeckCharAutoRevive":
return s.handleAutoRecovery(req)
case "/WaypointInfo":
return s.handleWaypoint(path, req)
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
}
n := clone(s.state)
n.Deck = x
e = s.commit(n)
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", "/WaypointUse":
return s.handleWaypoint(path, req)
case "/CostumeUse":
return s.handleCostumeUse(req)
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
}