feat: complete equipment, inventory, mail, and talent flows
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user