fix(server): correct batch alchemy and recipe collections and emit only story party changes
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user