Files
bd2/go/internal/deck/deck.go
T
Flechazo 113e941bef feat: expand gameplay and state persistence
- add character imprint, awakening, and equipment refinement

- add equipment ticket gacha and persistent progression fields

- add request-scoped account transactions with crash recovery

- persist age confirmation and isolate client plugin patches

- separate one-off save repair tools from shipped tooling
2026-09-23 00:55:36 +08:00

534 lines
15 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"
"log/slog"
"os"
"path/filepath"
"sort"
"sync"
"bd2server/internal/wire"
)
const version = "2.34.13"
type DeckEntry struct {
CharacterInvenIndex uint64 `json:"character_inven_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,omitempty"`
}
type Store struct {
mu sync.RWMutex
path string
state state
}
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 != version {
return errors.New("deck: wrong seed version")
}
return validField(s.FieldDeck)
}
func validField(entries []FieldEntry) error {
seen := map[uint64]bool{}
for _, e := range entries {
if e.Slot == 0 || e.CharacterInvenIndex == 0 || e.CostumeInvenIndex == 0 || seen[e.Slot] {
return errors.New("deck: invalid field deck")
}
seen[e.Slot] = true
}
return nil
}
func NewStore(seed Seed) (*Store, error) {
if e := seed.validate(); e != nil {
return nil, e
}
return &Store{state: state{Version: version, FieldDeck: append([]FieldEntry(nil), seed.FieldDeck...), FieldCharControlDeckType: seed.FieldCharControlDeckType, AutoReviveCatalyst: seed.AutoReviveCatalyst, Waypoints: map[uint64]uint64{}, Costumes: map[uint64]uint64{}, Packs: map[uint64]uint64{}}}, nil
}
func OpenStore(path string, seed Seed) (*Store, error) {
s, e := NewStore(seed)
if e != nil {
return nil, e
}
if path == "" {
return nil, errors.New("deck: empty save path")
}
s.path = filepath.Clean(path)
b, e := os.ReadFile(s.path)
if errors.Is(e, os.ErrNotExist) {
return s, nil
}
if e != nil {
return nil, fmt.Errorf("deck: read state: %w", e)
}
if e = json.Unmarshal(b, &s.state); e != nil {
return nil, fmt.Errorf("deck: malformed state: %w", e)
}
if s.state.Version != version || validField(s.state.FieldDeck) != nil {
return nil, errors.New("deck: invalid saved state")
}
if s.state.Waypoints == nil {
s.state.Waypoints = map[uint64]uint64{}
}
if s.state.Costumes == nil {
s.state.Costumes = map[uint64]uint64{}
}
if s.state.Packs == nil {
s.state.Packs = map[uint64]uint64{}
}
// Saves from before the auto-revive protocol had no catalyst field. The
// versioned initial account seed provides its observed initial balance.
if s.state.AutoReviveCatalyst == 0 {
s.state.AutoReviveCatalyst = seed.AutoReviveCatalyst
}
return s, nil
}
func (s *Store) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
if _, e := os.Stat(s.path); e == nil {
return nil
} else if !errors.Is(e, os.ErrNotExist) {
return e
}
return s.commit(clone(s.state))
}
func (s *Store) commit(next state) error {
if s.path != "" {
b, e := json.MarshalIndent(next, "", " ")
if e != nil {
return e
}
dir := filepath.Dir(s.path)
if e = os.MkdirAll(dir, 0700); e != nil {
return e
}
f, e := os.CreateTemp(dir, ".deck-*.tmp")
if e != nil {
return e
}
name := f.Name()
defer os.Remove(name)
if _, e = f.Write(append(b, '\n')); e == nil {
e = f.Sync()
}
if closeErr := f.Close(); e == nil {
e = closeErr
}
if e != nil {
return e
}
if e = os.Rename(name, s.path); 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")
}
b, _, e := wire.Varint(f.Value, 2)
if e != nil {
return errors.New("deck: deck costume")
}
c, cok, e := wire.Varint(f.Value, 3)
if e != nil || !cok || c == 0 {
return errors.New("deck: deck slot")
}
out = append(out, DeckEntry{a, b, c})
return nil
})
if e != nil {
return nil, e
}
if len(out) == 0 {
return nil, errors.New("deck: empty deck")
}
seen := map[uint64]bool{}
for _, x := range out {
if seen[x.Slot] {
return nil, errors.New("deck: duplicate deck slot")
}
seen[x.Slot] = true
}
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, costumeOK, err := wire.Varint(f.Value, 3)
if err != nil || !costumeOK || costume == 0 {
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)
}
// 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 "/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":
x, e := triples(req)
if e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
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":
x, e := fieldEntries(req)
if e != nil {
return 0, nil, true, e
}
s.mu.Lock()
defer s.mu.Unlock()
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
}