fix(server): preserve chosen story parties and validate fixed tactics decks
This commit is contained in:
@@ -1101,10 +1101,6 @@ func serve(args []string) (serveErr error) {
|
||||
}
|
||||
return 0
|
||||
})
|
||||
eventActionsService.AttachOwnedCharacter(func(index, id uint64) bool {
|
||||
c, ok := worldService.CharacterService().Find(index)
|
||||
return ok && c.ID == id
|
||||
})
|
||||
eventActionsService.AttachChargeInfo(func() ([]byte, error) {
|
||||
rows, err := eventEconomy.ChargeInfo()
|
||||
if err != nil {
|
||||
|
||||
@@ -22,22 +22,3 @@ func (s *Store) visibleFieldDeckLocked() []FieldEntry {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetStoryParty stores the authored active quest formation using the same
|
||||
// character ownership checks as DeckSave. Empty formation keeps player choice.
|
||||
func (s *Store) SetStoryParty(entries []DeckEntry) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := validDeck(entries); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.validateOwnedDeckLocked(entries); err != nil {
|
||||
return err
|
||||
}
|
||||
next := clone(s.state)
|
||||
next.Deck = append([]DeckEntry(nil), entries...)
|
||||
return s.commit(next)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ func (s *Service) EnterBattle(req []byte, receipt string) ([]byte, error) {
|
||||
if uid == 0 {
|
||||
return nil, errors.New("eventactions: tactics event inactive")
|
||||
}
|
||||
if _, err := s.tacticsBlueParty(row.V(1)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The client sends BattleEnter before the new scene's DeckSave. Its
|
||||
// battle reset clears the prior stage's virtual party; do the same here.
|
||||
s.state.Deck = nil
|
||||
s.state.BattleUID, s.state.BattleStage, s.state.BattleDeck = uid, stage, deck
|
||||
s.state.Receipts[s.session+":enter:"+receipt] = receiptRecord(nil, digest)
|
||||
if e := s.save(); e != nil {
|
||||
|
||||
@@ -60,7 +60,6 @@ type Service struct {
|
||||
session string
|
||||
friendship func(uint64) uint64
|
||||
chargeInfo func() ([]byte, error)
|
||||
owned func(uint64, uint64) bool
|
||||
progress func(uint64, uint64, uint64) error
|
||||
voteTotals func(uint64, uint64) (map[uint64]uint64, error)
|
||||
miniContent MiniContentResolver
|
||||
@@ -134,7 +133,6 @@ func (s *Service) init() {
|
||||
func (s *Service) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
|
||||
func (s *Service) AttachFriendshipLevel(f func(uint64) uint64) { s.friendship = f }
|
||||
func (s *Service) AttachChargeInfo(f func() ([]byte, error)) { s.chargeInfo = f }
|
||||
func (s *Service) AttachOwnedCharacter(f func(uint64, uint64) bool) { s.owned = f }
|
||||
func (s *Service) AttachProgress(f func(uint64, uint64, uint64) error) { s.progress = f }
|
||||
|
||||
// The current server owns one account save. Its real votes are the default
|
||||
@@ -522,26 +520,9 @@ func (s *Service) handle(path string, b []byte, identity string) ([]byte, error)
|
||||
}
|
||||
return out, nil
|
||||
case "/TacticsBingoDeckSave":
|
||||
seen := map[uint64]bool{}
|
||||
count := 0
|
||||
err := wire.Walk(b, func(f wire.Field) error {
|
||||
if f.Number != 2 || f.Type != 2 {
|
||||
return nil
|
||||
}
|
||||
idx, id := val(f.Value, 1), val(f.Value, 2)
|
||||
if idx == 0 || id == 0 || seen[idx] || s.owned == nil || !s.owned(idx, id) || val(f.Value, 4) > 14 || val(f.Value, 5) == 0 {
|
||||
return errors.New("eventactions: invalid tactics deck")
|
||||
}
|
||||
seen[idx] = true
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if err := s.validateTacticsDeck(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 5 {
|
||||
return nil, errors.New("eventactions: tactics deck too large")
|
||||
}
|
||||
s.state.Deck = append([]byte(nil), b...)
|
||||
return nil, nil
|
||||
default:
|
||||
|
||||
@@ -31,6 +31,9 @@ func setup(t *testing.T) (*Service, *economyStub, stateio.Store) {
|
||||
}
|
||||
d := &gamedata.EventActionsDesign{Tables: map[string][]gamedata.EventActionRow{"VotingEventTable": {row(map[int]uint64{6: 1, 10: 1, 14: 1, 7: 1, 8: 6001, 9: 8, 1: 1, 2: 6002, 3: 8})}, "VotingCandidateTable": {row(map[int]uint64{1: 1, 2: 9})}, "VotingRoundTable": {row(map[int]uint64{2: 1, 3: 1})}, "TacticsBingoGroupTable": {row(map[int]uint64{3: 1, 1: 5, 4: 2})}, "TacticsBingoTable": {row(map[int]uint64{4: 5, 5: 1, 2: 99})}, "FieldSpawnEventTable": {row(map[int]uint64{4: 1, 5: 1, 2: 1})}, "FieldEventMonsterTable": {row(map[int]uint64{3: 1, 4: 1, 1: 3001, 5: 3009})}, "FieldEventDefaultTable": {row(map[int]uint64{4: 10, 5: 2, 12: 40})}}, SpawnRewards: map[[2]uint64]gamedata.Reward{{3009, 3001}: {Type: 4, Count: 10000}}}
|
||||
economy := &economyStub{}
|
||||
d.Tables["TacticsBingoTable"][0].Values[1] = 50
|
||||
d.Tables["CharGroupTable"] = []gamedata.EventActionRow{row(map[int]uint64{1: 30011, 2: 50, 3: 1, 5: 1}), row(map[int]uint64{1: 30022, 2: 50, 3: 2, 5: 1})}
|
||||
d.Tables["CharTable"] = []gamedata.EventActionRow{row(map[int]uint64{12: 30011, 5: 30101}), row(map[int]uint64{12: 30022, 5: 30201})}
|
||||
d.Tables["FieldSpawnEventTable"][0].Text = map[int]string{7: "12:00:00", 1: "12:10:00"}
|
||||
store := stateio.NewMemory()
|
||||
s, e := Open(store, d, r, economy)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package eventactions
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// BattlePlayLibrary gives TacticsBingo the standard 3 by 4 grid and five
|
||||
// characters. Positions are zero-based; DeckSave sequences start at one.
|
||||
const tacticsGridSize = 3 * 4
|
||||
const tacticsPartySize = 5
|
||||
|
||||
type tacticsDeckEntry struct {
|
||||
index, character, costume, position, sequence uint64
|
||||
}
|
||||
|
||||
func decodeTacticsDeck(request []byte) ([]tacticsDeckEntry, error) {
|
||||
var entries []tacticsDeckEntry
|
||||
indices, characters, positions, sequences := map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{}, map[uint64]bool{}
|
||||
err := wire.Walk(request, func(f wire.Field) error {
|
||||
if f.Number != 2 {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 2 {
|
||||
return errors.New("eventactions: malformed tactics deck entry")
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
if err := wire.Walk(f.Value, func(v wire.Field) error {
|
||||
if v.Number >= 1 && v.Number <= 5 {
|
||||
if v.Type != 0 || seen[v.Number] {
|
||||
return errors.New("eventactions: malformed tactics deck field")
|
||||
}
|
||||
seen[v.Number] = true
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
e := tacticsDeckEntry{val(f.Value, 1), val(f.Value, 2), val(f.Value, 3), val(f.Value, 4), val(f.Value, 5)}
|
||||
// These indices are client-generated virtual identities, not owned
|
||||
// inventory. Still enforce their signed protocol range and uniqueness.
|
||||
if e.index == 0 || e.index > math.MaxInt64 || e.character == 0 || e.character > math.MaxInt32 || e.costume == 0 || e.costume > math.MaxInt32 || e.position >= tacticsGridSize || e.sequence == 0 || e.sequence > tacticsPartySize || indices[e.index] || characters[e.character] || positions[e.position] || sequences[e.sequence] {
|
||||
return errors.New("eventactions: invalid tactics deck")
|
||||
}
|
||||
indices[e.index], characters[e.character], positions[e.position], sequences[e.sequence] = true, true, true, true
|
||||
entries = append(entries, e)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(entries) == 0 || len(entries) > tacticsPartySize {
|
||||
return nil, errors.New("eventactions: invalid tactics deck size")
|
||||
}
|
||||
for sequence := uint64(1); sequence <= uint64(len(entries)); sequence++ {
|
||||
if !sequences[sequence] {
|
||||
return nil, errors.New("eventactions: invalid tactics deck sequence")
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// SetPlayDataTarosTactics builds the first five authored CharGroup rows with
|
||||
// their CharTable default costumes; player ownership never enters this path.
|
||||
func (s *Service) tacticsBlueParty(group uint64) (map[uint64]uint64, error) {
|
||||
var rows []gamedata.EventActionRow
|
||||
for _, row := range s.design.Tables["CharGroupTable"] {
|
||||
if group != 0 && row.V(2) == group {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].V(3) < rows[j].V(3) })
|
||||
if len(rows) > tacticsPartySize {
|
||||
rows = rows[:tacticsPartySize]
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, errors.New("eventactions: tactics blue party missing")
|
||||
}
|
||||
party := map[uint64]uint64{}
|
||||
for _, row := range rows {
|
||||
id := row.V(1)
|
||||
character, ok := s.design.Row("CharTable", 12, id)
|
||||
if id == 0 || party[id] != 0 || row.V(5) == 0 || !ok || character.V(5) == 0 {
|
||||
return nil, errors.New("eventactions: invalid tactics blue party design")
|
||||
}
|
||||
party[id] = character.V(5)
|
||||
}
|
||||
return party, nil
|
||||
}
|
||||
|
||||
func matchesTacticsParty(entries []tacticsDeckEntry, party map[uint64]uint64) bool {
|
||||
if len(entries) != len(party) {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if party[e.character] != e.costume {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) validateTacticsDeck(request []byte) error {
|
||||
entries, err := decodeTacticsDeck(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The request only carries seq and deck_info. The client normally enters
|
||||
// the stage before saving; bind the saved party to that server context.
|
||||
if s.state.BattleUID != 0 {
|
||||
event, err := s.resolve(s.state.BattleUID, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
group, ok := s.design.Row("TacticsBingoGroupTable", 3, event.ID)
|
||||
stage, found := s.row("TacticsBingoTable", 4, 5, group.V(1), s.state.BattleStage)
|
||||
if !ok || !found || stage.V(2) != s.state.BattleDeck {
|
||||
return errors.New("eventactions: tactics battle context missing")
|
||||
}
|
||||
party, err := s.tacticsBlueParty(stage.V(1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matchesTacticsParty(entries, party) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("eventactions: tactics deck does not match entered stage")
|
||||
}
|
||||
// Without a bound stage, accept only a complete authored party belonging
|
||||
// to a currently active tactics schedule, never an arbitrary mixed roster.
|
||||
for _, event := range s.registry.List() {
|
||||
if event.Type != 20 || !s.active(event) {
|
||||
continue
|
||||
}
|
||||
group, ok := s.design.Row("TacticsBingoGroupTable", 3, event.ID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, stage := range s.design.Tables["TacticsBingoTable"] {
|
||||
if stage.V(4) != group.V(1) {
|
||||
continue
|
||||
}
|
||||
party, err := s.tacticsBlueParty(stage.V(1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if matchesTacticsParty(entries, party) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.New("eventactions: tactics deck has no active authored party")
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package eventactions
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInstalledTacticsBlueParties23510(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
design, err := gamedata.LoadEventActionsDesign(root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Service{design: design}
|
||||
for _, stage := range design.Tables["TacticsBingoTable"] {
|
||||
party, err := s.tacticsBlueParty(stage.V(1))
|
||||
if err != nil {
|
||||
t.Fatalf("group %d stage %d: %v", stage.V(4), stage.V(5), err)
|
||||
}
|
||||
entries := make([]tacticsDeckEntry, 0, len(party))
|
||||
for id, costume := range party {
|
||||
order := uint64(len(entries) + 1)
|
||||
entries = append(entries, tacticsDeckEntry{1000000 + order, id, costume, order - 1, order})
|
||||
}
|
||||
decoded, err := decodeTacticsDeck(tacticsSave(1, entries...))
|
||||
if err != nil || !matchesTacticsParty(decoded, party) {
|
||||
t.Fatalf("group %d stage %d could not save its authored virtual party: %v", stage.V(4), stage.V(5), err)
|
||||
}
|
||||
}
|
||||
// GameData query: group 1 stage 1 has three Lv1 companions and these
|
||||
// CharTable defaults, distinct from ordinary account costume identities.
|
||||
party, err := s.tacticsBlueParty(2006301)
|
||||
if err != nil || len(party) != 3 || party[30011] != 300110 || party[30022] != 300220 || party[30141] != 301410 {
|
||||
t.Fatalf("installed fixed tutorial party mismatch: %v, %v", party, err)
|
||||
}
|
||||
}
|
||||
|
||||
func tacticsSave(seq uint64, entries ...tacticsDeckEntry) []byte {
|
||||
b := req(seq)
|
||||
for _, e := range entries {
|
||||
var body []byte
|
||||
for i, value := range []uint64{e.index, e.character, e.costume, e.position, e.sequence} {
|
||||
body = wire.AppendVarint(body, i+1, value)
|
||||
}
|
||||
b = wire.AppendBytes(b, 2, body)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func tacticsEnter(seq, stage, deck uint64) []byte {
|
||||
b := req(seq)
|
||||
for i, value := range map[int]uint64{4: deck, 5: 29, 8: 5, 9: stage} {
|
||||
b = wire.AppendVarint(b, i, value)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func testTacticsEntries() []tacticsDeckEntry {
|
||||
return []tacticsDeckEntry{{900001, 30011, 30101, 0, 1}, {900002, 30022, 30201, 11, 2}}
|
||||
}
|
||||
|
||||
func TestTacticsVirtualPartySavePersistsAndBindsEnteredStage(t *testing.T) {
|
||||
s, economy, store := setup(t)
|
||||
// A second valid authored party proves that matching any individual
|
||||
// available character is insufficient once a specific stage is entered.
|
||||
s.design.Tables["TacticsBingoTable"] = append(s.design.Tables["TacticsBingoTable"], row(map[int]uint64{1: 51, 2: 100, 4: 5, 5: 2}))
|
||||
s.design.Tables["CharGroupTable"] = append(s.design.Tables["CharGroupTable"], row(map[int]uint64{1: 30033, 2: 51, 3: 1, 5: 1}))
|
||||
s.design.Tables["CharTable"] = append(s.design.Tables["CharTable"], row(map[int]uint64{12: 30033, 5: 30301}))
|
||||
first := tacticsSave(1, testTacticsEntries()...)
|
||||
if _, _, _, err := s.Handle("/TacticsBingoDeckSave", first); err != nil {
|
||||
t.Fatalf("virtual system party rejected without player ownership: %v", err)
|
||||
}
|
||||
next, err := Open(store, s.design, s.registry, economy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next.now = s.now
|
||||
next.BeginSession("test")
|
||||
if !bytes.Equal(next.state.Deck, first) {
|
||||
t.Fatal("special deck was not persisted")
|
||||
}
|
||||
if _, _, _, err := next.Handle("/TacticsBingoDeckSave", first); err != nil {
|
||||
t.Fatalf("exact save replay rejected: %v", err)
|
||||
}
|
||||
if _, err := next.EnterBattle(tacticsEnter(2, 2, 100), "enter2"); err != nil {
|
||||
t.Fatalf("previous stage deck blocked normal stage entry: %v", err)
|
||||
}
|
||||
if len(next.state.Deck) != 0 {
|
||||
t.Fatal("previous scene virtual deck survived new entry")
|
||||
}
|
||||
wrongStage := tacticsSave(3, testTacticsEntries()...)
|
||||
if _, _, _, err := next.Handle("/TacticsBingoDeckSave", wrongStage); err == nil {
|
||||
t.Fatal("another stage's otherwise legal party accepted")
|
||||
}
|
||||
second := tacticsSave(4, tacticsDeckEntry{900003, 30033, 30301, 4, 1})
|
||||
if _, _, _, err := next.Handle("/TacticsBingoDeckSave", second); err != nil {
|
||||
t.Fatalf("bound stage's system party rejected: %v", err)
|
||||
}
|
||||
end := wire.AppendVarint(req(5), 2, 1)
|
||||
if _, err := next.CompleteBattle(end, "end5"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if economy.calls != 0 || len(next.state.Tactics[2]) != 1 || next.state.Tactics[2][0] != 2 {
|
||||
t.Fatal("special deck changed ordinary economy or stage settlement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTacticsDeckRejectsForgedOrIncompleteSystemParties(t *testing.T) {
|
||||
valid := testTacticsEntries()
|
||||
tests := []struct {
|
||||
name string
|
||||
change func([]tacticsDeckEntry) []tacticsDeckEntry
|
||||
}{
|
||||
{"empty", func(e []tacticsDeckEntry) []tacticsDeckEntry { return nil }},
|
||||
{"missing companion", func(e []tacticsDeckEntry) []tacticsDeckEntry { return e[:1] }},
|
||||
{"invented character", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].character = 55555; return e }},
|
||||
{"wrong costume", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].costume++; return e }},
|
||||
{"duplicate character", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].character = e[0].character; return e }},
|
||||
{"duplicate virtual index", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].index = e[0].index; return e }},
|
||||
{"negative virtual index", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].index = math.MaxUint64; return e }},
|
||||
{"overlapping positions", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].position = e[0].position; return e }},
|
||||
{"unassigned position", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].position = math.MaxUint64; return e }},
|
||||
{"outside standard grid", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].position = 12; return e }},
|
||||
{"duplicate order", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].sequence = e[0].sequence; return e }},
|
||||
{"missing order", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[0].sequence = 0; return e }},
|
||||
{"order gap", func(e []tacticsDeckEntry) []tacticsDeckEntry { e[1].sequence = 3; return e }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, _, _ := setup(t)
|
||||
accepted := tacticsSave(1, valid...)
|
||||
if _, _, _, err := s.Handle("/TacticsBingoDeckSave", accepted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := tacticsSave(2, tt.change(append([]tacticsDeckEntry(nil), valid...))...)
|
||||
if _, _, _, err := s.Handle("/TacticsBingoDeckSave", bad); err == nil {
|
||||
t.Fatal("forged deck accepted")
|
||||
}
|
||||
if !bytes.Equal(s.state.Deck, accepted) {
|
||||
t.Fatal("failed validation changed saved special deck")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTacticsDeckRejectsInactiveEventAndMixedAuthoredGroups(t *testing.T) {
|
||||
s, _, _ := setup(t)
|
||||
s.design.Tables["TacticsBingoTable"] = append(s.design.Tables["TacticsBingoTable"], row(map[int]uint64{1: 51, 2: 100, 4: 5, 5: 2}))
|
||||
s.design.Tables["CharGroupTable"] = append(s.design.Tables["CharGroupTable"], row(map[int]uint64{1: 30033, 2: 51, 3: 1, 5: 1}), row(map[int]uint64{1: 30044, 2: 51, 3: 2, 5: 1}))
|
||||
s.design.Tables["CharTable"] = append(s.design.Tables["CharTable"], row(map[int]uint64{12: 30033, 5: 30301}), row(map[int]uint64{12: 30044, 5: 30401}))
|
||||
mixed := testTacticsEntries()
|
||||
mixed[1].character, mixed[1].costume = 30033, 30301
|
||||
if _, _, _, err := s.Handle("/TacticsBingoDeckSave", tacticsSave(1, mixed...)); err == nil {
|
||||
t.Fatal("mixed characters from different authored parties accepted")
|
||||
}
|
||||
s.now = func() time.Time { return time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) }
|
||||
if _, _, _, err := s.Handle("/TacticsBingoDeckSave", tacticsSave(2, testTacticsEntries()...)); err == nil {
|
||||
t.Fatal("expired event system party accepted")
|
||||
}
|
||||
if len(s.state.Deck) != 0 {
|
||||
t.Fatal("invalid saves changed deck")
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func LoadEventActionsDesign(root, version string) (*EventActionsDesign, error) {
|
||||
}
|
||||
defer done()
|
||||
d := &EventActionsDesign{Tables: map[string][]EventActionRow{}, SpawnRewards: map[[2]uint64]Reward{}}
|
||||
for _, table := range []string{"FieldEventDefaultTable", "FieldSpawnEventTable", "FieldEventMonsterTable", "FireworksTable", "VotingEventTable", "VotingCandidateTable", "VotingRoundTable", "VotingCountRewardTable", "VotingSeasonTable", "FriendshipSpecialEpisodeTable", "NpcQuizTable", "TacticsBingoGroupTable", "TacticsBingoTable", "CafeteriaDefaultTable", "CafeteriaEventTable"} {
|
||||
for _, table := range []string{"FieldEventDefaultTable", "FieldSpawnEventTable", "FieldEventMonsterTable", "FireworksTable", "VotingEventTable", "VotingCandidateTable", "VotingRoundTable", "VotingCountRewardTable", "VotingSeasonTable", "FriendshipSpecialEpisodeTable", "NpcQuizTable", "TacticsBingoGroupTable", "TacticsBingoTable", "CharGroupTable", "CharTable", "CafeteriaDefaultTable", "CafeteriaEventTable"} {
|
||||
rows, e := db.Query("SELECT ProtoBuf FROM " + table)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
|
||||
@@ -8,7 +8,8 @@ type StoryCharacterDesign struct {
|
||||
InitialTalentLevel uint64
|
||||
}
|
||||
|
||||
// StoryCharacterCatalog contains only authored temporary character rows.
|
||||
// StoryCharacterCatalog contains available temporary pack characters, not a
|
||||
// mandatory battle formation for ordinary story or master-story quests.
|
||||
type StoryCharacterCatalog struct {
|
||||
formations map[int]map[int]QuestFormation
|
||||
characters map[[2]int][]StoryCharacterDesign
|
||||
@@ -81,8 +82,8 @@ func LoadStoryCharacterCatalog(root, version string, packs []int, placeholderCos
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// CharGroup supplies battle instances. StoryCharGroup supplies
|
||||
// cosmetic field actors and cannot override a battle costume.
|
||||
// CharGroup supplies available temporary instances. StoryCharGroup
|
||||
// supplies cosmetic field actors and cannot override their costume.
|
||||
base, err := stats.BaseStats(row.CharacterID, row.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -72,7 +72,7 @@ func (s *Service) fieldBuffTargets(pack int, target uint64) ([]uint64, error) {
|
||||
var indices []uint64
|
||||
if s.questDifficulty(pack) == 0 && s.storyRoster != nil {
|
||||
if quest := s.firstUnclearedQuestFor(pack); quest != 0 {
|
||||
party, err := s.ResolveStoryParty(pack, quest)
|
||||
party, err := s.currentBattleParty()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,27 +1,43 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// A normal battle deck has five character slots (CommonPacket and DeckSave).
|
||||
const storyBattlePartySize = 5
|
||||
// currentBattleParty reads the player's chosen battle party without changing
|
||||
// membership or positions. Ordinary story quests do not force their temporary
|
||||
// guests into battle; fixed-character modes own their separate battle decks.
|
||||
func (s *Service) currentBattleParty() ([]player.Character, error) {
|
||||
if s.decks == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var party []player.Character
|
||||
for _, entry := range s.decks.CurrentDeck() {
|
||||
if s.characters == nil {
|
||||
return nil, fmt.Errorf("world: battle character store unavailable")
|
||||
}
|
||||
c, found := s.characters.Find(entry.CharacterInvenIndex)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("world: saved battle character %d unavailable", entry.CharacterInvenIndex)
|
||||
}
|
||||
party = append(party, c)
|
||||
}
|
||||
return party, nil
|
||||
}
|
||||
|
||||
// ResolveStoryParty applies the authored temporary CharGroup members to the
|
||||
// saved battle party. StoryCharGroup costume designs only describe the field
|
||||
// story cast: FieldDeckPacket builds their cosmetic actors without inventory
|
||||
// identities, so they do not reserve battle slots, including placeholders.
|
||||
func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, error) {
|
||||
// resolveStoryCharacters provisions authored temporary guests for the pack's
|
||||
// character and talent UI. Availability is independent of battle selection;
|
||||
// StoryCharGroup supplies cosmetic field actors, not inventory identities.
|
||||
func (s *Service) resolveStoryCharacters(packID, questID int) ([]player.Character, error) {
|
||||
if s.storyRoster == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if s.characters == nil {
|
||||
return nil, fmt.Errorf("world: story character store unavailable")
|
||||
}
|
||||
formation, ok := s.storyRoster.Formation(packID, questID)
|
||||
_, ok := s.storyRoster.Formation(packID, questID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("world: missing formation pack%d quest%d", packID, questID)
|
||||
}
|
||||
@@ -52,9 +68,6 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er
|
||||
authoredOrder = append(authoredOrder, index)
|
||||
temporary = append(temporary, player.Character{InvenIndex: index, ID: d.CharacterID, HP: d.HP, Level: d.Level, CostumeID: d.CostumeID, TalentLevel: d.InitialTalentLevel})
|
||||
}
|
||||
if len(designs) > storyBattlePartySize {
|
||||
return nil, fmt.Errorf("world: pack%d quest%d character group%d exceeds battle capacity: %d", packID, questID, formation.CharGroupID, len(designs))
|
||||
}
|
||||
if err := s.characters.EnsureStoryCharacters(temporary); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -73,97 +86,27 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er
|
||||
}
|
||||
authored[current.InvenIndex] = current
|
||||
}
|
||||
owned := map[uint64]player.Character{}
|
||||
for _, c := range s.visibleOwnedCharacters(s.characters.All()) {
|
||||
banned := false
|
||||
for _, row := range formation.Characters {
|
||||
// PackManager.CalcBanCharUniqueId groups growth rows by ID/10.
|
||||
if row.Banned && row.CharacterID/10 == c.ID/10 {
|
||||
banned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if banned {
|
||||
continue
|
||||
}
|
||||
owned[c.InvenIndex] = c
|
||||
}
|
||||
var candidates []uint64
|
||||
if s.decks != nil {
|
||||
for _, entry := range s.decks.CurrentDeck() {
|
||||
candidates = append(candidates, entry.CharacterInvenIndex)
|
||||
}
|
||||
// With no saved battle choice, the account's field party supplies its
|
||||
// initial controlled members. A saved battle choice takes precedence.
|
||||
if len(candidates) == 0 {
|
||||
for _, entry := range s.decks.CurrentFieldDeck() {
|
||||
candidates = append(candidates, entry.CharacterInvenIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
result := make([]player.Character, 0, storyBattlePartySize)
|
||||
used := map[uint64]bool{}
|
||||
controlledSlots := storyBattlePartySize - len(authored)
|
||||
for _, index := range candidates {
|
||||
if used[index] {
|
||||
continue
|
||||
}
|
||||
if c, ok := authored[index]; ok {
|
||||
result = append(result, c)
|
||||
used[index] = true
|
||||
continue
|
||||
}
|
||||
if c, ok := owned[index]; ok && controlledSlots > 0 {
|
||||
result = append(result, c)
|
||||
used[index] = true
|
||||
controlledSlots--
|
||||
}
|
||||
}
|
||||
result := make([]player.Character, 0, len(authoredOrder))
|
||||
for _, index := range authoredOrder {
|
||||
if !used[index] {
|
||||
result = append(result, authored[index])
|
||||
used[index] = true
|
||||
}
|
||||
result = append(result, authored[index])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]byte, error) {
|
||||
party, err := s.ResolveStoryParty(packID, questID)
|
||||
party, err := s.resolveStoryCharacters(packID, questID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(party) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
formation, _ := s.storyRoster.Formation(packID, questID)
|
||||
entries := make([]deck.DeckEntry, 0, len(party))
|
||||
characters := make([][]byte, 0, len(party))
|
||||
wires := make([][]byte, 0, len(party))
|
||||
savedPositions := map[uint64]uint64{}
|
||||
for _, c := range party {
|
||||
characters = append(characters, encodeCharacter(c))
|
||||
}
|
||||
var wires [][]byte
|
||||
if s.decks != nil {
|
||||
for _, entry := range s.decks.CurrentDeck() {
|
||||
savedPositions[entry.CharacterInvenIndex] = entry.CostumeInvenIndex
|
||||
}
|
||||
}
|
||||
for i, c := range party {
|
||||
position := ^uint64(0)
|
||||
if i < len(formation.DeckList) {
|
||||
position = formation.DeckList[i]
|
||||
} else if len(formation.DeckList) == 0 {
|
||||
if saved, ok := savedPositions[c.InvenIndex]; ok {
|
||||
position = saved
|
||||
}
|
||||
}
|
||||
entry := deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: position, Slot: uint64(i + 1)}
|
||||
entries = append(entries, entry)
|
||||
characters = append(characters, encodeCharacter(c))
|
||||
data := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, c.InvenIndex), 2, position), 3, uint64(i+1))
|
||||
wires = append(wires, data)
|
||||
}
|
||||
if s.decks != nil {
|
||||
if err := s.decks.SetStoryParty(entries); err != nil {
|
||||
return nil, nil, fmt.Errorf("world: save battle party pack%d quest%d charGroup%d storyCharGroup%d members%d: %w", packID, questID, formation.CharGroupID, formation.StoryCharGroupID, len(entries), err)
|
||||
data := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, entry.CharacterInvenIndex), 2, entry.CostumeInvenIndex), 3, entry.Slot)
|
||||
wires = append(wires, data)
|
||||
}
|
||||
}
|
||||
return characters, wires, nil
|
||||
|
||||
@@ -14,10 +14,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CharTable10140 is the temporary blacksmith joining pack1 quest11.
|
||||
// Source: GameData 20260923193640 CharTable10140 is the temporary blacksmith
|
||||
// joining pack1 quest11, and Proto/Net/CharDBInfo supplies talent_level field8.
|
||||
// Its TalentSkillTable group901 has row1 only; omitted field8 defaults to zero
|
||||
// in the client and causes a lookup of the nonexistent group901/row0.
|
||||
func TestInstalledStoryPartyInitialTalentPersistsAndEncodes(t *testing.T) {
|
||||
func TestInstalledStoryCharactersInitialTalentPersistsAndEncodes(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
@@ -50,7 +51,7 @@ func TestInstalledStoryPartyInitialTalentPersistsAndEncodes(t *testing.T) {
|
||||
return storage, s
|
||||
}
|
||||
storage, s := open()
|
||||
party, err := s.ResolveStoryParty(1, 11)
|
||||
party, err := s.resolveStoryCharacters(1, 11)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -63,6 +64,11 @@ func TestInstalledStoryPartyInitialTalentPersistsAndEncodes(t *testing.T) {
|
||||
if joined.InvenIndex == 0 || joined.TalentLevel != 1 {
|
||||
t.Fatalf("temporary blacksmith has invalid initial talent: %+v", joined)
|
||||
}
|
||||
before := s.characters.RawAll()
|
||||
repeated, err := s.resolveStoryCharacters(1, 11)
|
||||
if err != nil || !reflect.DeepEqual(repeated, party) || !reflect.DeepEqual(s.characters.RawAll(), before) {
|
||||
t.Fatalf("repeated temporary resolution changed identity or character state: err=%v", err)
|
||||
}
|
||||
if err = s.characters.SetCurrentHealth(joined.InvenIndex, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -70,10 +76,13 @@ func TestInstalledStoryPartyInitialTalentPersistsAndEncodes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, s = open()
|
||||
characters, _, err := s.resolveActivePartyWires(1, 11)
|
||||
characters, decks, err := s.resolveActivePartyWires(1, 11)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decks) != 0 {
|
||||
t.Fatal("temporary talent character fabricated an unsaved battle deck")
|
||||
}
|
||||
for _, data := range characters {
|
||||
id, _, err := wire.Varint(data, 2)
|
||||
if err != nil {
|
||||
@@ -95,9 +104,9 @@ func TestInstalledStoryPartyInitialTalentPersistsAndEncodes(t *testing.T) {
|
||||
t.Fatal("temporary blacksmith omitted from party CharDBInfo")
|
||||
}
|
||||
|
||||
// Quest13 and quest14 share CharGroup102, but their field StoryCharGroup grows
|
||||
// from three to four costume designs. Owning the whole field cast used to add
|
||||
// a sixth battle member and roll back this otherwise completed quest clear.
|
||||
// Source: GameData 20260923193640 QuestTable1 quests 13..16 use CharGroup102
|
||||
// while their cosmetic StoryCharGroup changes. The saved five-player party
|
||||
// and positions below implement the user's policy for ordinary story battles.
|
||||
func TestInstalledQuest13ClearKeepsSavedBattlePartyAcrossReopen(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
@@ -116,10 +125,20 @@ func TestInstalledQuest13ClearKeepsSavedBattlePartyAcrossReopen(t *testing.T) {
|
||||
{InvenIndex: 102, ID: 20, HP: 72, Level: 30, CostumeID: 202, UseCostume: 1002, TalentLevel: 1, TalentExp: 14, Exp: 124},
|
||||
{InvenIndex: 103, ID: 130, HP: 73, Level: 30, CostumeID: 1301, UseCostume: 1003, TalentLevel: 1, TalentExp: 15, Exp: 125},
|
||||
{InvenIndex: 104, ID: 140, HP: 74, Level: 30, CostumeID: 1401, UseCostume: 1004, TalentLevel: 1},
|
||||
{InvenIndex: 105, ID: 350, HP: 75, Level: 30, CostumeID: 3501, UseCostume: 1005, TalentLevel: 1},
|
||||
}, Costumes: []player.Costume{
|
||||
{InvenIndex: 1001, ID: 101, UseChar: 101}, {InvenIndex: 1002, ID: 202, UseChar: 102},
|
||||
{InvenIndex: 1003, ID: 1301, UseChar: 103}, {InvenIndex: 1004, ID: 1401, UseChar: 104},
|
||||
{InvenIndex: 1005, ID: 3501, UseChar: 105},
|
||||
}}
|
||||
// Inventory order, wire order, slot sequence, and grid position are distinct.
|
||||
saved := []deck.DeckEntry{
|
||||
{CharacterInvenIndex: 105, CostumeInvenIndex: 0, Slot: 3},
|
||||
{CharacterInvenIndex: 103, CostumeInvenIndex: 11, Slot: 1},
|
||||
{CharacterInvenIndex: 101, CostumeInvenIndex: 3, Slot: 5},
|
||||
{CharacterInvenIndex: 104, CostumeInvenIndex: ^uint64(0), Slot: 2},
|
||||
{CharacterInvenIndex: 102, CostumeInvenIndex: 8, Slot: 4},
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
open := func() (*accountstate.Repository, *Service) {
|
||||
t.Helper()
|
||||
@@ -154,81 +173,76 @@ func TestInstalledQuest13ClearKeepsSavedBattlePartyAcrossReopen(t *testing.T) {
|
||||
return repo, s
|
||||
}
|
||||
repo, s := open()
|
||||
saveStoryTestDeck(t, s.decks, saved)
|
||||
for id := 1; id < 13; id++ {
|
||||
if err := s.state.ClearQuest(id, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
update := wire.AppendVarint(selectionRequest(13, 1, 0), 4, 1)
|
||||
if _, err := s.state.UpdateQuest(update); err != nil {
|
||||
if _, err := s.state.UpdateQuest(wire.AppendVarint(selectionRequest(13, 1, 0), 4, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
party, err := s.ResolveStoryParty(1, 13)
|
||||
if err != nil || len(party) != 3 { // Two temporaries and the only initial field choice.
|
||||
t.Fatalf("initial story party=%+v err=%v", party, err)
|
||||
beforeResolve := s.characters.RawAll()
|
||||
assertStoryTestBattleParty(t, s, saved)
|
||||
if !reflect.DeepEqual(s.characters.RawAll(), beforeResolve) {
|
||||
t.Fatal("battle party resolution materialized temporary characters")
|
||||
}
|
||||
var saved []deck.DeckEntry
|
||||
for _, c := range party {
|
||||
if !player.IsStoryCharacter(c) {
|
||||
continue
|
||||
}
|
||||
if err := s.characters.SetCurrentHealth(c.InvenIndex, 7+uint64(len(saved))); err != nil {
|
||||
temporary, err := s.resolveStoryCharacters(1, 13)
|
||||
if err != nil || len(temporary) == 0 {
|
||||
t.Fatalf("quest13 temporary talent characters=%+v err=%v", temporary, err)
|
||||
}
|
||||
for i, c := range temporary {
|
||||
if err := s.characters.SetCurrentHealth(c.InvenIndex, uint64(7+i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved = append(saved, deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: uint64(len(saved) * 2), Slot: uint64(len(saved) + 1)})
|
||||
}
|
||||
for _, c := range seed.Characters[:3] {
|
||||
saved = append(saved, deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: uint64(len(saved) * 2), Slot: uint64(len(saved) + 1)})
|
||||
}
|
||||
if err := s.decks.SetStoryParty(saved); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := s.characters.RawAll()
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, response, handled, err := s.Handle("/QuestClear", selectionRequest(13, 1, 0))
|
||||
if err != nil || !handled {
|
||||
_ = op.Rollback()
|
||||
t.Fatalf("quest13 clear handled=%v err=%v", handled, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var echoed []deck.DeckEntry
|
||||
var joins int
|
||||
if err := wire.Walk(response, func(f wire.Field) error {
|
||||
if f.Number == 5 {
|
||||
joins++
|
||||
for id := 13; id <= 15; id++ {
|
||||
chars, decks, err := s.resolveActivePartyWires(1, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Number == 4 {
|
||||
index, _, _ := wire.Varint(f.Value, 1)
|
||||
position, _, _ := wire.Varint(f.Value, 2)
|
||||
slot, _, _ := wire.Varint(f.Value, 3)
|
||||
echoed = append(echoed, deck.DeckEntry{CharacterInvenIndex: index, CostumeInvenIndex: position, Slot: slot})
|
||||
if len(chars) != len(temporary) || !reflect.DeepEqual(decodeStoryTestDeck(t, decks), saved) {
|
||||
t.Fatalf("quest%d displaced a player or moved positions", id)
|
||||
}
|
||||
assertStoryTestBattleParty(t, s, saved)
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, response, handled, err := s.Handle("/QuestClear", selectionRequest(uint64(id), 1, 0))
|
||||
if err != nil || !handled {
|
||||
_ = op.Rollback()
|
||||
t.Fatalf("quest%d clear handled=%v err=%v", id, handled, err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
echoed, joins := storyTestClearParty(t, response)
|
||||
if !reflect.DeepEqual(echoed, saved) || len(joins) != 0 {
|
||||
t.Fatalf("quest%d changed choices/positions or fabricated join UI: deck=%+v want=%+v joins=%v", id, echoed, saved, joins)
|
||||
}
|
||||
assertStoryTestBattleParty(t, s, saved)
|
||||
if !reflect.DeepEqual(s.characters.RawAll(), before) {
|
||||
t.Fatalf("quest%d transition changed character stats, talents, health, or identities", id)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, s = open()
|
||||
if !s.state.QuestCleared(id, 1) || s.firstUnclearedQuestFor(1) != id+1 {
|
||||
t.Fatalf("quest%d clear did not survive SQLite reopen", id)
|
||||
}
|
||||
assertStoryTestBattleParty(t, s, saved)
|
||||
if !reflect.DeepEqual(s.characters.RawAll(), before) {
|
||||
t.Fatalf("quest%d reopen changed character state", id)
|
||||
}
|
||||
return nil
|
||||
}); err != nil || !reflect.DeepEqual(echoed, saved) || joins != 0 {
|
||||
t.Fatalf("clear changed saved battle choices/positions or repeated join UI: deck=%+v want=%+v joins=%d err=%v", echoed, saved, joins, err)
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, s = open()
|
||||
if !s.state.QuestCleared(13, 1) || s.firstUnclearedQuestFor(1) != 14 {
|
||||
t.Fatal("quest13 clear did not survive SQLite reopen")
|
||||
}
|
||||
if !reflect.DeepEqual(s.decks.CurrentDeck(), saved) || !reflect.DeepEqual(s.characters.RawAll(), before) {
|
||||
t.Fatal("quest transition or reopen changed party, positions, character stats, talents, or health")
|
||||
}
|
||||
party, err = s.ResolveStoryParty(1, 14)
|
||||
if err != nil || len(party) != 5 {
|
||||
t.Fatalf("reopened quest14 battle party=%+v err=%v", party, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstalledAdjacentStoryPartyRetainsHealth(t *testing.T) {
|
||||
// Source: installed GameData CharGroup rows choose an adjacent unchanged
|
||||
// temporary cast; the server's instance policy preserves its identity and HP.
|
||||
func TestInstalledAdjacentStoryCharactersRetainHealth(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
@@ -275,7 +289,7 @@ func TestInstalledAdjacentStoryPartyRetainsHealth(t *testing.T) {
|
||||
s := testService()
|
||||
s.characters = characters
|
||||
s.storyRoster = catalog
|
||||
party, err := s.ResolveStoryParty(21, first)
|
||||
party, err := s.resolveStoryCharacters(21, first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -285,7 +299,7 @@ func TestInstalledAdjacentStoryPartyRetainsHealth(t *testing.T) {
|
||||
if err := characters.SetCurrentHealth(party[0].InvenIndex, 7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := s.ResolveStoryParty(21, second)
|
||||
next, err := s.resolveStoryCharacters(21, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// Current client QuestPacket feeds every QuestClear.CharInfo into the join UI.
|
||||
// Exercise the real authored formations through quest clear and SQLite reopen,
|
||||
// including the pack-1 gate quest that used to reopen that UI unnecessarily.
|
||||
// Source: GameData 20260923193640 supplies all main-quest edges and temporary
|
||||
// CharGroup designs; client QuestPacket feeds QuestClear.CharInfo into its join
|
||||
// UI. User policy keeps the saved player battle deck on every ordinary edge.
|
||||
func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
@@ -37,6 +37,7 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
edges, changedEdges := 0, 0
|
||||
for _, pack := range packs {
|
||||
t.Run(fmt.Sprintf("pack%d", pack), func(t *testing.T) {
|
||||
design := catalog.Packs[pack]
|
||||
@@ -49,6 +50,13 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
{InvenIndex: 80, ID: 140, Level: 1, HP: 7, CostumeID: 1401, TalentLevel: 1},
|
||||
{InvenIndex: 81, ID: 350, Level: 1, HP: 7, CostumeID: 3501, TalentLevel: 1},
|
||||
}}
|
||||
chosen := []deck.DeckEntry{
|
||||
{CharacterInvenIndex: 81, CostumeInvenIndex: 0, Slot: 3},
|
||||
{CharacterInvenIndex: 79, CostumeInvenIndex: 11, Slot: 1},
|
||||
{CharacterInvenIndex: 77, CostumeInvenIndex: 3, Slot: 5},
|
||||
{CharacterInvenIndex: 80, CostumeInvenIndex: ^uint64(0), Slot: 2},
|
||||
{CharacterInvenIndex: 78, CostumeInvenIndex: 8, Slot: 4},
|
||||
}
|
||||
open := func() (*accountstate.Repository, *Service) {
|
||||
t.Helper()
|
||||
repo, err := accountstate.Open(path)
|
||||
@@ -74,18 +82,12 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
if err = s.characters.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var chosen []deck.DeckEntry
|
||||
for i, c := range seed.Characters {
|
||||
chosen = append(chosen, deck.DeckEntry{CharacterInvenIndex: c.InvenIndex, CostumeInvenIndex: uint64(i), Slot: uint64(i + 1)})
|
||||
}
|
||||
s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.decks.CurrentDeck()) == 0 {
|
||||
if err = s.decks.SetStoryParty(chosen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saveStoryTestDeck(t, s.decks, chosen)
|
||||
}
|
||||
s.quests = map[int]gamedata.QuestDesign{}
|
||||
for _, id := range ids {
|
||||
@@ -103,7 +105,9 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
if next == 0 {
|
||||
continue
|
||||
}
|
||||
before, err := s.ResolveStoryParty(pack, id)
|
||||
edges++
|
||||
assertStoryTestBattleParty(t, s, chosen)
|
||||
before, err := s.resolveStoryCharacters(pack, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -119,7 +123,7 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
if err = op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := s.ResolveStoryParty(pack, next)
|
||||
after, err := s.resolveStoryCharacters(pack, next)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -127,28 +131,34 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
for _, c := range before {
|
||||
known[c.InvenIndex] = c
|
||||
}
|
||||
var want, got []uint64
|
||||
var want []uint64
|
||||
for _, c := range after {
|
||||
old, exists := known[c.InvenIndex]
|
||||
if !exists || old.ID != c.ID || old.Level != c.Level || old.CostumeID != c.CostumeID || old.UseCostume != c.UseCostume {
|
||||
want = append(want, c.InvenIndex)
|
||||
}
|
||||
}
|
||||
decks := 0
|
||||
if err = wire.Walk(body, func(f wire.Field) error {
|
||||
if f.Number == 5 {
|
||||
index, _, err := wire.Varint(f.Value, 1)
|
||||
got = append(got, index)
|
||||
return err
|
||||
echoed, got := storyTestClearParty(t, body)
|
||||
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(echoed, chosen) {
|
||||
t.Fatalf("pack%d quest%d→%d joins=%v want=%v deck=%+v want=%+v", pack, id, next, got, want, echoed, chosen)
|
||||
}
|
||||
for _, index := range got {
|
||||
c, found := s.characters.Find(index)
|
||||
if !found || !player.IsStoryCharacter(c) {
|
||||
t.Fatalf("pack%d quest%d→%d fabricated owned join %+v", pack, id, next, c)
|
||||
}
|
||||
if f.Number == 4 {
|
||||
decks++
|
||||
}
|
||||
assertStoryTestBattleParty(t, s, chosen)
|
||||
for _, c := range seed.Characters {
|
||||
current, found := s.characters.Find(c.InvenIndex)
|
||||
if !found || !reflect.DeepEqual(current, c) {
|
||||
t.Fatalf("pack%d quest%d→%d changed player state: got=%+v want=%+v", pack, id, next, current, c)
|
||||
}
|
||||
return nil
|
||||
}); err != nil || !reflect.DeepEqual(got, want) || len(after) > 0 && decks != len(after) || decks > 5 {
|
||||
t.Fatalf("pack%d quest%d→%d joins=%v want=%v formation=%d/%d err=%v", pack, id, next, got, want, decks, len(after), err)
|
||||
}
|
||||
unchanged = unchanged || len(want) == 0
|
||||
if len(want) != 0 {
|
||||
changedEdges++
|
||||
}
|
||||
unchanged = unchanged || len(before) > 0 && len(want) == 0
|
||||
if pack == 1 && id == 2 {
|
||||
if len(got) != 0 {
|
||||
t.Fatal("pack-1 gate quest repeated character acquisition")
|
||||
@@ -157,6 +167,7 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, s = open()
|
||||
assertStoryTestBattleParty(t, s, chosen)
|
||||
c, ok := s.characters.Find(77)
|
||||
if !ok || c.HP != 7 {
|
||||
t.Fatal("quest transition healed or replaced existing character")
|
||||
@@ -166,6 +177,140 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) {
|
||||
if !unchanged {
|
||||
t.Fatal("authored regression did not exercise stable party transitions")
|
||||
}
|
||||
beforeReopen := s.characters.RawAll()
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, s = open()
|
||||
assertStoryTestBattleParty(t, s, chosen)
|
||||
if !reflect.DeepEqual(s.characters.RawAll(), beforeReopen) {
|
||||
t.Fatal("SQLite reopen changed player or temporary character state")
|
||||
}
|
||||
})
|
||||
}
|
||||
if edges == 0 || changedEdges == 0 {
|
||||
t.Fatalf("main-quest regression omitted edges or temporary joins: edges=%d changes=%d", edges, changedEdges)
|
||||
}
|
||||
t.Logf("validated %d packs, %d main-quest edges, and %d temporary join changes", len(packs), edges, changedEdges)
|
||||
}
|
||||
|
||||
// Source: seed reward handling is server policy. Inventory acquisition must
|
||||
// preserve the user's five saved battle choices, including cell 0 and -1.
|
||||
func TestQuestUnlockRewardKeepsSavedBattleParty(t *testing.T) {
|
||||
s := testService()
|
||||
s.seed.BattleUnlockQuestID = 1
|
||||
s.seed.RewardCharacter = player.Character{InvenIndex: 900, ID: 90, Level: 1, HP: 100}
|
||||
s.seed.RewardCostume = player.Costume{InvenIndex: 901, ID: 901}
|
||||
s.seed.StoryCharacters = []player.Character{{InvenIndex: 902, ID: 10090, Level: 1, HP: 100}}
|
||||
var err error
|
||||
s.decks, err = deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 105}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved := []deck.DeckEntry{
|
||||
{CharacterInvenIndex: 105, CostumeInvenIndex: 0, Slot: 1},
|
||||
{CharacterInvenIndex: 104, CostumeInvenIndex: 8, Slot: 2},
|
||||
{CharacterInvenIndex: 103, CostumeInvenIndex: 11, Slot: 3},
|
||||
{CharacterInvenIndex: 102, CostumeInvenIndex: 3, Slot: 4},
|
||||
{CharacterInvenIndex: 101, CostumeInvenIndex: ^uint64(0), Slot: 5},
|
||||
}
|
||||
saveStoryTestDeck(t, s.decks, saved)
|
||||
_, response, handled, err := s.Handle("/QuestClear", selectionRequest(1, 21, 0))
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("unlock clear handled=%v err=%v", handled, err)
|
||||
}
|
||||
echoed, joins := storyTestClearParty(t, response)
|
||||
if !reflect.DeepEqual(echoed, saved) || !reflect.DeepEqual(s.decks.CurrentDeck(), saved) || len(joins) != 0 {
|
||||
t.Fatalf("unlock reward replaced deck or fabricated joins: got=%+v want=%+v joins=%v", echoed, saved, joins)
|
||||
}
|
||||
rewardFound := false
|
||||
if err := wire.Walk(response, func(f wire.Field) error {
|
||||
if f.Number != 1 {
|
||||
return nil
|
||||
}
|
||||
return wire.Walk(f.Value, func(reward wire.Field) error {
|
||||
if reward.Number == 2 {
|
||||
index, _, err := wire.Varint(reward.Value, 1)
|
||||
rewardFound = rewardFound || index == s.seed.RewardCharacter.InvenIndex
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil || !rewardFound {
|
||||
t.Fatalf("saved party policy lost unlock reward: found=%v err=%v", rewardFound, err)
|
||||
}
|
||||
}
|
||||
|
||||
func saveStoryTestDeck(t *testing.T, store *deck.Store, entries []deck.DeckEntry) {
|
||||
t.Helper()
|
||||
request := wire.AppendVarint(nil, 1, 1)
|
||||
for _, entry := range entries {
|
||||
data := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex)
|
||||
data = wire.AppendVarint(data, 2, entry.CostumeInvenIndex)
|
||||
data = wire.AppendVarint(data, 3, entry.Slot)
|
||||
request = wire.AppendBytes(request, 2, data)
|
||||
}
|
||||
if _, _, handled, err := store.Handle("/DeckSave", request); err != nil || !handled {
|
||||
t.Fatalf("save test player deck handled=%v err=%v", handled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStoryTestDeck(t *testing.T, wires [][]byte) []deck.DeckEntry {
|
||||
t.Helper()
|
||||
var out []deck.DeckEntry
|
||||
for _, data := range wires {
|
||||
index, present, err := wire.Varint(data, 1)
|
||||
if err != nil || !present {
|
||||
t.Fatalf("deck character absent: %v", err)
|
||||
}
|
||||
// Proto/Net/DeckDBInfo.position_ defaults to 0 when field 2 is omitted.
|
||||
position, _, err := wire.Varint(data, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("deck position: %v", err)
|
||||
}
|
||||
slot, present, err := wire.Varint(data, 3)
|
||||
if err != nil || !present {
|
||||
t.Fatalf("deck slot absent: %v", err)
|
||||
}
|
||||
out = append(out, deck.DeckEntry{CharacterInvenIndex: index, CostumeInvenIndex: position, Slot: slot})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func storyTestClearParty(t *testing.T, response []byte) ([]deck.DeckEntry, []uint64) {
|
||||
t.Helper()
|
||||
var decks [][]byte
|
||||
var joins []uint64
|
||||
if err := wire.Walk(response, func(f wire.Field) error {
|
||||
if f.Number == 4 {
|
||||
decks = append(decks, f.Value)
|
||||
}
|
||||
if f.Number == 5 {
|
||||
index, present, err := wire.Varint(f.Value, 1)
|
||||
if err != nil || !present {
|
||||
t.Fatalf("join character absent: %v", err)
|
||||
}
|
||||
joins = append(joins, index)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return decodeStoryTestDeck(t, decks), joins
|
||||
}
|
||||
|
||||
func assertStoryTestBattleParty(t *testing.T, s *Service, saved []deck.DeckEntry) {
|
||||
t.Helper()
|
||||
party, err := s.currentBattleParty()
|
||||
if err != nil || len(party) != len(saved) {
|
||||
t.Fatalf("player battle party=%+v want=%+v err=%v", party, saved, err)
|
||||
}
|
||||
for i, c := range party {
|
||||
if c.InvenIndex != saved[i].CharacterInvenIndex || player.IsStoryCharacter(c) {
|
||||
t.Fatalf("displaced player %d: %+v want=%+v", i, c, saved[i])
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(s.decks.CurrentDeck(), saved) {
|
||||
t.Fatalf("rewrote saved positions or order: got=%+v want=%+v", s.decks.CurrentDeck(), saved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
wasCleared := s.state.QuestCleared(quest, pack, s.questDifficultyFor(pack, quest))
|
||||
var previousParty []player.Character
|
||||
if design.Type == 0 && s.storyRoster != nil {
|
||||
previousParty, err = s.ResolveStoryParty(pack, quest)
|
||||
previousParty, err = s.resolveStoryCharacters(pack, quest)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
@@ -502,7 +502,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Info("team trace: quest cleared", "pack", pack, "quest", quest, "changesBattleDeck", pack == s.seed.PackID && quest == s.seed.BattleUnlockQuestID)
|
||||
slog.Info("team trace: quest cleared", "pack", pack, "quest", quest, "changesBattleDeck", false)
|
||||
return 18, s.clearResponse(pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)], items, questEquipment, nextItems, nextChars), true, nil
|
||||
default:
|
||||
return 0, nil, false, nil
|
||||
@@ -922,32 +922,13 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa
|
||||
out = wire.AppendBytes(out, 11, info)
|
||||
}
|
||||
}
|
||||
if packID == s.seed.PackID && quest == s.seed.BattleUnlockQuestID && s.questDifficultyFor(packID, quest) == 0 {
|
||||
deckIDs := []uint64{s.seed.RewardCharacter.InvenIndex}
|
||||
for _, character := range s.seed.StoryCharacters {
|
||||
deckIDs = append(deckIDs, character.InvenIndex)
|
||||
}
|
||||
for _, character := range s.starter.Characters {
|
||||
deckIDs = append(deckIDs, character.InvenIndex)
|
||||
}
|
||||
for index, characterID := range deckIDs {
|
||||
deck := wire.AppendVarint(nil, 1, characterID)
|
||||
deck = wire.AppendVarint(deck, 2, ^uint64(0))
|
||||
deck = wire.AppendVarint(deck, 3, uint64(index+1))
|
||||
out = wire.AppendBytes(out, 4, deck)
|
||||
}
|
||||
for _, character := range s.seed.StoryCharacters {
|
||||
out = wire.AppendBytes(out, 5, encodeCharacter(character))
|
||||
}
|
||||
} else if s.decks != nil {
|
||||
// QuestClear echoes the authoritative current
|
||||
// DeckInfo. Omitting it after quest 29 makes the client rebuild a party
|
||||
// from ordinary owned characters and overwrite the story formation.
|
||||
if s.decks != nil {
|
||||
// Echo the player's saved formation, including quests that unlock a
|
||||
// character. Receiving a character is not a request to change the deck.
|
||||
for _, current := range s.decks.CurrentDeck() {
|
||||
entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex)
|
||||
if current.CostumeInvenIndex != 0 {
|
||||
entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex)
|
||||
}
|
||||
// Preserve the saved battle-grid position, including cell zero.
|
||||
entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex)
|
||||
entry = wire.AppendVarint(entry, 3, current.Slot)
|
||||
out = wire.AppendBytes(out, 4, entry)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user