fix(server): preserve chosen story parties and validate fixed tactics decks

This commit is contained in:
2026-10-06 17:05:01 +08:00
parent 9e9e86a658
commit c428fc308e
14 changed files with 635 additions and 257 deletions
-4
View File
@@ -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 {
-19
View File
@@ -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 {
+1 -20
View File
@@ -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")
}
}
+1 -1
View File
@@ -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
}
+34 -91
View File
@@ -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)
}
}
+7 -26
View File
@@ -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)
}