fix(server): correct batch alchemy and recipe collections and emit only story party changes

This commit is contained in:
2026-10-06 14:37:40 +08:00
parent ced789adc1
commit 8fd9ea0e5a
7 changed files with 307 additions and 2 deletions
+1 -1
View File
@@ -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})
}
}
@@ -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 == "" {
+4 -1
View File
@@ -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)
@@ -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 {
@@ -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
}
@@ -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")
}
})
}
}
+11
View File
@@ -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