feat: complete equipment, inventory, mail, and talent flows

This commit is contained in:
2026-09-25 01:36:49 +08:00
parent f94cfa1cf3
commit 7cb5e36c2a
33 changed files with 3309 additions and 93 deletions
+42 -2
View File
@@ -77,6 +77,7 @@ func serve(args []string) (serveErr error) {
gachaScheduleSeed := fs.String("gacha-schedule-seed", "", "versioned dynamic gacha schedule")
gameDir := fs.String("game-dir", "", "Brown Dust II client directory (required)")
identityPlugin := fs.String("identity-plugin", "", "optional BD2LocalIdentity.dll override for development")
devToolsConfig := fs.String("dev-tools-config", "", "optional local development-tool settings JSON")
if err := fs.Parse(args); err != nil {
return err
}
@@ -240,12 +241,16 @@ func serve(args []string) (serveErr error) {
if err != nil {
return fmt.Errorf("read account seed hope powder: %w", err)
}
catalyst, err := login.SeedCatalyst()
if err != nil {
return fmt.Errorf("read account seed catalyst: %w", err)
}
equipMileage, equipMileageExchangeGage, err := login.SeedEquipmentMileage()
if err != nil {
return fmt.Errorf("read account seed equipment mileage: %w", err)
}
wallet, err := player.OpenWallet(stateRepository, player.Currency{
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Mileage: mileage, HopePowder: hopePowder,
Gold: gold, FreeJewelry: freeJewelry, Jewelry: jewelry, Catalyst: catalyst, Mileage: mileage, HopePowder: hopePowder,
EquipMileage: equipMileage, EquipMileageExchangeGage: equipMileageExchangeGage,
})
if err != nil {
@@ -254,6 +259,26 @@ func serve(args []string) (serveErr error) {
if err := login.AttachCurrencies(wallet); err != nil {
return fmt.Errorf("attach wallet to login: %w", err)
}
slotDesign, err := gamedata.LoadInventorySlotDesign(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return fmt.Errorf("load inventory slot GameData: %w", err)
}
itemSlots, storageSlots, equipmentInventorySlots, equipmentStorageSlots, err := login.SeedInventorySlots()
if err != nil {
return fmt.Errorf("read account seed inventory slots: %w", err)
}
inventorySlots, err := player.OpenInventorySlots(stateRepository, slotDesign, player.InventorySlotCounts{
Items: itemSlots, Storage: storageSlots, Equipment: equipmentInventorySlots, EquipmentStorage: equipmentStorageSlots,
}, wallet)
if err != nil {
return fmt.Errorf("load inventory slot state: %w", err)
}
if *devToolsConfig != "" {
inventorySlots.AttachDevelopmentSettings(filepath.Clean(*devToolsConfig))
}
if err := login.AttachInventorySlots(inventorySlots); err != nil {
return fmt.Errorf("attach inventory slots to login: %w", err)
}
mailService, err := mail.OpenService(stateRepository, mailbox, ownedItems, wallet)
if err != nil {
return fmt.Errorf("load mail state: %w", err)
@@ -299,6 +324,17 @@ func serve(args []string) (serveErr error) {
if err := ownedEquipment.AttachUpgrade(equipmentUpgrade, wallet, ownedItems); err != nil {
return fmt.Errorf("attach equipment upgrade GameData: %w", err)
}
equipmentCraft, err := gamedata.LoadEquipmentCraftDesign(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return fmt.Errorf("load equipment crafting GameData: %w", err)
}
talentGrowth, err := gamedata.LoadTalentGrowthDesign(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return fmt.Errorf("load talent growth GameData: %w", err)
}
if err := ownedEquipment.AttachCraft(equipmentCraft); err != nil {
return fmt.Errorf("attach equipment crafting GameData: %w", err)
}
equipmentSmelting, err := gamedata.LoadEquipmentSmeltingDesign(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return fmt.Errorf("load equipment smelting GameData: %w", err)
@@ -391,6 +427,9 @@ func serve(args []string) (serveErr error) {
if err := worldService.CharacterService().AttachWallet(wallet); err != nil {
return fmt.Errorf("attach character promotion wallet: %w", err)
}
if err := worldService.CharacterService().AttachTalentGrowth(talentGrowth); err != nil {
return fmt.Errorf("attach character talent growth: %w", err)
}
costumePotentialDesign, err := gamedata.LoadCostumePotentialDesign(filepath.Clean(*gameData), *gameDataVersion)
if err != nil {
return fmt.Errorf("load costume potential GameData: %w", err)
@@ -415,6 +454,7 @@ func serve(args []string) (serveErr error) {
deckStateStore,
ownedItems,
ownedEquipment,
inventorySlots,
charAwakeService,
costumePotentialService,
starter,
@@ -432,7 +472,7 @@ func serve(args []string) (serveErr error) {
if stateRepository.IsNew() {
if err := ensureAccountStateInitialized(
progressState, deckStateStore, ownedItems, ownedEquipment,
worldService.CharacterService(), collection, wallet, mailService, missionService,
worldService.CharacterService(), collection, wallet, inventorySlots, mailService, missionService,
); err != nil {
return fmt.Errorf("initialize complete account state generation: %w", err)
}
+59
View File
@@ -35,6 +35,7 @@ type LoginSeed struct {
currencies CurrencyProvider
purchaseCounts PurchaseCountProvider
presetSlots PresetSlotProvider
inventorySlots InventorySlotProvider
firstGacha *bool
}
@@ -49,6 +50,10 @@ type HopePowderProvider interface {
HopePowderBalance() uint64
}
type CatalystProvider interface {
CatalystBalance() uint64
}
type EquipmentMileageProvider interface {
EquipmentMileageBalances() (mileage, exchangeGage uint64)
}
@@ -67,6 +72,13 @@ type PresetSlotProvider interface {
PresetSlotCount() uint64
}
// InventorySlotProvider owns the four mutable UserDBInfo capacity fields.
// Development overrides are applied by the provider as a login-time view;
// the immutable account seed is never rewritten.
type InventorySlotProvider interface {
UserInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error)
}
func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error {
if provider == nil {
return errors.New("account: nil currency provider")
@@ -91,6 +103,29 @@ func (s *LoginSeed) AttachPresetSlots(provider PresetSlotProvider) error {
return nil
}
func (s *LoginSeed) AttachInventorySlots(provider InventorySlotProvider) error {
if provider == nil {
return errors.New("account: nil inventory slot provider")
}
s.inventorySlots = provider
return nil
}
func (s *LoginSeed) SeedInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error) {
if err = s.Validate(); err != nil {
return 0, 0, 0, 0, err
}
values := []*uint64{&items, &storage, &equipment, &equipmentStorage}
for i, field := range []int{5, 6, 10, 15} {
value, _, readErr := wire.Varint(s.UserInfo, field)
if readErr != nil {
return 0, 0, 0, 0, readErr
}
*values[i] = value
}
return items, storage, equipment, equipmentStorage, nil
}
// SeedCurrencies returns the immutable starting balances embedded in the
// versioned UserDBInfo template. Mutable balances live in player.Wallet.
func (s *LoginSeed) SeedCurrencies() (gold, freeJewelry, jewelry, mileage uint64, err error) {
@@ -133,6 +168,14 @@ func (s *LoginSeed) SeedHopePowder() (uint64, error) {
return value, nil
}
func (s *LoginSeed) SeedCatalyst() (uint64, error) {
if err := s.Validate(); err != nil {
return 0, err
}
value, _, err := wire.Varint(s.UserInfo, 11)
return value, err
}
func (s *LoginSeed) SeedEquipmentMileage() (mileage, exchangeGage uint64, err error) {
if err = s.Validate(); err != nil {
return 0, 0, err
@@ -284,6 +327,11 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
if user, _, err = wire.ReplaceVarint(user, 23, mileage); err != nil {
return nil, fmt.Errorf("account: replace mileage: %w", err)
}
if provider, ok := s.currencies.(CatalystProvider); ok {
if user, _, err = wire.ReplaceVarint(user, 11, provider.CatalystBalance()); err != nil {
return nil, fmt.Errorf("account: replace catalyst: %w", err)
}
}
if provider, ok := s.currencies.(HopePowderProvider); ok {
if user, _, err = wire.ReplaceVarint(user, 24, provider.HopePowderBalance()); err != nil {
return nil, fmt.Errorf("account: replace hope powder: %w", err)
@@ -321,6 +369,17 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
return nil, fmt.Errorf("account: replace preset slots: %w", err)
}
}
if s.inventorySlots != nil {
items, storage, equipment, equipmentStorage, err := s.inventorySlots.UserInventorySlots()
if err != nil {
return nil, fmt.Errorf("account: inventory slots: %w", err)
}
for field, value := range map[int]uint64{5: items, 6: storage, 10: equipment, 15: equipmentStorage} {
if user, _, err = wire.ReplaceVarint(user, field, value); err != nil {
return nil, fmt.Errorf("account: replace inventory slot field %d: %w", field, err)
}
}
}
user = wire.AppendBytes(user, 3, sessionKey)
return append(wire.AppendBytes(nil, 1, user), s.ResponseFields...), nil
}
+46
View File
@@ -209,6 +209,52 @@ func TestAttachPresetSlotsRejectsNil(t *testing.T) {
}
}
type loginInventorySlotFixture struct {
items, storage, equipment, equipmentStorage uint64
err error
}
func (f *loginInventorySlotFixture) UserInventorySlots() (uint64, uint64, uint64, uint64, error) {
return f.items, f.storage, f.equipment, f.equipmentStorage, f.err
}
func TestLoginReplacesAllInventorySlotFieldsFromProvider(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 100, 10: 500, 15: 100} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: user}
provider := &loginInventorySlotFixture{items: 500, storage: 100, equipment: 2000, equipmentStorage: 100}
if err := seed.AttachInventorySlots(provider); err != nil {
t.Fatal(err)
}
response, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
result, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("missing UserInfo: %v", err)
}
for field, want := range map[int]uint64{5: 500, 6: 100, 10: 2000, 15: 100} {
if got, found, err := wire.Varint(result, field); err != nil || !found || got != want {
t.Fatalf("field %d=%d found=%t err=%v want=%d", field, got, found, err, want)
}
}
}
func TestSeedInventorySlotsReadsUserInfoFields(t *testing.T) {
user := wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{5: 100, 6: 101, 10: 500, 15: 102} {
user = wire.AppendVarint(user, field, value)
}
seed := &LoginSeed{Version: ProtocolVersion(), PacketCode: 3, UserInfo: user}
items, storage, equipment, equipmentStorage, err := seed.SeedInventorySlots()
if err != nil || items != 100 || storage != 101 || equipment != 500 || equipmentStorage != 102 {
t.Fatalf("slots=%d/%d/%d/%d err=%v", items, storage, equipment, equipmentStorage, err)
}
}
func byteFields(data []byte, number int) [][]byte {
var result [][]byte
_ = wire.Walk(data, func(field wire.Field) error {
+6 -6
View File
@@ -75,15 +75,15 @@ func validateSnapshot(snapshot validationSnapshot) []Problem {
problems = append(problems, nextIndexProblems("collection.next_character_index", []string{"collection", "next_character_index"}, snapshot.characterNextIndex, snapshot.characterIndices)...)
problems = append(problems, nextIndexProblems("collection.next_costume_index", []string{"collection", "next_costume_index"}, snapshot.costumeNextIndex, snapshot.costumeIndices)...)
ownedEquipment := make(map[uint64]bool, len(snapshot.equipmentIndices))
for _, index := range snapshot.equipmentIndices {
ownedEquipment[index] = true
}
for _, grant := range snapshot.equipmentGrants {
if grant.index == 0 || !ownedEquipment[grant.index] {
// Equipment can be dismantled after it was granted. The grant entry is
// an issuance/idempotency ledger, just like items.grant_items, so the
// historical index need only be nonzero and below the monotonic next
// index; it does not have to remain in the current ownership set.
if grant.index == 0 || grant.index >= snapshot.equipmentNextIndex {
problems = append(problems, Problem{
Code: "equipment.grant_missing_equipment", Path: []string{"equipment", "grants", grant.identity},
Message: "equipment grant must point to an owned equipment instance", RelatedIDs: []uint64{grant.index},
Message: "equipment grant index must be nonzero and below next_index", RelatedIDs: []uint64{grant.index},
})
}
}
+10
View File
@@ -35,6 +35,16 @@ func TestValidateSnapshotAcceptsConsumedGrantedItem(t *testing.T) {
}
}
func TestValidateSnapshotAcceptsDismantledGrantedEquipment(t *testing.T) {
snapshot := validValidationSnapshot()
snapshot.equipmentIndices = nil
// Index 20 was issued below next_index and its grant marker must survive
// dismantling so a retried grant cannot create a duplicate instance.
if problems := validateSnapshot(snapshot); len(problems) != 0 {
t.Fatalf("unexpected problems: %#v", problems)
}
}
func TestValidateSnapshotPortsEveryExternalValidatorRule(t *testing.T) {
snapshot := validValidationSnapshot()
snapshot.formatVersion = 2
+6 -1
View File
@@ -207,6 +207,8 @@ func (s *Service) saveSelection(request []byte) (int, []byte, bool, error) {
if group, ok := s.regular.Group(groupID); ok && group.GachaSubType == 1 && group.TenTimeGachaID != 0 {
if special := s.regular.SpecialSelectionIDs(group.TenTimeGachaID); len(special) != 0 {
eligible = special
} else if fiveStars := s.regular.FiveStarIDs(group.TenTimeGachaID); group.UseSelectionOnlyFixedApply && len(fiveStars) != 0 {
eligible = fiveStars
}
}
validItems := make(map[uint64]bool, len(eligible))
@@ -348,6 +350,9 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
}
}
if !already {
if group.BuyLimitCount != 0 && s.collection.GachaUser(group.ID).TotalBuyCount+uint64(design.Count) > group.BuyLimitCount {
return 146, nil, true, fmt.Errorf("gacha: group %d buy limit %d exhausted", group.ID, group.BuyLimitCount)
}
day := dailyResetKey(s.now())
var dailyLimit uint64
if isDailyFree {
@@ -384,7 +389,7 @@ func (s *Service) buy(request []byte, seq uint64) (int, []byte, bool, error) {
if !group.UseSelectionOnlyFixedApply {
normalSelected = selected
}
if group.IsSelectedFromPity {
if group.IsSelectedFromPity || group.UseSelectionOnlyFixedApply {
pitySelected = selected
}
roll, fixedResult, err = design.RollWithCostumeFixedSelection(s.collection.GachaFixedCount(group.FixedID, 0), s.collection.GachaFixedCount(group.FixedID, 1), fixed, normalSelected, pitySelected)
+87
View File
@@ -1338,6 +1338,93 @@ func TestOrdinaryTwelvePickGacha101UsesSavedSelectionGroupAndPersists(t *testing
}
}
func TestInstalledNewbieSelectionGuaranteeStopsAfterThirtyWithoutPoints(t *testing.T) {
root := os.Getenv("BD2_TEST_GAMEDATA_ROOT")
if root == "" {
t.Skip("set BD2_TEST_GAMEDATA_ROOT for installed GameData integration test")
}
infinite, err := gamedata.LoadInfiniteGacha(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
regular, _, err := gamedata.LoadActiveGachaForSchedules(root, "20260923193640", []uint64{1009}, nil)
if err != nil {
t.Fatal(err)
}
choices := regular.FiveStarIDs(31)
if len(choices) < 3 {
t.Fatalf("newbie selectable five-stars=%v", choices)
}
storage := stateio.NewMemory()
collection, err := player.OpenCollectionStore(storage, nil)
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{Jewelry: 6000})
if err != nil {
t.Fatal(err)
}
service, err := NewService(infinite, regular, collection, wallet)
if err != nil {
t.Fatal(err)
}
service.BeginSession("newbie-30")
selection := wire.AppendVarint(nil, 1, 1)
selected := map[uint64]bool{}
for slot, costumeID := range choices[:3] {
selected[costumeID] = true
entry := wire.AppendVarint(nil, 1, 1009)
if slot != 0 {
entry = wire.AppendVarint(entry, 2, uint64(slot))
}
entry = wire.AppendVarint(entry, 3, costumeID)
selection = wire.AppendBytes(selection, 2, entry)
}
if _, _, _, err := service.Handle("/GachaSelectionSave", selection); err != nil {
t.Fatal(err)
}
for draw := uint64(0); draw < 3; draw++ {
request := wire.AppendVarint(nil, 1, draw+2)
request = wire.AppendVarint(request, 2, 31)
request = wire.AppendVarint(request, 3, 1)
code, response, handled, err := service.Handle("/GachaBuy", request)
if err != nil || !handled || code != 146 {
t.Fatalf("draw %d code=%d handled=%v err=%v", draw+1, code, handled, err)
}
point, _, err := wire.Varint(response, 2)
if err != nil || point != 0 {
t.Fatalf("draw %d point=%d err=%v", draw+1, point, err)
}
applySort, found, err := wire.Varint(response, 4)
if err != nil || !found || applySort != 9 {
t.Fatalf("draw %d selected guarantee sort=%d found=%v err=%v response=%x", draw+1, applySort, found, err, response)
}
grant, ok := collection.Grant(service.requestIdentity(31, draw+2))
if !ok || len(grant.ViewCostumeIDs) != 10 || !selected[grant.ViewCostumeIDs[9]] {
t.Fatalf("draw %d grant=%+v", draw+1, grant)
}
}
user := collection.GachaUser(1009)
if user.TotalBuyCount != 30 || user.Point != 0 || wallet.Snapshot().Jewelry != 0 {
t.Fatalf("newbie state user=%+v wallet=%+v", user, wallet.Snapshot())
}
for _, fixed := range collection.GachaFixedStates() {
if fixed.FixedID == 3 && fixed.Type == 1 && fixed.Count != 0 {
t.Fatalf("newbie five-star fixed count accumulated: %+v", fixed)
}
}
before := wallet.Snapshot()
fourth := wire.AppendVarint(nil, 1, 5)
fourth = wire.AppendVarint(fourth, 2, 31)
fourth = wire.AppendVarint(fourth, 3, 1)
if _, _, _, err := service.Handle("/GachaBuy", fourth); err == nil {
t.Fatal("newbie draw accepted after 30 pulls")
}
if wallet.Snapshot() != before || collection.GachaUser(1009).TotalBuyCount != 30 {
t.Fatalf("rejected fourth draw mutated state wallet=%+v user=%+v", wallet.Snapshot(), collection.GachaUser(1009))
}
}
func countFields(data []byte, number int) int {
count := 0
_ = wire.Walk(data, func(field wire.Field) error {
+354
View File
@@ -0,0 +1,354 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
)
const equipmentMakingTalentClass = 9
type EquipmentCraftRecipe struct {
ID uint64
ResultCount uint64
TalentLevel uint64
Costs []PromotionCost
Results []WeightedEquipment
}
type equipmentMakingTalent struct {
SkillGroup uint64
GrowthGroup uint64
}
type equipmentMakingTalentSkill struct {
Experience uint64
Catalyst uint64
}
type GeneratedEquipment struct {
Design EquipmentDesign
Main []EquipmentOptionChoice
Sub []EquipmentOptionChoice
Private *EquipmentOptionChoice
}
type EquipmentCraftDesign struct {
Recipes map[uint64]EquipmentCraftRecipe
Equipment map[uint64]EquipmentDesign
CharTalent map[uint64]equipmentMakingTalent
Skills map[[2]uint64]equipmentMakingTalentSkill
TalentNeed map[[2]uint64]uint64
draw func(uint64) (uint64, error)
}
func LoadEquipmentCraftDesign(root, version string) (*EquipmentCraftDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadEquipmentCraftDesign(db)
}
func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) {
design := &EquipmentCraftDesign{
Recipes: make(map[uint64]EquipmentCraftRecipe), Equipment: make(map[uint64]EquipmentDesign),
CharTalent: make(map[uint64]equipmentMakingTalent), Skills: make(map[[2]uint64]equipmentMakingTalentSkill),
TalentNeed: make(map[[2]uint64]uint64), draw: cryptoDraw,
}
rows, err := db.Query("SELECT id,ProtoBuf FROM EquipmentMakingTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
counts, _ := packedInts(raw, 4)
materialIDs, _ := packedInts(raw, 5)
materialTypes, _ := packedInts(raw, 6)
resultCounts, _ := packedInts(raw, 7)
boxIDs, _ := packedInts(raw, 8)
resultTypes, _ := packedInts(raw, 9)
talentLevels, _ := packedInts(raw, 11)
if len(counts) == 0 || len(counts) != len(materialIDs) || len(counts) != len(materialTypes) ||
len(resultCounts) != 1 || resultCounts[0] != 1 || len(boxIDs) != 1 ||
len(resultTypes) != 1 || resultTypes[0] != 9 || len(talentLevels) != 1 || talentLevels[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d malformed", id)
}
var box []byte
if err := db.QueryRow("SELECT ProtoBuf FROM RandomBoxTable WHERE id=?", boxIDs[0]).Scan(&box); err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d random box: %w", id, err)
}
groups, _ := packedInts(box, 9)
if len(groups) != 1 || groups[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making random box %d malformed", boxIDs[0])
}
results, equipmentOnly, err := classifyEquipmentRewardPool(db, groups[0])
if err != nil || !equipmentOnly || len(results) == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d reward group: %w", id, err)
}
recipe := EquipmentCraftRecipe{ID: id, ResultCount: resultCounts[0], TalentLevel: talentLevels[0], Results: results}
for i := range counts {
if counts[i] == 0 || materialIDs[i] == 0 || materialTypes[i] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d invalid material", id)
}
recipe.Costs = append(recipe.Costs, PromotionCost{Type: materialTypes[i], ID: materialIDs[i], Count: counts[i]})
}
if err := design.loadEquipmentPool(db, results); err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making recipe %d: %w", id, err)
}
design.Recipes[id] = recipe
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
talents := make(map[uint64]equipmentMakingTalent)
rows, err = db.Query("SELECT id,ProtoBuf FROM TalentTable")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
classes, _ := packedInts(raw, 4)
growthGroups, _ := packedInts(raw, 6)
skillGroups, _ := packedInts(raw, 18)
if len(classes) == 1 && classes[0] == equipmentMakingTalentClass && len(growthGroups) == 1 && len(skillGroups) == 1 {
talents[id] = equipmentMakingTalent{SkillGroup: skillGroups[0], GrowthGroup: growthGroups[0]}
}
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
talentIDs, _ := packedInts(raw, 18)
if len(talentIDs) == 1 {
if talent, ok := talents[talentIDs[0]]; ok {
design.CharTalent[id] = talent
}
}
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable WHERE classType=?", equipmentMakingTalentClass)
if err != nil {
return nil, err
}
for rows.Next() {
var group, level uint64
var raw []byte
if err := rows.Scan(&group, &level, &raw); err != nil {
rows.Close()
return nil, err
}
catalysts, _ := packedInts(raw, 1)
experience, _ := packedInts(raw, 5)
if len(catalysts) > 1 || len(experience) > 1 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment making talent skill %d/%d malformed", group, level)
}
var skill equipmentMakingTalentSkill
if len(experience) == 1 {
skill.Experience = experience[0]
}
if len(catalysts) == 1 {
skill.Catalyst = catalysts[0]
}
design.Skills[[2]uint64{group, level}] = skill
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentGrowthTable")
if err != nil {
return nil, err
}
for rows.Next() {
var group, level uint64
var raw []byte
if err := rows.Scan(&group, &level, &raw); err != nil {
rows.Close()
return nil, err
}
need, _ := packedInts(raw, 6)
if len(need) == 1 {
design.TalentNeed[[2]uint64{group, level}] = need[0]
}
}
if err := rows.Close(); err != nil {
return nil, err
}
return design, nil
}
func (d *EquipmentCraftDesign) loadEquipmentPool(db *sql.DB, pool []WeightedEquipment) error {
for _, result := range pool {
if result.ID == 0 {
if len(result.Children) == 0 {
return fmt.Errorf("empty nested equipment reward")
}
if err := d.loadEquipmentPool(db, result.Children); err != nil {
return err
}
continue
}
if len(result.Children) != 0 {
return fmt.Errorf("equipment reward %d also has children", result.ID)
}
if _, exists := d.Equipment[result.ID]; exists {
continue
}
equipment, err := loadEquipmentDesign(db, result.ID)
if err != nil {
return err
}
d.Equipment[result.ID] = equipment
}
return nil
}
func (d *EquipmentCraftDesign) Recipe(id uint64) (EquipmentCraftRecipe, bool) {
if d == nil {
return EquipmentCraftRecipe{}, false
}
recipe, ok := d.Recipes[id]
return recipe, ok
}
func (d *EquipmentCraftDesign) Generate(recipeID uint64) (GeneratedEquipment, error) {
if d == nil {
return GeneratedEquipment{}, fmt.Errorf("gamedata: equipment crafting unavailable")
}
recipe, ok := d.Recipes[recipeID]
if !ok {
return GeneratedEquipment{}, fmt.Errorf("gamedata: unknown equipment making recipe %d", recipeID)
}
draw := d.draw
if draw == nil {
draw = cryptoDraw
}
id, err := rollEquipmentChoiceWith(recipe.Results, draw)
if err != nil {
return GeneratedEquipment{}, err
}
equipment, ok := d.Equipment[id]
if !ok {
return GeneratedEquipment{}, fmt.Errorf("gamedata: equipment making result %d missing", id)
}
main, sub, private, err := rollEquipmentOptionsWith(equipment, draw)
if err != nil {
return GeneratedEquipment{}, err
}
return GeneratedEquipment{Design: equipment, Main: main, Sub: sub, Private: private}, nil
}
func rollEquipmentOptionsWith(design EquipmentDesign, draw func(uint64) (uint64, error)) (main, sub []EquipmentOptionChoice, private *EquipmentOptionChoice, err error) {
pick := func(groups []OptionGroup) ([]EquipmentOptionChoice, error) {
out := make([]EquipmentOptionChoice, 0, len(groups))
for _, group := range groups {
pool := make([]WeightedEquipment, 0, len(group.Choices))
for _, choice := range group.Choices {
pool = append(pool, WeightedEquipment{ID: choice.ID, Weight: choice.Weight})
}
id, err := rollEquipmentChoiceWith(pool, draw)
if err != nil {
return nil, err
}
out = append(out, EquipmentOptionChoice{GroupID: group.ID, ID: id})
}
return out, nil
}
if main, err = pick(design.Main); err != nil {
return nil, nil, nil, err
}
if sub, err = pick(design.Sub); err != nil {
return nil, nil, nil, err
}
choices, err := pick(design.Private)
if err != nil {
return nil, nil, nil, err
}
if len(choices) > 1 {
return nil, nil, nil, fmt.Errorf("gamedata: equipment %d has %d private option groups", design.ID, len(choices))
}
if len(choices) == 1 {
choice := choices[0]
private = &choice
}
return main, sub, private, nil
}
func (d *EquipmentCraftDesign) Talent(characterID, characterLevel, recipeLevel, count, currentExperience uint64) (gain, catalyst, maximum uint64, err error) {
if d == nil || characterID == 0 || characterLevel == 0 || recipeLevel == 0 || count == 0 {
return 0, 0, 0, fmt.Errorf("gamedata: invalid equipment making talent request")
}
talent, ok := d.CharTalent[characterID]
if !ok {
return 0, 0, 0, fmt.Errorf("gamedata: character %d has no equipment making talent", characterID)
}
if characterLevel < recipeLevel {
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent level %d is below recipe level %d", characterLevel, recipeLevel)
}
recipeSkill, ok := d.Skills[[2]uint64{talent.SkillGroup, recipeLevel}]
if !ok {
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent skill %d/%d missing", talent.SkillGroup, recipeLevel)
}
currentSkill, ok := d.Skills[[2]uint64{talent.SkillGroup, characterLevel}]
if !ok {
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent skill %d/%d missing", talent.SkillGroup, characterLevel)
}
if currentSkill.Experience != 0 && count > math.MaxUint64/currentSkill.Experience {
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent experience overflow")
}
if recipeSkill.Catalyst != 0 && count > math.MaxUint64/recipeSkill.Catalyst {
return 0, 0, 0, fmt.Errorf("gamedata: equipment making catalyst overflow")
}
for level := uint64(1); level <= characterLevel; level++ {
need, exists := d.TalentNeed[[2]uint64{talent.GrowthGroup, level}]
if !exists {
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent growth %d/%d missing", talent.GrowthGroup, level)
}
if maximum > math.MaxUint64-need {
return 0, 0, 0, fmt.Errorf("gamedata: equipment making talent threshold overflow")
}
maximum += need
}
wanted := currentSkill.Experience * count
if currentExperience >= maximum {
wanted = 0
} else if wanted > maximum-currentExperience {
wanted = maximum - currentExperience
}
return wanted, recipeSkill.Catalyst * count, maximum, nil
}
@@ -0,0 +1,47 @@
package gamedata
import (
"os"
"testing"
)
func TestEquipmentCraftAgainstInstalledCurrentVersion(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
}
design, err := LoadEquipmentCraftDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
recipe, ok := design.Recipe(1)
if !ok || recipe.TalentLevel != 1 || recipe.ResultCount != 1 || len(recipe.Costs) != 2 ||
recipe.Costs[0] != (PromotionCost{Type: 8, ID: 204, Count: 3}) ||
recipe.Costs[1] != (PromotionCost{Type: 8, ID: 201, Count: 3}) {
t.Fatalf("recipe=%+v found=%t", recipe, ok)
}
design.draw = func(limit uint64) (uint64, error) { return 0, nil }
generated, err := design.Generate(1)
if err != nil || generated.Design.ID != 10010 || len(generated.Main) == 0 {
t.Fatalf("generated=%+v err=%v", generated, err)
}
gain, catalyst, maximum, err := design.Talent(140, 1, 1, 1, 0)
if err != nil || gain == 0 || catalyst == 0 || maximum == 0 || gain > maximum {
t.Fatalf("talent gain=%d catalyst=%d maximum=%d err=%v", gain, catalyst, maximum, err)
}
}
func TestEquipmentUpgradeBreakAgainstInstalledCurrentVersion(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
}
design, err := LoadEquipmentUpgradeDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
rewards, err := design.BreakRewards(10010, 0)
if err != nil || len(rewards) == 0 {
t.Fatalf("break rewards=%+v err=%v", rewards, err)
}
}
+48
View File
@@ -22,6 +22,8 @@ type EquipmentUpgradeDesign struct {
RankGroup map[uint64]uint64
Levels map[[2]uint64]EquipmentUpgradeLevel
RankRatio map[[2]uint64][]float64
Break map[[2]uint64][]BattleReward
NotTrash map[uint64]bool
roll func(float64) (bool, error)
rankRoll func([]float64) (uint64, error)
}
@@ -52,6 +54,7 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) {
d := &EquipmentUpgradeDesign{
MaxLevel: map[uint64]uint64{}, Group: map[uint64]uint64{}, RankGroup: map[uint64]uint64{},
Levels: map[[2]uint64]EquipmentUpgradeLevel{}, RankRatio: map[[2]uint64][]float64{},
Break: map[[2]uint64][]BattleReward{}, NotTrash: map[uint64]bool{},
roll: cryptoRatioRoll, rankRoll: cryptoRankRoll,
}
groupMaximum := map[uint64]uint64{}
@@ -69,12 +72,14 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) {
}
groups, _ := packedInts(proto, 4)
maximum, _ := packedInts(proto, 13)
notTrash, _ := packedInts(proto, 14)
rankGroup, _ := packedInts(proto, 19)
if len(groups) != 1 || len(maximum) != 1 || len(rankGroup) != 1 || maximum[0] == 0 || rankGroup[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment %d invalid upgrade design", id)
}
d.Group[id], d.MaxLevel[id], d.RankGroup[id] = groups[0], maximum[0], rankGroup[0]
d.NotTrash[id] = len(notTrash) == 1 && notTrash[0] != 0
rankGroups[rankGroup[0]] = true
if prior, found := groupMaximum[groups[0]]; found && prior != maximum[0] {
rows.Close()
@@ -100,6 +105,22 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) {
if !referenced {
continue
}
breakCounts, _ := packedInts(proto, 1)
breakIDs, _ := packedInts(proto, 2)
breakTypes, _ := packedInts(proto, 3)
if len(breakCounts) == 0 || len(breakCounts) != len(breakIDs) || len(breakCounts) != len(breakTypes) {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment break result %d/%d malformed", group, level)
}
breakRewards := make([]BattleReward, 0, len(breakCounts))
for i := range breakCounts {
if breakCounts[i] == 0 || breakIDs[i] == 0 || breakTypes[i] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: equipment break result %d/%d invalid", group, level)
}
breakRewards = append(breakRewards, BattleReward{Type: breakTypes[i], ID: breakIDs[i], Count: breakCounts[i]})
}
d.Break[[2]uint64{group, level}] = breakRewards
// EquipmentGrowthTable includes the terminal maximum-level row, which
// intentionally has no next-upgrade cost or success ratio.
if level >= maximum {
@@ -173,6 +194,33 @@ func loadEquipmentUpgradeDesign(db *sql.DB) (*EquipmentUpgradeDesign, error) {
return d, nil
}
func (d *EquipmentUpgradeDesign) CanBreak(equipmentID uint64) bool {
if d == nil || equipmentID == 0 {
return false
}
// EquipmentTable.NotTrash controls the ordinary discard button. The
// client's batch break predicate is instead EquipDBInfo.IsAvailableBreak
// (not equipped and not locked), so crafting results with NotTrash=1 are
// still valid inputs to EquipMakingToBreakAuto.
_, exists := d.Group[equipmentID]
return exists
}
func (d *EquipmentUpgradeDesign) BreakRewards(equipmentID, level uint64) ([]BattleReward, error) {
if !d.CanBreak(equipmentID) {
return nil, fmt.Errorf("gamedata: equipment %d cannot be broken", equipmentID)
}
maximum := d.MaxLevel[equipmentID]
if level > maximum {
return nil, fmt.Errorf("gamedata: equipment %d invalid break level %d", equipmentID, level)
}
rewards, exists := d.Break[[2]uint64{d.Group[equipmentID], level}]
if !exists || len(rewards) == 0 {
return nil, fmt.Errorf("gamedata: missing equipment break result %d/%d", d.Group[equipmentID], level)
}
return append([]BattleReward(nil), rewards...), nil
}
func (d *EquipmentUpgradeDesign) Level(equipmentID, level uint64) (EquipmentUpgradeLevel, uint64, error) {
if d == nil || equipmentID == 0 {
return EquipmentUpgradeLevel{}, 0, fmt.Errorf("gamedata: invalid equipment upgrade request")
@@ -21,6 +21,9 @@ func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) {
}
equipment := testVarintField(testVarintField(testVarintField(nil, 4, 954), 13, 9), 19, 904)
growth := testVarintField(testVarintField(nil, 4, 954), 5, 670)
growth = testPackedField(growth, 1, 2)
growth = testPackedField(growth, 2, 201)
growth = testPackedField(growth, 3, 8)
growth = testPackedField(growth, 7, 960)
growth = testPackedField(growth, 8, 0)
growth = testPackedField(growth, 9, 4)
@@ -53,6 +56,9 @@ func TestEquipmentUpgradeDesignReadsCostsAndRatio(t *testing.T) {
if rank, err := design.RollRank(943035, 1); err != nil || rank != 1 {
t.Fatalf("rank=%d err=%v", rank, err)
}
if rewards, err := design.BreakRewards(943035, 0); err != nil || len(rewards) != 1 || rewards[0] != (BattleReward{Type: 8, ID: 201, Count: 2}) {
t.Fatalf("break rewards=%+v err=%v", rewards, err)
}
}
func TestEquipmentUpgradeAgainstInstalledVersion23413(t *testing.T) {
+67
View File
@@ -418,6 +418,34 @@ func (c *RegularGachaCatalog) SpecialSelectionIDs(gachaID uint64) []uint64 {
return result
}
// FiveStarIDs returns the selectable five-star costumes in one ordinary
// gacha's authoritative reward pool. Unlike SpecialSelectionIDs it also
// supports a normal rarity tree, which is used by the newbie guaranteed draw.
func (c *RegularGachaCatalog) FiveStarIDs(gachaID uint64) []uint64 {
gacha, ok := c.Gacha(gachaID)
if !ok {
return nil
}
seen := map[uint64]bool{}
var result []uint64
var visit func([]WeightedCostume)
visit = func(pool []WeightedCostume) {
for _, entry := range pool {
if entry.ID == 0 {
visit(entry.Children)
continue
}
if c.grades[entry.ID] == 5 && !seen[entry.ID] {
seen[entry.ID] = true
result = append(result, entry.ID)
}
}
}
visit(gacha.Pool)
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}
func (c *RegularGachaCatalog) SpecialSelectionCount(gachaID uint64) uint64 {
gacha, ok := c.Gacha(gachaID)
if !ok || gacha.RewardGroup == nil || gacha.RewardGroup.DropType != 1 || len(gacha.RewardGroup.Entries) == 0 || gacha.RewardGroup.Entries[0].Group == nil {
@@ -861,6 +889,11 @@ func LoadActiveGacha(root, version string, costumeGroupIDs, equipmentGroupIDs, s
}
func LoadActiveGachaForSchedules(root, version string, scheduleGroupIDs, stepUpGroupIDs []uint64) (*RegularGachaCatalog, *EquipmentGachaCatalog, error) {
permanent, err := permanentGachaGroupIDs(root, version)
if err != nil {
return nil, nil, err
}
scheduleGroupIDs = append(append([]uint64(nil), scheduleGroupIDs...), permanent...)
costume, equipment, err := ClassifyActiveGachaGroups(root, version, scheduleGroupIDs)
if err != nil {
return nil, nil, err
@@ -868,6 +901,40 @@ func LoadActiveGachaForSchedules(root, version string, scheduleGroupIDs, stepUpG
return LoadActiveGacha(root, version, costume, equipment, stepUpGroupIDs)
}
// permanentGachaGroupIDs returns static selection groups. ScheduleType=0
// groups are deliberately absent from the dynamic schedule response; the
// selection metadata is what distinguishes permanent 12PICK-style banners
// from standalone content-ticket banners handled by other flows.
func permanentGachaGroupIDs(root, version string) ([]uint64, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
rows, err := db.Query("SELECT id,ProtoBuf FROM GachaGroupTable WHERE scheduleType=0 AND gachaType=1 ORDER BY id")
if err != nil {
return nil, fmt.Errorf("gamedata: list permanent gacha groups: %w", err)
}
defer rows.Close()
var result []uint64
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
return nil, err
}
subTypes, _ := packedInts(raw, 16)
selectCounts, _ := packedInts(raw, 29)
if len(subTypes) == 1 && subTypes[0] == 1 && len(selectCounts) == 1 && selectCounts[0] != 0 {
result = append(result, id)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
// ClassifyActiveGachaGroups reads static type metadata for groups already
// selected by a captured schedule. Resemara is owned by LoadInfiniteGacha and
// is intentionally omitted from both ordinary catalogs.
+9 -1
View File
@@ -117,7 +117,7 @@ func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, id := range []uint64{10100113, 11000113, 10100145, 11000145, 10100040, 11000040, 10100146, 11000146, 31, 9100037, 8100118, 8100119, 8100120, 8100121, 8100122, 8100123, 8100124, 8100125} {
for _, id := range []uint64{101, 10100113, 11000113, 10100145, 11000145, 10100040, 11000040, 10100146, 11000146, 31, 9100037, 8100118, 8100119, 8100120, 8100121, 8100122, 8100123, 8100124, 8100125} {
if _, ok := regular.Gacha(id); !ok {
t.Errorf("costume gacha %d missing", id)
}
@@ -137,6 +137,14 @@ func TestActiveGachaAgainstInstalledVersion23510(t *testing.T) {
if !ok || moonriseGroup.BuyLimitCount != 10 || moonriseGroup.CashProductGroupID != 1500001 || moonriseGroup.CashProductID != 9100037 || moonriseGroup.SelectCount != 12 || moonriseGroup.SelectionChoiceRate != 100 {
t.Fatalf("moonrise group=%+v ok=%v", moonriseGroup, ok)
}
permanentSelection, ok := regular.Group(10001)
if !ok || permanentSelection.TenTimeGachaID != 101 || permanentSelection.SelectCount != 12 || permanentSelection.GachaSubType != 1 {
t.Fatalf("permanent selection group=%+v ok=%v", permanentSelection, ok)
}
newbie, ok := regular.Group(1009)
if !ok || newbie.TenTimeGachaID != 31 || newbie.BuyLimitCount != 30 || newbie.SelectCount != 3 || newbie.PointCount != 0 || newbie.FixedID != 3 || !newbie.UseSelectionOnlyFixedApply {
t.Fatalf("newbie group=%+v ok=%v", newbie, ok)
}
for _, id := range []uint64{8100121, 8100125} {
gacha, ok := regular.Gacha(id)
if !ok || gacha.FixedCostumeID == 0 || gacha.RewardGroup == nil || len(gacha.Pool) != 3 {
+105
View File
@@ -0,0 +1,105 @@
package gamedata
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
// InventorySlotRule is one of the four capacities represented directly in
// UserDBInfo. Prices and limits belong to the current GameData version.
type InventorySlotRule struct {
Default uint64
Maximum uint64
PriceType uint64
BasePrice uint64
MaxPrice uint64
}
type InventorySlotDesign struct {
Items InventorySlotRule
Storage InventorySlotRule
Equipment InventorySlotRule
EquipmentStorage InventorySlotRule
}
func LoadInventorySlotDesign(root, version string) (*InventorySlotDesign, error) {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-inventory-slots-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
return loadInventorySlotDesign(db)
}
func loadInventorySlotDesign(db *sql.DB) (*InventorySlotDesign, error) {
var raw []byte
if err := db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); err != nil {
return nil, fmt.Errorf("gamedata: inventory slot defaults: %w", err)
}
read := func(field int) (uint64, error) {
values, err := packedInts(raw, field)
if err != nil || len(values) != 1 || values[0] == 0 {
return 0, fmt.Errorf("gamedata: inventory slot field %d is missing or invalid", field)
}
return values[0], nil
}
maxPrice, err := read(79)
if err != nil {
return nil, err
}
makeRule := func(defaultField, maximumField, priceTypeField, basePriceField int) (InventorySlotRule, error) {
initial, err := read(defaultField)
if err != nil {
return InventorySlotRule{}, err
}
maximum, err := read(maximumField)
if err != nil {
return InventorySlotRule{}, err
}
priceType, err := read(priceTypeField)
if err != nil {
return InventorySlotRule{}, err
}
basePrice, err := read(basePriceField)
if err != nil {
return InventorySlotRule{}, err
}
rule := InventorySlotRule{Default: initial, Maximum: maximum, PriceType: priceType, BasePrice: basePrice, MaxPrice: maxPrice}
if rule.Default > rule.Maximum || rule.PriceType != 4 {
return InventorySlotRule{}, errors.New("gamedata: unsupported inventory slot rule")
}
return rule, nil
}
design := &InventorySlotDesign{}
if design.Items, err = makeRule(34, 80, 14, 13); err != nil {
return nil, err
}
if design.Storage, err = makeRule(37, 83, 16, 15); err != nil {
return nil, err
}
if design.Equipment, err = makeRule(30, 73, 10, 9); err != nil {
return nil, err
}
if design.EquipmentStorage, err = makeRule(31, 74, 12, 11); err != nil {
return nil, err
}
return design, nil
}
@@ -0,0 +1,37 @@
package gamedata
import (
"database/sql"
"testing"
"bd2server/internal/wire"
_ "modernc.org/sqlite"
)
func TestLoadInventorySlotDesignUsesGameDefaultFields(t *testing.T) {
db, err := sql.Open("sqlite", "file::memory:?cache=shared")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE GameDefaultTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
var raw []byte
for field, value := range map[int]uint64{
9: 300, 10: 4, 11: 300, 12: 4, 13: 300, 14: 4, 15: 300, 16: 4,
30: 500, 31: 100, 34: 100, 37: 100, 73: 2000, 74: 400, 79: 10000, 80: 500, 83: 400,
} {
raw = wire.AppendVarint(raw, field, value)
}
if _, err := db.Exec("INSERT INTO GameDefaultTable(id,ProtoBuf) VALUES(0,?)", raw); err != nil {
t.Fatal(err)
}
design, err := loadInventorySlotDesign(db)
if err != nil {
t.Fatal(err)
}
if design.Items.Default != 100 || design.Items.Maximum != 500 || design.Equipment.Default != 500 || design.Equipment.Maximum != 2000 || design.Storage.Maximum != 400 || design.EquipmentStorage.Maximum != 400 {
t.Fatalf("design=%+v", design)
}
}
+236
View File
@@ -0,0 +1,236 @@
package gamedata
import (
"database/sql"
"errors"
"fmt"
"math"
)
// CharacterTalent identifies the GameData rows which govern one character's
// talent. MaxLevel is deliberately read per talent: not every talent has five
// levels.
type CharacterTalent struct {
TalentID uint64
GrowthGroup uint64
MaxLevel uint64
}
// TalentGrowthLevel describes the upgrade from Level to Level+1. NeedExp is
// the experience earned within this level; player talent experience itself is
// cumulative and is not reset by an upgrade.
type TalentGrowthLevel struct {
Level uint64
NeedExp uint64
Costs []PromotionCost
}
type TalentUpgradeRule struct {
CharacterID uint64
TalentID uint64
GrowthGroup uint64
CurrentLevel uint64
MaxLevel uint64
RequiredTotalExp uint64
Costs []PromotionCost
}
// TalentGrowthDesign joins CharTable -> TalentTable -> TalentGrowthTable so
// the runtime never guesses a growth group or hard-codes skill-book IDs.
type TalentGrowthDesign struct {
Characters map[uint64]CharacterTalent
Levels map[[2]uint64]TalentGrowthLevel
}
func LoadTalentGrowthDesign(root, version string) (*TalentGrowthDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadTalentGrowthDesign(db)
}
func loadTalentGrowthDesign(db *sql.DB) (*TalentGrowthDesign, error) {
if db == nil {
return nil, errors.New("gamedata: nil talent growth database")
}
talents := make(map[uint64]CharacterTalent)
rows, err := db.Query("SELECT id,ProtoBuf FROM TalentTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
rows.Close()
return nil, err
}
groups, err := packedInts(raw, 6)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: talent %d growth group: %w", id, err)
}
maximum, err := packedInts(raw, 11)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: talent %d maximum level: %w", id, err)
}
if len(groups) != 1 || groups[0] == 0 || len(maximum) != 1 || maximum[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: talent %d has invalid growth metadata", id)
}
talents[id] = CharacterTalent{TalentID: id, GrowthGroup: groups[0], MaxLevel: maximum[0]}
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
design := &TalentGrowthDesign{
Characters: make(map[uint64]CharacterTalent),
Levels: make(map[[2]uint64]TalentGrowthLevel),
}
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable ORDER BY id")
if err != nil {
return nil, err
}
for rows.Next() {
var characterID uint64
var raw []byte
if err := rows.Scan(&characterID, &raw); err != nil {
rows.Close()
return nil, err
}
talentIDs, err := packedInts(raw, 18)
if err != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: character %d talent: %w", characterID, err)
}
if len(talentIDs) == 0 {
continue
}
if len(talentIDs) != 1 || talentIDs[0] == 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: character %d has invalid talent reference", characterID)
}
talent, found := talents[talentIDs[0]]
if !found {
rows.Close()
return nil, fmt.Errorf("gamedata: character %d references unknown talent %d", characterID, talentIDs[0])
}
design.Characters[characterID] = talent
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentGrowthTable ORDER BY groupId,id")
if err != nil {
return nil, err
}
for rows.Next() {
var group, level uint64
var raw []byte
if err := rows.Scan(&group, &level, &raw); err != nil {
rows.Close()
return nil, err
}
counts, countErr := packedInts(raw, 2)
ids, idErr := packedInts(raw, 3)
types, typeErr := packedInts(raw, 4)
needs, needErr := packedInts(raw, 6)
if countErr != nil || idErr != nil || typeErr != nil || needErr != nil {
rows.Close()
return nil, fmt.Errorf("gamedata: talent growth %d/%d has invalid fields", group, level)
}
if group == 0 || level == 0 || len(counts) != len(ids) || len(ids) != len(types) || len(needs) > 1 {
rows.Close()
return nil, fmt.Errorf("gamedata: talent growth %d/%d has mismatched fields", group, level)
}
entry := TalentGrowthLevel{Level: level}
if len(needs) == 1 {
entry.NeedExp = needs[0]
}
for i := range counts {
if types[i] == 0 || counts[i] == 0 || (types[i] == 4 && ids[i] != 0) || (types[i] != 4 && ids[i] == 0) {
rows.Close()
return nil, fmt.Errorf("gamedata: talent growth %d/%d has invalid cost", group, level)
}
entry.Costs = append(entry.Costs, PromotionCost{Type: types[i], ID: ids[i], Count: counts[i]})
}
key := [2]uint64{group, level}
if _, exists := design.Levels[key]; exists {
rows.Close()
return nil, fmt.Errorf("gamedata: duplicate talent growth %d/%d", group, level)
}
design.Levels[key] = entry
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
if len(design.Characters) == 0 || len(design.Levels) == 0 {
return nil, errors.New("gamedata: empty talent growth design")
}
validated := make(map[uint64]bool)
for _, talent := range design.Characters {
if validated[talent.TalentID] {
continue
}
validated[talent.TalentID] = true
for level := uint64(1); level < talent.MaxLevel; level++ {
entry, ok := design.Levels[[2]uint64{talent.GrowthGroup, level}]
if !ok || entry.NeedExp == 0 || len(entry.Costs) == 0 {
return nil, fmt.Errorf("gamedata: talent %d missing upgrade level %d", talent.TalentID, level)
}
}
}
return design, nil
}
// UpgradeRule returns the exact current-level requirement and cumulative
// experience threshold. It intentionally refuses max-level characters.
func (d *TalentGrowthDesign) UpgradeRule(characterID, currentLevel uint64) (TalentUpgradeRule, error) {
if d == nil || characterID == 0 || currentLevel == 0 {
return TalentUpgradeRule{}, errors.New("gamedata: invalid talent upgrade lookup")
}
talent, found := d.Characters[characterID]
if !found {
return TalentUpgradeRule{}, fmt.Errorf("gamedata: character %d has no talent growth design", characterID)
}
if currentLevel >= talent.MaxLevel {
return TalentUpgradeRule{}, fmt.Errorf("gamedata: character %d talent is already at maximum level %d", characterID, talent.MaxLevel)
}
var required uint64
for level := uint64(1); level <= currentLevel; level++ {
entry, ok := d.Levels[[2]uint64{talent.GrowthGroup, level}]
if !ok || entry.NeedExp == 0 {
return TalentUpgradeRule{}, fmt.Errorf("gamedata: talent %d missing growth level %d", talent.TalentID, level)
}
if required > math.MaxUint64-entry.NeedExp {
return TalentUpgradeRule{}, fmt.Errorf("gamedata: talent %d experience threshold overflows", talent.TalentID)
}
required += entry.NeedExp
}
current := d.Levels[[2]uint64{talent.GrowthGroup, currentLevel}]
if len(current.Costs) == 0 {
return TalentUpgradeRule{}, fmt.Errorf("gamedata: talent %d level %d has no upgrade costs", talent.TalentID, currentLevel)
}
return TalentUpgradeRule{
CharacterID: characterID, TalentID: talent.TalentID, GrowthGroup: talent.GrowthGroup,
CurrentLevel: currentLevel, MaxLevel: talent.MaxLevel, RequiredTotalExp: required,
Costs: append([]PromotionCost(nil), current.Costs...),
}, nil
}
+110
View File
@@ -0,0 +1,110 @@
package gamedata
import (
"database/sql"
"os"
"testing"
"bd2server/internal/wire"
)
func TestTalentGrowthDesignJoinsCharacterAndUsesCumulativeExperience(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil {
t.Fatal(err)
}
talent := testVarintField(testVarintField(nil, 6, 904), 11, 5)
character := testVarintField(nil, 18, 904)
if _, err := db.Exec("INSERT INTO TalentTable VALUES (?,?)", 904, talent); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", 140, character); err != nil {
t.Fatal(err)
}
needs := []uint64{14, 28, 404, 1008}
gold := []uint64{1000, 2000, 4000, 10000}
for level, need := range needs {
growth := testPackedField(nil, 2, 1, gold[level])
growth = testPackedField(growth, 3, uint64(level+3), 0)
growth = testPackedField(growth, 4, 8, 4)
growth = testVarintField(growth, 6, need)
if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 904, level+1, growth); err != nil {
t.Fatal(err)
}
}
if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 904, 5, testVarintField(nil, 5, 5)); err != nil {
t.Fatal(err)
}
design, err := loadTalentGrowthDesign(db)
if err != nil {
t.Fatal(err)
}
rule, err := design.UpgradeRule(140, 3)
if err != nil {
t.Fatal(err)
}
if rule.TalentID != 904 || rule.GrowthGroup != 904 || rule.MaxLevel != 5 || rule.RequiredTotalExp != 446 {
t.Fatalf("rule=%+v", rule)
}
want := []PromotionCost{{Type: 8, ID: 5, Count: 1}, {Type: 4, Count: 4000}}
if len(rule.Costs) != len(want) || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] {
t.Fatalf("costs=%+v want=%+v", rule.Costs, want)
}
if _, err := design.UpgradeRule(140, 5); err == nil {
t.Fatal("max-level talent was upgradeable")
}
}
func TestTalentGrowthDesignRejectsMismatchedCostArrays(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE TalentTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE CharTable (id INTEGER PRIMARY KEY, ProtoBuf BLOB); CREATE TABLE TalentGrowthTable (groupId INTEGER,id INTEGER,ProtoBuf BLOB,PRIMARY KEY(groupId,id))"); err != nil {
t.Fatal(err)
}
talent := testVarintField(testVarintField(nil, 6, 9), 11, 2)
character := testVarintField(nil, 18, 9)
broken := testPackedField(testPackedField(nil, 2, 1, 1000), 3, 3)
broken = testPackedField(broken, 4, 8, 4)
broken = wire.AppendVarint(broken, 6, 10)
if _, err := db.Exec("INSERT INTO TalentTable VALUES (?,?)", 9, talent); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO CharTable VALUES (?,?)", 1, character); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO TalentGrowthTable VALUES (?,?,?)", 9, 1, broken); err != nil {
t.Fatal(err)
}
if _, err := loadTalentGrowthDesign(db); err == nil {
t.Fatal("mismatched talent costs were accepted")
}
}
func TestTalentGrowthAgainstInstalledCurrentVersion(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
}
design, err := LoadTalentGrowthDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
rule, err := design.UpgradeRule(140, 1)
if err != nil {
t.Fatal(err)
}
want := []PromotionCost{{Type: 8, ID: 3, Count: 1}, {Type: 4, Count: 1000}}
if rule.TalentID != 904 || rule.MaxLevel != 5 || rule.RequiredTotalExp != 14 || len(rule.Costs) != 2 || rule.Costs[0] != want[0] || rule.Costs[1] != want[1] {
t.Fatalf("installed rule=%+v", rule)
}
if _, err := design.UpgradeRule(10140, 1); err == nil {
t.Fatal("the current max-level-one talent was upgradeable")
}
}
+10 -4
View File
@@ -42,6 +42,12 @@ var itemDBInfoTypes = map[uint64]bool{
29: true, // instant-use item
}
var currencyRewardTypes = map[uint64]bool{
3: true, // free jewelry
4: true, // gold
12: true, // catalyst / talent elixir
}
// MailDBInfo is the persisted shape used by the client. A mail either has a
// literal title/body (ordinary system mail), or a TemplateID and Sender (the
// localized/template-driven mail form). Reward fields are parallel arrays:
@@ -460,12 +466,12 @@ func (s *Service) open(request []byte) ([]byte, error) {
for i := range entry.RewardTypes {
reward := gamedata.Reward{Type: entry.RewardTypes[i], ID: entry.RewardIDs[i], Count: entry.RewardCounts[i]}
rewards[i] = reward
switch reward.Type {
case 3, 4:
switch {
case currencyRewardTypes[reward.Type]:
currency := wire.AppendVarint(nil, 3, reward.Type)
currency = wire.AppendVarint(currency, 4, reward.Count)
bundle = wire.AppendBytes(bundle, 1, currency)
case 28:
case reward.Type == 28:
// DataManager recognizes this type, but RewardDBInfoBundle
// carries MyRoomTrophyDBInfo in a separate field. Encoding it as
// ItemDBInfo would make the local state and client model disagree.
@@ -580,7 +586,7 @@ func (s *Service) EnqueueCompensation(identity, title, body string, rewards []ga
SentAt: uint64(sentAt.UTC().UnixMilli()), ExpiresAt: uint64(sentAt.UTC().Add(30 * 24 * time.Hour).UnixMilli()),
}
for _, reward := range rewards {
if reward.Type == 0 || reward.Count == 0 || (reward.Type != 3 && reward.Type != 4 && (!itemDBInfoTypes[reward.Type] || reward.ID == 0)) {
if reward.Type == 0 || reward.Count == 0 || (!currencyRewardTypes[reward.Type] && (!itemDBInfoTypes[reward.Type] || reward.ID == 0)) {
return fmt.Errorf("mail: compensation %q has unsupported reward type=%d id=%d count=%d", identity, reward.Type, reward.ID, reward.Count)
}
entry.RewardTypes = append(entry.RewardTypes, reward.Type)
+56
View File
@@ -161,6 +161,62 @@ func TestMailOpenGrantsNonResourceItemDBInfoType(t *testing.T) {
}
}
func TestMailOpenGrantsCatalystCurrencyAndPersists(t *testing.T) {
seed := &Starter{Version: "2.34.13", MailCount: 2, MaxMailID: 14, Mails: []MailDBInfo{{
MailID: 14, MailType: 2, ExpiresAt: 100, SentAt: 10,
RewardTypes: []uint64{12}, RewardIDs: []uint64{0}, RewardCounts: []uint64{250},
}}}
storage := stateio.NewMemory()
inv, err := player.OpenInventory(storage, &player.Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
wallet, err := player.OpenWallet(storage, player.Currency{Catalyst: 10})
if err != nil {
t.Fatal(err)
}
service, err := OpenService(storage, seed, inv, wallet)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendBytes(request, 2, packed([]uint64{14}))
_, response, _, err := service.Handle("/MailOpen", request)
if err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Catalyst != 260 || len(inv.All()) != 0 {
t.Fatalf("wallet=%+v items=%+v", wallet.Snapshot(), inv.All())
}
bundle, found, _ := wire.Bytes(response, 1)
if !found {
t.Fatal("reward bundle missing")
}
item, found, _ := wire.Bytes(bundle, 1)
if !found {
t.Fatal("currency ItemDBInfo missing")
}
if typ, _, _ := wire.Varint(item, 3); typ != 12 {
t.Fatalf("currency type=%d", typ)
}
if count, _, _ := wire.Varint(item, 4); count != 250 {
t.Fatalf("currency count=%d", count)
}
if _, _, _, err := service.Handle("/MailOpen", request); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Catalyst != 260 {
t.Fatalf("replay catalyst=%d", wallet.Snapshot().Catalyst)
}
reopened, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
if reopened.Snapshot().Catalyst != 260 {
t.Fatalf("persisted catalyst=%d", reopened.Snapshot().Catalyst)
}
}
func TestWatchedSeedReloadsOnlyValidAtomicReplacement(t *testing.T) {
dir := t.TempDir()
seedPath := filepath.Join(dir, "seed.json")
+24 -1
View File
@@ -62,6 +62,7 @@ type EquipmentInventory struct {
characters *CharacterStore
slots map[uint64]uint64
upgrade *gamedata.EquipmentUpgradeDesign
craft *gamedata.EquipmentCraftDesign
smelting *gamedata.EquipmentSmeltingDesign
optionReroll *gamedata.EquipmentOptionRerollDesign
wallet *Wallet
@@ -91,6 +92,16 @@ func (s *EquipmentInventory) AttachUpgrade(design *gamedata.EquipmentUpgradeDesi
return nil
}
func (s *EquipmentInventory) AttachCraft(design *gamedata.EquipmentCraftDesign) error {
if design == nil {
return errors.New("player: incomplete equipment crafting configuration")
}
s.mu.Lock()
defer s.mu.Unlock()
s.craft = design
return nil
}
func (s *EquipmentInventory) AttachSmelting(design *gamedata.EquipmentSmeltingDesign, wallet *Wallet, inventory *Inventory) error {
if design == nil || wallet == nil || inventory == nil {
return errors.New("player: incomplete equipment smelting configuration")
@@ -369,7 +380,7 @@ func equipmentOptionWire(option EquipmentOption) []byte {
}
func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipBatchUse" && path != "/EquipPresetInfo" && path != "/EquipPresetSave" && path != "/EquipPresetNameChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipSmelting" && path != "/EquipSequenceSmelting" && path != "/EquipOptionReRoll" && path != "/EquipOptionReRollConfirm" && path != "/EquipMainOptChange" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" {
if path != "/EquipInfo" && path != "/EquipUse" && path != "/EquipClear" && path != "/EquipChange" && path != "/EquipBatchUse" && path != "/EquipPresetInfo" && path != "/EquipPresetSave" && path != "/EquipPresetNameChange" && path != "/EquipUpgrade" && path != "/EquipSequenceUpgrade" && path != "/EquipSmelting" && path != "/EquipSequenceSmelting" && path != "/EquipOptionReRoll" && path != "/EquipOptionReRollConfirm" && path != "/EquipMainOptChange" && path != "/EquipMarkSet" && path != "/EquipMarkDelete" && path != "/EquipLock" && path != "/EquipMaking" && path != "/EquipBreak" && path != "/EquipMakingToBreakAuto" && path != "/EquipUpgradeToBreakAuto" {
return 0, nil, false, nil
}
if seq, found, err := wire.Varint(request, 1); err != nil || !found || seq == 0 {
@@ -399,6 +410,18 @@ func (s *EquipmentInventory) Handle(path string, request []byte) (int, []byte, b
if path == "/EquipSequenceUpgrade" {
return s.upgradeSequence(request)
}
if path == "/EquipMaking" {
return s.makeEquipment(request)
}
if path == "/EquipBreak" {
return s.breakEquipment(request)
}
if path == "/EquipMakingToBreakAuto" {
return s.makeToBreakAuto(request)
}
if path == "/EquipUpgradeToBreakAuto" {
return s.upgradeToBreakAuto(request)
}
if path == "/EquipSmelting" {
return s.smeltOnce(request)
}
+582
View File
@@ -0,0 +1,582 @@
package player
import (
"errors"
"fmt"
"math"
"sort"
"strconv"
"bd2server/internal/gamedata"
"bd2server/internal/wire"
)
const equipmentBatchLimit = 100
type equipmentAutoBreakResult struct {
Equipment []Equipment
Result uint64
Attempts uint64
Consumed []Item
Lack []Item
Gold uint64
Granted []Item
}
func (s *EquipmentInventory) makeEquipment(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
characterIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || characterIndex == 0 {
return 0, nil, true, errors.New("player: EquipMaking missing character")
}
recipeID, found, err := wire.Varint(request, 3)
if err != nil || !found || recipeID == 0 {
return 0, nil, true, errors.New("player: EquipMaking missing recipe")
}
count, found, err := wire.Varint(request, 4)
if err != nil || !found || count == 0 || count > 1000 {
return 0, nil, true, errors.New("player: EquipMaking invalid count")
}
materials, err := equipmentRequestItems(request, 5, "EquipMaking")
if err != nil {
return 0, nil, true, err
}
cacheKey := s.smeltingCacheKey("making", seq)
if response, ok := s.cachedEquipmentReply(cacheKey); ok {
return response.code, response.body, true, nil
}
generated, gain, catalyst, maximum, err := s.prepareEquipmentMaking(characterIndex, recipeID, count, materials)
if err != nil {
return 0, nil, true, err
}
if err := s.inventory.Consume(materials); err != nil {
return 0, nil, true, fmt.Errorf("player: consume equipment making materials: %w", err)
}
if catalyst != 0 {
identity := "equip-making-catalyst:" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
if _, err := s.wallet.SpendCatalystOnce(identity, catalyst); err != nil {
return 0, nil, true, fmt.Errorf("player: spend equipment making catalyst: %w", err)
}
}
created, err := s.grantGeneratedBatch("equip-making:"+s.sessionID+":"+strconv.FormatUint(seq, 10), generated, true)
if err != nil {
return 0, nil, true, fmt.Errorf("player: persist made equipment: %w", err)
}
if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil {
return 0, nil, true, err
}
var response []byte
for _, entry := range created {
response = wire.AppendBytes(response, 1, EquipmentWire(entry))
}
if gain != 0 {
response = wire.AppendVarint(response, 2, gain)
}
s.cacheEquipmentReply(cacheKey, 50, response)
return 50, response, true, nil
}
func (s *EquipmentInventory) prepareEquipmentMaking(characterIndex, recipeID, count uint64, materials []Item) ([]Equipment, uint64, uint64, uint64, error) {
if s.craft == nil || s.inventory == nil || s.characters == nil || s.wallet == nil {
return nil, 0, 0, 0, errors.New("player: equipment making unavailable")
}
recipe, ok := s.craft.Recipe(recipeID)
if !ok {
return nil, 0, 0, 0, fmt.Errorf("player: unknown equipment making recipe %d", recipeID)
}
character, ok := s.characters.Find(characterIndex)
if !ok {
return nil, 0, 0, 0, fmt.Errorf("player: unknown equipment making character %d", characterIndex)
}
gain, catalyst, maximum, err := s.craft.Talent(character.ID, character.TalentLevel, recipe.TalentLevel, count, character.TalentExp)
if err != nil {
return nil, 0, 0, 0, err
}
if catalyst != 0 && !s.wallet.CanSpendCatalyst(catalyst) {
return nil, 0, 0, 0, errors.New("player: insufficient catalyst for equipment making")
}
if err := validateMakingMaterials(recipe.Costs, count, materials); err != nil {
return nil, 0, 0, 0, err
}
if err := s.inventory.CanConsume(materials); err != nil {
return nil, 0, 0, 0, err
}
if recipe.ResultCount != 0 && count > math.MaxUint64/recipe.ResultCount {
return nil, 0, 0, 0, errors.New("player: equipment making result count overflow")
}
resultCount := count * recipe.ResultCount
generated := make([]Equipment, 0, resultCount)
for i := uint64(0); i < resultCount; i++ {
rolled, err := s.craft.Generate(recipeID)
if err != nil {
return nil, 0, 0, 0, fmt.Errorf("player: roll equipment making result: %w", err)
}
entry := Equipment{ID: rolled.Design.ID, Rank: []uint64{0, 0, 0}}
for _, option := range rolled.Main {
entry.MainOption = append(entry.MainOption, EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
for _, option := range rolled.Sub {
entry.SubOption = append(entry.SubOption, EquipmentOption{GroupID: option.GroupID, ID: option.ID})
}
if rolled.Private != nil {
entry.PrivateOption = &EquipmentOption{GroupID: rolled.Private.GroupID, ID: rolled.Private.ID}
}
generated = append(generated, entry)
}
return generated, gain, catalyst, maximum, nil
}
func validateMakingMaterials(costs []gamedata.PromotionCost, count uint64, materials []Item) error {
want := make(map[[2]uint64]uint64, len(costs))
for _, cost := range costs {
if cost.Count == 0 || count > math.MaxUint64/cost.Count {
return errors.New("player: equipment making material cost overflow")
}
key := [2]uint64{cost.Type, cost.ID}
value := cost.Count * count
if math.MaxUint64-want[key] < value {
return errors.New("player: equipment making material cost overflow")
}
want[key] += value
}
got := make(map[[2]uint64]uint64, len(materials))
for _, item := range materials {
key := [2]uint64{item.Type, item.ID}
if math.MaxUint64-got[key] < item.Count {
return errors.New("player: equipment making material count overflow")
}
got[key] += item.Count
}
if len(got) != len(want) {
return errors.New("player: equipment making material kinds mismatch")
}
for key, count := range want {
if got[key] != count {
return fmt.Errorf("player: equipment making material %d/%d=%d want=%d", key[0], key[1], got[key], count)
}
}
return nil
}
func (s *EquipmentInventory) breakEquipment(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
indices, err := repeatedEquipmentIndices(request, 2, equipmentBatchLimit)
if err != nil {
return 0, nil, true, err
}
cacheKey := s.smeltingCacheKey("break", seq)
if response, ok := s.cachedEquipmentReply(cacheKey); ok {
return response.code, response.body, true, nil
}
s.mu.Lock()
if s.upgrade == nil || s.inventory == nil {
s.mu.Unlock()
return 0, nil, true, errors.New("player: equipment break unavailable")
}
rewards := make(map[[2]uint64]uint64)
positions := make([]int, 0, len(indices))
for _, index := range indices {
position := s.equipmentPositionLocked(index)
if position < 0 {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: EquipBreak unknown equipment %d", index)
}
entry := s.owned.Equipment[position]
if entry.UseChar != 0 || entry.LockFlag != 0 || !s.upgrade.CanBreak(entry.ID) {
s.mu.Unlock()
return 0, nil, true, fmt.Errorf("player: equipment %d cannot be broken", index)
}
result, err := s.upgrade.BreakRewards(entry.ID, entry.Level)
if err != nil {
s.mu.Unlock()
return 0, nil, true, err
}
if err := addBattleRewards(rewards, result); err != nil {
s.mu.Unlock()
return 0, nil, true, err
}
positions = append(positions, position)
}
sort.Sort(sort.Reverse(sort.IntSlice(positions)))
next := cloneEquipmentSnapshot(s.owned)
for _, position := range positions {
next.Equipment = append(next.Equipment[:position], next.Equipment[position+1:]...)
}
if err := s.commitLocked(next, "break"); err != nil {
s.mu.Unlock()
return 0, nil, true, err
}
s.mu.Unlock()
identity := "equip-break:" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
granted, err := s.inventory.GrantOnce(identity, aggregateBattleRewards(rewards))
if err != nil {
return 0, nil, true, fmt.Errorf("player: grant equipment break rewards: %w", err)
}
if granted == nil {
granted = s.inventory.GrantedItems(identity)
}
response := wire.AppendBytes(nil, 1, rewardItemBundle(granted))
s.cacheEquipmentReply(cacheKey, 56, response)
return 56, response, true, nil
}
func (s *EquipmentInventory) upgradeToBreakAuto(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
indices, err := repeatedEquipmentIndices(request, 2, equipmentBatchLimit)
if err != nil {
return 0, nil, true, err
}
target, _, err := wire.Varint(request, 3)
if err != nil {
return 0, nil, true, errors.New("player: EquipUpgradeToBreakAuto invalid target")
}
cacheKey := s.smeltingCacheKey("upgrade-break-auto", seq)
if response, ok := s.cachedEquipmentReply(cacheKey); ok {
return response.code, response.body, true, nil
}
result, err := s.runUpgradeToBreak(indices, target, "equip-upgrade-break:"+s.sessionID+":"+strconv.FormatUint(seq, 10))
if err != nil {
return 0, nil, true, err
}
response := encodeUpgradeBreakResponse(result, 1, 2, 3, 4, 5, 6, 7)
s.cacheEquipmentReply(cacheKey, 516, response)
return 516, response, true, nil
}
func (s *EquipmentInventory) makeToBreakAuto(request []byte) (int, []byte, bool, error) {
seq, _, _ := wire.Varint(request, 1)
characterIndex, found, err := wire.Varint(request, 2)
if err != nil || !found || characterIndex == 0 {
return 0, nil, true, errors.New("player: EquipMakingToBreakAuto missing character")
}
recipeID, found, err := wire.Varint(request, 3)
if err != nil || !found || recipeID == 0 {
return 0, nil, true, errors.New("player: EquipMakingToBreakAuto missing recipe")
}
count, found, err := wire.Varint(request, 4)
if err != nil || !found || count == 0 || count > equipmentBatchLimit {
return 0, nil, true, errors.New("player: EquipMakingToBreakAuto invalid count")
}
materials, err := equipmentRequestItems(request, 5, "EquipMakingToBreakAuto")
if err != nil {
return 0, nil, true, err
}
target, _, err := wire.Varint(request, 6)
if err != nil {
return 0, nil, true, errors.New("player: EquipMakingToBreakAuto invalid target")
}
cacheKey := s.smeltingCacheKey("making-break-auto", seq)
if response, ok := s.cachedEquipmentReply(cacheKey); ok {
return response.code, response.body, true, nil
}
generated, gain, catalyst, maximum, err := s.prepareEquipmentMaking(characterIndex, recipeID, count, materials)
if err != nil {
return 0, nil, true, err
}
if err := s.inventory.Consume(materials); err != nil {
return 0, nil, true, fmt.Errorf("player: consume equipment making materials: %w", err)
}
if catalyst != 0 {
identity := "equip-making-break-catalyst:" + s.sessionID + ":" + strconv.FormatUint(seq, 10)
if _, err := s.wallet.SpendCatalystOnce(identity, catalyst); err != nil {
return 0, nil, true, fmt.Errorf("player: spend automatic equipment making catalyst: %w", err)
}
}
created, err := s.grantGeneratedBatch("", generated, false)
if err != nil {
return 0, nil, true, fmt.Errorf("player: persist auto-break made equipment: %w", err)
}
indices := make([]uint64, 0, len(created))
for _, entry := range created {
indices = append(indices, entry.InvenIndex)
}
if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil {
return 0, nil, true, err
}
result, err := s.runUpgradeToBreak(indices, target, "equip-making-break-reward:"+s.sessionID+":"+strconv.FormatUint(seq, 10))
if err != nil {
return 0, nil, true, err
}
var response []byte
if gain != 0 {
response = wire.AppendVarint(response, 1, gain)
}
response = append(response, encodeUpgradeBreakResponse(result, 2, 3, 4, 5, 6, 7, 8)...)
s.cacheEquipmentReply(cacheKey, 515, response)
return 515, response, true, nil
}
func (s *EquipmentInventory) grantGeneratedBatch(identityPrefix string, entries []Equipment, trackReplay bool) ([]Equipment, error) {
if len(entries) == 0 || (trackReplay && identityPrefix == "") {
return nil, errors.New("player: invalid generated equipment batch")
}
s.mu.Lock()
defer s.mu.Unlock()
if trackReplay {
existing := make([]Equipment, 0, len(entries))
found := 0
for i := range entries {
index := s.owned.Granted[identityPrefix+":"+strconv.Itoa(i)]
if index == 0 {
continue
}
found++
position := s.equipmentPositionLocked(index)
if position < 0 {
return nil, errors.New("player: generated equipment replay index is missing")
}
existing = append(existing, cloneEquipment(s.owned.Equipment[position]))
}
if found != 0 {
if found != len(entries) {
return nil, errors.New("player: partial generated equipment replay batch")
}
return existing, nil
}
}
next := cloneEquipmentSnapshot(s.owned)
created := make([]Equipment, 0, len(entries))
for i, source := range entries {
entry := cloneEquipment(source)
if entry.ID == 0 || len(entry.Rank) != 3 {
return nil, errors.New("player: invalid generated equipment in batch")
}
entry.InvenIndex = next.NextIndex
next.NextIndex++
next.Equipment = append(next.Equipment, entry)
created = append(created, cloneEquipment(entry))
if trackReplay {
next.Granted[identityPrefix+":"+strconv.Itoa(i)] = entry.InvenIndex
}
}
if err := s.commitLocked(next, "generated equipment batch"); err != nil {
return nil, err
}
return created, nil
}
func (s *EquipmentInventory) runUpgradeToBreak(indices []uint64, target uint64, rewardIdentity string) (equipmentAutoBreakResult, error) {
s.mu.Lock()
if s.upgrade == nil || s.wallet == nil || s.inventory == nil {
s.mu.Unlock()
return equipmentAutoBreakResult{}, errors.New("player: automatic equipment upgrade and break unavailable")
}
for _, index := range indices {
position := s.equipmentPositionLocked(index)
if position < 0 {
s.mu.Unlock()
return equipmentAutoBreakResult{}, fmt.Errorf("player: unknown equipment %d", index)
}
entry := s.owned.Equipment[position]
if entry.UseChar != 0 || entry.LockFlag != 0 || !s.upgrade.CanBreak(entry.ID) {
s.mu.Unlock()
return equipmentAutoBreakResult{}, fmt.Errorf("player: equipment %d cannot be automatically broken", index)
}
}
result := equipmentAutoBreakResult{Result: equipUpgradeStopTargetLevel}
stopUpgrades := false
for _, index := range indices {
position := s.equipmentPositionLocked(index)
entry := s.owned.Equipment[position]
maximum := s.upgrade.MaxLevel[entry.ID]
goal := target
if goal > maximum {
goal = maximum
}
for !stopUpgrades && entry.Level < goal {
level, _, err := s.upgrade.Level(entry.ID, entry.Level)
if err != nil {
s.mu.Unlock()
return equipmentAutoBreakResult{}, err
}
materials, gold, err := s.selectUpgradeCosts(level.Costs)
if err != nil || (gold != 0 && !s.wallet.CanSpendGold(gold)) {
result.Result = equipUpgradeStopNotEnough
result.Lack = upgradeLackItems(level.Costs)
stopUpgrades = true
break
}
updated, _, spent, consumed, err := s.attemptUpgradeLocked(index, materials)
if err != nil {
s.mu.Unlock()
return equipmentAutoBreakResult{}, err
}
result.Attempts++
result.Gold += spent
for _, item := range consumed {
if item.Type != 4 {
result.Consumed = append(result.Consumed, item)
}
}
entry = updated
if result.Attempts > 300000 {
s.mu.Unlock()
return equipmentAutoBreakResult{}, errors.New("player: automatic equipment upgrade exceeded safety limit")
}
}
position = s.equipmentPositionLocked(index)
result.Equipment = append(result.Equipment, cloneEquipment(s.owned.Equipment[position]))
}
rewards := make(map[[2]uint64]uint64)
positions := make([]int, 0, len(indices))
for _, entry := range result.Equipment {
breakRewards, err := s.upgrade.BreakRewards(entry.ID, entry.Level)
if err != nil {
s.mu.Unlock()
return equipmentAutoBreakResult{}, err
}
if err := addBattleRewards(rewards, breakRewards); err != nil {
s.mu.Unlock()
return equipmentAutoBreakResult{}, err
}
positions = append(positions, s.equipmentPositionLocked(entry.InvenIndex))
}
sort.Sort(sort.Reverse(sort.IntSlice(positions)))
next := cloneEquipmentSnapshot(s.owned)
for _, position := range positions {
next.Equipment = append(next.Equipment[:position], next.Equipment[position+1:]...)
}
if err := s.commitLocked(next, "automatic upgrade and break"); err != nil {
s.mu.Unlock()
return equipmentAutoBreakResult{}, err
}
s.mu.Unlock()
granted, err := s.inventory.GrantOnce(rewardIdentity, aggregateBattleRewards(rewards))
if err != nil {
return equipmentAutoBreakResult{}, fmt.Errorf("player: grant automatic equipment break rewards: %w", err)
}
if granted == nil {
granted = s.inventory.GrantedItems(rewardIdentity)
}
result.Granted = granted
result.Consumed = aggregateItemCounts(result.Consumed)
result.Lack = aggregateItemCounts(result.Lack)
return result, nil
}
func encodeUpgradeBreakResponse(result equipmentAutoBreakResult, equipmentField, resultField, attemptsField, consumedField, lackField, goldField, rewardsField int) []byte {
var response []byte
for _, entry := range result.Equipment {
response = wire.AppendBytes(response, equipmentField, EquipmentWire(entry))
}
if result.Result != 0 {
response = wire.AppendVarint(response, resultField, result.Result)
}
if result.Attempts != 0 {
response = wire.AppendVarint(response, attemptsField, result.Attempts)
}
for _, item := range result.Consumed {
response = wire.AppendBytes(response, consumedField, ItemWire(item))
}
for _, item := range result.Lack {
response = wire.AppendBytes(response, lackField, ItemWire(item))
}
if result.Gold != 0 {
response = wire.AppendVarint(response, goldField, result.Gold)
}
response = wire.AppendBytes(response, rewardsField, rewardItemBundle(result.Granted))
return response
}
func repeatedEquipmentIndices(request []byte, number, maximum int) ([]uint64, error) {
var result []uint64
err := wire.Walk(request, func(field wire.Field) error {
if field.Number != number {
return nil
}
values, err := decodeRepeatedUint64(field)
if err != nil {
return err
}
result = append(result, values...)
return nil
})
if err != nil || len(result) == 0 || len(result) > maximum {
return nil, errors.New("player: invalid equipment index batch")
}
seen := make(map[uint64]bool, len(result))
for _, index := range result {
if index == 0 || seen[index] {
return nil, errors.New("player: invalid or duplicate equipment index")
}
seen[index] = true
}
return result, nil
}
func addBattleRewards(total map[[2]uint64]uint64, rewards []gamedata.BattleReward) error {
for _, reward := range rewards {
key := [2]uint64{reward.Type, reward.ID}
if reward.Count == 0 || math.MaxUint64-total[key] < reward.Count {
return errors.New("player: equipment break reward overflow")
}
total[key] += reward.Count
}
return nil
}
func aggregateBattleRewards(values map[[2]uint64]uint64) []gamedata.BattleReward {
keys := make([][2]uint64, 0, len(values))
for key, count := range values {
if count != 0 {
keys = append(keys, key)
}
}
sort.Slice(keys, func(i, j int) bool {
if keys[i][0] != keys[j][0] {
return keys[i][0] < keys[j][0]
}
return keys[i][1] < keys[j][1]
})
out := make([]gamedata.BattleReward, 0, len(keys))
for _, key := range keys {
out = append(out, gamedata.BattleReward{Type: key[0], ID: key[1], Count: values[key]})
}
return out
}
func aggregateItemCounts(items []Item) []Item {
values := make(map[[2]uint64]uint64)
for _, item := range items {
values[[2]uint64{item.Type, item.ID}] += item.Count
}
keys := make([][2]uint64, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i][0] != keys[j][0] {
return keys[i][0] < keys[j][0]
}
return keys[i][1] < keys[j][1]
})
out := make([]Item, 0, len(keys))
for _, key := range keys {
out = append(out, Item{Type: key[0], ID: key[1], Count: values[key]})
}
return out
}
func rewardItemBundle(items []Item) []byte {
var bundle []byte
for _, item := range items {
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
}
return bundle
}
func (s *EquipmentInventory) cachedEquipmentReply(key string) (smeltingReply, bool) {
s.mu.Lock()
defer s.mu.Unlock()
reply, ok := s.smeltCache[key]
if ok {
reply.body = append([]byte(nil), reply.body...)
}
return reply, ok
}
func (s *EquipmentInventory) cacheEquipmentReply(key string, code int, body []byte) {
s.mu.Lock()
defer s.mu.Unlock()
s.smeltCache[key] = smeltingReply{code: code, body: append([]byte(nil), body...)}
}
@@ -0,0 +1,66 @@
package player
import (
"errors"
"fmt"
"math"
)
// AddTalentExperience applies the same clamp as the client: talent experience
// may reach the cumulative threshold of the current level, but this request
// never promotes the talent level itself.
func (s *CharacterStore) AddTalentExperience(index, gain, maximum uint64) (Character, error) {
if s == nil || index == 0 || maximum == 0 {
return Character{}, errors.New("player: invalid talent experience update")
}
s.mu.Lock()
for position, current := range s.characters {
if current.InvenIndex != index {
continue
}
if current.TalentExp > maximum {
s.mu.Unlock()
return Character{}, fmt.Errorf("player: character %d talent experience exceeds current threshold", index)
}
if gain > maximum-current.TalentExp {
gain = maximum - current.TalentExp
}
if current.TalentExp > math.MaxUint64-gain {
s.mu.Unlock()
return Character{}, errors.New("player: talent experience overflow")
}
current.TalentExp += gain
next := append([]Character(nil), s.characters...)
next[position] = current
if err := s.persist(next); err != nil {
s.mu.Unlock()
return Character{}, fmt.Errorf("player: persist equipment making talent experience: %w", err)
}
s.characters = next
s.mu.Unlock()
return current, nil
}
collection := s.collection
s.mu.Unlock()
if collection == nil {
return Character{}, fmt.Errorf("player: unknown equipment making character %d", index)
}
current, found := collection.FindCharacter(index)
if !found {
return Character{}, fmt.Errorf("player: unknown equipment making character %d", index)
}
if current.TalentExp > maximum {
return Character{}, fmt.Errorf("player: character %d talent experience exceeds current threshold", index)
}
if gain > maximum-current.TalentExp {
gain = maximum - current.TalentExp
}
if current.TalentExp > math.MaxUint64-gain {
return Character{}, errors.New("player: talent experience overflow")
}
current.TalentExp += gain
if err := collection.UpdateCharacter(current.ID, current); err != nil {
return Character{}, fmt.Errorf("player: persist collection equipment making talent experience: %w", err)
}
return current, nil
}
+208
View File
@@ -0,0 +1,208 @@
package player
import (
"encoding/binary"
"os"
"path/filepath"
"testing"
"bd2server/internal/gamedata"
"bd2server/internal/wire"
)
func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) {
root := os.Getenv("BD2_REAL_GAMEDATA")
if root == "" {
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
}
dir := t.TempDir()
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
materials, err := inventory.GrantOnce("making-materials", []gamedata.BattleReward{{Type: 8, ID: 204, Count: 3}, {Type: 8, ID: 201, Count: 3}})
if err != nil || len(materials) != 2 {
t.Fatalf("materials=%+v err=%v", materials, err)
}
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Catalyst: 10})
if err != nil {
t.Fatal(err)
}
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1}}, inventory, root, "20260923193640")
if err != nil {
t.Fatal(err)
}
equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
if err != nil {
t.Fatal(err)
}
craft, err := gamedata.LoadEquipmentCraftDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
upgrade, err := gamedata.LoadEquipmentUpgradeDesign(root, "20260923193640")
if err != nil {
t.Fatal(err)
}
if err := equipment.AttachUpgrade(upgrade, wallet, inventory); err != nil {
t.Fatal(err)
}
if err := equipment.AttachCraft(craft); err != nil {
t.Fatal(err)
}
if err := equipment.AttachCharacters(characters); err != nil {
t.Fatal(err)
}
equipment.BeginSession("making-test")
request := wire.AppendVarint(nil, 1, 5)
request = wire.AppendVarint(request, 2, 77)
request = wire.AppendVarint(request, 3, 1)
request = wire.AppendVarint(request, 4, 1)
for _, item := range materials {
request = wire.AppendBytes(request, 5, ItemWire(item))
}
code, response, handled, err := equipment.Handle("/EquipMaking", request)
if err != nil || !handled || code != 50 {
t.Fatalf("making code=%d handled=%t response=%x err=%v", code, handled, response, err)
}
if len(equipment.All()) != 1 || len(inventory.All()) != 0 {
t.Fatalf("making equipment=%+v inventory=%+v", equipment.All(), inventory.All())
}
if wallet.Snapshot().Catalyst != 7 {
t.Fatalf("making catalyst=%d", wallet.Snapshot().Catalyst)
}
if character, ok := characters.Find(77); !ok || character.TalentExp != 14 {
t.Fatalf("making character=%+v found=%t", character, ok)
}
replayCode, replay, _, err := equipment.Handle("/EquipMaking", request)
if err != nil || replayCode != code || string(replay) != string(response) || len(equipment.All()) != 1 || wallet.Snapshot().Catalyst != 7 {
t.Fatalf("making replay code=%d response=%x equipment=%+v catalyst=%d err=%v", replayCode, replay, equipment.All(), wallet.Snapshot().Catalyst, err)
}
autoMaterials, err := inventory.GrantOnce("making-auto-materials", []gamedata.BattleReward{{Type: 8, ID: 204, Count: 3}, {Type: 8, ID: 201, Count: 3}})
if err != nil || len(autoMaterials) != 2 {
t.Fatalf("auto materials=%+v err=%v", autoMaterials, err)
}
autoRequest := wire.AppendVarint(nil, 1, 6)
autoRequest = wire.AppendVarint(autoRequest, 2, 77)
autoRequest = wire.AppendVarint(autoRequest, 3, 1)
autoRequest = wire.AppendVarint(autoRequest, 4, 1)
for _, item := range autoMaterials {
autoRequest = wire.AppendBytes(autoRequest, 5, ItemWire(item))
}
autoCode, autoResponse, autoHandled, err := equipment.Handle("/EquipMakingToBreakAuto", autoRequest)
if err != nil || !autoHandled || autoCode != 515 {
t.Fatalf("making auto code=%d handled=%t response=%x err=%v", autoCode, autoHandled, autoResponse, err)
}
if _, found, _ := wire.Bytes(autoResponse, 2); !found {
t.Fatalf("making auto omitted source equipment: %x", autoResponse)
}
if _, found, _ := wire.Bytes(autoResponse, 8); !found || len(equipment.All()) != 1 || len(inventory.All()) == 0 || wallet.Snapshot().Catalyst != 4 {
t.Fatalf("making auto response=%x equipment=%+v inventory=%+v catalyst=%d", autoResponse, equipment.All(), inventory.All(), wallet.Snapshot().Catalyst)
}
}
func TestEquipBreakUsesLevelRewardAndReplays(t *testing.T) {
dir := t.TempDir()
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 100})
if err != nil {
t.Fatal(err)
}
equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
if err != nil {
t.Fatal(err)
}
design := equipmentBreakTestDesign()
if err := equipment.AttachUpgrade(design, wallet, inventory); err != nil {
t.Fatal(err)
}
equipment.BeginSession("break-test")
entry, err := equipment.GrantGeneratedOnce("break-source", Equipment{ID: 10, Level: 1, Rank: []uint64{0, 0, 0}})
if err != nil {
t.Fatal(err)
}
var packed [10]byte
n := binary.PutUvarint(packed[:], entry.InvenIndex)
request := wire.AppendBytes(wire.AppendVarint(nil, 1, 7), 2, packed[:n])
code, response, handled, err := equipment.Handle("/EquipBreak", request)
if err != nil || !handled || code != 56 || len(response) == 0 {
t.Fatalf("break code=%d handled=%t response=%x err=%v", code, handled, response, err)
}
if len(equipment.All()) != 0 {
t.Fatalf("broken equipment remained: %+v", equipment.All())
}
if got := inventory.All(); len(got) != 1 || got[0].ID != 201 || got[0].Type != 8 || got[0].Count != 2 {
t.Fatalf("break inventory=%+v", got)
}
replayCode, replay, _, err := equipment.Handle("/EquipBreak", request)
if err != nil || replayCode != code || string(replay) != string(response) || len(inventory.All()) != 1 {
t.Fatalf("break replay code=%d response=%x inventory=%+v err=%v", replayCode, replay, inventory.All(), err)
}
}
func TestEquipUpgradeToBreakAutoUpgradesThenRemoves(t *testing.T) {
dir := t.TempDir()
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(testStore(filepath.Join(dir, "wallet.json")), Currency{Gold: 100})
if err != nil {
t.Fatal(err)
}
equipment, err := OpenEquipmentInventory(testStore(filepath.Join(dir, "equipment.json")))
if err != nil {
t.Fatal(err)
}
design := equipmentBreakTestDesign()
if err := equipment.AttachUpgrade(design, wallet, inventory); err != nil {
t.Fatal(err)
}
equipment.BeginSession("upgrade-break-test")
entry, err := equipment.GrantOnce("upgrade-break-source", 10)
if err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(nil, 1, 9)
request = wire.AppendVarint(request, 2, entry.InvenIndex)
request = wire.AppendVarint(request, 3, 2)
code, response, handled, err := equipment.Handle("/EquipUpgradeToBreakAuto", request)
if err != nil || !handled || code != 516 {
t.Fatalf("auto code=%d handled=%t response=%x err=%v", code, handled, response, err)
}
if attempts, _, _ := wire.Varint(response, 3); attempts != 2 {
t.Fatalf("auto attempts=%d response=%x", attempts, response)
}
if used, _, _ := wire.Varint(response, 6); used != 30 || wallet.Snapshot().Gold != 70 {
t.Fatalf("auto used=%d wallet=%+v", used, wallet.Snapshot())
}
if len(equipment.All()) != 0 {
t.Fatalf("auto-broken equipment remained: %+v", equipment.All())
}
if got := inventory.All(); len(got) != 1 || got[0].ID != 202 || got[0].Count != 3 {
t.Fatalf("auto break inventory=%+v", got)
}
replayCode, replay, _, err := equipment.Handle("/EquipUpgradeToBreakAuto", request)
if err != nil || replayCode != code || string(replay) != string(response) || wallet.Snapshot().Gold != 70 || len(inventory.All()) != 1 {
t.Fatalf("auto replay code=%d response=%x wallet=%+v inventory=%+v err=%v", replayCode, replay, wallet.Snapshot(), inventory.All(), err)
}
}
func equipmentBreakTestDesign() *gamedata.EquipmentUpgradeDesign {
return &gamedata.EquipmentUpgradeDesign{
MaxLevel: map[uint64]uint64{10: 2}, Group: map[uint64]uint64{10: 20}, RankGroup: map[uint64]uint64{10: 30},
Levels: map[[2]uint64]gamedata.EquipmentUpgradeLevel{
{20, 0}: {Level: 0, Costs: []gamedata.PromotionCost{{Type: 4, Count: 10}}, SuccessRatio: 1},
{20, 1}: {Level: 1, Costs: []gamedata.PromotionCost{{Type: 4, Count: 20}}, SuccessRatio: 1},
},
Break: map[[2]uint64][]gamedata.BattleReward{
{20, 0}: {{Type: 8, ID: 200, Count: 1}},
{20, 1}: {{Type: 8, ID: 201, Count: 2}},
{20, 2}: {{Type: 8, ID: 202, Count: 3}},
},
NotTrash: map[uint64]bool{10: true},
}
}
+32 -1
View File
@@ -34,6 +34,14 @@ type CharacterStore struct {
wallet *Wallet
maxHealth func(Character) (uint64, error)
promoteGrowth func(Character, []gamedata.PromotionCost) (gamedata.PromotionGrowthResult, error)
talentGrowth *gamedata.TalentGrowthDesign
sessionID string
talentReplies map[string]talentUpgradeReply
}
type talentUpgradeReply struct {
code int
body []byte
}
func (s *CharacterStore) AttachWallet(wallet *Wallet) error {
@@ -46,6 +54,26 @@ func (s *CharacterStore) AttachWallet(wallet *Wallet) error {
return nil
}
func (s *CharacterStore) AttachTalentGrowth(design *gamedata.TalentGrowthDesign) error {
if design == nil {
return errors.New("player: nil talent growth design")
}
s.mu.Lock()
defer s.mu.Unlock()
s.talentGrowth = design
return nil
}
// BeginSession scopes protobuf sequence replay. Network retries reuse the
// exact TalentSkillUpgrade request and must receive success without a second
// level increase or charge.
func (s *CharacterStore) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = id
s.talentReplies = make(map[string]talentUpgradeReply)
}
// AttachMaxHealth makes growth and post-battle revival consume the same
// GameData/collection calculator as AllCharRefresh and BattleEnter.
func (s *CharacterStore) AttachMaxHealth(maxHealth func(Character) (uint64, error)) error {
@@ -76,7 +104,7 @@ func OpenCharacterStore(store stateio.Store, seed []Character, inventory *Invent
if !ok {
return nil, errors.New("player: character store requires atomic entries")
}
s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion}
s := &CharacterStore{store: entries, inventory: inventory, characters: append([]Character(nil), seed...), persisted: make(map[uint64]bool), gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion, talentReplies: make(map[string]talentUpgradeReply)}
s.grow = func(character Character, materials []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
return gamedata.CharacterGrowth(s.gameDataRoot, s.gameDataVersion, int(character.ID), character.Level, character.Exp, materials)
}
@@ -197,6 +225,9 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
if path == "/CharImmortal" {
return s.charImmortal(request)
}
if path == "/TalentSkillUpgrade" {
return s.talentSkillUpgrade(request)
}
if path != "/CharGrowth" {
return 0, nil, false, nil
}
+261
View File
@@ -0,0 +1,261 @@
package player
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"os"
"sync"
"bd2server/internal/gamedata"
"bd2server/internal/stateio"
"bd2server/internal/versionconfig"
"bd2server/internal/wire"
)
const developmentSettingsVersion = 1
type InventorySlotCounts struct {
Items uint64 `json:"items"`
Storage uint64 `json:"storage"`
Equipment uint64 `json:"equipment"`
EquipmentStorage uint64 `json:"equipment_storage"`
}
type inventorySlotSnapshot struct {
Version string `json:"version"`
InventorySlotCounts
}
type developmentSettings struct {
Version int `json:"version"`
Inventory *struct {
Unlimited bool `json:"unlimited"`
} `json:"inventory"`
}
type inventorySlotReply struct {
code int
body []byte
}
// InventorySlots owns the four ordinary UserDBInfo capacity fields and the
// four matching expansion endpoints. The optional development settings file
// changes only the two backpack values advertised at login; purchased values
// remain durable and become visible again when the switch is disabled.
type InventorySlots struct {
mu sync.Mutex
store stateio.AtomicEntryStore
design *gamedata.InventorySlotDesign
wallet *Wallet
state inventorySlotSnapshot
settingsPath string
sessionID string
replies map[string]inventorySlotReply
}
func OpenInventorySlots(store stateio.Store, design *gamedata.InventorySlotDesign, initial InventorySlotCounts, wallet *Wallet) (*InventorySlots, error) {
entries, ok := store.(stateio.AtomicEntryStore)
if !ok || design == nil || wallet == nil {
return nil, errors.New("player: incomplete inventory slot configuration")
}
s := &InventorySlots{store: entries, design: design, wallet: wallet, state: inventorySlotSnapshot{
Version: versionconfig.Protocol(), InventorySlotCounts: initial,
}, replies: map[string]inventorySlotReply{}}
data, found, err := entries.LoadEntry("items", "slots", "capacity")
if err != nil {
return nil, err
}
if found {
if err := stateio.RequireExactJSONObject(data, "version", "items", "storage", "equipment", "equipment_storage"); err != nil {
return nil, fmt.Errorf("player: incompatible inventory slot layout: %w", err)
}
if err := json.Unmarshal(data, &s.state); err != nil {
return nil, fmt.Errorf("player: decode inventory slots: %w", err)
}
}
if err := s.validateCounts(s.state.InventorySlotCounts); err != nil {
return nil, err
}
return s, nil
}
func (s *InventorySlots) validateCounts(counts InventorySlotCounts) error {
if s.state.Version != versionconfig.Protocol() ||
counts.Items < s.design.Items.Default || counts.Items > s.design.Items.Maximum ||
counts.Storage < s.design.Storage.Default || counts.Storage > s.design.Storage.Maximum ||
counts.Equipment < s.design.Equipment.Default || counts.Equipment > s.design.Equipment.Maximum ||
counts.EquipmentStorage < s.design.EquipmentStorage.Default || counts.EquipmentStorage > s.design.EquipmentStorage.Maximum {
return errors.New("player: invalid saved inventory slot counts")
}
return nil
}
func (s *InventorySlots) AttachDevelopmentSettings(path string) {
s.mu.Lock()
defer s.mu.Unlock()
s.settingsPath = path
}
func (s *InventorySlots) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = id
s.replies = map[string]inventorySlotReply{}
}
func (s *InventorySlots) EnsurePersisted() error {
s.mu.Lock()
defer s.mu.Unlock()
_, found, err := s.store.LoadEntry("items", "slots", "capacity")
if err != nil || found {
return err
}
return s.persistLocked(s.state)
}
func (s *InventorySlots) InventorySlotCounts() (InventorySlotCounts, error) {
s.mu.Lock()
defer s.mu.Unlock()
counts := s.state.InventorySlotCounts
unlimited, err := loadUnlimitedInventory(s.settingsPath)
if err != nil {
return InventorySlotCounts{}, err
}
if unlimited {
counts.Items = s.design.Items.Maximum
counts.Equipment = s.design.Equipment.Maximum
}
return counts, nil
}
func (s *InventorySlots) UserInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error) {
counts, err := s.InventorySlotCounts()
if err != nil {
return 0, 0, 0, 0, err
}
return counts.Items, counts.Storage, counts.Equipment, counts.EquipmentStorage, nil
}
func loadUnlimitedInventory(path string) (bool, error) {
if path == "" {
return false, nil
}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("player: read development settings: %w", err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
var settings developmentSettings
if err := decoder.Decode(&settings); err != nil {
return false, errors.New("player: malformed development settings")
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return false, errors.New("player: malformed development settings")
}
if settings.Version != developmentSettingsVersion || settings.Inventory == nil {
return false, errors.New("player: unsupported development settings version")
}
return settings.Inventory.Unlimited, nil
}
func (s *InventorySlots) Handle(path string, request []byte) (int, []byte, bool, error) {
var code int
var current *uint64
var rule gamedata.InventorySlotRule
switch path {
case "/InvenAddSlot":
code, current, rule = 24, &s.state.Items, s.design.Items
case "/StorageAddSlot":
code, current, rule = 25, &s.state.Storage, s.design.Storage
case "/EquipAddSlot":
code, current, rule = 39, &s.state.Equipment, s.design.Equipment
case "/EquipStorageAddSlot":
code, current, rule = 82, &s.state.EquipmentStorage, s.design.EquipmentStorage
default:
return 0, nil, false, nil
}
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, nil, true, errors.New("player: inventory slot request missing sequence")
}
count, found, err := wire.Varint(request, 2)
if err != nil || !found || count == 0 {
return 0, nil, true, errors.New("player: inventory slot request invalid count")
}
s.mu.Lock()
defer s.mu.Unlock()
key := fmt.Sprintf("%s:%d", path, seq)
if prior, ok := s.replies[key]; ok {
return prior.code, append([]byte(nil), prior.body...), true, nil
}
if *current > rule.Maximum || count > rule.Maximum-*current {
return 0, nil, true, errors.New("player: inventory slot expansion exceeds GameData maximum")
}
cost, err := inventorySlotPrice(rule, *current, count)
if err != nil {
return 0, nil, true, err
}
identity := fmt.Sprintf("inventory-slot:%s:%s:%d", path, s.sessionID, seq)
if _, err := s.wallet.SpendGoldOnce(identity, cost); err != nil {
return 0, nil, true, fmt.Errorf("player: inventory slot price: %w", err)
}
next := s.state
switch path {
case "/InvenAddSlot":
next.Items += count
case "/StorageAddSlot":
next.Storage += count
case "/EquipAddSlot":
next.Equipment += count
case "/EquipStorageAddSlot":
next.EquipmentStorage += count
}
if err := s.persistLocked(next); err != nil {
return 0, nil, true, err
}
s.replies[key] = inventorySlotReply{code: code}
return code, nil, true, nil
}
func inventorySlotPrice(rule gamedata.InventorySlotRule, current, count uint64) (uint64, error) {
if rule.PriceType != 4 || count == 0 || current < rule.Default || current > rule.Maximum || count > rule.Maximum-current {
return 0, errors.New("player: invalid inventory slot price request")
}
var total uint64
step := rule.BasePrice / 3
for i := uint64(0); i < count; i++ {
offset := current + i + 1 - rule.Default
if step != 0 && offset > (math.MaxUint64-rule.BasePrice)/step {
return 0, errors.New("player: inventory slot price overflow")
}
price := rule.BasePrice + step*offset
if price > rule.MaxPrice {
price = rule.MaxPrice
}
if math.MaxUint64-total < price {
return 0, errors.New("player: inventory slot total price overflow")
}
total += price
}
return total, nil
}
func (s *InventorySlots) persistLocked(next inventorySlotSnapshot) error {
data, err := json.Marshal(next)
if err != nil {
return err
}
if err := s.store.SaveWithEntries("items", nil, []stateio.EntryMutation{{Bucket: "slots", Key: "capacity", Payload: data}}); err != nil {
return fmt.Errorf("player: persist inventory slots: %w", err)
}
s.state = next
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package player
import (
"os"
"path/filepath"
"testing"
"bd2server/internal/gamedata"
"bd2server/internal/stateio"
"bd2server/internal/wire"
)
func inventorySlotTestDesign() *gamedata.InventorySlotDesign {
return &gamedata.InventorySlotDesign{
Items: gamedata.InventorySlotRule{Default: 100, Maximum: 500, PriceType: 4, BasePrice: 300, MaxPrice: 10000},
Storage: gamedata.InventorySlotRule{Default: 100, Maximum: 400, PriceType: 4, BasePrice: 300, MaxPrice: 10000},
Equipment: gamedata.InventorySlotRule{Default: 500, Maximum: 2000, PriceType: 4, BasePrice: 300, MaxPrice: 10000},
EquipmentStorage: gamedata.InventorySlotRule{Default: 100, Maximum: 400, PriceType: 4, BasePrice: 300, MaxPrice: 10000},
}
}
func inventorySlotTestCounts() InventorySlotCounts {
return InventorySlotCounts{Items: 100, Storage: 100, Equipment: 500, EquipmentStorage: 100}
}
func TestInventorySlotExpansionChargesPersistsAndReplays(t *testing.T) {
store := stateio.NewMemory()
wallet, err := OpenWallet(store, Currency{Gold: 100000})
if err != nil {
t.Fatal(err)
}
slots, err := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet)
if err != nil {
t.Fatal(err)
}
slots.BeginSession("test")
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 7), 2, 2)
code, response, ok, err := slots.Handle("/InvenAddSlot", request)
if err != nil || !ok || code != 24 || len(response) != 0 {
t.Fatalf("code=%d response=%x ok=%t err=%v", code, response, ok, err)
}
if wallet.Snapshot().Gold != 99100 { // first two slots cost 400 + 500
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
}
if _, _, _, err := slots.Handle("/InvenAddSlot", request); err != nil {
t.Fatal(err)
}
if wallet.Snapshot().Gold != 99100 {
t.Fatalf("replay charged twice: %d", wallet.Snapshot().Gold)
}
reopened, err := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet)
if err != nil {
t.Fatal(err)
}
counts, err := reopened.InventorySlotCounts()
if err != nil || counts.Items != 102 {
t.Fatalf("counts=%+v err=%v", counts, err)
}
}
func TestInventorySlotEndpointsUseTheirProtocolCodes(t *testing.T) {
for path, code := range map[string]int{
"/InvenAddSlot": 24, "/StorageAddSlot": 25, "/EquipAddSlot": 39, "/EquipStorageAddSlot": 82,
} {
t.Run(path, func(t *testing.T) {
store := stateio.NewMemory()
wallet, _ := OpenWallet(store, Currency{Gold: 1000})
slots, _ := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet)
slots.BeginSession("test")
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)
got, body, ok, err := slots.Handle(path, request)
if err != nil || !ok || got != code || len(body) != 0 || wallet.Snapshot().Gold != 600 {
t.Fatalf("code=%d body=%x ok=%t gold=%d err=%v", got, body, ok, wallet.Snapshot().Gold, err)
}
})
}
}
func TestUnlimitedInventoryUsesGameDataMaximumAtLoginOnly(t *testing.T) {
store := stateio.NewMemory()
wallet, _ := OpenWallet(store, Currency{})
slots, _ := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet)
path := filepath.Join(t.TempDir(), "dev-tools.json")
slots.AttachDevelopmentSettings(path)
if err := os.WriteFile(path, []byte(`{"version":1,"inventory":{"unlimited":true}}`), 0o600); err != nil {
t.Fatal(err)
}
counts, err := slots.InventorySlotCounts()
if err != nil {
t.Fatal(err)
}
if counts.Items != 500 || counts.Equipment != 2000 || counts.Storage != 100 || counts.EquipmentStorage != 100 {
t.Fatalf("counts=%+v", counts)
}
if err := os.WriteFile(path, []byte(`{"version":1,"inventory":{"unlimited":false}}`), 0o600); err != nil {
t.Fatal(err)
}
counts, err = slots.InventorySlotCounts()
if err != nil || counts != inventorySlotTestCounts() {
t.Fatalf("restored counts=%+v err=%v", counts, err)
}
}
func TestUnlimitedInventoryRejectsMalformedDevelopmentSettings(t *testing.T) {
store := stateio.NewMemory()
wallet, _ := OpenWallet(store, Currency{})
slots, _ := OpenInventorySlots(store, inventorySlotTestDesign(), inventorySlotTestCounts(), wallet)
path := filepath.Join(t.TempDir(), "dev-tools.json")
slots.AttachDevelopmentSettings(path)
if err := os.WriteFile(path, []byte(`{"version":1,"inventory":{"unlimited":true},"extra":1}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := slots.InventorySlotCounts(); err == nil {
t.Fatal("malformed settings accepted")
}
if err := os.WriteFile(path, []byte(`{"version":1}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := slots.InventorySlotCounts(); err == nil {
t.Fatal("settings without inventory accepted")
}
}
func TestInventorySlotPriceCapsEachAddedSlot(t *testing.T) {
rule := gamedata.InventorySlotRule{Default: 100, Maximum: 500, PriceType: 4, BasePrice: 300, MaxPrice: 10000}
price, err := inventorySlotPrice(rule, 196, 2)
if err != nil || price != 20000 {
t.Fatalf("price=%d err=%v", price, err)
}
}
+150
View File
@@ -0,0 +1,150 @@
package player
import (
"errors"
"fmt"
"math"
"strconv"
"bd2server/internal/gamedata"
"bd2server/internal/wire"
)
const talentSkillUpgradePacketCode = 44
func (s *CharacterStore) talentSkillUpgrade(request []byte) (int, []byte, bool, error) {
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 {
return 0, nil, true, errors.New("player: TalentSkillUpgrade missing sequence")
}
index, found, err := wire.Varint(request, 2)
if err != nil || !found || index == 0 {
return 0, nil, true, errors.New("player: TalentSkillUpgrade missing character")
}
materials, err := equipmentRequestItems(request, 3, "TalentSkillUpgrade")
if err != nil {
return 0, nil, true, err
}
s.mu.Lock()
defer s.mu.Unlock()
cacheKey := "talent-upgrade:" + s.sessionID + ":seq:" + strconv.FormatUint(seq, 10)
if reply, ok := s.talentReplies[cacheKey]; ok {
return reply.code, append([]byte(nil), reply.body...), true, nil
}
if s.talentGrowth == nil || s.wallet == nil || s.inventory == nil {
return 0, nil, true, errors.New("player: talent upgrade unavailable")
}
position := -1
var current Character
fromCollection := false
for i, character := range s.characters {
if character.InvenIndex == index {
position, current = i, character
break
}
}
if position < 0 && s.collection != nil {
current, fromCollection = s.collection.FindCharacter(index)
}
if position < 0 && !fromCollection {
return 0, nil, true, fmt.Errorf("player: unknown talent character inventory index %d", index)
}
rule, err := s.talentGrowth.UpgradeRule(current.ID, current.TalentLevel)
if err != nil {
return 0, nil, true, fmt.Errorf("player: resolve talent upgrade: %w", err)
}
if current.TalentExp < rule.RequiredTotalExp {
return 0, nil, true, fmt.Errorf("player: character %d talent experience %d is below required %d", index, current.TalentExp, rule.RequiredTotalExp)
}
items, gold, err := validateTalentUpgradeMaterials(rule.Costs, materials)
if err != nil {
return 0, nil, true, err
}
if gold != 0 && !s.wallet.CanSpendGold(gold) {
return 0, nil, true, errors.New("player: insufficient gold for talent upgrade")
}
if len(items) != 0 {
if err := s.inventory.CanConsume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: validate talent upgrade items: %w", err)
}
}
previousID := current.ID
current.TalentLevel++
if fromCollection {
if err := s.collection.CanUpdateCharacter(previousID, current); err != nil {
return 0, nil, true, fmt.Errorf("player: validate collection talent upgrade: %w", err)
}
}
identity := cacheKey + ":character:" + strconv.FormatUint(index, 10) + ":level:" + strconv.FormatUint(rule.CurrentLevel, 10)
if gold != 0 {
if _, err := s.wallet.SpendGoldOnce(identity, gold); err != nil {
return 0, nil, true, fmt.Errorf("player: consume talent upgrade gold: %w", err)
}
}
if len(items) != 0 {
if err := s.inventory.Consume(items); err != nil {
return 0, nil, true, fmt.Errorf("player: consume talent upgrade items: %w", err)
}
}
if fromCollection {
if err := s.collection.UpdateCharacter(previousID, current); err != nil {
return 0, nil, true, fmt.Errorf("player: persist collection talent upgrade: %w", err)
}
} else {
next := append([]Character(nil), s.characters...)
next[position] = current
if err := s.persist(next); err != nil {
return 0, nil, true, fmt.Errorf("player: persist talent upgrade: %w", err)
}
s.characters = next
}
// TalentSkillUpgradeResponse.item_info is an optional grant list. Current
// GameData defines no refund, so the correct protobuf response is empty.
s.talentReplies[cacheKey] = talentUpgradeReply{code: talentSkillUpgradePacketCode}
return talentSkillUpgradePacketCode, nil, true, nil
}
func validateTalentUpgradeMaterials(costs []gamedata.PromotionCost, materials []Item) ([]Item, uint64, error) {
want := make(map[[2]uint64]uint64, len(costs))
for _, cost := range costs {
if cost.Type == 0 || cost.Count == 0 || (cost.Type == 4 && cost.ID != 0) || (cost.Type != 4 && cost.ID == 0) {
return nil, 0, errors.New("player: invalid GameData talent upgrade cost")
}
key := [2]uint64{cost.Type, cost.ID}
if want[key] > math.MaxUint64-cost.Count {
return nil, 0, errors.New("player: talent upgrade GameData cost overflow")
}
want[key] += cost.Count
}
got := make(map[[2]uint64]uint64, len(materials))
var items []Item
var gold uint64
for _, material := range materials {
key := [2]uint64{material.Type, material.ID}
if got[key] > math.MaxUint64-material.Count {
return nil, 0, errors.New("player: talent upgrade material overflow")
}
got[key] += material.Count
if material.Type == 4 {
if gold != 0 || material.InvenIndex != 0 || material.ID != 0 {
return nil, 0, errors.New("player: invalid talent upgrade currency")
}
gold = material.Count
} else {
items = append(items, material)
}
}
if len(got) != len(want) {
return nil, 0, errors.New("player: talent upgrade material kinds mismatch")
}
for key, count := range want {
if got[key] != count {
return nil, 0, fmt.Errorf("player: talent upgrade material %d/%d=%d want=%d", key[0], key[1], got[key], count)
}
}
return items, gold, nil
}
+191
View File
@@ -0,0 +1,191 @@
package player
import (
"path/filepath"
"testing"
"bd2server/internal/gamedata"
"bd2server/internal/wire"
)
func testTalentGrowthDesign() *gamedata.TalentGrowthDesign {
return &gamedata.TalentGrowthDesign{
Characters: map[uint64]gamedata.CharacterTalent{
140: {TalentID: 904, GrowthGroup: 904, MaxLevel: 5},
},
Levels: map[[2]uint64]gamedata.TalentGrowthLevel{
{904, 1}: {Level: 1, NeedExp: 14, Costs: []gamedata.PromotionCost{{Type: 8, ID: 3, Count: 1}, {Type: 4, Count: 1000}}},
{904, 2}: {Level: 2, NeedExp: 28, Costs: []gamedata.PromotionCost{{Type: 8, ID: 4, Count: 1}, {Type: 4, Count: 2000}}},
{904, 3}: {Level: 3, NeedExp: 404, Costs: []gamedata.PromotionCost{{Type: 8, ID: 5, Count: 1}, {Type: 4, Count: 4000}}},
{904, 4}: {Level: 4, NeedExp: 1008, Costs: []gamedata.PromotionCost{{Type: 8, ID: 6, Count: 1}, {Type: 4, Count: 10000}}},
},
}
}
func talentUpgradeRequest(seq, character uint64, materials ...Item) []byte {
request := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, character)
for _, material := range materials {
request = wire.AppendBytes(request, 3, ItemWire(material))
}
return request
}
func TestTalentSkillUpgradeConsumesExactCostsPersistsAndReplays(t *testing.T) {
dir := t.TempDir()
store := testStore(filepath.Join(dir, "state.json"))
inventory, err := OpenInventory(store, &Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
books, err := inventory.GrantOnce("talent-books", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 2}})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(store, Currency{Gold: 5000})
if err != nil {
t.Fatal(err)
}
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err := characters.AttachWallet(wallet); err != nil {
t.Fatal(err)
}
if err := characters.AttachTalentGrowth(testTalentGrowthDesign()); err != nil {
t.Fatal(err)
}
characters.BeginSession("login-a")
book := books[0]
book.Count = 1
request := talentUpgradeRequest(9, 77, book, Item{Type: 4, Count: 1000})
code, response, handled, err := characters.Handle("/TalentSkillUpgrade", request)
if err != nil || !handled || code != talentSkillUpgradePacketCode || len(response) != 0 {
t.Fatalf("upgrade code=%d handled=%v response=%x err=%v", code, handled, response, err)
}
got, found := characters.Find(77)
if !found || got.TalentLevel != 2 || got.TalentExp != 14 {
t.Fatalf("upgraded character=%+v found=%v", got, found)
}
if wallet.Snapshot().Gold != 4000 {
t.Fatalf("gold=%d", wallet.Snapshot().Gold)
}
if err := inventory.CanConsume([]Item{book}); err != nil {
t.Fatalf("one talent book should remain: %v", err)
}
code, response, handled, err = characters.Handle("/TalentSkillUpgrade", request)
if err != nil || !handled || code != talentSkillUpgradePacketCode || len(response) != 0 {
t.Fatalf("replay code=%d handled=%v response=%x err=%v", code, handled, response, err)
}
if wallet.Snapshot().Gold != 4000 {
t.Fatal("replay charged gold again")
}
if err := inventory.CanConsume([]Item{book}); err != nil {
t.Fatal("replay consumed the remaining talent book")
}
reloaded, err := OpenCharacterStore(store, nil, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if got, found := reloaded.Find(77); !found || got.TalentLevel != 2 || got.TalentExp != 14 {
t.Fatalf("reloaded character=%+v found=%v", got, found)
}
}
func TestTalentSkillUpgradeRejectsInvalidStateWithoutCharging(t *testing.T) {
tests := []struct {
name string
level uint64
experience uint64
materialID uint64
gold uint64
}{
{name: "experience", level: 1, experience: 13, materialID: 3, gold: 1000},
{name: "wrong-book", level: 1, experience: 14, materialID: 4, gold: 1000},
{name: "wrong-gold", level: 1, experience: 14, materialID: 3, gold: 999},
{name: "maximum", level: 5, experience: 1454, materialID: 6, gold: 10000},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
store := testStore(filepath.Join(dir, "state.json"))
inventory, err := OpenInventory(store, &Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
books, err := inventory.GrantOnce("books", []gamedata.BattleReward{{Type: 8, ID: test.materialID, Count: 2}})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(store, Currency{Gold: 20000})
if err != nil {
t.Fatal(err)
}
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: test.level, TalentExp: test.experience}}, inventory, "", "")
if err != nil {
t.Fatal(err)
}
_ = characters.AttachWallet(wallet)
_ = characters.AttachTalentGrowth(testTalentGrowthDesign())
book := books[0]
book.Count = 1
request := talentUpgradeRequest(1, 77, book, Item{Type: 4, Count: test.gold})
if _, _, handled, err := characters.Handle("/TalentSkillUpgrade", request); err == nil || !handled {
t.Fatalf("invalid upgrade accepted: handled=%v err=%v", handled, err)
}
if wallet.Snapshot().Gold != 20000 {
t.Fatal("invalid upgrade charged gold")
}
if err := inventory.CanConsume([]Item{books[0]}); err != nil {
t.Fatalf("invalid upgrade consumed a book: %v", err)
}
if current, _ := characters.Find(77); current.TalentLevel != test.level || current.TalentExp != test.experience {
t.Fatalf("invalid upgrade changed character: %+v", current)
}
})
}
}
func TestTalentSkillUpgradePersistsCollectionCharacter(t *testing.T) {
dir := t.TempDir()
store := testStore(filepath.Join(dir, "state.json"))
inventory, err := OpenInventory(store, &Starter{Version: "2.34.13"})
if err != nil {
t.Fatal(err)
}
books, err := inventory.GrantOnce("book", []gamedata.BattleReward{{Type: 8, ID: 3, Count: 1}})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(store, Currency{Gold: 1000})
if err != nil {
t.Fatal(err)
}
collection, err := OpenCollectionStore(store, nil)
if err != nil {
t.Fatal(err)
}
next := cloneCollection(collection.data)
next.Characters = []Character{{InvenIndex: 920000021, ID: 140, Level: 1, TalentLevel: 1, TalentExp: 14}}
if err := collection.commit(next); err != nil {
t.Fatal(err)
}
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, TalentLevel: 1}}, inventory, "", "")
if err != nil {
t.Fatal(err)
}
_ = characters.AttachCollection(collection)
_ = characters.AttachWallet(wallet)
_ = characters.AttachTalentGrowth(testTalentGrowthDesign())
request := talentUpgradeRequest(4, 920000021, books[0], Item{Type: 4, Count: 1000})
if code, _, handled, err := characters.Handle("/TalentSkillUpgrade", request); err != nil || !handled || code != talentSkillUpgradePacketCode {
t.Fatalf("collection upgrade code=%d handled=%v err=%v", code, handled, err)
}
reloaded, err := OpenCollectionStore(store, nil)
if err != nil {
t.Fatal(err)
}
if got, found := reloaded.FindCharacter(920000021); !found || got.TalentLevel != 2 || got.TalentExp != 14 {
t.Fatalf("reloaded collection character=%+v found=%v", got, found)
}
}
+38 -3
View File
@@ -13,12 +13,13 @@ import (
)
// Currency uses the UserDBInfo currency fields: type 3 is free jewelry and
// type 4 is gold. Paid jewelry is persisted as well, although quest rewards
// in the audited tutorial range do not grant it.
// type 4 is gold and type 12 is catalyst (天赋神药). Paid jewelry is persisted
// as well, although quest rewards in the audited tutorial range do not grant it.
type Currency struct {
Gold uint64 `json:"gold"`
FreeJewelry uint64 `json:"free_jewelry"`
Jewelry uint64 `json:"jewelry"`
Catalyst uint64 `json:"catalyst"`
Mileage uint64 `json:"mileage"`
HopePowder uint64 `json:"hope_powder"`
EquipMileage uint64 `json:"equip_mileage"`
@@ -51,7 +52,7 @@ func OpenWallet(store stateio.Store, initial Currency) (*Wallet, error) {
return nil, fmt.Errorf("player: load wallet: %w", err)
}
if b != nil {
if err := stateio.RequireExactJSONObject(b, "version", "gold", "free_jewelry", "jewelry", "mileage", "hope_powder", "equip_mileage", "equip_mileage_exchange_gage"); err != nil {
if err := stateio.RequireExactJSONObject(b, "version", "gold", "free_jewelry", "jewelry", "catalyst", "mileage", "hope_powder", "equip_mileage", "equip_mileage_exchange_gage"); err != nil {
return nil, fmt.Errorf("player: incompatible wallet layout: %w", err)
}
var shape map[string]json.RawMessage
@@ -192,6 +193,35 @@ func (s *Wallet) Currencies() (gold, freeJewelry, jewelry, mileage uint64) {
func (s *Wallet) HopePowderBalance() uint64 { return s.Snapshot().HopePowder }
func (s *Wallet) CatalystBalance() uint64 { return s.Snapshot().Catalyst }
func (s *Wallet) CanSpendCatalyst(amount uint64) bool {
s.mu.Lock()
defer s.mu.Unlock()
return amount > 0 && s.state.Catalyst >= amount
}
func (s *Wallet) SpendCatalystOnce(identity string, amount uint64) (Currency, error) {
if identity == "" || amount == 0 {
return Currency{}, errors.New("player: invalid catalyst spend")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Spent[identity] {
return s.state.Currency, nil
}
if s.state.Catalyst < amount {
return Currency{}, errors.New("player: insufficient catalyst")
}
next := cloneWallet(s.state)
next.Catalyst -= amount
next.Spent[identity] = true
if err := s.commit(next, entry("spent", identity, []byte("true"))...); err != nil {
return Currency{}, err
}
return next.Currency, nil
}
func (s *Wallet) EquipmentMileageBalances() (mileage, exchangeGage uint64) {
c := s.Snapshot()
return c.EquipMileage, c.EquipMileageExchangeGage
@@ -293,6 +323,11 @@ func (s *Wallet) GrantQuestOnce(identity string, rewards []gamedata.Reward) (Cur
return Currency{}, errors.New("player: gold overflow")
}
next.Gold += reward.Count
case 12:
if math.MaxUint64-next.Catalyst < reward.Count {
return Currency{}, errors.New("player: catalyst overflow")
}
next.Catalyst += reward.Count
}
}
next.Granted[identity] = true
+2 -2
View File
@@ -13,7 +13,7 @@ func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) {
if err != nil {
t.Fatal(err)
}
rewards := []gamedata.Reward{{Type: 4, Count: 1500}, {Type: 3, Count: 70}, {Type: 25, ID: 210, Count: 1}}
rewards := []gamedata.Reward{{Type: 4, Count: 1500}, {Type: 3, Count: 70}, {Type: 12, Count: 45}, {Type: 25, ID: 210, Count: 1}}
if _, err := wallet.GrantQuestOnce("quest:1", rewards); err != nil {
t.Fatal(err)
}
@@ -25,7 +25,7 @@ func TestWalletGrantPersistsAndIsIdempotent(t *testing.T) {
t.Fatal(err)
}
got := wallet.Snapshot()
if got.Gold != 1600 || got.FreeJewelry != 70 {
if got.Gold != 1600 || got.FreeJewelry != 70 || got.Catalyst != 45 {
t.Fatalf("wallet=%+v", got)
}
}