diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go index 04a9a56..c63202a 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -7,7 +7,6 @@ import ( "bytes" "errors" "fmt" - "log/slog" "math" "sync" @@ -223,7 +222,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) state.phase++ state.index, state.deck, state.phaseStarted = next.DeckID, next.DeckID, false state.phaseSeq, state.phaseReply = seq, append([]byte(nil), response...) - slog.Info("team trace: battle phase changed", "pack", state.pack, "monster", state.monster, "group", next.GroupID, "phase", next.ID, "enemyDeck", next.DeckID) return 632, response, true, nil case "/BattleVerifyState": if !state.entered { @@ -355,7 +353,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) state.enterReceipt = identity state.fieldInstance = fieldInstance state.phases, state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = phases, 0, false, 0, nil - slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode) return 52, response, true, nil case "/BattleRetry": if !state.entered { @@ -424,19 +421,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) state.index, state.round, state.phaseStarted = index, nextRound, true if nextRound == 1 { state.initialBlue = initialBlue - // Log the actual submitted team after pre-battle DeckSave, rather - // than inferring it from the deck that existed at BattleEnter. - // BattleCharDBInfo fields 2/3/7/9 are inventory/character/costume/grid. - blueTeam := make([]string, 0, len(initialBlue)) - for _, character := range initialBlue { - inventory, _, _ := wire.Varint(character, 2) - id, _, _ := wire.Varint(character, 3) - costume, _, _ := wire.Varint(character, 7) - grid, _, _ := wire.Varint(character, 9) - blueTeam = append(blueTeam, fmt.Sprintf("inven=%d char=%d costume=%d grid=%d", inventory, id, costume, int32(grid))) - } - seq, _, _ := wire.Varint(request, 1) - slog.Info("team trace: battle started", "seq", seq, "pack", state.pack, "monster", state.monster, "enemyDeck", state.deck, "mode", state.mode, "blueTeam", blueTeam) } // Stable per-battle/round seed; reproducible across retries. seed := index*7919 + state.round*104729 diff --git a/go/internal/server/deck/deck.go b/go/internal/server/deck/deck.go index 6618765..2fcb12c 100644 --- a/go/internal/server/deck/deck.go +++ b/go/internal/server/deck/deck.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "os" "sync" @@ -401,7 +400,6 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) { } s.mu.RLock() defer s.mu.RUnlock() - slog.Info("team trace: deliver saved battle deck", "deck", s.state.Deck) out := encodeDeck(s.state.Deck) if s.fieldSettingsDesign != nil { v, e := s.loadFieldSettings() @@ -437,14 +435,9 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) { if e = s.validateOwnedDeckLocked(x); e != nil { return 0, nil, true, e } - seq, _, _ := wire.Varint(req, 1) - slog.Info("team trace: client requested battle deck replacement", "seq", seq, "before", s.state.Deck, "after", x) n := clone(s.state) n.Deck = x e = s.commit(n) - if e != nil { - slog.Error("team trace: deck replacement failed", "seq", seq, "error", e) - } return 10, nil, true, e case "/FieldDeckSave": if e := checkSeq(req); e != nil { diff --git a/go/internal/server/world/quest_selection.go b/go/internal/server/world/quest_selection.go index 0d2e10a..91afb3b 100644 --- a/go/internal/server/world/quest_selection.go +++ b/go/internal/server/world/quest_selection.go @@ -130,7 +130,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte if err := s.state.RemoveQuest(quest, pack, 0); err != nil { return 0, nil, true, err } - return 20, wire.AppendVarint(nil, 1, uint64(quest)), true, nil + return 20, s.appendCurrentBattleDeck(wire.AppendVarint(nil, 1, uint64(quest)), 2), true, nil } selection, ok := s.state.Selection(pack) if !ok || selection.QuestID != quest || selection.Difficulty == 0 { @@ -140,7 +140,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte if err := s.state.SelectQuest(pack, progress.QuestSelection{}); err != nil { return 0, nil, true, err } - return 20, wire.AppendVarint(nil, 1, uint64(quest)), true, nil + return 20, s.appendCurrentBattleDeck(wire.AppendVarint(nil, 1, uint64(quest)), 2), true, nil } level, _, err := wire.Varint(request, 4) if err != nil || level > 4 { @@ -164,6 +164,7 @@ func (s *Service) handleQuestSelection(path string, request []byte) (int, []byte return 0, nil, true, err } out := wire.AppendBytes(nil, 1, s.questInfoWire(pack, quest)) + out = s.appendCurrentBattleDeck(out, 3) items, err := s.ensureQuestItems(pack, quest) if err != nil { return 0, nil, true, err diff --git a/go/internal/server/world/quest_selection_test.go b/go/internal/server/world/quest_selection_test.go index f6105aa..26bf02c 100644 --- a/go/internal/server/world/quest_selection_test.go +++ b/go/internal/server/world/quest_selection_test.go @@ -1,11 +1,13 @@ package world import ( + "bd2server/internal/server/deck" "bd2server/internal/server/gamedata" "bd2server/internal/server/player" "bd2server/internal/server/progress" "bd2server/internal/server/stateio" "bd2server/internal/server/wire" + "reflect" "testing" ) @@ -92,18 +94,48 @@ func TestDifficultySelectionPersistsAndRewardsRemainIndependent(t *testing.T) { } func TestSideQuestRemainsIndependentFromMainDifficulty(t *testing.T) { s := testService() + var err error + s.decks, err = deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 905}}}) + if err != nil { + t.Fatal(err) + } + saved := []deck.DeckEntry{ + {CharacterInvenIndex: 905, CostumeInvenIndex: 0, Slot: 1}, + {CharacterInvenIndex: 904, CostumeInvenIndex: 8, Slot: 2}, + {CharacterInvenIndex: 903, CostumeInvenIndex: 11, Slot: 3}, + {CharacterInvenIndex: 902, CostumeInvenIndex: 3, Slot: 4}, + {CharacterInvenIndex: 901, CostumeInvenIndex: ^uint64(0), Slot: 5}, + } + saveStoryTestDeck(t, s.decks, saved) + call := func(path string, deckField int) { + t.Helper() + _, response, handled, err := s.Handle(path, selectionRequest(50, 21, 0)) + if err != nil || !handled { + t.Fatalf("%s handled=%v err=%v", path, handled, err) + } + var entries [][]byte + if err := wire.Walk(response, func(f wire.Field) error { + if f.Number == deckField { + entries = append(entries, f.Value) + } + return nil + }); err != nil { + t.Fatal(err) + } + if got := decodeStoryTestDeck(t, entries); !reflect.DeepEqual(got, saved) || !reflect.DeepEqual(s.decks.CurrentDeck(), saved) { + t.Fatalf("%s changed saved battle deck: response=%+v saved=%+v", path, got, s.decks.CurrentDeck()) + } + } pack := s.storyCatalog.Packs[21] pack.Quests[50] = gamedata.QuestDesign{ID: 50, Type: 1} s.storyCatalog.Packs[21] = pack - if _, _, _, err := s.Handle("/QuestAccept", selectionRequest(50, 21, 0)); err != nil { - t.Fatal(err) - } + call("/QuestAccept", 3) + call("/QuestGiveUp", 2) + call("/QuestAccept", 3) if first := s.firstUnclearedQuestFor(21); first != 1 { t.Fatalf("side replaced main%d", first) } - if _, _, _, err := s.Handle("/QuestClear", selectionRequest(50, 21, 0)); err != nil { - t.Fatal(err) - } + call("/QuestClear", 4) if !s.state.QuestCleared(50, 21) || s.state.QuestCleared(1, 21) { t.Fatal("side clear affected main") } diff --git a/go/internal/server/world/story_characters.go b/go/internal/server/world/story_characters.go index 710679d..e31cf0b 100644 --- a/go/internal/server/world/story_characters.go +++ b/go/internal/server/world/story_characters.go @@ -2,7 +2,6 @@ package world import ( "bd2server/internal/server/player" - "bd2server/internal/server/wire" "fmt" ) @@ -102,12 +101,5 @@ func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]by for _, c := range party { characters = append(characters, encodeCharacter(c)) } - var wires [][]byte - if s.decks != nil { - for _, entry := range s.decks.CurrentDeck() { - 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 + return characters, s.currentBattleDeckWires(), nil } diff --git a/go/internal/server/world/today_quests.go b/go/internal/server/world/today_quests.go index 3dbac75..22cc79f 100644 --- a/go/internal/server/world/today_quests.go +++ b/go/internal/server/world/today_quests.go @@ -2,6 +2,7 @@ package world import ( "bd2server/internal/server/todayquest" + "bd2server/internal/server/wire" "fmt" ) @@ -13,6 +14,48 @@ func (s *Service) AttachTodayQuests(service *todayquest.Service) error { return nil } +// QuestPacket always passes the repeated DeckInfo to CommonPacket.RefreshDeck, +// which replaces the entire battle deck even when that list is empty. Keep +// this live snapshot outside the commission reward receipt: retrying a clear +// after the player changes formation must return the current saved deck. +func (s *Service) commissionResponseDeck(code int, response []byte) []byte { + field := 0 + switch code { + case 17: // QuestAcceptResponse.deck_info + field = 3 + case 18: // QuestClearResponse.deck_info + field = 4 + case 20: // QuestGiveUpResponse.deck_info + field = 2 + } + if field != 0 { + return s.appendCurrentBattleDeck(response, field) + } + return response +} + +func (s *Service) currentBattleDeckWires() [][]byte { + if s.decks == nil { + return nil + } + var entries [][]byte + for _, current := range s.decks.CurrentDeck() { + entry := wire.AppendVarint(nil, 1, current.CharacterInvenIndex) + // DeckDBInfo field 2 is the battle-grid position, including zero and -1. + entry = wire.AppendVarint(entry, 2, current.CostumeInvenIndex) + entry = wire.AppendVarint(entry, 3, current.Slot) + entries = append(entries, entry) + } + return entries +} + +func (s *Service) appendCurrentBattleDeck(response []byte, field int) []byte { + for _, entry := range s.currentBattleDeckWires() { + response = wire.AppendBytes(response, field, entry) + } + return response +} + // NPCController checks whether any owned pack has completed its main story, // rather than requiring completion of the board's own pack. func (s *Service) CommissionPackUnlocked(pack int) bool { diff --git a/go/internal/server/world/today_quests_test.go b/go/internal/server/world/today_quests_test.go index 38d272e..82365a1 100644 --- a/go/internal/server/world/today_quests_test.go +++ b/go/internal/server/world/today_quests_test.go @@ -2,6 +2,7 @@ package world import ( "bd2server/internal/server/accountstate" + "bd2server/internal/server/deck" "bd2server/internal/server/gamedata" "bd2server/internal/server/player" "bd2server/internal/server/progress" @@ -24,8 +25,9 @@ func (e commissionEconomy) Apply(id string, costs, rewards []gamedata.Reward) ([ } // Synthetic IDs describe a two-node collection commission and an independent -// pack. The source-backed contract is PackInGameInfo replacing both client -// quest lists (CommonPacket.SendPackInGameInfoRequest), not an official capture. +// pack. CommonPacket parses PackInGameInfo, requests TodayQuestInfo, then calls +// PackManager.Enter from the waypoint callback. Those last two callbacks can +// arrive in either order; only TodayQuestInfo restores commissions. func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { path := filepath.Join(t.TempDir(), "state.db") design := &gamedata.TodayQuestCatalog{Limit: 3, PostCount: 3, AchievementScore: 7, @@ -34,6 +36,7 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { 101: {ID: 101, PackID: 21, NextID: 102, ConditionType: 19, ConditionCount: 1}, 102: {ID: 102, PackID: 21, PriorID: 101, ConditionType: 2, ConditionCount: 2, MagicValues: []uint64{71, 72}, GiveItemIDs: []uint64{100001}, Rewards: []gamedata.Reward{{Type: 4, Count: 10}}}, 201: {ID: 201, PackID: 22, ConditionType: 19, ConditionCount: 1}, + 301: {ID: 301, PackID: 21, ConditionType: 19, ConditionCount: 1}, }} open := func() (*accountstate.Repository, *Service, *player.Wallet, *player.Inventory) { t.Helper() @@ -48,6 +51,10 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { if err != nil { t.Fatal(err) } + s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 901}}}) + if err != nil { + t.Fatal(err) + } wallet, err := player.OpenWallet(repo, player.Currency{}) if err != nil { t.Fatal(err) @@ -81,6 +88,24 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { if !handled || err != nil { t.Fatalf("%s: handled=%v %v", path, handled, err) } + // QuestAccept, QuestClear and QuestGiveUp all call client RefreshDeck + // with a non-null repeated list: the response must contain the complete + // current battle deck, even for a replayed commission reward receipt. + deckField := map[string]int{"/QuestAccept": 3, "/QuestClear": 4, "/QuestGiveUp": 2}[path] + if deckField != 0 { + var entries [][]byte + if err := wire.Walk(b, func(f wire.Field) error { + if f.Number == deckField { + entries = append(entries, f.Value) + } + return nil + }); err != nil { + t.Fatal(err) + } + if got := decodeStoryTestDeck(t, entries); !reflect.DeepEqual(got, s.decks.CurrentDeck()) { + t.Fatalf("%s replaced saved formation: got=%+v want=%+v", path, got, s.decks.CurrentDeck()) + } + } return b } assertEntry := func(s *Service, pack int, active map[int][]uint64, cleared []int) { @@ -90,13 +115,52 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { if !handled || err != nil { t.Fatalf("pack entry: handled=%v %v", handled, err) } - got := map[int][]uint64{} - var clears []int + var packQuests []int + var packClears []int + // The pack response is parsed before TodayQuestInfo is requested. Its + // regular task list is applied later, when the waypoint request finishes. if err := wire.Walk(body, func(f wire.Field) error { if f.Number == 2 { id, _, _ := wire.Varint(f.Value, 1) if _, exists := design.Quests[int(id)]; exists { + t.Fatalf("commission %d duplicated into PackInGameInfo", id) + } + packQuests = append(packQuests, int(id)) + } + if f.Number == 3 { + for b := f.Value; len(b) > 0; { + id, n := binary.Uvarint(b) + if n <= 0 { + t.Fatal("invalid cleared IDs") + } + if _, exists := design.Quests[int(id)]; exists { + t.Fatalf("commission %d restored through pack completion list", id) + } + packClears = append(packClears, int(id)) + b = b[n:] + } + } + return nil + }); err != nil { + t.Fatal(err) + } + if pack == 21 && !reflect.DeepEqual(packQuests, []int{1}) { + t.Fatalf("pack entry lost ordinary main quest: %v", packQuests) + } + today := call(s, "/TodayQuestInfo", 0, 0) + got := map[int][]uint64{} + var clears []int + var currentPackToday []int + if err := wire.Walk(today, func(f wire.Field) error { + if f.Number == 1 { + id, _, _ := wire.Varint(f.Value, 1) + qpack, _, _ := wire.Varint(f.Value, 6) + if int(qpack) == pack { + if _, duplicate := got[int(id)]; duplicate { + t.Fatalf("TodayQuestInfo repeated commission %d", id) + } got[int(id)] = nil + currentPackToday = append(currentPackToday, int(id)) return wire.Walk(f.Value, func(q wire.Field) error { if q.Number == 3 { n, _ := binary.Uvarint(q.Value) @@ -106,16 +170,10 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { }) } } - if f.Number == 3 { - for b := f.Value; len(b) > 0; { - id, n := binary.Uvarint(b) - if n <= 0 { - t.Fatal("invalid cleared IDs") - } - if _, exists := design.Quests[int(id)]; exists { - clears = append(clears, int(id)) - } - b = b[n:] + if f.Number == 2 { + id, _ := binary.Uvarint(f.Value) + if design.Quests[int(id)].PackID == pack { + clears = append(clears, int(id)) } } return nil @@ -123,12 +181,76 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { t.Fatal(err) } if !reflect.DeepEqual(got, active) || !reflect.DeepEqual(clears, cleared) { - t.Fatalf("pack %d active=%v cleared=%v", pack, got, clears) + t.Fatalf("pack %d TodayQuestInfo active=%v cleared=%v", pack, got, clears) + } + for _, todayFirst := range []bool{true, false} { + var clientQuests []int + // The pack completion snapshot was assigned before either of + // these callbacks; TodayQuestInfo subsequently merges its IDs. + clientClears := append([]int(nil), packClears...) + addToday := func() { + var ordinary []int + for _, id := range clientQuests { + if _, today := design.Quests[id]; !today { + ordinary = append(ordinary, id) + } + } + clientQuests = append(ordinary, currentPackToday...) + for _, id := range clears { + present := false + for _, existing := range clientClears { + present = present || existing == id + } + if !present { + clientClears = append(clientClears, id) + } + } + } + if todayFirst { + addToday() + clientQuests = append(clientQuests, packQuests...) + } else { + clientQuests = append(clientQuests, packQuests...) + addToday() + } + seen := map[int]bool{} + for _, id := range clientQuests { + if seen[id] { + t.Fatalf("HUD duplicate quest %d when todayFirst=%v", id, todayFirst) + } + seen[id] = true + } + if len(seen) != len(packQuests)+len(active) { + t.Fatalf("entry callbacks lost tasks when todayFirst=%v: %v", todayFirst, clientQuests) + } + var completedToday []int + for _, id := range clientClears { + if _, today := design.Quests[id]; today { + completedToday = append(completedToday, id) + } + } + if !reflect.DeepEqual(completedToday, cleared) { + t.Fatalf("entry callbacks lost completed commissions when todayFirst=%v: %v", todayFirst, completedToday) + } } } repo, s, wallet, inv := open() + savedDeck := []deck.DeckEntry{ + {CharacterInvenIndex: 901, CostumeInvenIndex: 0, Slot: 1}, + {CharacterInvenIndex: 905, CostumeInvenIndex: 8, Slot: 2}, + {CharacterInvenIndex: 903, CostumeInvenIndex: 11, Slot: 3}, + {CharacterInvenIndex: 904, CostumeInvenIndex: 3, Slot: 4}, + {CharacterInvenIndex: 902, CostumeInvenIndex: ^uint64(0), Slot: 5}, + } + saveStoryTestDeck(t, s.decks, savedDeck) call(s, "/QuestAccept", 101, 21) call(s, "/QuestAccept", 201, 22) + call(s, "/QuestAccept", 301, 21) + call(s, "/QuestAccept", 101, 21) + call(s, "/QuestGiveUp", 301, 21) + if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) { + t.Fatal("commission accepts or abandonment changed saved formation") + } call(s, "/QuestUpdate", 101, 21, 1) call(s, "/QuestClear", 101, 21) call(s, "/QuestUpdate", 102, 21, 71) @@ -139,6 +261,9 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { t.Fatal(err) } repo, s, wallet, inv = open() + if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) { + t.Fatal("SQLite reopen lost selected formation") + } assertEntry(s, 21, map[int][]uint64{102: {71}}, []int{101}) assertEntry(s, 22, map[int][]uint64{201: nil}, nil) op, err := repo.BeginOperation() @@ -163,11 +288,23 @@ func TestCommissionPackEntryRestoresSQLiteChainAndRollback(t *testing.T) { } call(s, "/QuestUpdate", 102, 21, 72) call(s, "/QuestClear", 102, 21) + // A retry is an idempotent reward operation, but its deck projection is a + // live snapshot. Persist a different formation and reopen before replay. + savedDeck[0], savedDeck[4] = savedDeck[4], savedDeck[0] + savedDeck[0].Slot, savedDeck[4].Slot = 1, 5 + saveStoryTestDeck(t, s.decks, savedDeck) + if err := repo.Close(); err != nil { + t.Fatal(err) + } + repo, s, wallet, inv = open() call(s, "/QuestClear", 102, 21) assertEntry(s, 21, map[int][]uint64{}, []int{101, 102}) if wallet.Snapshot().Gold != 10 { t.Fatal("reward replay changed balance") } + if !reflect.DeepEqual(s.decks.CurrentDeck(), savedDeck) { + t.Fatal("replayed commission clear restored stale formation") + } call(s, "/QuestGiveUp", 201, 22) assertEntry(s, 22, map[int][]uint64{}, nil) } diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index 5787cb3..57dccf7 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -261,6 +261,9 @@ func (s *Service) AttachDecks(decks *deck.Store) error { func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) { if s.todayQuests != nil { if code, body, handled, err := s.todayQuests.Handle(path, request); handled { + if err == nil { + body = s.commissionResponseDeck(code, body) + } return code, body, handled, err } } @@ -356,7 +359,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } } } - slog.Info("team trace: deliver owned characters", "characters", characters) for _, character := range characters { response = wire.AppendBytes(response, 1, encodeCharacter(character)) } @@ -375,7 +377,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if s.collection != nil { costumes = s.collection.Costumes() } - slog.Info("team trace: deliver owned costumes", "costumes", costumes, "quest26", s.seed.RewardCostume) var selections map[uint64]uint64 if s.prestigeSelections != nil { var err error @@ -422,7 +423,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if !s.packUnlocked(pack) { return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack) } - slog.Info("team trace: deliver pack progress", "pack", pack, "clearedQuests", s.state.ClearedQuests(pack), "storyCharacters", s.storyCharacters(pack)) if active := s.firstUnclearedQuestFor(pack); active != 0 { if _, err := s.ensureQuestItems(pack, active); err != nil { return 0, nil, true, err @@ -502,7 +502,6 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) } } } - 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 @@ -740,19 +739,10 @@ func (s *Service) basePackInfoFor(packID int) ([]byte, error) { out = wire.AppendBytes(out, 2, quest) } cleared := s.state.ClearedQuests(packID, s.questDifficulty(packID)) - // Entering a pack replaces the client's entire task and completion lists. - // Include commissions here as well as in QuestInfo so cross-pack travel and - // login restore the same active chain and already completed nodes. - if s.todayQuests != nil { - quests, ids, err := s.todayQuests.Info(packID) - if err != nil { - return nil, err - } - for _, quest := range quests { - out = wire.AppendBytes(out, 2, quest) - } - cleared = append(cleared, ids...) - } + // CommonPacket requests TodayQuestInfo after parsing this response, before + // the waypoint callback calls PackManager.Enter. That separate response owns + // commission restoration; including commissions here lets Enter append them + // a second time when TodayQuestInfo arrives first, crashing the quest HUD. if len(cleared) != 0 { var packed []byte for _, id := range cleared { @@ -922,17 +912,8 @@ func (s *Service) clearResponse(packID, quest int, designRewards []gamedata.Rewa out = wire.AppendBytes(out, 11, info) } } - 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) - // 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) - } - } + // Receiving a character is not a request to change the saved formation. + out = s.appendCurrentBattleDeck(out, 4) for _, char := range nextChars { out = wire.AppendBytes(out, 5, char) }