diff --git a/go/internal/server/gamedata/story_characters.go b/go/internal/server/gamedata/story_characters.go index 141a922..a54e5ca 100644 --- a/go/internal/server/gamedata/story_characters.go +++ b/go/internal/server/gamedata/story_characters.go @@ -15,13 +15,9 @@ type StoryCharacterCatalog struct { } func LoadStoryCharacterCatalog(root, version string, packs []int, placeholderCostumes ...uint64) (*StoryCharacterCatalog, error) { - var placeholder uint64 if len(placeholderCostumes) > 1 { return nil, fmt.Errorf("gamedata: multiple story placeholders") } - if len(placeholderCostumes) == 1 { - placeholder = placeholderCostumes[0] - } db, cleanup, err := openStatDatabase(root, version) if err != nil { return nil, err @@ -85,12 +81,8 @@ func LoadStoryCharacterCatalog(root, version string, packs []int, placeholderCos if err != nil { return nil, err } - for _, story := range formation.StoryCostumes { - if story.UniqueCharacterID == unique && (placeholder == 0 || story.CostumeID != placeholder) { - costume = story.CostumeID - break - } - } + // CharGroup supplies battle instances. StoryCharGroup supplies + // cosmetic field actors and cannot override a battle costume. base, err := stats.BaseStats(row.CharacterID, row.Level) if err != nil { return nil, err diff --git a/go/internal/server/world/story_characters.go b/go/internal/server/world/story_characters.go index 3c7be3a..7d4b879 100644 --- a/go/internal/server/world/story_characters.go +++ b/go/internal/server/world/story_characters.go @@ -7,8 +7,13 @@ import ( "fmt" ) -// ResolveStoryParty combines the authored temporary CharGroup members with -// actual account instances named by StoryCharGroup costume rows. +// A normal battle deck has five character slots (CommonPacket and DeckSave). +const storyBattlePartySize = 5 + +// 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) { if s.storyRoster == nil { return nil, nil @@ -26,6 +31,7 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er } temporary := make([]player.Character, 0, len(designs)) reused := make([]player.Character, 0, len(designs)) + var authoredOrder []uint64 for _, d := range designs { var existing player.Character for _, c := range s.characters.RawAll() { @@ -36,119 +42,87 @@ func (s *Service) ResolveStoryParty(packID, questID int) ([]player.Character, er } if existing.InvenIndex != 0 { reused = append(reused, existing) + authoredOrder = append(authoredOrder, existing.InvenIndex) continue } if packID <= 0 || packID >= 65536 || d.CharacterID == 0 || d.CharacterID >= 1<<32 || d.Level == 0 || d.Level >= 256 { return nil, fmt.Errorf("world: story instance namespace overflow") } index := player.StoryCharacterIndexBase | uint64(packID)<<40 | d.CharacterID<<8 | d.Level + 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 s.characters == nil { - return nil, fmt.Errorf("world: story character store unavailable") + 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 } - result := make([]player.Character, 0, len(temporary)+len(formation.StoryCostumes)) - used := map[uint64]bool{} + authored := make(map[uint64]player.Character, len(designs)) for _, c := range reused { current, ok := s.characters.Find(c.InvenIndex) if !ok { return nil, fmt.Errorf("world: saved story instance unavailable") } - result = append(result, current) - used[current.InvenIndex] = true + authored[current.InvenIndex] = current } for _, c := range temporary { current, ok := s.characters.Find(c.InvenIndex) if !ok { return nil, fmt.Errorf("world: story character was not saved") } - result = append(result, current) - used[current.InvenIndex] = true + authored[current.InvenIndex] = current } - // Placeholder entries are player-controlled party slots. Their costume - // and character cannot be inferred from the design placeholder itself. - for _, costume := range formation.StoryCostumes { - if s.seed.PlaceholderCostumeID != 0 && costume.CostumeID == s.seed.PlaceholderCostumeID { - continue - } - temporaryMember := false - for _, design := range designs { - if design.UniqueCharacterID == costume.UniqueCharacterID { - temporaryMember = true + 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 temporaryMember { + if banned { continue } - var ownedCostumes []player.Costume - if s.starter != nil { - ownedCostumes = append(ownedCostumes, s.starter.Costumes...) - } - if s.collection != nil { - ownedCostumes = append(ownedCostumes, s.collection.Costumes()...) - } - for _, c := range s.visibleOwnedCharacters(s.characters.All()) { - matched := c.CostumeID == costume.CostumeID - for _, owned := range ownedCostumes { - if owned.ID == costume.CostumeID && owned.UseChar == c.InvenIndex { - matched = true - c.CostumeID = owned.ID - c.UseCostume = owned.InvenIndex - break - } - } - if player.IsStoryCharacter(c) || used[c.InvenIndex] || !matched { - continue - } - result = append(result, c) - used[c.InvenIndex] = true - break - } + owned[c.InvenIndex] = c } - // Fill authored player-controlled slots from the persisted field party, - // then the persisted battle party. Never synthesize the placeholder ID. - for _, costume := range formation.StoryCostumes { - if (s.seed.PlaceholderCostumeID == 0 || costume.CostumeID != s.seed.PlaceholderCostumeID) || s.decks == nil { - continue - } - var candidates []uint64 - for _, entry := range s.decks.CurrentFieldDeck() { - candidates = append(candidates, entry.CharacterInvenIndex) - } + var candidates []uint64 + if s.decks != nil { for _, entry := range s.decks.CurrentDeck() { candidates = append(candidates, entry.CharacterInvenIndex) } - for _, index := range candidates { - if used[index] { - continue + // 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) } - var controlled player.Character - for _, c := range s.visibleOwnedCharacters(s.characters.All()) { - if c.InvenIndex == index { - controlled = c - break - } - } - if controlled.InvenIndex == 0 { - continue - } - temporaryDesign := false - for _, d := range designs { - if d.CharacterID == controlled.ID { - temporaryDesign = true - break - } - } - if temporaryDesign { - continue - } - result = append(result, controlled) + } + } + 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-- + } + } + for _, index := range authoredOrder { + if !used[index] { + result = append(result, authored[index]) used[index] = true - break } } return result, nil @@ -166,10 +140,20 @@ func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]by entries := make([]deck.DeckEntry, 0, len(party)) characters := make([][]byte, 0, len(party)) wires := make([][]byte, 0, len(party)) + savedPositions := map[uint64]uint64{} + 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) @@ -179,7 +163,7 @@ func (s *Service) resolveActivePartyWires(packID, questID int) ([][]byte, [][]by } if s.decks != nil { if err := s.decks.SetStoryParty(entries); err != nil { - return nil, nil, err + 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) } } return characters, wires, nil diff --git a/go/internal/server/world/story_characters_test.go b/go/internal/server/world/story_characters_test.go index d26a963..629f3a2 100644 --- a/go/internal/server/world/story_characters_test.go +++ b/go/internal/server/world/story_characters_test.go @@ -2,8 +2,10 @@ package world import ( "bd2server/internal/server/accountstate" + "bd2server/internal/server/deck" "bd2server/internal/server/gamedata" "bd2server/internal/server/player" + "bd2server/internal/server/progress" "bd2server/internal/server/stateio" "bd2server/internal/server/wire" "os" @@ -93,6 +95,139 @@ 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. +func TestInstalledQuest13ClearKeepsSavedBattlePartyAcrossReopen(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("BD2_REAL_GAMEDATA not configured") + } + roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{1}, 996000) + if err != nil { + t.Fatal(err) + } + catalog, err := gamedata.LoadStoryCatalog(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{ + {InvenIndex: 101, ID: 10, HP: 71, Level: 30, CostumeID: 101, UseCostume: 1001, TalentLevel: 1, TalentExp: 13, Exp: 123}, + {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}, + }, 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}, + }} + path := filepath.Join(t.TempDir(), "state.db") + open := func() (*accountstate.Repository, *Service) { + t.Helper() + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = repo.Close() }) + s := testService() + s.seed.PackID, s.seed.StartQuestID, s.startingPackID = 1, 1, 1 + s.storyRoster, s.storyCatalog, s.starter = roster, catalog, seed + s.quests = catalog.Packs[1].Quests + s.state, err = progress.OpenStore(repo) + if err != nil { + t.Fatal(err) + } + s.inventory, err = player.OpenInventory(repo, seed) + if err != nil { + t.Fatal(err) + } + s.characters, err = player.OpenCharacterStore(repo, seed.Characters, s.inventory, "", "") + if err != nil { + t.Fatal(err) + } + if err = s.characters.EnsurePersisted(); err != nil { + t.Fatal(err) + } + s.decks, err = deck.OpenStore(repo, deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 104}}}) + if err != nil { + t.Fatal(err) + } + return repo, s + } + repo, s := open() + 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 { + 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) + } + 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 { + 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++ + } + 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}) + } + 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) { root := os.Getenv("BD2_REAL_GAMEDATA") if root == "" { diff --git a/go/internal/server/world/story_party_changes_test.go b/go/internal/server/world/story_party_changes_test.go index 3fd7599..a1d1600 100644 --- a/go/internal/server/world/story_party_changes_test.go +++ b/go/internal/server/world/story_party_changes_test.go @@ -24,23 +24,31 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) { if root == "" { t.Skip("set BD2_REAL_GAMEDATA for authored party transition regression") } - roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{1, 21}, 996000) + catalog, err := gamedata.LoadStoryCatalog(root, "20260923193640") if err != nil { t.Fatal(err) } - for _, pack := range []int{1, 21} { + var packs []int + for id := range catalog.Packs { + packs = append(packs, id) + } + sort.Ints(packs) + roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", packs, 996000) + if err != nil { + t.Fatal(err) + } + for _, pack := range packs { t.Run(fmt.Sprintf("pack%d", pack), func(t *testing.T) { - formations, err := gamedata.LoadQuestFormations(root, "20260923193640", pack) - if err != nil { - t.Fatal(err) - } - var ids []int - for id := range formations { - ids = append(ids, id) - } - sort.Ints(ids) + design := catalog.Packs[pack] + ids := append([]int(nil), design.MainQuestIDs...) path := filepath.Join(t.TempDir(), "state.db") - seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{{InvenIndex: 77, ID: 10, Level: 1, HP: 7, CostumeID: 101}}} + seed := &player.Starter{Version: "2.35.10", Characters: []player.Character{ + {InvenIndex: 77, ID: 10, Level: 1, HP: 7, CostumeID: 101, TalentLevel: 1}, + {InvenIndex: 78, ID: 20, Level: 1, HP: 7, CostumeID: 202, TalentLevel: 1}, + {InvenIndex: 79, ID: 130, Level: 1, HP: 7, CostumeID: 1301, TalentLevel: 1}, + {InvenIndex: 80, ID: 140, Level: 1, HP: 7, CostumeID: 1401, TalentLevel: 1}, + {InvenIndex: 81, ID: 350, Level: 1, HP: 7, CostumeID: 3501, TalentLevel: 1}, + }} open := func() (*accountstate.Repository, *Service) { t.Helper() repo, err := accountstate.Open(path) @@ -66,20 +74,35 @@ 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) + } + } s.quests = map[int]gamedata.QuestDesign{} for _, id := range ids { - s.quests[id] = gamedata.QuestDesign{ID: id} + q := design.Quests[id] + s.quests[id] = gamedata.QuestDesign{ID: id, Type: q.Type, NextQuestID: q.NextQuestID, PriorQuestID: q.PriorQuestID} } - attachTestStoryCatalog(s) + s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{pack: {ID: pack, Quests: s.quests, MainQuestIDs: ids}}} + s.packs = map[int]map[int]gamedata.QuestDesign{pack: s.quests} return repo, s } repo, s := open() - unchanged, joined := false, false - for i, id := range ids[:len(ids)-1] { + unchanged := false + for _, id := range ids { + next := design.Quests[id].NextQuestID + if next == 0 { + continue + } before, err := s.ResolveStoryParty(pack, id) if err != nil { t.Fatal(err) @@ -96,7 +119,7 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) { if err = op.Commit(); err != nil { t.Fatal(err) } - after, err := s.ResolveStoryParty(pack, ids[i+1]) + after, err := s.ResolveStoryParty(pack, next) if err != nil { t.Fatal(err) } @@ -122,11 +145,10 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) { decks++ } return nil - }); err != nil || !reflect.DeepEqual(got, want) || len(after) > 0 && decks != len(after) { - t.Fatalf("pack%d quest%d→%d joins=%v want=%v formation=%d/%d err=%v", pack, id, ids[i+1], got, want, decks, len(after), err) + }); 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(before) > 0 && len(want) == 0 - joined = joined || len(want) > 0 if pack == 1 && id == 2 { if len(got) != 0 { t.Fatal("pack-1 gate quest repeated character acquisition") @@ -140,12 +162,9 @@ func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) { t.Fatal("quest transition healed or replaced existing character") } } - if unchanged && (pack == 1 && id >= 3 || pack == 21 && joined) { - break - } } - if !unchanged || pack == 21 && !joined { - t.Fatal("authored regression did not exercise both stable and joining party transitions") + if !unchanged { + t.Fatal("authored regression did not exercise stable party transitions") } }) }