package world import ( "bytes" "encoding/binary" "sort" "testing" "bd2server/internal/server/deck" "bd2server/internal/server/gamedata" "bd2server/internal/server/player" "bd2server/internal/server/progress" "bd2server/internal/server/stateio" "bd2server/internal/server/wire" ) // Legacy regression fixtures define their own linear IDs. Production uses // GameData links; this helper gives those small test fixtures explicit links. func attachTestStoryCatalog(s *Service) { packs := s.packs if packs == nil { packs = map[int]map[int]gamedata.QuestDesign{s.seed.PackID: s.quests} } s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{}} for id, quests := range packs { var ids []int for qid := range quests { ids = append(ids, qid) } sort.Ints(ids) for i, qid := range ids { q := quests[qid] q.Type = 0 if i > 0 { q.PriorQuestID = ids[i-1] } if i+1 < len(ids) { q.NextQuestID = ids[i+1] } quests[qid] = q } s.storyCatalog.Packs[id] = gamedata.StoryPack{ID: id, Quests: quests, MainQuestIDs: ids} } if s.transition.NextPackID != 0 { if _, exists := s.storyCatalog.Packs[s.transition.NextPackID]; !exists { s.storyCatalog.Packs[s.transition.NextPackID] = gamedata.StoryPack{ID: s.transition.NextPackID} } } } func testService() *Service { s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}} attachTestStoryCatalog(s) return s } func TestQuest29EchoesCurrentStoryDeck(t *testing.T) { state := progress.NewStore() quests := make(map[int]gamedata.QuestDesign) for id := 1; id <= 30; id++ { quests[id] = gamedata.QuestDesign{ID: id} if id < 29 { if err := state.ClearQuest(id, 21); err != nil { t.Fatal(err) } } } decks, err := deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{ {Slot: 1, CharacterInvenIndex: 535607162}, }}) if err != nil { t.Fatal(err) } save := wire.AppendVarint(nil, 1, 1) want := []uint64{535607162, 535607159, 535607160, 535607161, 535604120} for i, index := range want { entry := wire.AppendVarint(nil, 1, index) entry = wire.AppendVarint(entry, 2, uint64(i+1)) entry = wire.AppendVarint(entry, 3, uint64(i+1)) save = wire.AppendBytes(save, 2, entry) } if _, _, ok, err := decks.Handle("/DeckSave", save); err != nil || !ok { t.Fatalf("deck save ok=%v err=%v", ok, err) } s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: &player.Starter{Version: "2.35.10"}, quests: quests, decks: decks} attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 2) request = wire.AppendVarint(request, 2, 29) request = wire.AppendVarint(request, 3, 21) _, response, ok, err := s.Handle("/QuestClear", request) if err != nil || !ok { t.Fatalf("quest clear ok=%v err=%v", ok, err) } var got []uint64 if err := wire.Walk(response, func(field wire.Field) error { if field.Number == 4 { index, _, _ := wire.Varint(field.Value, 1) got = append(got, index) } return nil }); err != nil { t.Fatal(err) } if len(got) != len(want) { t.Fatalf("deck rows=%v want=%v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("deck=%v want=%v", got, want) } } } func TestInitialPackInfoMatchesVersionedProtocol(t *testing.T) { s := testService() request := wire.AppendVarint(nil, 1, 89) request = wire.AppendVarint(request, 2, 21) code, got, ok, err := s.Handle("/PackInGameInfo", request) if err != nil || !ok || code != 5 { t.Fatalf("Handle: code=%d ok=%v err=%v", code, ok, err) } want := []byte{0x12, 4, 8, 1, 0x30, 0x15, 0x22, 2, '{', '}', 0x4a, 4, 8, 1, 0x10, 1, 0x62, 2, 0x28, 0x15, 0x72, 6, 8, 3, 0x10, 0x4d, 0x18, 1, 0x82, 1, 0x0e, 0x0a, 5, 0x18, 3, 0x20, 0x96, 1, 0x32, 5, 0x18, 3, 0x20, 0x96, 1} if !bytes.Equal(got, want) { t.Fatalf("pack proto mismatch\ngot %x\nwant %x", got, want) } } func TestQuestClearAdvancesAndPersists(t *testing.T) { s := testService() request := wire.AppendVarint(nil, 1, 107) request = wire.AppendVarint(request, 2, 1) request = wire.AppendVarint(request, 3, 21) code, response, ok, err := s.Handle("/QuestClear", request) if err != nil || !ok || code != 18 { t.Fatalf("first clear: code=%d ok=%v err=%v", code, ok, err) } cleared, found, err := wire.Varint(response, 3) if err != nil || !found || cleared != 1 { t.Fatalf("clear echo: %d %v %v", cleared, found, err) } next, found, err := wire.Bytes(response, 2) if err != nil || !found { t.Fatalf("next: %v", err) } id, found, err := wire.Varint(next, 1) if err != nil || !found || id != 2 { t.Fatalf("next id=%d found=%v err=%v", id, found, err) } if !s.state.QuestCleared(1, 21) { t.Fatal("clear was not stored") } var deckRows int if err := wire.Walk(response, func(field wire.Field) error { if field.Number == 4 { deckRows++ } return nil }); err != nil { t.Fatal(err) } if deckRows != 0 { t.Fatalf("ordinary quest clear unexpectedly replaced deck with %d rows", deckRows) } packRequest := wire.AppendVarint(nil, 1, 200) packRequest = wire.AppendVarint(packRequest, 2, 21) _, restored, _, err := s.Handle("/PackInGameInfo", packRequest) if err != nil { t.Fatal(err) } active, found, err := wire.Bytes(restored, 2) if err != nil || !found { t.Fatalf("restored active quest missing: %v", err) } activeID, found, err := wire.Varint(active, 1) if err != nil || !found || activeID != 2 { t.Fatalf("restored active quest=%d found=%v err=%v", activeID, found, err) } clearedIDs, found, err := wire.Bytes(restored, 3) clearedID, count := binary.Uvarint(clearedIDs) if err != nil || !found || count <= 0 || clearedID != 1 { t.Fatalf("restored cleared quests=%x found=%v err=%v", clearedIDs, found, err) } request = wire.AppendVarint(nil, 1, 108) request = wire.AppendVarint(request, 2, 3) request = wire.AppendVarint(request, 3, 21) if _, _, _, err := s.Handle("/QuestClear", request); err == nil { t.Fatal("out-of-order clear succeeded") } } func TestFinalQuestClearIncludesEmptyNextQuestInfo(t *testing.T) { // The client completion coroutine unconditionally evaluates // QuestClearResponse.QuestInfo.Id. An absent protobuf field becomes a // null C# reference; an explicitly present zero-length message becomes a // non-null QuestDBInfo with ID 0, satisfying the client's final-quest // sentinel path. Exact official final-pack wire parity is still unverified. s := &Service{ seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 38}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{38: {ID: 38}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}, } attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 38) request = wire.AppendVarint(request, 3, 21) _, response, ok, err := s.Handle("/QuestClear", request) if err != nil || !ok { t.Fatalf("final clear ok=%v err=%v", ok, err) } var nextCount int if err := wire.Walk(response, func(field wire.Field) error { if field.Number != 2 { return nil } nextCount++ if len(field.Value) != 0 { t.Fatalf("final next QuestInfo=%x, want explicitly empty message", field.Value) } return nil }); err != nil { t.Fatal(err) } if nextCount != 1 { t.Fatalf("final clear has %d QuestInfo fields, want one empty message", nextCount) } var packs []uint64 if err := wire.Walk(response, func(field wire.Field) error { if field.Number == 11 { id, _, _ := wire.Varint(field.Value, 1) packs = append(packs, id) } return nil }); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 { t.Fatalf("final pack transition=%v err=%v", packs, err) } } func TestPackInfoRestoresCompletedPackAndUnlockedNextPack(t *testing.T) { state := progress.NewStore() for _, id := range []int{1, 2} { if err := state.ClearQuest(id, 21); err != nil { t.Fatal(err) } } s := &Service{seed: Seed{Version: "2.35.10", PackID: 21}, state: state, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}} attachTestStoryCatalog(s) code, response, handled, err := s.Handle("/PackInfo", wire.AppendVarint(nil, 1, 1)) if err != nil || !handled || code != 4 { t.Fatalf("PackInfo code=%d handled=%v err=%v", code, handled, err) } var packs []uint64 if err := wire.Walk(response, func(field wire.Field) error { if field.Number == 1 { id, _, _ := wire.Varint(field.Value, 1) packs = append(packs, id) if id == 22 { bought, _, _ := wire.Varint(field.Value, 8) if bought != 0 { t.Fatalf("newly unlocked pack22 is already marked bought") } } } return nil }); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 { t.Fatalf("PackInfo packs=%v err=%v", packs, err) } } func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) { state := progress.NewStore() if err := state.ClearQuest(1, 21); err != nil { t.Fatal(err) } pack21 := map[int]gamedata.QuestDesign{1: {ID: 1}} pack22 := map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}} s := &Service{ seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: &player.Starter{Version: "2.35.10"}, quests: pack21, packs: map[int]map[int]gamedata.QuestDesign{21: pack21, 22: pack22}, transitions: map[int]gamedata.PackTransition{ 21: {PackID: 21, NextPackID: 22}, 22: {PackID: 22}, }, } attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 22) code, response, handled, err := s.Handle("/PackInGameInfo", request) if err != nil || !handled || code != 5 { t.Fatalf("pack22 init code=%d handled=%v err=%v", code, handled, err) } currentPack, err := s.CurrentPackID() if err != nil || currentPack != 22 { t.Fatalf("current pack after pack22 init=%d err=%v", currentPack, err) } active, found, err := wire.Bytes(response, 2) if err != nil || !found { t.Fatalf("pack22 active quest missing: %v", err) } id, _, _ := wire.Varint(active, 1) packID, _, _ := wire.Varint(active, 6) if id != 1 || packID != 22 { t.Fatalf("pack22 active quest id=%d pack=%d", id, packID) } if err := wire.Walk(response, func(field wire.Field) error { if field.Number == 1 || field.Number == 9 || field.Number == 14 || field.Number == 16 { t.Fatalf("pack22 inherited pack21-only field %d", field.Number) } return nil }); err != nil { t.Fatal(err) } clear := wire.AppendVarint(nil, 1, 2) clear = wire.AppendVarint(clear, 2, 1) clear = wire.AppendVarint(clear, 3, 22) if _, _, _, err := s.Handle("/QuestClear", clear); err != nil { t.Fatal(err) } if !state.QuestCleared(1, 21) || !state.QuestCleared(1, 22) { t.Fatal("quest 1 did not remain independently cleared in pack21 and pack22") } } func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) { state := progress.NewStore() quests := make(map[int]gamedata.QuestDesign) for id := 1; id <= 29; id++ { quests[id] = gamedata.QuestDesign{ID: id} if id < 28 { if err := state.ClearQuest(id, 21); err != nil { t.Fatal(err) } } } storage := stateio.NewMemory() equipment, err := player.OpenEquipmentInventory(storage) if err != nil { t.Fatal(err) } starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(storage, starter) if err != nil { t.Fatal(err) } entry := quests[28] entry.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}, {Type: 10, ID: 10010}} quests[28] = entry s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: starter, equipment: equipment, inventory: inventory, quests: quests} attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 28) request = wire.AppendVarint(request, 3, 21) code, response, ok, err := s.Handle("/QuestClear", request) if err != nil || !ok || code != 18 { t.Fatalf("clear: code=%d ok=%v err=%v", code, ok, err) } bundle, found, err := wire.Bytes(response, 1) if err != nil || !found { t.Fatalf("reward bundle: %v", err) } encoded, found, err := wire.Bytes(bundle, 4) if err != nil || !found { t.Fatalf("equipment reward: %v", err) } base, found, err := wire.Bytes(encoded, 5) if err != nil || !found { t.Fatalf("equipment base: %v", err) } id, found, err := wire.Varint(base, 1) if err != nil || !found || id != 10010 { t.Fatalf("equipment id=%d found=%v err=%v", id, found, err) } } func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) { storage := stateio.NewMemory() state := progress.NewStore() quests := make(map[int]gamedata.QuestDesign) for id := 1; id <= 27; id++ { quests[id] = gamedata.QuestDesign{ID: id} if id < 27 { if err := state.ClearQuest(id, 21); err != nil { t.Fatal(err) } } } design := quests[27] design.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}} quests[27] = design wallet, err := player.OpenWallet(storage, player.Currency{}) if err != nil { t.Fatal(err) } s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1}, state: state, starter: &player.Starter{Version: "2.35.10"}, wallet: wallet, quests: quests} attachTestStoryCatalog(s) request := wire.AppendVarint(nil, 1, 1) request = wire.AppendVarint(request, 2, 27) request = wire.AppendVarint(request, 3, 21) _, response, _, err := s.Handle("/QuestClear", request) if err != nil { t.Fatal(err) } if got := wallet.Snapshot().FreeJewelry; got != 70 { t.Fatalf("free jewelry=%d", got) } bundle, _, _ := wire.Bytes(response, 1) item, _, _ := wire.Bytes(bundle, 1) typ, _, _ := wire.Varint(item, 3) count, _, _ := wire.Varint(item, 4) if typ != 3 || count != 70 { t.Fatalf("currency type=%d count=%d", typ, count) } } func TestPackInfoDoesNotEmitFixedRewardRosterWithoutActiveFormation(t *testing.T) { storage := stateio.NewMemory() starter := &player.Starter{Version: "2.35.10"} inventory, err := player.OpenInventory(storage, starter) if err != nil { t.Fatal(err) } characters, err := player.OpenCharacterStore(storage, []player.Character{{InvenIndex: 77, ID: 350, Level: 20}}, inventory, "", "") if err != nil { t.Fatal(err) } state := progress.NewStore() if err := state.ClearQuest(26, 21); err != nil { t.Fatal(err) } s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: player.Character{InvenIndex: 77, ID: 350, Level: 1}}, state: state, starter: starter, characters: characters} attachTestStoryCatalog(s) response, err := s.packInfo() if err != nil { t.Fatal(err) } var level uint64 if err := wire.Walk(response, func(field wire.Field) error { if field.Number != 1 { return nil } index, _, _ := wire.Varint(field.Value, 1) if index == 77 { level, _, _ = wire.Varint(field.Value, 4) } return nil }); err != nil { t.Fatal(err) } if level != 0 { t.Fatalf("fixed reward character leaked without an authored active formation: level=%d", level) } }