From 8fd9ea0e5a67037a91ce714bd864c9906232f535 Mon Sep 17 00:00:00 2001 From: Flechazo <2558755403@qq.com> Date: Tue, 6 Oct 2026 14:37:40 +0800 Subject: [PATCH] fix(server): correct batch alchemy and recipe collections and emit only story party changes --- go/internal/server/pictorial/service.go | 2 +- go/internal/server/pictorial/service_test.go | 60 +++++++ go/internal/server/player/item_craft.go | 5 +- go/internal/server/player/item_craft_test.go | 44 +++++ .../server/world/story_party_changes.go | 35 ++++ .../server/world/story_party_changes_test.go | 152 ++++++++++++++++++ go/internal/server/world/world.go | 11 ++ 7 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 go/internal/server/world/story_party_changes.go create mode 100644 go/internal/server/world/story_party_changes_test.go diff --git a/go/internal/server/pictorial/service.go b/go/internal/server/pictorial/service.go index 80ef427..c41b123 100644 --- a/go/internal/server/pictorial/service.go +++ b/go/internal/server/pictorial/service.go @@ -161,7 +161,7 @@ func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) { } } for _, row := range d.Cooking { - if items[[2]uint64{14, row.RecipeID}] || books[Entry{GroupID: gamedata.PictorialCooking, ID: row.ID}] { + if items[[2]uint64{7, row.RecipeID}] || books[Entry{GroupID: gamedata.PictorialCooking, ID: row.ID}] { entries = append(entries, Entry{gamedata.PictorialCooking, row.ID, row.BuffID}) } } diff --git a/go/internal/server/pictorial/service_test.go b/go/internal/server/pictorial/service_test.go index 9164723..6326e80 100644 --- a/go/internal/server/pictorial/service_test.go +++ b/go/internal/server/pictorial/service_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "testing" + "bd2server/internal/server/accountstate" "bd2server/internal/server/gamedata" "bd2server/internal/server/player" "bd2server/internal/server/wire" @@ -161,6 +162,65 @@ func TestGrowthResourceIDCannotMasqueradeAsCollectionItem(t *testing.T) { } } +func TestLearnedRecipePictorialAndBuffSurviveSQLiteRestart(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("set BD2_REAL_GAMEDATA for current recipe collection regression") + } + design, err := gamedata.LoadPictorialDesign(root, "20260923193640") + if err != nil { + t.Fatal(err) + } + // Current CookingPictorialBookTable row 1 points at recipe 101; the + // client EElementType.CookingRecipe is 7, while 14 is an unrelated use item. + path := filepath.Join(t.TempDir(), "state.db") + repo, err := accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = repo.Close() }() + items, err := player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + _, err = items.GrantOnce("unrelated-use-item", []gamedata.BattleReward{{Type: 14, ID: 101, Count: 1}}) + if err != nil { + t.Fatal(err) + } + owned := &ownedState{items: items.All()} + service := &Service{Design: design, Owned: owned} + before, _, err := service.Snapshot() + if err != nil || len(before) != 0 { + t.Fatalf("use item unlocked a recipe collection: %v %v", before, err) + } + _, err = items.GrantOnce("learned-recipe", []gamedata.BattleReward{{Type: 7, ID: 101, Count: 1}}) + if err != nil { + t.Fatal(err) + } + if err = repo.Close(); err != nil { + t.Fatal(err) + } + repo, err = accountstate.Open(path) + if err != nil { + t.Fatal(err) + } + items, err = player.OpenInventory(repo, &player.Starter{Version: "2.35.10"}) + if err != nil { + t.Fatal(err) + } + owned.items = items.All() + entries, buffs, err := service.Snapshot() + if err != nil || len(entries) != 1 || entries[0].GroupID != gamedata.PictorialCooking || entries[0].ID != 1 || len(buffs) != 1 { + t.Fatalf("recipe collection after restart: %v buffs=%v err=%v", entries, buffs, err) + } + for _, route := range []string{"/PictorialBookInfo", "/AllCharRefresh"} { + _, body, handled, err := service.Handle(route, wire.AppendVarint(nil, 1, 1)) + if err != nil || !handled || len(body) == 0 { + t.Fatalf("learned recipe projection %s: %x %v", route, body, err) + } + } +} + func BenchmarkCharInfoMaxHealthLoadedDesign(b *testing.B) { root := os.Getenv("BD2_REAL_GAMEDATA") if root == "" { diff --git a/go/internal/server/player/item_craft.go b/go/internal/server/player/item_craft.go index fca5153..fdfa0f6 100644 --- a/go/internal/server/player/item_craft.go +++ b/go/internal/server/player/item_craft.go @@ -132,7 +132,10 @@ func (s *ItemCraftService) Handle(path string, request []byte) (int, []byte, boo if recipe.Class == 7 { limitIndex = 1 } - if len(current.Values) <= limitIndex || count > uint64(current.Values[limitIndex]) { + // Batch count is the missing intermediate quantity requested by equipment + // making (EquipmentMakingUI.OnClickUI), not the AlchemyUI craft slider. + // Its full material graph and int32 quantities are validated below. + if path != "/AlchemyBatch" && (len(current.Values) <= limitIndex || current.Values[limitIndex] < 1 || math.IsNaN(current.Values[limitIndex]) || math.IsInf(current.Values[limitIndex], 0) || count > uint64(current.Values[limitIndex])) { return fail(fmt.Errorf("craft: count exceeds talent limit")) } gain, catalyst, maximum, err := s.talents.CraftTalent(character.ID, character.TalentLevel, recipe.Class, recipe.TalentLevel, count, character.TalentExp) diff --git a/go/internal/server/player/item_craft_test.go b/go/internal/server/player/item_craft_test.go index 4e36f6b..f6cc7f5 100644 --- a/go/internal/server/player/item_craft_test.go +++ b/go/internal/server/player/item_craft_test.go @@ -221,6 +221,50 @@ func TestAlchemyConversionChargesProducedQuantity(t *testing.T) { } } +// Equipment making requests missing intermediate resources in one batch; the +// amount can exceed the ordinary alchemy slider limit. Check settlement and +// replay using the same SQLite transaction boundary as the request dispatcher. +func TestAlchemyBatchSQLiteCanExceedOrdinaryCraftLimit(t *testing.T) { + repo, err := accountstate.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + defer repo.Close() + s, items, chars, wallet := craftFixture(t, repo, 8) + rule := s.talents.Rules[[2]uint64{42, 2}] + rule.Values[0] = 1 + s.talents.Rules[[2]uint64{42, 2}] = rule + request := craftRequest(items, 102, 2, gamedata.BattleReward{Type: 8, ID: 2, Count: 2}, gamedata.BattleReward{Type: 8, ID: 1, Count: 20}) + if _, _, _, err = s.Handle("/Alchemy", request); err == nil { + t.Fatal("ordinary alchemy exceeded its slider limit") + } + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + _, response, _, err := s.Handle("/AlchemyBatch", request) + if err != nil { + _ = op.Rollback() + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + c, _ := chars.Find(77) + if craftCount(items, 8, 3) != 2 || wallet.Snapshot().Catalyst != 84 || c.TalentExp != 20 { + t.Fatalf("batch settlement items=%+v currency=%+v producer=%+v", items.All(), wallet.Snapshot(), c) + } + next, reloadedItems, reloadedChars, reloadedWallet := craftFixture(t, repo, 8) + _, replay, _, err := next.Handle("/AlchemyBatch", request) + if err != nil || !bytes.Equal(response, replay) { + t.Fatalf("persisted replay response=%x err=%v", replay, err) + } + c, _ = reloadedChars.Find(77) + if craftCount(reloadedItems, 8, 3) != 2 || reloadedWallet.Snapshot().Catalyst != 84 || c.TalentExp != 20 { + t.Fatal("persisted replay changed settlement") + } +} + type failCraftReceipt struct{ stateio.Store } func (s failCraftReceipt) Save(name string, b []byte) error { diff --git a/go/internal/server/world/story_party_changes.go b/go/internal/server/world/story_party_changes.go new file mode 100644 index 0000000..698c264 --- /dev/null +++ b/go/internal/server/world/story_party_changes.go @@ -0,0 +1,35 @@ +package world + +import ( + "bd2server/internal/server/player" + "bd2server/internal/server/wire" +) + +// QuestClear.CharInfo is processed by AddCharDBInfoReward and the join UI, +// not as a complete roster snapshot. DeckInfo still carries the full formation; +// only joining characters or authored level/costume changes belong in CharInfo. +func storyPartyChanges(previous []player.Character, next [][]byte) ([][]byte, error) { + type appearance struct{ id, level, costume, useCostume uint64 } + known := make(map[uint64]appearance, len(previous)) + for _, c := range previous { + known[c.InvenIndex] = appearance{c.ID, c.Level, c.CostumeID, c.UseCostume} + } + var changed [][]byte + for _, body := range next { + var index uint64 + var value appearance + for field, destination := range map[int]*uint64{1: &index, 2: &value.id, 4: &value.level, 5: &value.costume, 7: &value.useCostume} { + var err error + *destination, _, err = wire.Varint(body, field) + if err != nil { + return nil, err + } + } + if old, exists := known[index]; exists && old == value { + continue + } + changed = append(changed, body) + known[index] = value + } + return changed, nil +} diff --git a/go/internal/server/world/story_party_changes_test.go b/go/internal/server/world/story_party_changes_test.go new file mode 100644 index 0000000..3fd7599 --- /dev/null +++ b/go/internal/server/world/story_party_changes_test.go @@ -0,0 +1,152 @@ +package world + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "bd2server/internal/server/accountstate" + "bd2server/internal/server/deck" + "bd2server/internal/server/gamedata" + "bd2server/internal/server/player" + "bd2server/internal/server/progress" + "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. +func TestInstalledQuestClearProjectsPartyChangesOnly(t *testing.T) { + root := os.Getenv("BD2_REAL_GAMEDATA") + if root == "" { + t.Skip("set BD2_REAL_GAMEDATA for authored party transition regression") + } + roster, err := gamedata.LoadStoryCharacterCatalog(root, "20260923193640", []int{1, 21}, 996000) + if err != nil { + t.Fatal(err) + } + for _, pack := range []int{1, 21} { + 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) + 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}}} + 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.startingPackID = pack, pack + s.storyRoster, s.starter = roster, seed + 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: 77}}}) + if err != nil { + t.Fatal(err) + } + s.quests = map[int]gamedata.QuestDesign{} + for _, id := range ids { + s.quests[id] = gamedata.QuestDesign{ID: id} + } + attachTestStoryCatalog(s) + return repo, s + } + repo, s := open() + unchanged, joined := false, false + for i, id := range ids[:len(ids)-1] { + before, err := s.ResolveStoryParty(pack, id) + if err != nil { + t.Fatal(err) + } + op, err := repo.BeginOperation() + if err != nil { + t.Fatal(err) + } + _, body, _, err := s.Handle("/QuestClear", selectionRequest(uint64(id), uint64(pack), 0)) + if err != nil { + _ = op.Rollback() + t.Fatal(err) + } + if err = op.Commit(); err != nil { + t.Fatal(err) + } + after, err := s.ResolveStoryParty(pack, ids[i+1]) + if err != nil { + t.Fatal(err) + } + known := map[uint64]player.Character{} + for _, c := range before { + known[c.InvenIndex] = c + } + var want, got []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 + } + if f.Number == 4 { + 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) + } + 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") + } + if err = repo.Close(); err != nil { + t.Fatal(err) + } + repo, s = open() + c, ok := s.characters.Find(77) + if !ok || c.HP != 7 { + 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") + } + }) + } +} diff --git a/go/internal/server/world/world.go b/go/internal/server/world/world.go index c1c7737..b5a7927 100644 --- a/go/internal/server/world/world.go +++ b/go/internal/server/world/world.go @@ -450,6 +450,13 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) return 0, nil, true, fmt.Errorf("%w: quest %d is not active", ErrInvalidRequest, quest) } 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) + if err != nil { + return 0, nil, true, err + } + } items, questEquipment, err := s.grantQuestRewards(pack, quest, design.Rewards[s.questDifficultyFor(pack, quest)]) if err != nil { return 0, nil, true, err @@ -477,6 +484,10 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) if err != nil { return 0, nil, true, err } + nextChars, err = storyPartyChanges(previousParty, nextChars) + if err != nil { + return 0, nil, true, err + } nextItems, err = s.ensureQuestItems(pack, next) if err != nil { return 0, nil, true, err